diff --git a/.env b/.env index 633eb3fb..39e663ea 100644 --- a/.env +++ b/.env @@ -6,3 +6,4 @@ MAINNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/mainnet' TESTNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/testnet' TESTNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/testnet' MAINNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/mainnet' +EXCHANGE_RATES_SERVER='https://rates-api.mintlayer.org' diff --git a/.env.production b/.env.production index 5c2cd694..97e71525 100644 --- a/.env.production +++ b/.env.production @@ -5,4 +5,5 @@ GENERATE_SOURCEMAP=false MAINNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/mainnet' TESTNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/testnet' TESTNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/testnet' -MAINNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/mainnet' \ No newline at end of file +MAINNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/mainnet' +EXCHANGE_RATES_SERVER='https://rates-api.mintlayer.org' \ No newline at end of file diff --git a/.env.staging b/.env.staging index bbb883e4..c38c0b73 100644 --- a/.env.staging +++ b/.env.staging @@ -6,3 +6,4 @@ MAINNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/mainnet' TESTNET_ELECTRUM_SERVERS='https://mojito-api.mintlayer.org/bitcoin/testnet' TESTNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/testnet' MAINNET_MINTLAYER_SERVERS='https://mojito-api.mintlayer.org/mintlayer/mainnet' +EXCHANGE_RATES_SERVER='https://rates-api.mintlayer.org' diff --git a/.gitignore b/.gitignore index 6da3defb..ec11ca26 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/.cache/ + +# llm +.claude diff --git a/README.md b/README.md index 971e4f74..647ab5d6 100644 --- a/README.md +++ b/README.md @@ -55,16 +55,6 @@ The build process generates a `ext.zip` and a `extFF.zip` files in the project's They can be imported in the browser as a developer extension on Mozilla Firefox. To test in Chomium-based browsers, you can point the `build` directory as the `unpacked extension`. -### Build specificities - -### CSP HTML meta tag - -On `public/index.html` there is a `meta` tag named `CSP`. This is tags render just in DEVELOPMENT mode. But it has no use in delevelopment mode. - -The only purpose of that tag is to serve as a placeholder to the real `Content-Security-Policy` meta tag, which will be inserted just on the build process for the final packages and the `build` path. - -This meta tag is needed to load properly all the external scripts, stylesheets, fonts, and images on the final product. - ## How to Contribute [Check here](./CONTRIBUTING.md) what you should do, and the rules you should follow, to contribute to this project. diff --git a/babel.config.js b/babel.config.js index 6183f4ea..aa1b3db6 100644 --- a/babel.config.js +++ b/babel.config.js @@ -2,5 +2,6 @@ module.exports = { presets: [ ['@babel/preset-env', { targets: { node: 'current' } }], ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-typescript', ], } diff --git a/eslint.config.mjs b/eslint.config.mjs index f3998022..1cdfe6e0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,12 +1,57 @@ import js from '@eslint/js' +import tseslint from 'typescript-eslint' import reactPlugin from 'eslint-plugin-react' import reactHooksPlugin from 'eslint-plugin-react-hooks' import globals from 'globals' +const sharedRules = { + ...reactPlugin.configs.recommended.rules, + semi: ['error', 'never'], + 'react/jsx-no-target-blank': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/prop-types': 'off', + quotes: ['error', 'single'], + 'no-const-assign': 'error', + 'prefer-const': 'error', + 'no-new-object': 'error', + 'quote-props': ['error', 'as-needed'], + 'no-array-constructor': 'error', + 'no-eval': 'error', + 'no-trailing-spaces': 'error', + 'max-params': ['error', 4], + 'max-depth': ['error', 3], + 'eol-last': ['error', 'always'], + 'testing-library/no-unnecessary-act': 'off', + ...reactHooksPlugin.configs.recommended.rules, +} + +const sharedLanguageOptions = { + ecmaVersion: 2020, + sourceType: 'module', + globals: { + ...globals.browser, + ...globals.es2020, + ...globals.jest, + Buffer: 'readonly', + process: 'readonly', + }, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, +} + export default [ js.configs.recommended, { - ignores: ['src/commons/utils/main.js', 'tests/**', 'src/**/*.test.js'], + ignores: [ + 'src/commons/utils/main.js', + 'tests/**', + 'src/**/*.test.js', + 'src/services/Crypto/Mintlayer/@mintlayerlib-js/**', + 'src/tests/mock/wasmCrypro/**', + ], }, { files: ['*.js', '*.mjs'], @@ -24,6 +69,14 @@ export default [ }, }, }, + { + files: ['src/tests/mock/**/*.js'], + languageOptions: { + globals: { + ...globals.node, + }, + }, + }, { files: ['src/**/*.js'], ignores: ['src/version/*.js'], @@ -31,47 +84,37 @@ export default [ react: reactPlugin, 'react-hooks': reactHooksPlugin, }, - languageOptions: { - ecmaVersion: 2020, - sourceType: 'module', - globals: { - ...globals.browser, - ...globals.es2020, - ...globals.jest, - Buffer: 'readonly', - process: 'readonly', - }, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, + languageOptions: sharedLanguageOptions, + settings: { + react: { + version: 'detect', }, }, + rules: { + ...sharedRules, + 'no-unused-vars': 'error', + }, + }, + { + files: ['src/**/*.ts', 'src/**/*.tsx'], + plugins: { + react: reactPlugin, + 'react-hooks': reactHooksPlugin, + '@typescript-eslint': tseslint.plugin, + }, + languageOptions: { + ...sharedLanguageOptions, + parser: tseslint.parser, + }, settings: { react: { version: 'detect', }, }, rules: { - ...reactPlugin.configs.recommended.rules, - semi: ['error', 'never'], - 'react/jsx-no-target-blank': 'off', - 'react/react-in-jsx-scope': 'off', - 'react/prop-types': 'off', - quotes: ['error', 'single'], - 'no-const-assign': 'error', - 'no-unused-vars': 'error', - 'prefer-const': 'error', - 'no-new-object': 'error', - 'quote-props': ['error', 'as-needed'], - 'no-array-constructor': 'error', - 'no-eval': 'error', - 'no-trailing-spaces': 'error', - 'max-params': ['error', 4], - 'max-depth': ['error', 3], - 'eol-last': ['error', 'always'], - 'testing-library/no-unnecessary-act': 'off', - ...reactHooksPlugin.configs.recommended.rules, + ...sharedRules, + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': 'error', }, }, ] diff --git a/jest.config.js b/jest.config.js index f4749e5e..aedc5619 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,9 @@ module.exports = { testEnvironment: 'jsdom', setupFilesAfterEnv: ['/src/setupTests.js'], testPathIgnorePatterns: ['/node_modules/', '/tests/', 'src/pages'], + transform: { + '\\.[jt]sx?$': 'babel-jest', + }, transformIgnorePatterns: [ 'node_modules/(?!(react-router|react-router-dom|@remix-run|date-fns|konva|react-konva|@mintlayer)/)', ], diff --git a/package-lock.json b/package-lock.json index e77c29f5..498f5a1e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "browser-extension", - "version": "1.5.3", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser-extension", - "version": "1.5.3", + "version": "1.6.0", "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", - "@mintlayer/sdk": "1.0.30", + "@mintlayer/sdk": "1.0.37", "@noble/secp256k1": "^3.0.0", "bip32": "^5.0.0", "bip39": "^3.1.0", @@ -35,6 +35,7 @@ "devDependencies": { "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", "@eslint/js": "^9.39.2", "@playwright/test": "^1.58.2", "@svgr/webpack": "^8.1.0", @@ -42,7 +43,10 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", "@types/node": "^25.2.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/tiny-secp256k1": "^2.0.1", "babel-jest": "^30.2.0", "babel-loader": "^10.0.0", @@ -69,6 +73,7 @@ "pretty-quick": "^4.2.2", "style-loader": "^4.0.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.59.3", "url-loader": "^4.1.1", "vm-browserify": "^1.1.2", "wallet-address-validator": "^0.2.4", @@ -4724,9 +4729,9 @@ "license": "MIT" }, "node_modules/@mintlayer/sdk": { - "version": "1.0.30", - "resolved": "https://registry.npmjs.org/@mintlayer/sdk/-/sdk-1.0.30.tgz", - "integrity": "sha512-9sQumFS/nVp3/xyGZneDvb3ORCWRpBL9nk6VZb77PmC2tRtLun/nLeacTZyM28oUwSPjXGLTzlUjQpYWm/6SIg==", + "version": "1.0.37", + "resolved": "https://registry.npmjs.org/@mintlayer/sdk/-/sdk-1.0.37.tgz", + "integrity": "sha512-roZgeeShLTVTjyosFi55G1F5mEg4cIxu3WpfKPVPBl44cd9GW/l+lG5+cOkH83DDparq+ix2ZhavP6RlSQFBLg==", "license": "ISC", "dependencies": { "@mintlayer/wasm-lib": "^0.1.0" @@ -5490,6 +5495,290 @@ "@types/node": "*" } }, + "node_modules/@types/d3": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz", + "integrity": "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/d3-axis": "*", + "@types/d3-brush": "*", + "@types/d3-chord": "*", + "@types/d3-color": "*", + "@types/d3-contour": "*", + "@types/d3-delaunay": "*", + "@types/d3-dispatch": "*", + "@types/d3-drag": "*", + "@types/d3-dsv": "*", + "@types/d3-ease": "*", + "@types/d3-fetch": "*", + "@types/d3-force": "*", + "@types/d3-format": "*", + "@types/d3-geo": "*", + "@types/d3-hierarchy": "*", + "@types/d3-interpolate": "*", + "@types/d3-path": "*", + "@types/d3-polygon": "*", + "@types/d3-quadtree": "*", + "@types/d3-random": "*", + "@types/d3-scale": "*", + "@types/d3-scale-chromatic": "*", + "@types/d3-selection": "*", + "@types/d3-shape": "*", + "@types/d3-time": "*", + "@types/d3-time-format": "*", + "@types/d3-timer": "*", + "@types/d3-transition": "*", + "@types/d3-zoom": "*" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-axis": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-axis/-/d3-axis-3.0.6.tgz", + "integrity": "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-brush": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.6.tgz", + "integrity": "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-chord": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-chord/-/d3-chord-3.0.6.tgz", + "integrity": "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-contour": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-contour/-/d3-contour-3.0.6.tgz", + "integrity": "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-array": "*", + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-delaunay": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz", + "integrity": "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-dispatch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz", + "integrity": "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-dsv": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.7.tgz", + "integrity": "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-fetch": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-fetch/-/d3-fetch-3.0.7.tgz", + "integrity": "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-dsv": "*" + } + }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-format": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.4.tgz", + "integrity": "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.0.tgz", + "integrity": "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-hierarchy": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz", + "integrity": "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-polygon": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-polygon/-/d3-polygon-3.0.2.tgz", + "integrity": "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-quadtree": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz", + "integrity": "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-random": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-random/-/d3-random-3.0.3.tgz", + "integrity": "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-time-format": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time-format/-/d3-time-format-4.0.3.tgz", + "integrity": "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -5558,6 +5847,13 @@ "@types/send": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/html-minifier-terser": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", @@ -5660,15 +5956,24 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.13", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", - "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@types/react-reconciler": { "version": "0.32.3", "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.3.tgz", @@ -5780,6 +6085,301 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.3.tgz", + "integrity": "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/type-utils": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.3", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.3.tgz", + "integrity": "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.3.tgz", + "integrity": "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.3", + "@typescript-eslint/types": "^8.59.3", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.3.tgz", + "integrity": "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.3.tgz", + "integrity": "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.3.tgz", + "integrity": "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.3.tgz", + "integrity": "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.3.tgz", + "integrity": "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.3", + "@typescript-eslint/tsconfig-utils": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/visitor-keys": "8.59.3", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.3.tgz", + "integrity": "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.3", + "@typescript-eslint/types": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.3.tgz", + "integrity": "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.3", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", @@ -8236,8 +8836,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/d3": { "version": "7.9.0", @@ -18699,6 +19298,19 @@ "tslib": "2" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -18866,6 +19478,30 @@ "node": ">=14.17" } }, + "node_modules/typescript-eslint": { + "version": "8.59.3", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.3.tgz", + "integrity": "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.3", + "@typescript-eslint/parser": "8.59.3", + "@typescript-eslint/typescript-estree": "8.59.3", + "@typescript-eslint/utils": "8.59.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, "node_modules/uint8array-tools": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/uint8array-tools/-/uint8array-tools-0.0.7.tgz", diff --git a/package.json b/package.json index ac588341..3a370287 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { "name": "browser-extension", - "version": "1.5.3", + "version": "1.6.0", "private": true, "dependencies": { "@bitcoinerlab/secp256k1": "^1.2.0", - "@mintlayer/sdk": "1.0.30", + "@mintlayer/sdk": "1.0.37", "@noble/secp256k1": "^3.0.0", "bip32": "^5.0.0", "bip39": "^3.1.0", @@ -37,7 +37,7 @@ "e2e": "npx playwright test", "e2e:debug": "npx playwright test --headed", "e2e:ui": "npx playwright test --ui", - "lint": "eslint src/**/*.js", + "lint": "eslint 'src/**/*.{js,ts,tsx}'", "prettier": "pretty-quick --staged", "pretty-quick": "pretty-quick", "pretty-quick-check": "pretty-quick --check", @@ -60,6 +60,7 @@ "devDependencies": { "@babel/preset-env": "^7.29.0", "@babel/preset-react": "^7.28.5", + "@babel/preset-typescript": "^7.28.5", "@eslint/js": "^9.39.2", "@playwright/test": "^1.58.2", "@svgr/webpack": "^8.1.0", @@ -67,7 +68,10 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", + "@types/d3": "^7.4.3", "@types/node": "^25.2.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", "@types/tiny-secp256k1": "^2.0.1", "babel-jest": "^30.2.0", "babel-loader": "^10.0.0", @@ -94,6 +98,7 @@ "pretty-quick": "^4.2.2", "style-loader": "^4.0.0", "typescript": "^5.9.3", + "typescript-eslint": "^8.59.3", "url-loader": "^4.1.1", "vm-browserify": "^1.1.2", "wallet-address-validator": "^0.2.4", diff --git a/packing.sh b/packing.sh index ce7a9fad..a638d798 100644 --- a/packing.sh +++ b/packing.sh @@ -12,7 +12,7 @@ node ./src/version/version-mojito.js # mode inside build dir cd build -CSPHEADER="" +CSPHEADER="" sed -i '' "s//$CSPHEADER/g" index.html # Copying index.html to popup.html to have different entry points for popup and extended view diff --git a/playwright.config.js b/playwright.config.js index 6eb94590..35854d38 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -13,13 +13,13 @@ const { defineConfig, devices } = require('@playwright/test') module.exports = defineConfig({ testDir: './tests', /* Run tests in files in parallel */ - fullyParallel: true, + fullyParallel: false, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + workers: 1, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ diff --git a/public/background.js b/public/background.js index 33657fd0..5c8550a4 100644 --- a/public/background.js +++ b/public/background.js @@ -33,125 +33,129 @@ sendResponse({ result: { isConnected: !!connectedSites[origin] }, }) + // External dApp API disabled — uncomment blocks below to re-enable } else if (message.method === 'connect') { - if (connectWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - connectWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - // networkType: message.params.networkType, - // permission: message.params.permission, - action: 'connect', - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, - ) - }, - ) - return true // Keep channel open - } else if (typeof connectWindowId === 'number') { - api.windows.update(connectWindowId, { focused: true }) - sendResponse({ error: 'Connection window already open' }) - } + sendResponse({ error: 'External connections are disabled' }) + // if (connectWindowId === false) { + // pendingResponses.set(message.requestId, sendResponse) + // api.windows.create( + // { + // url: api.runtime.getURL('popup.html'), + // type: 'popup', + // width: 800, + // height: 600, + // focused: true, + // }, + // (win) => { + // connectWindowId = win.id + // api.storage.local.set( + // { + // pendingRequest: { + // origin, + // requestId: message.requestId, + // // networkType: message.params.networkType, + // // permission: message.params.permission, + // action: 'connect', + // }, + // }, + // () => { + // if (api.runtime.lastError) { + // console.error( + // '[Mintlayer] Storage set error:', + // api.runtime.lastError, + // ) + // } + // }, + // ) + // }, + // ) + // return true // Keep channel open + // } else if (typeof connectWindowId === 'number') { + // api.windows.update(connectWindowId, { focused: true }) + // sendResponse({ error: 'Connection window already open' }) + // } } else if (message.method === 'signTransaction') { - if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) - } else if (popupWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - popupWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - action: 'signTransaction', - data: message.params || {}, - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, - ) - }, - ) - return true - } else if (typeof popupWindowId === 'number') { - api.windows.update(popupWindowId, { focused: true }) - sendResponse({ error: 'Transaction signing window already open' }) - } + sendResponse({ error: 'External signing is disabled' }) + // if (!connectedSites[origin]) { + // sendResponse({ error: 'Not connected. Call connect first.' }) + // } else if (popupWindowId === false) { + // pendingResponses.set(message.requestId, sendResponse) + // api.windows.create( + // { + // url: api.runtime.getURL('popup.html'), + // type: 'popup', + // width: 800, + // height: 600, + // focused: true, + // }, + // (win) => { + // popupWindowId = win.id + // api.storage.local.set( + // { + // pendingRequest: { + // origin, + // requestId: message.requestId, + // action: 'signTransaction', + // data: message.params || {}, + // }, + // }, + // () => { + // if (api.runtime.lastError) { + // console.error( + // '[Mintlayer] Storage set error:', + // api.runtime.lastError, + // ) + // } + // }, + // ) + // }, + // ) + // return true + // } else if (typeof popupWindowId === 'number') { + // api.windows.update(popupWindowId, { focused: true }) + // sendResponse({ error: 'Transaction signing window already open' }) + // } } else if (message.method === 'signChallenge') { - if (!connectedSites[origin]) { - sendResponse({ error: 'Not connected. Call connect first.' }) - } else if (popupWindowId === false) { - pendingResponses.set(message.requestId, sendResponse) - api.windows.create( - { - url: api.runtime.getURL('popup.html'), - type: 'popup', - width: 800, - height: 600, - focused: true, - }, - (win) => { - popupWindowId = win.id - api.storage.local.set( - { - pendingRequest: { - origin, - requestId: message.requestId, - action: 'signChallenge', - data: message.params || {}, - }, - }, - () => { - if (api.runtime.lastError) { - console.error( - '[Mintlayer] Storage set error:', - api.runtime.lastError, - ) - } - }, - ) - }, - ) - return true - } else if (typeof popupWindowId === 'number') { - api.windows.update(popupWindowId, { focused: true }) - sendResponse({ error: 'Transaction signing window already open' }) - } + sendResponse({ error: 'External signing is disabled' }) + // if (!connectedSites[origin]) { + // sendResponse({ error: 'Not connected. Call connect first.' }) + // } else if (popupWindowId === false) { + // pendingResponses.set(message.requestId, sendResponse) + // api.windows.create( + // { + // url: api.runtime.getURL('popup.html'), + // type: 'popup', + // width: 800, + // height: 600, + // focused: true, + // }, + // (win) => { + // popupWindowId = win.id + // api.storage.local.set( + // { + // pendingRequest: { + // origin, + // requestId: message.requestId, + // action: 'signChallenge', + // data: message.params || {}, + // }, + // }, + // () => { + // if (api.runtime.lastError) { + // console.error( + // '[Mintlayer] Storage set error:', + // api.runtime.lastError, + // ) + // } + // }, + // ) + // }, + // ) + // return true + // } else if (typeof popupWindowId === 'number') { + // api.windows.update(popupWindowId, { focused: true }) + // sendResponse({ error: 'Transaction signing window already open' }) + // } } else if (message.method === 'version') { sendResponse({ result: api.runtime.getManifest().version }) } else if (message.method === 'getSession') { diff --git a/public/index.html b/public/index.html index 87c59910..6896c69c 100644 --- a/public/index.html +++ b/public/index.html @@ -1,7 +1,6 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/btc-logo.svg b/src/assets/images/btc-logo.svg index 2249e495..68908070 100644 --- a/src/assets/images/btc-logo.svg +++ b/src/assets/images/btc-logo.svg @@ -1,4 +1,4 @@ -
+ +
+ +
+ Mojito +
+

+ Your non-custodial +
+ Mintlayer wallet +

+
+
+ ) +} + +export default BrandPanel diff --git a/src/components/basic/Button/Button.css b/src/components/basic/Button/Button.module.css similarity index 58% rename from src/components/basic/Button/Button.css rename to src/components/basic/Button/Button.module.css index 9569fabb..35bc7c2a 100644 --- a/src/components/basic/Button/Button.css +++ b/src/components/basic/Button/Button.module.css @@ -2,16 +2,18 @@ display: flex; align-items: center; justify-content: center; - background-color: rgb(var(--color-green)); + background-color: rgb(var(--mojito-green)); border: none; border-radius: var(--round-size-big); - color: rgb(var(--color-dark-teal)); + color: rgb(var(--color-white)); cursor: pointer; - font-size: 16px; + font-size: 14px; font-weight: 600; opacity: 1; padding: 14px 22px 14px 22px; width: fit-content; + min-height: max-content; + gap: 10px; } .btn svg { @@ -27,34 +29,34 @@ .btn:hover, .btn:focus { - background-color: rgb(var(--color-darker-green)); - color: rgb(var(--color-green)); + background-color: rgb(var(--mojito-green-dark)); + color: rgb(var(--color-white)); transition: 0.4s ease-in-out; } -.btn:hover svg path, -.btn:focus svg path { - stroke: rgb(var(--color-green)); +.btn svg path, +.btn svg path { + stroke: rgb(var(--color-white)); } .btn.alternate { background-color: transparent; - border: 1px solid rgb(var(--color-darker-green)); - color: rgb(var(--color-darker-green)); + border: 1.5px solid rgb(var(--mojito-green)); + color: rgb(var(--mojito-green)); transition: - opacity 0.4s ease-in-out, - border 0.4s ease-in-out; + opacity 0.3s ease-in-out, + border 0.3s ease-in-out; } .btn.alternate:hover, .btn.alternate:focus { - background-color: rgb(var(--color-darker-green)); - color: rgb(var(--color-white)); - transition: 0.4s ease-in-out; + background: rgb(var(--mojito-green-soft)); + transition: 0.3s ease-in-out; } -.btn.alternate:hover svg path { - stroke: rgb(var(--color-white)); +.btn.alternate svg path, +.btn.alternate svg path { + stroke: rgb(var(--mojito-green)); } .btn.dark { @@ -62,8 +64,8 @@ border: 2px solid transparent; color: rgb(var(--color-white)); transition: - opacity 0.4s ease-in-out, - border 0.4s ease-in-out; + opacity 0.3s ease-in-out, + border 0.3s ease-in-out; } .btn.dark:hover, diff --git a/src/components/basic/Button/Button.test.js b/src/components/basic/Button/Button.test.js index a3530188..8db069cf 100644 --- a/src/components/basic/Button/Button.test.js +++ b/src/components/basic/Button/Button.test.js @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import Button from './Button' +import Button from './Button.tsx' test('Button component', () => { render( + + ) + } + + return inputElement +} + +export default Input diff --git a/src/components/basic/Input/InputBTC.js b/src/components/basic/Input/InputBTC.js index 730fe35b..10af3d0a 100644 --- a/src/components/basic/Input/InputBTC.js +++ b/src/components/basic/Input/InputBTC.js @@ -1,4 +1,4 @@ -import { useEffect, useState, useContext } from 'react' +import { useState, useContext } from 'react' import { AppInfo, Expressions } from '@Constants' import { NumbersHelper } from '@Helpers' import Input from './Input' @@ -16,6 +16,12 @@ const InputBTC = (props) => { const breakersRegex = /[.,]/g const [value, setValue] = useState(props.value || '') + const [prevPropsValue, setPrevPropsValue] = useState(props.value) + + if (props.value !== prevPropsValue) { + setPrevPropsValue(props.value) + setValue(props.value) + } const removeBreakers = (value) => value.replaceAll(breakersRegex, '') @@ -79,10 +85,6 @@ const InputBTC = (props) => { return parsedVal.value || ev.target.value } - useEffect(() => { - setValue(props.value) - }, [props.value]) - return ( Number(item[1]))) const max = Math.max(...points.map((item) => Number(item[1]))) + const padding = parseInt(strokeWidth) / 2 const scale = scaleLinear() .domain([min, max]) - .range([0, parseInt(height)]) + .range([parseInt(height) - padding, padding]) const lineGenerator = line() .y((d) => scale(d[1]).toFixed(2)) - .curve(curveNatural) + .curve(curveMonotoneX) const pathData = lineGenerator(points) diff --git a/src/components/basic/Logo/Logo.css b/src/components/basic/Logo/Logo.css index fb6a4457..54211492 100644 --- a/src/components/basic/Logo/Logo.css +++ b/src/components/basic/Logo/Logo.css @@ -3,37 +3,35 @@ display: flex; justify-content: space-between; align-items: center; - line-height: 60px; - width: 190px; + width: 107px; } .logoContainer .logo { - width: 50px; - height: 50px; + width: 30px; + height: 30px; margin: 0; } .logoContainer .mojitoLettering { - font-size: 2.4rem; + font-size: 1.5rem; color: rgb(var(--color-black)); } .logoContainer .mojitoLettering .testnetMark { - font-size: 2.5rem; + font-size: 1.5rem; color: rgb(var(--color-orange)); } .testnetMessage { position: absolute; - bottom: -3px; - right: 0; + bottom: -2px; + right: -1px; display: flex; align-items: center; color: rgb(var(--color-orange)); border-radius: 20px; - font-size: 0.8rem; + font-size: 0.55rem; font-weight: 600; - height: 1.5rem; margin: 0; text-align: center; } diff --git a/src/components/basic/OptionCard/OptionCard.module.css b/src/components/basic/OptionCard/OptionCard.module.css new file mode 100644 index 00000000..fa6ac583 --- /dev/null +++ b/src/components/basic/OptionCard/OptionCard.module.css @@ -0,0 +1,67 @@ +.card { + display: flex; + flex-direction: column; + gap: 8px; + padding: 24px; + border-radius: 20px; + background: rgb(var(--color-white)); + border: 1px solid rgba(var(--color-light-gray), 0.4); + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.card:hover { + border-color: rgba(var(--mojito-green), 0.5); + box-shadow: 0 2px 12px rgba(var(--mojito-green), 0.08); +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 14px; + background: rgba(var(--mojito-green), 0.1); + margin-bottom: 4px; +} + +.icon svg { + width: 24px; + height: 24px; + color: rgb(var(--mojito-green)); +} + +.title { + font-size: 16px; + font-weight: 700; + color: rgb(var(--color-black)); +} + +.description { + font-size: 13px; + color: rgb(var(--color-dark-gray)); + line-height: 1.4; +} + +.link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 14px; + font-weight: 600; + color: rgb(var(--mojito-green)); + margin-top: 4px; +} + +.link svg { + width: 14px; + height: 14px; + transition: transform 0.2s ease; +} + +.card:hover .link svg { + transform: translateX(3px); +} diff --git a/src/components/basic/OptionCard/OptionCard.test.js b/src/components/basic/OptionCard/OptionCard.test.js new file mode 100644 index 00000000..f9f83b5a --- /dev/null +++ b/src/components/basic/OptionCard/OptionCard.test.js @@ -0,0 +1,61 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import OptionCard from './OptionCard' + +const defaultProps = { + icon: IC, + title: 'Create wallet', + description: 'Generate a new seed phrase', + onClick: jest.fn(), +} + +const renderCard = (overrides = {}) => + render( + , + ) + +describe('OptionCard', () => { + beforeEach(() => jest.clearAllMocks()) + + it('renders title and description', () => { + renderCard() + + expect(screen.getByText('Create wallet')).toBeInTheDocument() + expect(screen.getByText('Generate a new seed phrase')).toBeInTheDocument() + }) + + it('renders the icon', () => { + renderCard() + + expect(screen.getByTestId('test-icon')).toBeInTheDocument() + }) + + it('shows default link text "Select"', () => { + renderCard() + + expect(screen.getByText('Select')).toBeInTheDocument() + }) + + it('shows custom link text when provided', () => { + renderCard({ linkText: 'Continue' }) + + expect(screen.getByText('Continue')).toBeInTheDocument() + expect(screen.queryByText('Select')).not.toBeInTheDocument() + }) + + it('calls onClick when card is clicked', () => { + renderCard() + + fireEvent.click(screen.getByText('Create wallet')) + expect(defaultProps.onClick).toHaveBeenCalledTimes(1) + }) + + it('renders arrow SVG inside link area', () => { + const { container } = renderCard() + + const svg = container.querySelector('svg') + expect(svg).toBeInTheDocument() + }) +}) diff --git a/src/components/basic/OptionCard/OptionCard.tsx b/src/components/basic/OptionCard/OptionCard.tsx new file mode 100644 index 00000000..2a27e923 --- /dev/null +++ b/src/components/basic/OptionCard/OptionCard.tsx @@ -0,0 +1,45 @@ +import { ReactNode } from 'react' +import styles from './OptionCard.module.css' + +interface OptionCardProps { + icon: ReactNode + title: string + description: string + linkText?: string + onClick: () => void +} + +const OptionCard = ({ + icon, + title, + description, + linkText = 'Select', + onClick, +}: OptionCardProps) => ( +
+
{icon}
+ {title} + {description} + + {linkText} + + + + +
+) + +export default OptionCard diff --git a/src/components/basic/PageWrapper/PageWrapper.module.css b/src/components/basic/PageWrapper/PageWrapper.module.css new file mode 100644 index 00000000..b70e9f3c --- /dev/null +++ b/src/components/basic/PageWrapper/PageWrapper.module.css @@ -0,0 +1,13 @@ +.wrapper { + position: relative; + display: flex; + flex-direction: column; + height: 530px; + padding: 1rem; + flex: 1; + + @media screen and (min-width: 801px) { + height: 93%; + padding: 2rem; + } +} diff --git a/src/components/basic/PageWrapper/PageWrapper.test.js b/src/components/basic/PageWrapper/PageWrapper.test.js new file mode 100644 index 00000000..465e5c82 --- /dev/null +++ b/src/components/basic/PageWrapper/PageWrapper.test.js @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import PageWrapper from './PageWrapper' + +describe('PageWrapper', () => { + it('renders children inside a section element', () => { + render( + +

Hello

+
, + ) + + expect(screen.getByText('Hello')).toBeInTheDocument() + expect(screen.getByText('Hello').closest('section')).toBeInTheDocument() + }) + + it('applies inline style', () => { + const { container } = render( + +

Styled

+
, + ) + + const section = container.querySelector('section') + expect(section.style.backgroundColor).toBe('red') + }) + + it('appends custom className alongside default', () => { + const { container } = render( + +

Classed

+
, + ) + + const section = container.querySelector('section') + expect(section.className).toContain('custom-class') + expect(section.className).toContain('wrapper') + }) + + it('handles no className without extra spaces', () => { + const { container } = render( + +

Plain

+
, + ) + + const section = container.querySelector('section') + expect(section.className).not.toContain('undefined') + expect(section.className).not.toContain('null') + }) + + it('handles empty string className', () => { + const { container } = render( + +

Empty

+
, + ) + + const section = container.querySelector('section') + expect(section.className).not.toContain(' ') + }) +}) diff --git a/src/components/basic/PageWrapper/PageWrapper.tsx b/src/components/basic/PageWrapper/PageWrapper.tsx new file mode 100644 index 00000000..5dfab98f --- /dev/null +++ b/src/components/basic/PageWrapper/PageWrapper.tsx @@ -0,0 +1,22 @@ +import { ReactNode, CSSProperties } from 'react' +import styles from './PageWrapper.module.css' + +interface PageWrapperProps { + children: ReactNode + style?: CSSProperties + className?: string +} + +const PageWrapper = ({ children, style, className }: PageWrapperProps) => { + const classNames = [styles.wrapper, className].filter(Boolean).join(' ') + return ( +
+ {children} +
+ ) +} + +export default PageWrapper diff --git a/src/components/basic/SkeletonLoader/SkeletonLoader.css b/src/components/basic/SkeletonLoader/SkeletonLoader.css index 29a06f51..346051f6 100644 --- a/src/components/basic/SkeletonLoader/SkeletonLoader.css +++ b/src/components/basic/SkeletonLoader/SkeletonLoader.css @@ -8,6 +8,14 @@ color: rgb(var(--color-white)); } +.card-compact { + padding: 8px 20px 8px 12px; + margin-bottom: 0; + border-radius: 35px; + max-height: 72px; + min-height: 72px; +} + .skeleton { animation: skeleton-loading 1s linear infinite alternate; } @@ -30,6 +38,13 @@ min-height: 72px; } +.cardHeader-compact { + height: 50px; + width: 50px; + min-width: 50px; + min-height: 50px; +} + .cardBodyWrapper { display: flex; align-items: center; diff --git a/src/components/basic/SkeletonLoader/SkeletonLoader.js b/src/components/basic/SkeletonLoader/SkeletonLoader.js index 1f3b9084..fd2b7f53 100644 --- a/src/components/basic/SkeletonLoader/SkeletonLoader.js +++ b/src/components/basic/SkeletonLoader/SkeletonLoader.js @@ -15,10 +15,12 @@ const SkeletonText = () => { ) } -const SkeletonLoader = () => { +const SkeletonLoader = ({ variant = 'default' }) => { + const isCompact = variant === 'compact' + return ( @@ -46,7 +48,7 @@ const SkeletonLoader = () => { className="cardBody cardBodyRight" data-testid="body-item" > - {Array.from({ length: 3 }).map((item, index) => ( + {Array.from({ length: isCompact ? 2 : 3 }).map((item, index) => ( ))} diff --git a/src/components/basic/Svg/Svg.js b/src/components/basic/Svg/Svg.js index faef6c9a..ad4bc69b 100644 --- a/src/components/basic/Svg/Svg.js +++ b/src/components/basic/Svg/Svg.js @@ -16,6 +16,7 @@ const Svg = ({ width={width} height={height} viewBox={`0 0 ${viewboxWidth} ${viewboxHeight}`} + preserveAspectRatio="none" data-testid="svg-container" > {children} diff --git a/src/components/basic/SwapTokenLogo/SwapTokenLogo.test.js b/src/components/basic/SwapTokenLogo/SwapTokenLogo.test.js new file mode 100644 index 00000000..b0e24ff6 --- /dev/null +++ b/src/components/basic/SwapTokenLogo/SwapTokenLogo.test.js @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react' +import SwapTokenLogo from './SwapTokenLogo' + +describe('SwapTokenLogo', () => { + it('renders ML logo SVG when tokenId is undefined', () => { + render() + + const wrapper = screen.getByTestId('swap-token-logo') + expect(wrapper).toBeInTheDocument() + expect(wrapper.querySelector('svg')).toBeInTheDocument() + }) + + it('renders ticker first letter as fallback for unknown tokenId', () => { + render( + , + ) + + expect(screen.getByText('U')).toBeInTheDocument() + }) + + it('renders empty when unknown tokenId and no ticker', () => { + render() + + const wrapper = screen.getByTestId('swap-token-logo') + expect(wrapper.querySelector('svg')).not.toBeInTheDocument() + expect(wrapper.querySelector('span')).not.toBeInTheDocument() + }) + + it('applies small class by default', () => { + render() + + const wrapper = screen.getByTestId('swap-token-logo') + expect(wrapper.className).not.toContain('big') + }) + + it('applies big class when size is "big"', () => { + render() + + const wrapper = screen.getByTestId('swap-token-logo') + expect(wrapper.className).toContain('big') + }) + + it('renders fallback span with correct class for ticker', () => { + const { container } = render( + , + ) + + const fallback = container.querySelector('.swap-token-logo-fallback') + expect(fallback).toBeInTheDocument() + expect(fallback).toHaveTextContent('B') + }) +}) diff --git a/src/components/basic/Timer/Timer.test.js b/src/components/basic/Timer/Timer.test.js new file mode 100644 index 00000000..e1c9c1de --- /dev/null +++ b/src/components/basic/Timer/Timer.test.js @@ -0,0 +1,83 @@ +import { render, act } from '@testing-library/react' +import Timer from './Timer' + +describe('Timer', () => { + beforeEach(() => jest.useFakeTimers()) + afterEach(() => jest.useRealTimers()) + + it('calls onTimerEnd after duration', () => { + const onTimerEnd = jest.fn() + render( + , + ) + + expect(onTimerEnd).not.toHaveBeenCalled() + + act(() => jest.advanceTimersByTime(5000)) + expect(onTimerEnd).toHaveBeenCalledTimes(1) + }) + + it('does not call onTimerEnd before duration elapses', () => { + const onTimerEnd = jest.fn() + render( + , + ) + + act(() => jest.advanceTimersByTime(4999)) + expect(onTimerEnd).not.toHaveBeenCalled() + }) + + it('repeats when repeat is true', () => { + const onTimerEnd = jest.fn() + render( + , + ) + + act(() => jest.advanceTimersByTime(1000)) + expect(onTimerEnd).toHaveBeenCalledTimes(1) + + act(() => jest.advanceTimersByTime(1000)) + expect(onTimerEnd).toHaveBeenCalledTimes(2) + + act(() => jest.advanceTimersByTime(1000)) + expect(onTimerEnd).toHaveBeenCalledTimes(3) + }) + + it('does not repeat when repeat is falsy', () => { + const onTimerEnd = jest.fn() + render( + , + ) + + act(() => jest.advanceTimersByTime(1000)) + expect(onTimerEnd).toHaveBeenCalledTimes(1) + + act(() => jest.advanceTimersByTime(3000)) + expect(onTimerEnd).toHaveBeenCalledTimes(1) + }) + + it('renders an empty div', () => { + const { container } = render( + , + ) + + expect(container.querySelector('div')).toBeInTheDocument() + expect(container.querySelector('div')).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/basic/TokenLogoRound/TokenLogoRound.test.js b/src/components/basic/TokenLogoRound/TokenLogoRound.test.js new file mode 100644 index 00000000..962e090b --- /dev/null +++ b/src/components/basic/TokenLogoRound/TokenLogoRound.test.js @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import TokenLogoRound from './TokenLogoRound' + +describe('TokenLogoRound', () => { + it('renders with data-testid', () => { + render() + + expect(screen.getByTestId('token-logo-round')).toBeInTheDocument() + }) + + it('renders text content', () => { + render() + + expect(screen.getByText('ML')).toBeInTheDocument() + }) + + it('renders logo image with alt text', () => { + render() + + const img = screen.getByAltText('Logo') + expect(img).toBeInTheDocument() + expect(img.tagName).toBe('IMG') + }) + + it('applies small class when small prop is true', () => { + render() + + const wrapper = screen.getByTestId('token-logo-round') + expect(wrapper.className).toContain('small') + }) + + it('does not apply small class by default', () => { + render() + + const wrapper = screen.getByTestId('token-logo-round') + expect(wrapper.className).not.toContain('small') + }) + + it('renders without text when text prop is omitted', () => { + const { container } = render() + + const wrapper = screen.getByTestId('token-logo-round') + expect(wrapper.querySelector('img')).toBeInTheDocument() + expect(container.textContent).toBe('') + }) +}) diff --git a/src/components/basic/index.js b/src/components/basic/index.js index d203b9fe..1e3e0b91 100644 --- a/src/components/basic/index.js +++ b/src/components/basic/index.js @@ -1,12 +1,12 @@ import * as Arc from './Arc/Arc' -import Button from './Button/Button' -import Input from './Input/Input' +import Button from './Button/Button.tsx' +import Input from './Input/Input.tsx' import InputInteger from './Input/InputInteger' import InputFloat from './Input/InputFloat' import InputBTC from './Input/InputBTC' import Line from './Line/Line' import Svg from './Svg/Svg' -import Error from './Error/Error' +import Error from './Error/Error.tsx' import Toggle from './Toggle/Toggle' import Logo from './Logo/Logo' import LogoRound from './LogoRound/LogoRound' @@ -15,6 +15,9 @@ import Tooltip from './Tooltip/Tooltip' import Textarea from './Textarea/Textarea' import EmptyListMessage from './EmptyList/EmptyList' import SwapTokenLogo from './SwapTokenLogo/SwapTokenLogo' +import PageWrapper from './PageWrapper/PageWrapper.tsx' +import BrandPanel from './BrandPanel/BrandPanel' +import OptionCard from './OptionCard/OptionCard' export { Arc, @@ -34,4 +37,7 @@ export { Textarea, EmptyListMessage, SwapTokenLogo, + PageWrapper, + BrandPanel, + OptionCard, } diff --git a/src/components/composed/AddressList/AddressList.css b/src/components/composed/AddressList/AddressList.css deleted file mode 100644 index d88b881c..00000000 --- a/src/components/composed/AddressList/AddressList.css +++ /dev/null @@ -1,146 +0,0 @@ -.address-table { - width: 100%; - height: 96%; - display: block; -} - -.address-table-wrapper { - display: flex; - justify-content: center; - align-items: flex-start; - height: 465px; - overflow: hidden; - height: 100%; - - @media screen and (min-width: 801px) { - flex-grow: 1; - } -} - -.address-table thead { - display: block; - width: 100%; -} - -.address-table tbody { - display: block; - height: 400px; - overflow-y: auto; - width: 100%; - height: 100%; -} - -.address-table thead tr, -.address-table tbody tr { - display: table; - width: 100%; - table-layout: fixed; -} - -.address-title { - text-align: left; - padding: 14px 24px; - background: rgba(var(--color-green), 0.8); - width: 33.33%; - color: rgb(var(--color-black)); - position: sticky; - top: 0; - z-index: 10; -} - -.address-title:nth-child(1) { - border-top-left-radius: var(--round-size); - width: 40%; -} - -.address-title:nth-child(2) { - width: 15%; -} - -.address-title:nth-child(3) { - border-top-right-radius: var(--round-size); - width: 45%; -} - -.address-loading-wrapper { - display: flex; - justify-content: center; - align-items: center; - height: 465px; -} - -.address-row { - border-bottom: 1px solid rgba(var(--color-green), 0.2); -} - -.address-row:hover { - background-color: rgba(var(--color-green), 0.1); -} - -.address-cell, -.used-cell, -.balance-cell { - padding: 14px 24px; - vertical-align: baseline; - color: rgb(var(--color-black)); -} - -.address-cell { - width: 40%; -} - -.used-cell { - width: 15%; -} - -.balance-cell { - width: 45%; -} - -.address-value { - font-family: monospace; - font-size: 0.9em; - max-width: 200px; - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.address-value:hover { - color: rgba(var(--color-green), 1); -} - -.used-status.used { - color: rgba(var(--color-black), 1); - font-weight: 500; -} - -.used-status.unused { - color: rgba(var(--color-green), 1); -} - -.no-addresses { - text-align: center; - padding: 2rem; - color: rgba(var(--color-black), 0.6); - font-style: italic; -} - -/* Custom scrollbar for webkit browsers */ -.address-table tbody::-webkit-scrollbar { - width: 6px; -} - -.address-table tbody::-webkit-scrollbar-track { - background: rgba(var(--color-green), 0.1); -} - -.address-table tbody::-webkit-scrollbar-thumb { - background: rgba(var(--color-green), 0.3); - border-radius: 3px; -} - -.address-table tbody::-webkit-scrollbar-thumb:hover { - background: rgba(var(--color-green), 0.5); -} diff --git a/src/components/composed/AddressList/AddressList.js b/src/components/composed/AddressList/AddressList.js index 7d8a19e7..87ea6f1b 100644 --- a/src/components/composed/AddressList/AddressList.js +++ b/src/components/composed/AddressList/AddressList.js @@ -4,7 +4,7 @@ import { useParams } from 'react-router' import { Loading } from '@ComposedComponents' import AddressListItem from './AddressListItem' -import './AddressList.css' +import styles from './AddressList.module.css' const getFormatedMlAddresses = (addressData) => { return addressData.map((address) => ({ @@ -60,21 +60,28 @@ const AddressList = ({ search }) => { }) return ( -
+
{fetchingBalances ? ( -
+
) : ( - - - + + + + @@ -89,8 +96,8 @@ const AddressList = ({ search }) => { ) : ( diff --git a/src/components/composed/AddressList/AddressList.module.css b/src/components/composed/AddressList/AddressList.module.css new file mode 100644 index 00000000..696fcc1c --- /dev/null +++ b/src/components/composed/AddressList/AddressList.module.css @@ -0,0 +1,91 @@ +.card { + background: rgb(var(--color-white)); + border-radius: 16px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06); + overflow: hidden; + flex-grow: 1; + display: flex; + flex-direction: column; +} + +.table { + width: 100%; + display: block; +} + +.table thead { + display: block; + width: 100%; +} + +.table tbody { + display: block; + overflow-y: auto; + width: 100%; + height: 93%; +} + +.table thead tr, +.table tbody tr { + display: table; + width: 100%; + table-layout: fixed; +} + +.colHeader { + text-align: left; + padding: 14px 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: rgba(var(--color-black), 0.4); + border-bottom: 1px solid rgba(var(--color-black), 0.06); + user-select: none; +} + +/* Column widths */ +.colAddress { + width: 45%; +} + +.colStatus { + width: 15%; +} + +.colBalance { + width: 30%; +} + +.colAction { + width: 10%; +} + +.loadingWrapper { + display: flex; + justify-content: center; + align-items: center; + height: 300px; +} + +.noAddresses { + text-align: center; + padding: 2rem; + color: rgba(var(--color-black), 0.4); +} + +.table tbody::-webkit-scrollbar { + width: 4px; +} + +.table tbody::-webkit-scrollbar-track { + background: transparent; +} + +.table tbody::-webkit-scrollbar-thumb { + background: rgba(var(--color-black), 0.1); + border-radius: 2px; +} + +.table tbody::-webkit-scrollbar-thumb:hover { + background: rgba(var(--color-black), 0.2); +} diff --git a/src/components/composed/AddressList/AddressListItem.css b/src/components/composed/AddressList/AddressListItem.css deleted file mode 100644 index 1ae816e6..00000000 --- a/src/components/composed/AddressList/AddressListItem.css +++ /dev/null @@ -1,128 +0,0 @@ -.balance-content { - display: flex; - flex-direction: column; - gap: 8px; -} - -.address-content { - display: flex; - justify-content: space-between; - align-items: center; -} - -.balance-value { - font-weight: 500; - color: rgb(var(--color-black)); - padding: 0 5px 0; -} - -.locked-balance { - font-size: 0.85em; - color: rgba(var(--color-black), 0.6); - padding: 0 5px 0; -} - -.tokens-toggle { - display: flex; - align-items: center; - justify-content: space-between; - gap: 6px; - background: none; - border: 1px solid #e0e0e0; - border-radius: 12px; - padding: 4px 12px; - cursor: pointer; - font-size: 0.85em; - width: 100%; - /* color: rgba(var(--color-green), 1); */ - transition: all 0.2s ease; -} - -.tokens-toggle:hover { - background: rgba(var(--color-green), 0.1); - border-color: rgba(var(--color-green), 0.5); -} - -.tokens-toggle.expanded { - background: rgba(var(--color-green), 0.1); - border-radius: 12px 12px 0 0; -} - -.tokens-count { - font-weight: 500; -} - -.toggle-icon { - font-size: 0.7em; - transition: transform 0.2s ease; -} - -.tokens-list { - padding: 12px 8px; - background: rgba(var(--color-green), 0.05); - border-radius: 24px; - border: 1px solid #e0e0e0; -} - -.tokens-list-expanded { - border-top: 0; - border-right: 1px solid #e0e0e0; - border-bottom: 1px solid #e0e0e0; - border-left: 1px solid #e0e0e0; - border-radius: 0 0 12px 12px; -} - -.token-item { - display: flex; - align-items: center; - justify-content: space-between; - padding: 4px 0; - border-bottom: 1px solid rgba(var(--color-green), 0.1); - font-size: 0.85em; - gap: 3px; -} - -.token-item:last-child { - border-bottom: none; -} - -.token-amount { - font-weight: 500; - color: rgb(var(--color-black)); -} - -.token-id { - font-family: monospace; - color: rgba(var(--color-black), 0.7); - font-size: 0.9em; -} - -/* Animation for smooth expansion */ -.tokens-list { - animation: tokensSlideDown 0.2s ease-out; -} - -.qr-button { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 6px; -} - -/* .icon-qr { - width: 100%; - height: 100%; -} */ - -@keyframes tokensSlideDown { - from { - opacity: 0; - transform: translateY(-5px); - } - to { - opacity: 1; - transform: translateY(0); - } -} diff --git a/src/components/composed/AddressList/AddressListItem.js b/src/components/composed/AddressList/AddressListItem.js index c4744dde..5feef6fc 100644 --- a/src/components/composed/AddressList/AddressListItem.js +++ b/src/components/composed/AddressList/AddressListItem.js @@ -1,4 +1,5 @@ import { useContext, useState } from 'react' +import Decimal from 'decimal.js' import { SettingsContext } from '@Contexts' import { ML, BTC } from '@Helpers' import { Button } from '@BasicComponents' @@ -7,7 +8,12 @@ import { Wallet } from '@ContainerComponents' import { ReactComponent as IconQr } from '@Assets/images/icons-qr.svg' import { useParams } from 'react-router' -import './AddressListItem.css' +import styles from './AddressListItem.module.css' + +const formatTokenAmount = (value) => { + const d = new Decimal(value || 0) + return d.isInteger() ? d.toFixed(0) : d.toFixed(4) +} const AddressListItem = ({ address, index }) => { const { networkType } = useContext(SettingsContext) @@ -21,86 +27,85 @@ const AddressListItem = ({ address, index }) => { : ML.getMlAddressLink(address.id, networkType) const hasTokens = address.tokens && address.tokens.length > 0 - const toggleTokens = () => { - setTokensExpanded(!tokensExpanded) - } - const ticker = isBitcoin ? 'BTC' : 'ML' + const hasBalance = + address.coin_balance.available && Number(address.coin_balance.available) > 0 return ( - - + + - - + + + + {openShowAddress && ( )} - + ) } diff --git a/src/components/composed/AddressList/AddressListItem.module.css b/src/components/composed/AddressList/AddressListItem.module.css new file mode 100644 index 00000000..78c969b3 --- /dev/null +++ b/src/components/composed/AddressList/AddressListItem.module.css @@ -0,0 +1,223 @@ +.row { + border-bottom: 1px solid rgba(var(--color-black), 0.05); + transition: background 0.15s ease; +} + +.row:last-child { + border-bottom: none; +} + +.row:hover { + background: rgba(var(--color-black), 0.02); +} + +.cell { + padding: 16px 20px; + vertical-align: middle; + color: rgb(var(--color-black)); + font-size: 14px; +} + +.rowUsed .addressValue { + font-weight: 700; +} + +.addressValue { + font-family: monospace; + font-size: 14px; + color: rgb(var(--color-black)); + text-decoration: none; + transition: color 0.15s ease; +} + +.addressValue:hover { + color: rgba(var(--color-main-green), 1); +} + +/* Status badges */ +.statusBadge { + display: inline-block; + padding: 4px 14px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.statusUsed { + background: rgba(var(--color-black), 0.07); + color: rgba(var(--color-black), 0.7); +} + +.statusUnused { + background: rgba(var(--color-green), 0.2); + color: rgba(var(--color-stats-green), 1); +} + +/* Balance */ +.balanceAmount { + font-size: var(--default-font-size); + color: rgb(var(--color-black)); +} + +.balanceAmount strong { + font-weight: 700; +} + +.balanceTicker { + font-weight: 400; + color: rgba(var(--color-black), 0.5); +} + +.balanceDash { + color: rgba(var(--color-black), 0.25); + font-size: 16px; +} + +.lockedBalance { + display: block; + font-size: 12px; + color: rgba(var(--color-black), 0.5); + margin-top: 2px; +} + +/* QR button */ +.qrButton.qrButton { + width: 36px; + height: 36px; + padding: 0; + background: rgba(var(--color-black), 0.04); + background-color: rgba(var(--color-black), 0.04); + border-radius: 8px; + color: rgb(var(--color-black)); + transition: background 0.15s ease; +} + +.qrButton.qrButton:hover, +.qrButton.qrButton:focus { + background: rgba(var(--color-black), 0.08); + background-color: rgba(var(--color-black), 0.08); +} + +.qrButton.qrButton svg path { + stroke: rgb(var(--color-black)); +} + +.qrButton.qrButton:hover svg path, +.qrButton.qrButton:focus svg path { + stroke: rgb(var(--color-black)); +} + +.qrButton svg { + width: 18px; + height: 18px; + opacity: 0.4; +} + +.qrButton:hover svg { + opacity: 0.6; +} + +/* Tokens */ +.tokensSection { + margin-top: 8px; +} + +.tokensToggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + background: none; + border: 1px solid rgba(var(--color-black), 0.1); + border-radius: 6px; + padding: 4px 12px; + cursor: pointer; + font-size: 12px; + width: 100%; + transition: all 0.2s ease; +} + +.tokensToggle:hover { + background: rgba(var(--color-green), 0.08); + border-color: rgba(var(--color-green), 0.3); +} + +.tokensToggleExpanded { + composes: tokensToggle; + background: rgba(var(--color-green), 0.08); + border-radius: 10px 10px 0 0; +} + +.tokensCount { + font-weight: 500; + color: rgb(var(--color-black)); +} + +.toggleIcon { + font-size: 8px; + transition: transform 0.2s ease; + color: rgba(var(--color-black), 0.4); +} + +.tokensList { + padding: 8px; + background: rgba(var(--color-green), 0.03); + border: 1px solid rgba(var(--color-black), 0.1); + border-top: 0; + border-radius: 0 0 10px 10px; + animation: tokensSlideDown 0.2s ease-out; +} + +.tokenItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 0; + border-bottom: 1px solid rgba(var(--color-black), 0.05); + font-size: 12px; + gap: 3px; +} + +.tokenItem:last-child { + border-bottom: none; +} + +.tokenAmount { + font-weight: 500; + color: rgb(var(--color-black)); +} + +.tokenId { + font-family: monospace; + color: rgba(var(--color-black), 0.5); + font-size: 11px; +} + +/* Column widths */ +.colAddress { + width: 45%; +} + +.colStatus { + width: 15%; +} + +.colBalance { + width: 30%; +} + +.colAction { + width: 10%; +} + +@keyframes tokensSlideDown { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/composed/AddressList/AddressListItem.test.js b/src/components/composed/AddressList/AddressListItem.test.js index fb4bf355..bfeb48d2 100644 --- a/src/components/composed/AddressList/AddressListItem.test.js +++ b/src/components/composed/AddressList/AddressListItem.test.js @@ -81,7 +81,8 @@ describe('AddressListItem', () => { const link = screen.getByRole('link') expect(link).toHaveAttribute('href', expectedHref) expect(link).toHaveTextContent(expectedText) - expect(screen.getByText(/1\.23 ML/)).toBeInTheDocument() + const balanceEl = screen.getByText('1.23').closest('.balanceAmount') + expect(balanceEl).toHaveTextContent('1.23 ML') }) it('renders BTC link and formatted text', () => { @@ -103,7 +104,8 @@ describe('AddressListItem', () => { const link = screen.getByRole('link') expect(link).toHaveAttribute('href', expectedHref) expect(link).toHaveTextContent(expectedText) - expect(screen.getByText(/1\.23 BTC/)).toBeInTheDocument() + const balanceEl = screen.getByText('1.23').closest('.balanceAmount') + expect(balanceEl).toHaveTextContent('1.23 BTC') }) it('expands and shows tokens when toggled', () => { diff --git a/src/components/composed/Balance/Balance.css b/src/components/composed/Balance/Balance.css index 817067df..7b8a0465 100644 --- a/src/components/composed/Balance/Balance.css +++ b/src/components/composed/Balance/Balance.css @@ -1,103 +1,96 @@ -.balance-wrapper { - display: flex; - justify-content: center; - width: fit-content; - min-height: 122px; - padding: 5px 5px 10px 5px; - font-weight: 600; - - width: 100%; - justify-content: space-between; - - @media screen and (min-width: 801px) { - min-height: 140px; - } -} - -.wallet-logo-wrapper { +.balance-card { display: flex; align-items: center; - min-width: max-content; - gap: 10px; -} - -.wallet-logo-wrapper h3 { - font-size: 1.4rem; + justify-content: space-between; + background: rgba(var(--color-black), 0.04); + border: 1px solid rgba(var(--color-black), 0.1); + border-radius: 16px; + padding: 16px 20px; + min-height: max-content; @media screen and (min-width: 801px) { - font-size: 1.6rem; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + padding: 20px 24px; + margin: 0 0 20px 0; + border-radius: 20px; } } -.btcLogo { - width: 80px; - height: 80px; - min-width: 80px; - min-height: 80px; +.balance-label { + font-size: 1rem; + font-weight: 500; + color: rgba(var(--color-black), 0.45); + margin-bottom: 4px; @media screen and (min-width: 801px) { - width: 90px; - height: 90px; - min-width: 90px; - min-height: 90px; + font-size: 1.1rem; } } -.balance { - margin-left: 17px; - position: relative; - overflow: visible; - width: 280px; - min-width: max-content; - margin-left: 5px; +.balance-amount { + margin: 0; display: flex; - flex-direction: column; - align-items: flex-end; - justify-content: center; + align-items: baseline; + gap: 8px; } -.balance-btc { - font-weight: 600; - font-size: 1.3rem; - margin-bottom: 9px; +.balance-value { + font-size: 1.8rem; + font-weight: 700; + color: rgb(var(--color-black)); + + @media screen and (min-width: 801px) { + font-size: 2rem; + } } -.balance-btc span { +.balance-ticker { + font-size: 0.9rem; font-weight: 600; - font-size: 1.5rem; + color: rgba(var(--color-black), 0.35); @media screen and (min-width: 801px) { - font-size: 1.8rem; + font-size: 1rem; } } -.balance-usd { - font-size: 1.2rem; +.balance-fiat { + font-size: 1rem; font-weight: 500; -} - -.balance-usd span { - font-weight: 500; - font-size: 1.3rem; - - @media screen and (min-width: 801px) { - font-size: 1.5rem; - } + color: rgba(var(--color-black), 0.45); + margin-top: 2px; } .balance-locked { - position: absolute; - bottom: -5px; - opacity: 0.4; - white-space: nowrap; + font-size: 14px; + font-weight: 500; + color: rgba(var(--color-black), 0.4); + margin: 6px 0 0 10px; cursor: pointer; + background: none; + border: none; + padding: 0; + text-align: left; } .balance-locked:hover { - opacity: 1; + color: rgb(var(--color-black)); } -.wallet-price { - font-size: 1 rem; - font-weight: 400; +.balance-chart { + display: flex; + flex-direction: column; + margin-top: 12px; + width: 50%; + gap: 10px; + @media screen and (min-width: 801px) { + width: 100%; + } +} + +.balance-chart svg { + width: 100%; + height: 60px; } diff --git a/src/components/composed/Balance/Balance.js b/src/components/composed/Balance/Balance.js index 30b0a0ec..56ba3b3b 100644 --- a/src/components/composed/Balance/Balance.js +++ b/src/components/composed/Balance/Balance.js @@ -2,59 +2,21 @@ import React, { useContext } from 'react' import { useNavigate, useParams } from 'react-router' import Decimal from 'decimal.js' -import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' -import { LogoRound } from '@BasicComponents' -import { Format, NumbersHelper, ML } from '@Helpers' -import { MintlayerContext, SettingsContext } from '@Contexts' +import { LineChart } from '@ComposedComponents' +import { Format, NumbersHelper } from '@Helpers' +import { + MintlayerContext, + SettingsContext, + ExchangeRatesContext, +} from '@Contexts' import { AppInfo } from '@Constants' import './Balance.css' -import TokenLogoRound from '../../basic/TokenLogoRound/TokenLogoRound' -import CopyButton from '../CopyButton/CopyButton' - -const WalletName = ({ walletType }) => { - const { tokenBalances } = useContext(MintlayerContext) - const name = - walletType.name.length > 18 - ? ML.formatAddress(walletType.name, 18) - : walletType.name - - const logo = () => { - if (walletType.name === 'Mintlayer') { - return - } - if (walletType.name === 'Bitcoin') { - return - } - if ( - !tokenBalances || - !tokenBalances[walletType.name] || - !tokenBalances[walletType.name].token_info - ) { - return - } - return ( - - ) - } - return ( -
- {logo()} -

{name}

- {tokenBalances[walletType.name]?.token_info && ( - - )} -
- ) -} const Balance = ({ balance, balanceLocked, exchangeRate, walletType }) => { const { networkType } = useContext(SettingsContext) const { tokenBalances } = useContext(MintlayerContext) + const { thirtyDaysHistoryRates } = useContext(ExchangeRatesContext) const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET const { coinType } = useParams() const navigate = useNavigate() @@ -63,70 +25,87 @@ const Balance = ({ balance, balanceLocked, exchangeRate, walletType }) => { walletType.name !== 'Mintlayer' && walletType.name !== 'Bitcoin' const balanceInUSD = isTestnet - ? '0,00' + ? 0 : new Decimal(NumbersHelper.floatStringToNumber(balance) || 0) .times(new Decimal(exchangeRate || 0)) .toNumber() - const symbol = () => { - if (walletType.name === 'Mintlayer') { - return 'ML' - } - if (walletType.name === 'Bitcoin') { - return 'BTC' - } + const getSymbol = () => { if ( - !tokenBalances || - !tokenBalances[walletType.name] || - !tokenBalances[walletType.name].token_info - ) { - return 'TKN' - } + walletType.name === 'Mintlayer' && + networkType === AppInfo.NETWORK_TYPES.TESTNET + ) + return 'TML' + if (walletType.name === 'Mintlayer') return 'ML' + if ( + walletType.name === 'Bitcoin' && + networkType === AppInfo.NETWORK_TYPES.TESTNET + ) + return 'TBTC' + if (walletType.name === 'Bitcoin') return 'BTC' + if (!tokenBalances?.[walletType.name]?.token_info) return 'TKN' return tokenBalances[walletType.name].token_info.token_ticker.string } + const ticker = walletType.ticker.toLowerCase() + const ratesKey = `${ticker}-usd` + + const thirtyDaysChartRates = thirtyDaysHistoryRates?.[ratesKey] + const thirtyDaysChartData = isToken + ? [ + [0, 80], + [100, 80], + ] + : thirtyDaysChartRates && + Object.values(thirtyDaysChartRates).map((value, idx) => [ + idx * 10, + Number(value), + ]) + + const chartColor = AppInfo.COLOR_LIST[ticker] + const onLockedClick = () => { navigate('/wallet/' + coinType + '/locked-balance') } return (
- - -
-

- {Format.BTCValue(balance)} {symbol()} +

+ Balance +

+ {Format.BTCValue(balance)}{' '} + {getSymbol()}

- {!isTestnet && !isToken && ( -

- {Format.fiatValue(balanceInUSD)} USD -

- )} - {!isTestnet && !isToken && ( - - Price: {exchangeRate.toFixed(2)} $ + {!isToken && ( + + ≈ ${Format.fiatValue(balanceInUSD)} )} - {parseFloat(balanceLocked) > 0 ? ( + {parseFloat(balanceLocked) > 0 && ( - ) : ( - <> )}
+ + {thirtyDaysChartData && thirtyDaysChartData.length > 0 && ( +
+ 30 days + +
+ )}
) } diff --git a/src/components/composed/Balance/Balance.test.js b/src/components/composed/Balance/Balance.test.js index 444e88d3..1d427075 100644 --- a/src/components/composed/Balance/Balance.test.js +++ b/src/components/composed/Balance/Balance.test.js @@ -1,6 +1,10 @@ import { render, screen } from '@testing-library/react' import Balance from './Balance' -import { SettingsProvider, MintlayerContext } from '@Contexts' +import { + SettingsProvider, + MintlayerContext, + ExchangeRatesContext, +} from '@Contexts' import { BrowserRouter } from 'react-router' const BALANCE_SAMPLE = 1 @@ -12,90 +16,53 @@ const memoryRouterFeature = { v7_partialHydration: true, } -test('Render account balance with ML', () => { - render( +const renderBalance = ({ networkType, walletType } = {}) => { + const settingsValue = networkType ? { networkType } : undefined + return render( - - - - - , + + + + + + - , , ) - const currantBalanceComponent = screen.getByTestId('current-balance') - const balanceParagraphs = screen.getAllByTestId('balance-paragraph') +} - expect(balanceParagraphs).toHaveLength(2) - expect(currantBalanceComponent).toBeInTheDocument() +test('Render account balance with ML', () => { + renderBalance({ walletType: { name: 'Mintlayer', ticker: 'ml' } }) - expect(balanceParagraphs[0].textContent).toBe(BALANCE_SAMPLE + ' ML') - expect(balanceParagraphs[1].textContent).toBe( - BALANCE_SAMPLE * EXCHANGE_RATE_SAMPLE + '.00 USD', - ) + const balanceCard = screen.getByTestId('current-balance') + expect(balanceCard).toBeInTheDocument() + expect(balanceCard).toHaveTextContent('ML') + expect(balanceCard).toHaveTextContent(String(BALANCE_SAMPLE)) }) test('Render account balance with BTC', () => { - render( - - - - - - , - - , - , - ) - const currantBalanceComponent = screen.getByTestId('current-balance') - const balanceParagraphs = screen.getAllByTestId('balance-paragraph') + renderBalance({ walletType: { name: 'Bitcoin', ticker: 'btc' } }) - expect(balanceParagraphs).toHaveLength(2) - expect(currantBalanceComponent).toBeInTheDocument() - - expect(balanceParagraphs[0].textContent).toBe(BALANCE_SAMPLE + ' BTC') - expect(balanceParagraphs[1].textContent).toBe( - BALANCE_SAMPLE * EXCHANGE_RATE_SAMPLE + '.00 USD', - ) + const balanceCard = screen.getByTestId('current-balance') + expect(balanceCard).toBeInTheDocument() + expect(balanceCard).toHaveTextContent('BTC') + expect(balanceCard).toHaveTextContent(String(BALANCE_SAMPLE)) }) test('renders balance with zero value when networkType is testnet', () => { - render( - - - - - - , - - , - , - ) - - const currantBalanceComponent = screen.getByTestId('current-balance') - const balanceParagraphs = screen.getAllByTestId('balance-paragraph') - - expect(balanceParagraphs).toHaveLength(1) - expect(currantBalanceComponent).toBeInTheDocument() + renderBalance({ + networkType: 'testnet', + walletType: { name: 'Bitcoin', ticker: 'btc' }, + }) - expect(balanceParagraphs[0].textContent).toBe(BALANCE_SAMPLE + ' BTC') + const balanceCard = screen.getByTestId('current-balance') + expect(balanceCard).toBeInTheDocument() + expect(balanceCard).toHaveTextContent('BTC') + expect(balanceCard).toHaveTextContent(String(BALANCE_SAMPLE)) }) diff --git a/src/components/composed/Carousel/Carousel.css b/src/components/composed/Carousel/Carousel.css index 0c1a2731..5ca77a88 100644 --- a/src/components/composed/Carousel/Carousel.css +++ b/src/components/composed/Carousel/Carousel.css @@ -13,9 +13,9 @@ button { .back { background: linear-gradient( 90deg, - rgba(var(--color-white), 1) 0%, - rgba(var(--color-white), 1) 87%, - rgba(var(--color-white), 0) 100% + rgba(var(--color-bg), 1) 0%, + rgba(var(--color-bg), 1) 87%, + rgba(var(--color-bg), 0) 100% ); cursor: pointer; margin-right: -0.5rem; @@ -129,9 +129,9 @@ button { .next { background: linear-gradient( 90deg, - rgba(var(--color-white), 0) 0%, - rgba(var(--color-white), 1) 13%, - rgba(var(--color-white), 1) 100% + rgba(var(--color-bg), 0) 0%, + rgba(var(--color-bg), 1) 13%, + rgba(var(--color-bg), 1) 100% ); cursor: pointer; margin-left: -0.5rem; diff --git a/src/components/composed/Charts/ArcChart/ArcChart.js b/src/components/composed/Charts/ArcChart/ArcChart.js index 1d47d7ae..f00b854f 100644 --- a/src/components/composed/Charts/ArcChart/ArcChart.js +++ b/src/components/composed/Charts/ArcChart/ArcChart.js @@ -26,18 +26,37 @@ const ArcChart = ({ data = DATASAMPLE, width = '200px', height = '100px' }) => { }) }, [data, pieGenerator, arcGenerator, tooltip]) + const sorted = [...data].sort((a, b) => b.value - a.value) + const firstColor = sorted[0]?.color || '#37DB8C' + const lastColor = sorted[sorted.length - 1]?.color || '#37DB8C' + const dotRadius = 98 + return ( + + ) } diff --git a/src/components/composed/CopyButton/CopyButton.css b/src/components/composed/CopyButton/CopyButton.css deleted file mode 100644 index 95e7efdf..00000000 --- a/src/components/composed/CopyButton/CopyButton.css +++ /dev/null @@ -1,14 +0,0 @@ -.copy-btn { - display: flex; - align-items: center; - justify-content: center; - width: 32px; - height: 32px; - padding: 8px; -} - -.copy-icon { - width: 15px; - height: 15px; - max-width: 100%; -} diff --git a/src/components/composed/CopyButton/CopyButton.js b/src/components/composed/CopyButton/CopyButton.js index 4f706a79..e7e70709 100644 --- a/src/components/composed/CopyButton/CopyButton.js +++ b/src/components/composed/CopyButton/CopyButton.js @@ -1,10 +1,9 @@ -import React, { useState } from 'react' -import { Button } from '@BasicComponents' +import { useState } from 'react' import { ReactComponent as CopyIcon } from '@Assets/images/icon-copy.svg' import { ReactComponent as SuccessIcon } from '@Assets/images/icon-success.svg' -import './CopyButton.css' +import styles from './CopyButton.module.css' const CopyButton = ({ content }) => { const [copied, setCopied] = useState(false) @@ -18,25 +17,18 @@ const CopyButton = ({ content }) => { } return ( - + ) } diff --git a/src/components/composed/CopyButton/CopyButton.module.css b/src/components/composed/CopyButton/CopyButton.module.css new file mode 100644 index 00000000..7de3330c --- /dev/null +++ b/src/components/composed/CopyButton/CopyButton.module.css @@ -0,0 +1,21 @@ +.copyButton { + background: none; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: rgba(var(--color-black), 0.3); + transition: color 0.2s; +} + +.copyButton:hover { + color: rgb(var(--mojito-green)); +} + +.copyButton svg { + width: 18px; + height: 18px; +} diff --git a/src/components/composed/CryptoFiatField/CryptoFiatField.css b/src/components/composed/CryptoFiatField/CryptoFiatField.css deleted file mode 100644 index 01ca7afa..00000000 --- a/src/components/composed/CryptoFiatField/CryptoFiatField.css +++ /dev/null @@ -1,81 +0,0 @@ -.crypto-fiat-field { - position: relative; - display: flex; - padding-bottom: 37px; - width: 100%; -} - -.crypto-fiat-field-label { - display: flex; - width: 7.5rem; - font-size: 1.5rem; - justify-content: flex-start; - align-items: center; - word-break: break-all; -} - -.fiat-field-input { - position: relative; - flex-grow: 1; -} - -.crypto-fiat-bottom-text { - position: absolute; - bottom: 8px; - left: 1.85rem; - font-size: 18px; - font-weight: 600; -} - -.crypto-fiat-input { - width: 100%; -} - -.crypto-fiat-input-button { - display: flex; - align-items: center; - justify-content: center; - width: 87px; -} - -.crypto-fiat-switch-button { - position: absolute; - top: 18px; - right: 23px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - top: 50%; - transform: translateY(-50%); -} - -.crypto-fiat-icon { - height: 30px; - width: auto; - margin-left: -5px; -} - -.crypto-fiat-icon-reverse { - height: 30px; - width: auto; - transform: rotate(180deg); -} - -.crypto-fiat-icon path, -.crypto-fiat-icon-reverse path { - fill: black; -} - -.current-value-type { - font-size: 1.5rem; - font-weight: bold; - margin-left: 5px; -} - -.bottom-note { - display: flex; - position: absolute; - bottom: 7px; - right: 0; -} diff --git a/src/components/composed/CryptoFiatField/CryptoFiatField.js b/src/components/composed/CryptoFiatField/CryptoFiatField.js index cbd583dc..3aa12e52 100644 --- a/src/components/composed/CryptoFiatField/CryptoFiatField.js +++ b/src/components/composed/CryptoFiatField/CryptoFiatField.js @@ -1,9 +1,8 @@ -import React, { useEffect, useState, useContext } from 'react' -import { InputBTC, InputFloat } from '@BasicComponents' -import { SettingsContext } from '@Contexts' +import React, { useEffect, useState } from 'react' +import { InputBTC } from '@BasicComponents' import { useParams } from 'react-router' -import './CryptoFiatField.css' +import styles from './CryptoFiatField.module.css' import { BTC, Format, NumbersHelper } from '@Helpers' import { AppInfo } from '@Constants' @@ -15,30 +14,22 @@ const CryptoFiatField = ({ id, changeValueHandle, setErrorMessage, - exchangeRate, maxValueInToken, setAmountValidity, totalFeeInCrypto, transactionMode = AppInfo.ML_TRANSACTION_MODES.TRANSACTION, + validate, + extraStyleClasses = [], }) => { const isDelegationWithdraw = transactionMode === AppInfo.ML_TRANSACTION_MODES.WITHDRAW - const { networkType } = useContext(SettingsContext) const parsedValueInToken = NumbersHelper.floatStringToNumber(maxValueInToken) const finalMaxValue = isDelegationWithdraw ? parsedValueInToken : parsedValueInToken - totalFeeInCrypto const [maxCryptoValue, setMaxCryptoValue] = useState(finalMaxValue) - const [maxFiatValue, setMaxFiatValue] = useState( - maxCryptoValue * exchangeRate, - ) const { coinType } = useParams() - const [bottomValue, setBottomValue] = useState('') - // eslint-disable-next-line no-unused-vars - const [currentValueType, setCurrentValueType] = useState( - transactionData ? transactionData.tokenName : 'Token', - ) const [value, setValue] = useState(inputValue) const [validity, setValidity] = useState(parentValidity) const amountErrorMessage = isDelegationWithdraw @@ -47,10 +38,6 @@ const CryptoFiatField = ({ const amountFormatErrorMessage = 'Amount format is invalid. Use 0.00 instead.' const zeroErrorMessage = 'Amount must be greater than 0.' - useEffect(() => { - setMaxFiatValue(maxCryptoValue * exchangeRate) - }, [exchangeRate, setMaxFiatValue, maxCryptoValue]) - useEffect(() => { const maxValue = finalMaxValue < 0 ? parsedValueInToken : finalMaxValue setMaxCryptoValue(maxValue) @@ -58,48 +45,13 @@ const CryptoFiatField = ({ if (!transactionData) return null - const { tokenName, fiatName } = transactionData - const inputExtraClasses = ['crypto-fiat-input'] - - const isTypeFiat = () => currentValueType === fiatName - - const formattedBottomValue = `≈ ${ - bottomValue ? bottomValue : Format.fiatValue(0) - } ${isTypeFiat() ? tokenName : fiatName}` - - // Consider the correct format for 0,00 that might also be 0.00 - const displayedBottomValue = - networkType === AppInfo.NETWORK_TYPES.TESTNET - ? `≈ 0.00 ${fiatName}` - : formattedBottomValue - - const calculateFiatValue = (value) => { - if (!value) { - return Format.fiatValue(0) - } - const parsedValue = NumbersHelper.floatStringToNumber(value) - return Format.fiatValue(parsedValue * exchangeRate) - } - - const calculateCryptoValue = (value) => { - const parsedValue = NumbersHelper.floatStringToNumber(value) - return Format.BTCValue(parsedValue / exchangeRate) - } - - const updateValue = (value) => { - isTypeFiat() - ? setBottomValue(calculateCryptoValue(value)) - : setBottomValue(calculateFiatValue(value)) - } - - const changeButtonClickHandler = () => { - return - } + const { tokenName } = transactionData + const inputExtraClasses = [styles.cryptoFiatInput, ...extraStyleClasses] const changeHandler = ({ target: { value, parsedValue } }) => { changeValueHandle && changeValueHandle({ - currency: currentValueType, + currency: tokenName, value, }) @@ -111,7 +63,6 @@ const CryptoFiatField = ({ setValidity('valid') } setValue(value || '') - updateValue(value || '') const validity = AppInfo.amountRegex.test(value) @@ -129,23 +80,28 @@ const CryptoFiatField = ({ return } - let isValid = isTypeFiat() - ? NumbersHelper.floatStringToNumber(calculateCryptoValue(parsedValue)) < - BTC.MAX_BTC - : parsedValue < BTC.MAX_BTC + let isValid = parsedValue < BTC.MAX_BTC setValidity(isValid ? 'valid' : 'invalid') setAmountValidity && setAmountValidity(isValid) setErrorMessage && setErrorMessage(isValid ? undefined : amountErrorMessage) if (!isValid) return - isValid = isTypeFiat() - ? parsedValue <= maxFiatValue - : parsedValue <= maxCryptoValue - - setValidity(isValid ? 'valid' : 'invalid') - setAmountValidity(isValid) - setErrorMessage && setErrorMessage(isValid ? undefined : amountErrorMessage) - if (!isValid) return + if (validate) { + const error = validate(parsedValue) + if (error) { + setValidity('invalid') + setAmountValidity(false) + setErrorMessage && setErrorMessage(error) + return + } + } else { + isValid = parsedValue <= maxCryptoValue + setValidity(isValid ? 'valid' : 'invalid') + setAmountValidity(isValid) + setErrorMessage && + setErrorMessage(isValid ? undefined : amountErrorMessage) + if (!isValid) return + } } const safeSpend = (value) => { @@ -153,7 +109,7 @@ const CryptoFiatField = ({ if (isDelegationWithdraw) { return value } - const result = value - totalFeeInCrypto - 0.5 // default fee in mainnet is 0.5 TODO: calculate fee + const result = value - totalFeeInCrypto - 0.5 if (result < 0) { return 0 } @@ -162,49 +118,28 @@ const CryptoFiatField = ({ return (
-
- {isTypeFiat() ? ( - - ) : ( - - )} - - -
- -
- Available to spend ≈ {safeSpend(maxValueInToken)} {currentValueType} +
+ + {tokenName}
- -

- {displayedBottomValue} -

+ {maxValueInToken && ( +
+ Available to spend ≈ {safeSpend(maxValueInToken)} {tokenName} +
+ )}
) } diff --git a/src/components/composed/CryptoFiatField/CryptoFiatField.module.css b/src/components/composed/CryptoFiatField/CryptoFiatField.module.css new file mode 100644 index 00000000..a26dca00 --- /dev/null +++ b/src/components/composed/CryptoFiatField/CryptoFiatField.module.css @@ -0,0 +1,45 @@ +.cryptoFiatField { + position: relative; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.cryptoFiatFieldSlim { + gap: 0; +} + +.inputWrapper { + position: relative; + display: flex; + align-items: center; +} + +.cryptoFiatInput { + width: 100%; + padding-right: 60px; +} + +.ticker { + position: absolute; + right: 16px; + font-size: 15px; + font-weight: 600; + color: rgba(var(--color-black), 0.4); + pointer-events: none; +} + +.bottomNote { + font-size: 13px; + color: rgba(var(--color-black), 0.5); +} + +.bottomNote strong { + font-weight: 700; + color: rgba(var(--color-black), 0.7); +} + +.separator { + color: rgba(var(--color-black), 0.25); +} diff --git a/src/components/composed/CryptoFiatField/CryptoFiatField.test.js b/src/components/composed/CryptoFiatField/CryptoFiatField.test.js index 927d19ca..a4178efa 100644 --- a/src/components/composed/CryptoFiatField/CryptoFiatField.test.js +++ b/src/components/composed/CryptoFiatField/CryptoFiatField.test.js @@ -1,5 +1,4 @@ import { render, screen, fireEvent } from '@testing-library/react' -// import { getDecimalNumber } from 'src/utils/Helpers/Number/Number' import CryptoFiatField from './CryptoFiatField' import { @@ -37,36 +36,25 @@ test('Render TextField component', () => { setAmountValidity={() => {}} totalFeeInCrypto={totalFeeCrypto} /> - , - , , ) const component = screen.getByTestId('crypto-fiat-field') const input = screen.getByTestId('input') - - // TODO: revert this after max button is implemented - // const actionButton = screen.getByTestId('button') const bottomNote = screen.getByTestId('crypto-fiat-bottom-text') expect(component).toBeInTheDocument() - expect(input).toBeInTheDocument() + expect(bottomNote).toBeInTheDocument() + expect(bottomNote).toHaveTextContent('Available to spend') fireEvent.change(input, { target: { value: maxValueInToken }, }) expect(input).toHaveValue(maxValueInToken.toString()) - expect(bottomNote).toHaveTextContent('≈ 10054453.50 USD') - - // TODO: revert this after max button is implemented - // expect(actionButton).toBeInTheDocument() - // expect(actionButton).toHaveTextContent(PROPSSAMPLE.buttonTitle) - - expect(bottomNote).toBeInTheDocument() }) test('Render TextField component fdf', async () => { @@ -86,22 +74,12 @@ test('Render TextField component fdf', async () => { setAmountValidity={() => {}} totalFeeInCrypto={totalFeeCrypto} /> - , , ) - // TODO: revert this after max button is implemented - // const actionButton = screen.getByTestId('button') - const cryptoInput = screen.getByTestId('input') - - // TODO: revert this after max button is implemented - // const maxValueInCrypto = maxValueInToken - totalFeeCrypto - // fireEvent.click(actionButton) - // expect(cryptoInput).toHaveValue(maxValueInCrypto.toString()) - fireEvent.change(cryptoInput, { target: { value: '' } }) }) @@ -120,35 +98,25 @@ test('Render TextField when networkType is testnet', () => { setAmountValidity={() => {}} totalFeeInCrypto={totalFeeCrypto} /> - , - , , ) const component = screen.getByTestId('crypto-fiat-field') const input = screen.getByTestId('input') - // TODO: revert this after max button is implemented - // const actionButton = screen.getByTestId('button') const bottomNote = screen.getByTestId('crypto-fiat-bottom-text') expect(component).toBeInTheDocument() - expect(input).toBeInTheDocument() + expect(bottomNote).toBeInTheDocument() + expect(bottomNote).toHaveTextContent('Available to spend') fireEvent.change(input, { target: { value: maxValueInToken }, }) expect(input).toHaveValue(maxValueInToken.toString()) - expect(bottomNote).toHaveTextContent('≈ 0.00 USD') - - // TODO: revert this after max button is implemented - // expect(actionButton).toBeInTheDocument() - // expect(actionButton).toHaveTextContent(PROPSSAMPLE.buttonTitle) - - expect(bottomNote).toBeInTheDocument() }) test('Render TextField component without transactionData', () => { @@ -156,7 +124,7 @@ test('Render TextField component without transactionData', () => { - {}} />, + {}} /> , diff --git a/src/components/composed/CurrentStaking/CurrentStaking.css b/src/components/composed/CurrentStaking/CurrentStaking.css deleted file mode 100644 index 78e5dab8..00000000 --- a/src/components/composed/CurrentStaking/CurrentStaking.css +++ /dev/null @@ -1,59 +0,0 @@ -.staking-title { - font-size: 24px; - font-weight: 600; - min-height: max-content; -} - -.total-staked { - font-size: 18px; -} - -.staking-title-wrapper { - margin: 30px 0 0; - display: flex; - justify-content: space-between; - overflow: visible; -} - -.delegation-button { - margin-top: 10px; -} - -.main-info { - overflow: visible; -} - -.guide-wraper { - display: flex; - align-items: center; - margin-bottom: 10px; - overflow: visible; -} - -.pool-list-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.delegation-inactive { - display: flex; - align-items: center; - justify-content: center; - border: 1px solid #e0e0e0; - padding: 2px 5px; - border-radius: 5px; - background: #ffc680; - text-align: center; - cursor: pointer; -} - -.delegation-inactive:hover { - background: #ff9f00; -} - -.pool-button:hover .pool-list-icon { - animation: moveArrowUpRight 0.3s ease-in-out; -} diff --git a/src/components/composed/CurrentStaking/CurrentStaking.js b/src/components/composed/CurrentStaking/CurrentStaking.js index 535b90e8..84fac6dc 100644 --- a/src/components/composed/CurrentStaking/CurrentStaking.js +++ b/src/components/composed/CurrentStaking/CurrentStaking.js @@ -8,7 +8,7 @@ import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-r import { MintlayerContext, SettingsContext } from '@Contexts' -import './CurrentStaking.css' +import styles from './CurrentStaking.module.css' import { useNavigate, useParams } from 'react-router' import { ReactComponent as IconWarning } from '@Assets/images/icon-warning.svg' @@ -66,33 +66,34 @@ const CurrentStaking = () => { return ( -
-
-
-

Your current staking

+
+
+
+

Your current staking

-

- Total staked: {mlDelegationsBalance} ML +

+ Total staked:{' '} + + {mlDelegationsBalance} + {' '} + ML

{decommissionedPools.length > 0 && (
-
-
- -
+
+
{ >
@@ -122,7 +123,7 @@ const CurrentStaking = () => { diff --git a/src/components/composed/CurrentStaking/CurrentStaking.module.css b/src/components/composed/CurrentStaking/CurrentStaking.module.css new file mode 100644 index 00000000..f220021d --- /dev/null +++ b/src/components/composed/CurrentStaking/CurrentStaking.module.css @@ -0,0 +1,68 @@ +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin: 30px 0 0; + overflow: visible; +} + +.mainInfo { + overflow: visible; +} + +.titleRow { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.4rem; + overflow: visible; +} + +.title { + font-size: 1.5rem; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0; +} + +.totalStaked { + font-size: 0.95rem; + color: rgb(var(--color-dark-gray)); + margin: 0; +} + +.totalStakedValue { + font-weight: 700; + color: rgb(var(--color-black)); +} + +.warningBadge { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + border-radius: 8px; + background: rgba(var(--color-orange), 0.2); + border: none; + cursor: pointer; + transition: background 0.2s; +} + +.warningBadge:hover { + background: rgba(var(--color-orange), 0.4); +} + +.poolButton { + padding: 0.6rem 1.2rem; + font-size: 0.85rem; +} + +.poolIcon { + width: 13px; + height: 13px; + margin-left: 8px; +} + +.poolButton:hover .poolIcon { + animation: moveArrowUpRight 0.3s ease-in-out; +} diff --git a/src/components/composed/FeeField/FeeField.css b/src/components/composed/FeeField/FeeField.css deleted file mode 100644 index b5d2b12a..00000000 --- a/src/components/composed/FeeField/FeeField.css +++ /dev/null @@ -1,38 +0,0 @@ -.fee-field-wrapper { - width: 100%; -} - -.fee-field { - display: flex; - flex-direction: row; - width: 100%; - position: relative; -} - -.fee-input-wrapper { - position: relative; - width: 100%; - margin-right: 0.5rem; -} - -.fee-input-wrapper.ml { - width: 200%; -} - -.fee-input-wrapper > *:nth-child(1) { - padding-right: 10px; -} - -.fee-field small { - position: absolute; - top: 50%; - right: 15px; - font-weight: bold; - font-size: 1.5rem; - transform: translateY(-50%); -} - -.fee-field-wrapper p { - margin: 0.5rem 0 0 1.85rem; - font-weight: bold; -} diff --git a/src/components/composed/FeeField/FeeField.js b/src/components/composed/FeeField/FeeField.js index 02badcb0..cdbc7780 100644 --- a/src/components/composed/FeeField/FeeField.js +++ b/src/components/composed/FeeField/FeeField.js @@ -1,11 +1,21 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' -import { InputInteger } from '@BasicComponents' -import { OptionButtons } from '@ComposedComponents' import { Electrum } from '@APIs' import { BTC } from '@Helpers' -import './FeeField.css' +import styles from './FeeField.module.css' + +const TIERS = [ + { key: 'low', label: 'Slow' }, + { key: 'norm', label: 'Medium' }, + { key: 'high', label: 'Fast' }, +] + +const formatTime = (minutes) => { + if (!Number.isFinite(minutes) || minutes <= 0) return '~∞' + if (minutes <= 60) return `~${minutes} min` + return `~${Math.ceil(minutes / 60)} hr` +} const FeeField = ({ value: parentValue, @@ -15,19 +25,16 @@ const FeeField = ({ }) => { const effectCalled = useRef(false) const [options, setOptions] = useState([]) + const [estimatedFees, setEstimatedFees] = useState({}) + const [selectedKey, setSelectedKey] = useState('norm') const [inputValue, setInputValue] = useState(0) - const [radioButtonValue, setButtonValue] = useState(undefined) - const [timeToFirstConfirmations, setTimeToFirstConfirmations] = - useState('15 minutes') - const [estimatedFees, setEstimatedFees] = useState([]) - const feeType = 'sat/B' const blocksToConfirm = useCallback( (value) => { - const seletedEstimate = Object.entries(estimatedFees).find( - ([_, fee]) => fee <= Number(value), + const selected = Object.entries(estimatedFees).find( + (entry) => entry[1] <= Number(value), ) - return seletedEstimate ? seletedEstimate[0] : Number.POSITIVE_INFINITY + return selected ? Number(selected[0]) : Number.POSITIVE_INFINITY }, [estimatedFees], ) @@ -37,97 +44,95 @@ const FeeField = ({ if (value === '' || !Number(value)) { setFeeValidity(false) setInputValue(0) - setTimeToFirstConfirmations('∞') return } setFeeValidity(Number(value) && value.toString()) - - const blocksAmount = blocksToConfirm(value) - const minutesTo1stConfirmation = blocksAmount - ? blocksAmount * BTC.AVERAGE_MIN_PER_BLOCK - : blocksAmount - const timeTo1stConfirmation = - minutesTo1stConfirmation <= 60 - ? `${minutesTo1stConfirmation} minutes` - : `${ - Number.isFinite(minutesTo1stConfirmation) - ? Math.ceil(minutesTo1stConfirmation / 60) - : '∞' - } hours` - setInputValue(Math.ceil(value)) - setTimeToFirstConfirmations(`${timeTo1stConfirmation}`) }, - [blocksToConfirm, setFeeValidity], + [setFeeValidity], ) - const inputChangeHandler = ({ target: { value } }) => changeInputValue(value) - - const optionSelectHandle = (selectedOption) => { - if (!selectedOption) return - changeInputValue(selectedOption.value) - } - useEffect(() => { if (effectCalled.current) return effectCalled.current = true const populateOptions = async () => { const btcFees = await Electrum.getFeesEstimates() - const fees = btcFees - const estimates = JSON.parse(fees) + const estimates = JSON.parse(btcFees) setEstimatedFees(estimates) - const parsedFees = BTC.parseFeesEstimates(estimates) setOptions([ - { name: 'low', value: parsedFees.LOW }, - { name: 'norm', value: parsedFees.MEDIUM }, - { name: 'high', value: parsedFees.HIGH }, + { key: 'low', value: parsedFees.LOW }, + { key: 'norm', value: parsedFees.MEDIUM }, + { key: 'high', value: parsedFees.HIGH }, ]) } populateOptions() }, []) + const parentValueRef = useRef(parentValue) + parentValueRef.current = parentValue + useEffect(() => { - if (Number(parentValue)) { - changeInputValue(parentValue) + const pv = parentValueRef.current + if (Number(pv)) { + changeInputValue(pv) return } - const optionSelected = options.find((item) => item.name === parentValue) - - setButtonValue(parentValue) + const optionSelected = options.find((item) => item.key === pv) + if (pv) setSelectedKey(pv) optionSelected ? changeInputValue(optionSelected.value) : changeInputValue(0) - }, [parentValue, options, changeInputValue]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [options]) useEffect(() => { changeValueHandle(inputValue) }, [inputValue, changeValueHandle]) + const handleSelect = (tier) => { + setSelectedKey(tier.key) + const option = options.find((o) => o.key === tier.key) + if (option) changeInputValue(option.value) + } + return ( -
-
-
- - {feeType} -
- -
-

Estimated time for 1st confirmation: {timeToFirstConfirmations}

+
+ {TIERS.map((tier) => { + const option = options.find((o) => o.key === tier.key) + const isSelected = selectedKey === tier.key + const blocks = option ? blocksToConfirm(option.value) : null + const time = + blocks != null + ? formatTime(blocks * BTC.AVERAGE_MIN_PER_BLOCK) + : '...' + return ( + + ) + })}
) } diff --git a/src/components/composed/FeeField/FeeField.module.css b/src/components/composed/FeeField/FeeField.module.css new file mode 100644 index 00000000..dceec6d6 --- /dev/null +++ b/src/components/composed/FeeField/FeeField.module.css @@ -0,0 +1,77 @@ +.tiers { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; +} + +.tierCard { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 14px 8px; + border-radius: 12px; + border: 1px solid rgba(var(--color-black), 0.1); + background: rgba(var(--color-black), 0.02); + cursor: pointer; + transition: + border-color 0.2s ease, + background 0.2s ease; +} + +.tierCard:hover { + border-color: rgba(var(--color-main-green), 0.4); +} + +.tierCardSelected { + border-color: rgb(var(--color-main-green)); + background: rgb(var(--color-white)); +} + +.tierLabel { + font-size: 14px; + font-weight: 700; + color: rgb(var(--color-black)); +} + +.tierLabelSelected { + color: rgb(var(--color-main-green)); +} + +.tierTime { + font-size: 12px; + color: rgba(var(--color-black), 0.45); +} + +.tierFee { + font-size: 12px; + color: rgba(var(--color-black), 0.5); + font-weight: 500; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.tierCardLoading { + animation: pulse 1.5s ease-in-out infinite; +} + +.feeDisplay { + padding: 12px 16px; + border-radius: 12px; + border: 1px solid rgba(var(--color-black), 0.1); + background: rgba(var(--color-black), 0.02); +} + +.feeDisplayValue { + font-size: 14px; + font-weight: 500; + color: rgb(var(--color-black)); +} diff --git a/src/components/composed/FeeField/FeeFieldML.js b/src/components/composed/FeeField/FeeFieldML.js index b8628054..4c6ebf41 100644 --- a/src/components/composed/FeeField/FeeFieldML.js +++ b/src/components/composed/FeeField/FeeFieldML.js @@ -1,31 +1,33 @@ import React, { useContext } from 'react' import { MintlayerContext } from '@Contexts' -import { Input } from '@BasicComponents' - -import './FeeField.css' import { ML as MLHelpers } from '@Helpers' -const FeeFieldML = ({ value: parentValue, id }) => { +import styles from './FeeField.module.css' + +const FeeFieldML = ({ value: parentValue, id, loading }) => { const { feerate } = useContext(MintlayerContext) const timeToFirstConfirmations = '~2 minutes' + const feeValue = parentValue + ? parentValue + : MLHelpers.getAmountInCoins(Number(feerate / 1000)) + return ( -
-
-
- - ML -
-
-

Estimated time for 1st confirmation: {timeToFirstConfirmations}

+
+
) } diff --git a/src/components/composed/Header/Header.css b/src/components/composed/Header/Header.css deleted file mode 100644 index 5a37bc1f..00000000 --- a/src/components/composed/Header/Header.css +++ /dev/null @@ -1,108 +0,0 @@ -.header-container { - display: flex; - justify-content: center; - min-height: 60px; -} - -header { - position: relative; - margin-bottom: 20px; - min-height: 60px; - - @media screen and (min-width: 801px) { - margin-bottom: 40px; - } -} - -.logo-wrapper { - display: flex; - justify-content: center; - align-items: center; -} - -.backButton { - height: 32px; - width: 32px; - padding: 0.2rem; - position: absolute; - top: 0.7rem; - left: 0; - background-color: transparent !important; -} - -.backButton svg { - width: 100%; - height: 100%; -} - -.backButton:hover svg path, -.backButton:focus svg path { - stroke: rgb(var(--color-black)); - opacity: 0.6; -} - -.logout { - height: 40px; - width: 40px; - padding: 0.2rem; - position: absolute; - top: 0.5rem; - right: 0; -} - -.header-menu-button { - height: 40px; - width: 40px; - padding: 0.2rem; - top: 0.5rem; - right: 45px; - outline: none; - background-color: transparent !important; -} - -.header-menu-button svg { - width: 24px; - height: 24px; -} - -.settings { - height: 40px; - width: 40px; - padding: 0.2rem; - top: 0.5rem; - right: 45px; - outline: none; -} - -.expand { - height: 40px; - width: 40px; - padding: 0.2rem; - top: 0.5rem; - right: 45px; - outline: none; -} - -.logout svg, -.settings svg, -.menu svg { - width: 100%; - height: 100%; -} - -.expand-wrapped { - position: absolute; - display: flex; - top: 8px; - right: 0; - overflow: visible; -} - -.expand-wrapped-unlocked { - right: 40px; -} - -.tooltipWrapper { - position: relative; - overflow: visible; -} diff --git a/src/components/composed/Header/Header.module.css b/src/components/composed/Header/Header.module.css new file mode 100644 index 00000000..cb5ef6c7 --- /dev/null +++ b/src/components/composed/Header/Header.module.css @@ -0,0 +1,117 @@ +.header { + display: flex; + justify-content: center; + position: relative; + min-height: 70px; + background: rgb(var(--color-white)); + padding: 20px 31px; + border-bottom: 1px solid rgba(var(--color-black), 0.1); +} + +.logoWrapper { + display: flex; + justify-content: center; + align-items: center; + gap: 10px; + + @media screen and (min-width: 801px) { + display: none; + } +} + +.backButton { + height: 35px; + width: 35px; + padding: 0.2rem; + position: absolute; + top: 18px; + left: 27px; + border-radius: 10px; + background-color: transparent !important; +} + +.backButton:hover { + background-color: rgba(var(--color-black), 0.05) !important; +} + +.backButton svg { + width: 23px; + height: 23px; +} + +.backButton svg path, +.backButton svg path { + stroke: rgb(var(--color-black)); +} + +.backButton:hover svg path, +.backButton:focus svg path { + opacity: 0.6; +} + +.menuButton { + height: 40px; + width: 40px; + padding: 0.2rem; + top: 0.5rem; + right: 45px; + outline: none; + background-color: transparent !important; +} + +.menuButton svg { + width: 24px; + height: 24px; +} + +.menuButton svg path { + stroke: rgb(var(--color-black)); +} + +.expandWrapped { + position: absolute; + display: flex; + top: 16px; + right: 22px; + overflow: visible; + + @media screen and (min-width: 801px) { + display: none; + } +} + +.settingsExpand { + display: none; + position: absolute; + top: 16px; + right: 22px; + + @media screen and (min-width: 801px) { + display: flex; + } +} + +.settingsButton { + height: 35px; + width: 35px; + padding: 0.2rem; + border-radius: 10px; + background-color: transparent !important; +} + +.settingsButton:hover { + background-color: rgba(var(--color-black), 0.05) !important; +} + +.settingsButton svg { + width: 20px; + height: 20px; +} + +.settingsButton svg path { + stroke: rgb(var(--color-black)); +} + +.invisible { + visibility: hidden; +} diff --git a/src/components/composed/Header/Header.test.js b/src/components/composed/Header/Header.test.js index 4594a6e2..f80a90a9 100644 --- a/src/components/composed/Header/Header.test.js +++ b/src/components/composed/Header/Header.test.js @@ -8,7 +8,7 @@ import { MintlayerProvider, BitcoinProvider, } from '@Contexts' -import Header from './Header' +import Header from './Header.tsx' global.AbortSignal = global.AbortSignal || {} @@ -149,15 +149,15 @@ test('Header component, navigate to Header and open menu', async () => { }) const nextPageComponent = screen.getByTestId('next-page') - const buttons = screen.getAllByTestId('button') expect(nextPageComponent).toBeInTheDocument() + const menuButton = screen.getByTestId('header-menu-button') act(() => { - buttons[1].click() + menuButton.click() }) await waitFor(async () => { - expect(value.setSliderMenuOpen).toHaveBeenCalled() + expect(value.setSliderMenuOpen).toHaveBeenCalledWith(true) }) const backdrop = screen.getByTestId('backdrop') diff --git a/src/components/composed/Header/Header.js b/src/components/composed/Header/Header.tsx similarity index 57% rename from src/components/composed/Header/Header.js rename to src/components/composed/Header/Header.tsx index bb856fa6..d24b5e10 100644 --- a/src/components/composed/Header/Header.js +++ b/src/components/composed/Header/Header.tsx @@ -1,21 +1,26 @@ -import React, { useContext, useEffect, useState } from 'react' +import { useContext, useEffect, useState } from 'react' import { useNavigate, useLocation } from 'react-router' import { ReactComponent as BackImg } from '@Assets/images/icon-arrow-left.svg' import { ReactComponent as MenuImg } from '@Assets/images/icon-hamburger.svg' +import { ReactComponent as SettingsImg } from '@Assets/images/icon-settings.svg' import { Button, Logo } from '@BasicComponents' import { UpdateButton, SliderMenu, Navigation } from '@ComposedComponents' import { AccountContext } from '@Contexts' -import './Header.css' +import styles from './Header.module.css' -const Header = ({ customBackAction }) => { +const Header = () => { const [unlocked, setUnlocked] = useState(false) const navigate = useNavigate() const location = useLocation() - const { isAccountUnlocked, sliderMenuOpen, setSliderMenuOpen } = - useContext(AccountContext) + const { + isAccountUnlocked, + sliderMenuOpen, + setSliderMenuOpen, + customBackAction, + } = useContext(AccountContext) const coinType = location.pathname.includes('/wallet/') ? location.pathname.split('/wallet/')[1].split('/')[0] @@ -27,8 +32,7 @@ const Header = ({ customBackAction }) => { const noBackButtonPages = ['/dashboard', '/'] const noBackButton = noBackButtonPages.includes(location.pathname) - - const hideWithoutCustomBack = ['/set-account', '/restore-account'] + const isCreateRestorePage = location.pathname === '/create-restore' useEffect(() => { const accountUnlocked = isAccountUnlocked() @@ -36,10 +40,6 @@ const Header = ({ customBackAction }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname]) - if (hideWithoutCustomBack.includes(location.pathname) && !customBackAction) { - return null - } - const goBack = () => { if (isWalletPage) { navigate('/dashboard') @@ -56,38 +56,58 @@ const Header = ({ customBackAction }) => { return customBackAction ? customBackAction() : navigate(-1) } - const toggleSliderMenu = () => { - setSliderMenuOpen(!sliderMenuOpen) + const openSliderMenu = () => { + setSliderMenuOpen(true) + } + + const closeSliderMenu = () => { + setSliderMenuOpen(false) } return ( -
-
+
+
-
+
-
- - {unlocked && } -
+ {!isCreateRestorePage && ( +
+ + {unlocked && } +
+ )} + + {!unlocked && ( +
+ +
+ )} diff --git a/src/components/composed/InputList/InputsList.css b/src/components/composed/InputList/InputsList.css deleted file mode 100644 index 19ec83d0..00000000 --- a/src/components/composed/InputList/InputsList.css +++ /dev/null @@ -1,11 +0,0 @@ -.inputs-list { - display: flex; - justify-content: space-between; - flex-wrap: wrap; - overflow-y: auto; -} - -.list-item { - position: relative; - margin-bottom: 9px; -} diff --git a/src/components/composed/InputList/InputsList.module.css b/src/components/composed/InputList/InputsList.module.css new file mode 100644 index 00000000..d6faf5af --- /dev/null +++ b/src/components/composed/InputList/InputsList.module.css @@ -0,0 +1,6 @@ +.inputsList { + display: flex; + justify-content: space-around; + flex-wrap: wrap; + overflow-y: auto; +} diff --git a/src/components/composed/InputList/InputsList.test.js b/src/components/composed/InputList/InputsList.test.js index e49605b4..bbfcfc04 100644 --- a/src/components/composed/InputList/InputsList.test.js +++ b/src/components/composed/InputList/InputsList.test.js @@ -25,7 +25,7 @@ test('Render Inputs list item', () => { const inputListItem = screen.getByTestId('inputs-list-item') expect(inputListComponent).toBeInTheDocument() - expect(inputListComponent).toHaveClass('inputs-list') + expect(inputListComponent).toHaveClass('inputsList') expect(inputListComponent).toContainElement(inputListItem) }) @@ -77,13 +77,13 @@ test('genNumberClasslist function valid', () => { }) test('genNumberClasslist function invalid', () => { - const input = { value: 'tree' } + const input = { value: 'tree', order: 0 } const validator = isInputValid(input, WORDS) expect(validator).toBe(false) }) test('genNumberClasslist function without words array', () => { - const input = { value: 'tree' } + const input = { value: 'tree', order: 0 } const validator = isInputValid(input, [], BTC.getWordList()) expect(validator).toBe(true) }) diff --git a/src/components/composed/InputList/InputsList.js b/src/components/composed/InputList/InputsList.tsx similarity index 55% rename from src/components/composed/InputList/InputsList.js rename to src/components/composed/InputList/InputsList.tsx index 849766fd..bf5b087a 100644 --- a/src/components/composed/InputList/InputsList.js +++ b/src/components/composed/InputList/InputsList.tsx @@ -1,16 +1,35 @@ -import React, { useEffect, useRef } from 'react' +import { useEffect, useRef, ChangeEvent } from 'react' import InputListItem from './InputsListItem' -import './InputsList.css' +import styles from './InputsList.module.css' -const isInputValid = (input, words, DefaultWordList = []) => { +interface InputField { + order: number + validity: boolean | null + value: string +} + +const isInputValid = ( + input: { value: string; order: number }, + words: string[], + DefaultWordList: string[] = [], +) => { const value = input.value return words?.length > 0 ? words[input.order] === value : DefaultWordList.includes(input.value) } +interface InputsListProps { + fields: InputField[] + setFields: (fields: InputField[]) => void + restoreMode: boolean + wordsList?: string[] + BIP39DefaultWordList?: string[] + amountOfWords?: number +} + const InputsList = ({ fields, setFields, @@ -18,7 +37,7 @@ const InputsList = ({ wordsList = [], BIP39DefaultWordList, amountOfWords = 12, -}) => { +}: InputsListProps) => { const effectCalled = useRef(false) useEffect(() => { @@ -31,13 +50,13 @@ const InputsList = ({ if (wordsList.length) { newFields = wordsList.map((word, index) => ({ order: index, - validity: false, + validity: null, value: restoreMode ? '' : word, })) } else { - newFields = [...new Array(amountOfWords)].map((word, index) => ({ + newFields = [...new Array(amountOfWords)].map((_, index) => ({ order: index, - validity: false, + validity: null, value: '', })) } @@ -45,11 +64,17 @@ const InputsList = ({ setFields(newFields) }, [wordsList, setFields, restoreMode, amountOfWords]) - const getFieldByIndex = (index) => fields[index] + const getFieldByIndex = (index: number) => fields[index] - const setFieldValidity = (field, validity) => ({ ...field, validity }) + const setFieldValidity = (field: InputField, validity: boolean) => ({ + ...field, + validity, + }) - const onChangeHandler = ({ target }, index) => { + const onChangeHandler = ( + { target }: ChangeEvent, + index: number, + ) => { const originalField = getFieldByIndex(index) originalField.value = target.value @@ -65,18 +90,25 @@ const InputsList = ({ return (
    {fields && fields.map((field) => ( onChangeHandler(e, field.order)} + onChangeHandle={(e: ChangeEvent) => + onChangeHandler(e, field.order) + } restoreMode={restoreMode} /> ))} @@ -85,4 +117,5 @@ const InputsList = ({ } export { isInputValid } +export type { InputField } export default InputsList diff --git a/src/components/composed/InputList/InputsListItem.css b/src/components/composed/InputList/InputsListItem.module.css similarity index 75% rename from src/components/composed/InputList/InputsListItem.css rename to src/components/composed/InputList/InputsListItem.module.css index 943ba23c..30e1e3fd 100644 --- a/src/components/composed/InputList/InputsListItem.css +++ b/src/components/composed/InputList/InputsListItem.module.css @@ -1,3 +1,8 @@ +.listItem { + position: relative; + margin-bottom: 9px; +} + .number { position: absolute; top: 50%; @@ -15,22 +20,22 @@ border-radius: 50%; } -.number-active { +.numberActive { background: rgb(var(--color-main-green)); color: rgb(var(--color-white)); } -.number-finished { +.numberFinished { background: rgb(var(--color-black)); color: rgb(var(--color-white)); } -.number-invalid { +.numberInvalid { background: rgb(var(--color-red)); color: rgb(var(--color-white)); } -.words-list-input { +.wordsListInput { max-width: 230px; padding: 0.85rem; text-align: center; @@ -38,28 +43,26 @@ font-size: 20px; } -.words-list-input-restore { +.wordsListInputRestore { padding: 1rem 1rem 1rem 3.4rem; background: transparent; - /* border: 1px solid rgb(var(--color-light-gray)); */ - /* border-style: dashed; */ } -.input-restore-finished { +.inputRestoreFinished { background: rgb(var(--color-extra-light-gray)); border: 2px solid rgb(var(--color-extra-light-gray)); } -.words-list-input:focus ~ .number, -.words-list-input.readonly ~ .number { +.wordsListInput:focus ~ .number, +.wordsListInput.readonly ~ .number { color: rgb(var(--color-white)); background: rgb(var(--color-main-green)); } -.words-list-input::placeholder { +.wordsListInput::placeholder { color: rgba(var(--color-white), 0.9); } -.words-list-input.readonly { +.readonly { border: 1px solid rgb(var(--color-medium-blue)); } diff --git a/src/components/composed/InputList/InputsListItem.test.js b/src/components/composed/InputList/InputsListItem.test.js index d42e0531..391c4e81 100644 --- a/src/components/composed/InputList/InputsListItem.test.js +++ b/src/components/composed/InputList/InputsListItem.test.js @@ -21,7 +21,7 @@ test('Render Inputs list item', () => { const inputComponent = screen.getByTestId('inputs-list-item') expect(inputComponent).toBeInTheDocument() - expect(inputComponent).toHaveClass('list-item') + expect(inputComponent).toHaveClass('listItem') }) test('Render Inputs list item in restore mode', () => { @@ -40,7 +40,7 @@ test('Render Inputs list item in restore mode', () => { expect(inputComponent).toBeInTheDocument() expect(inputComponent).toContainElement(inputNumber) expect(inputNumber).toHaveClass('number') - expect(inputComponent).toHaveClass('list-item') + expect(inputComponent).toHaveClass('listItem') }) test('genNumberClasslist function valid', () => { @@ -49,11 +49,11 @@ test('genNumberClasslist function valid', () => { VALIDITYSAMPLE, RESTOREMODESAMPLE, ) - expect(generator).toBe('number number-finished') + expect(generator).toBe('number numberFinished') }) test('genNumberClasslist function invalid', () => { const VALID = 'invalid' const generator = genNumberClasslist(VALUESAMPLE, VALID, RESTOREMODESAMPLE) - expect(generator).toBe('number number-invalid') + expect(generator).toBe('number numberInvalid') }) diff --git a/src/components/composed/InputList/InputsListItem.js b/src/components/composed/InputList/InputsListItem.tsx similarity index 54% rename from src/components/composed/InputList/InputsListItem.js rename to src/components/composed/InputList/InputsListItem.tsx index 63deea41..193062c0 100644 --- a/src/components/composed/InputList/InputsListItem.js +++ b/src/components/composed/InputList/InputsListItem.tsx @@ -1,39 +1,51 @@ -import React from 'react' +import { ChangeEvent } from 'react' import { Input } from '@BasicComponents' -import './InputsListItem.css' +import styles from './InputsListItem.module.css' -const genNumberClasslist = (value, validity, restoreMode) => { +const genNumberClasslist = ( + value: string, + validity: string | null, + restoreMode: boolean, +) => { if (value?.length > 0 && validity === 'valid' && restoreMode) { - return 'number number-finished' + return `${styles.number} ${styles.numberFinished}` } else if (validity === 'invalid' && value?.length > 0 && restoreMode) { - return 'number number-invalid' + return `${styles.number} ${styles.numberInvalid}` } else { - return 'number' + return styles.number } } +interface InputListItemProps { + number?: number + value: string + validity: string | null + onChangeHandle: (e: ChangeEvent) => void + restoreMode: boolean +} + const InputListItem = ({ number, value, validity, onChangeHandle, restoreMode, -}) => { - const inputExtraClasses = ['words-list-input'] +}: InputListItemProps) => { + const inputExtraClasses = [styles.wordsListInput] restoreMode - ? inputExtraClasses.push('words-list-input-restore') - : inputExtraClasses.push('readonly') + ? inputExtraClasses.push(styles.wordsListInputRestore) + : inputExtraClasses.push(styles.readonly) if (validity === 'valid' && restoreMode) { - inputExtraClasses.push('input-restore-finished') + inputExtraClasses.push(styles.inputRestoreFinished) } return (
  • diff --git a/src/components/composed/Loading/Loading.js b/src/components/composed/Loading/Loading.js deleted file mode 100644 index e809ccd7..00000000 --- a/src/components/composed/Loading/Loading.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react' -import { useStyleClasses } from '@Hooks' - -import './Loading.css' - -const Loading = ({ extraStyleClasses = [] }) => { - const classesList = ['lds-dual-ring', ...extraStyleClasses] - const { styleClasses } = useStyleClasses(classesList) - - return ( -
    - ) -} - -export default Loading diff --git a/src/components/composed/Loading/Loading.css b/src/components/composed/Loading/Loading.module.css similarity index 75% rename from src/components/composed/Loading/Loading.css rename to src/components/composed/Loading/Loading.module.css index b6472b79..e7a2579b 100644 --- a/src/components/composed/Loading/Loading.css +++ b/src/components/composed/Loading/Loading.module.css @@ -1,10 +1,10 @@ -.lds-dual-ring { +.ldsDualRing { display: inline-block; width: 80px; height: 80px; } -.lds-dual-ring:after { +.ldsDualRing:after { content: ' '; display: block; width: 64px; @@ -13,10 +13,10 @@ border: 6px solid #fff; border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) transparent; - animation: lds-dual-ring 1.2s linear infinite; + animation: ldsDualRing 1.2s linear infinite; } -@keyframes lds-dual-ring { +@keyframes ldsDualRing { 0% { transform: rotate(0deg); } diff --git a/src/components/composed/Loading/Loading.test.js b/src/components/composed/Loading/Loading.test.js index d933b46c..6ddb6d70 100644 --- a/src/components/composed/Loading/Loading.test.js +++ b/src/components/composed/Loading/Loading.test.js @@ -1,5 +1,5 @@ import { render, screen } from '@testing-library/react' -import Loading from './Loading' +import Loading from './Loading.tsx' test('Render Loading component', () => { render() diff --git a/src/components/composed/Loading/Loading.tsx b/src/components/composed/Loading/Loading.tsx new file mode 100644 index 00000000..f0214fe0 --- /dev/null +++ b/src/components/composed/Loading/Loading.tsx @@ -0,0 +1,18 @@ +import styles from './Loading.module.css' + +interface LoadingProps { + extraStyleClasses?: string[] +} + +const Loading = ({ extraStyleClasses = [] }: LoadingProps) => { + const styleClasses = [styles.ldsDualRing, ...extraStyleClasses].join(' ') + + return ( +
    + ) +} + +export default Loading diff --git a/src/components/composed/LockedBalanceList/LockedBalanceList.css b/src/components/composed/LockedBalanceList/LockedBalanceList.css deleted file mode 100644 index ce792021..00000000 --- a/src/components/composed/LockedBalanceList/LockedBalanceList.css +++ /dev/null @@ -1,38 +0,0 @@ -.locked-table { - width: 100%; -} - -.locked-table-wrapper { - display: flex; - justify-content: center; - align-items: flex-start; - height: 465px; - overflow: auto; - - @media screen and (min-width: 801px) { - flex-grow: 1; - } -} - -.locked-title { - text-align: left; - padding: 14px 24px; - background: rgba(var(--color-green), 0.8); - width: 50%; - color: rgb(var(--color-black)); -} - -.locked-title:nth-child(1) { - border-top-left-radius: var(--round-size); -} - -.locked-title:nth-child(2) { - border-top-right-radius: var(--round-size); -} - -.locked-loading-wrapper { - display: flex; - justify-content: center; - align-items: center; - height: 465px; -} diff --git a/src/components/composed/LockedBalanceList/LockedBalanceList.js b/src/components/composed/LockedBalanceList/LockedBalanceList.js index d33c127c..db9f2869 100644 --- a/src/components/composed/LockedBalanceList/LockedBalanceList.js +++ b/src/components/composed/LockedBalanceList/LockedBalanceList.js @@ -1,13 +1,21 @@ import React, { useContext } from 'react' import { MintlayerContext } from '@Contexts' - import { Loading } from '@ComposedComponents' +import { ReactComponent as LockIcon } from '@Assets/images/icon-lock.svg' + import LockedBalanceListItem from './LockedBalanceListItem' -import './LockedBalanceList.css' +import styles from './LockedBalanceList.module.css' + +const BLOCK_TIME_SECONDS = 120 const LockedBalanceList = () => { - const { lockedUtxos, transactions, fetchingUtxos, currentHeight } = - useContext(MintlayerContext) + const { + lockedUtxos, + lockedBalance, + transactions, + fetchingUtxos, + currentHeight, + } = useContext(MintlayerContext) const updatedUtxosList = lockedUtxos .map((utxo) => { @@ -16,77 +24,113 @@ const LockedBalanceList = () => { (tx) => tx.txid === utxo.outpoint.source_id, ) + if (!initialTransaction) return null + const blocksToUnlock = utxo.utxo.lock.content - initialTransaction.confirmations - if (blocksToUnlock < 0) { - return null - } + if (blocksToUnlock < 0) return null + + const unlockHeight = + currentHeight - + initialTransaction.confirmations + + utxo.utxo.lock.content + const timestamp = + parseInt(initialTransaction.date) + + utxo.utxo.lock.content * BLOCK_TIME_SECONDS + const progress = + initialTransaction.confirmations / utxo.utxo.lock.content return { ...utxo, - utxo: { - ...utxo.utxo, - lock: { - ...utxo.utxo.lock, - content: { - ...utxo.utxo.lock.content, - blocksToUnlock, - unlockHeight: - currentHeight - - initialTransaction.confirmations + - utxo.utxo.lock.content, - timestamp: - parseInt(initialTransaction.date) + - utxo.utxo.lock.content * 120, - }, - }, + computed: { + blocksToUnlock, + unlockHeight, + timestamp, + progress: Math.min(progress, 1), }, } } - return { - ...utxo, + if (utxo.utxo.lock.type === 'UntilTime') { + const timestamp = utxo.utxo.lock.content + const progress = 0.5 + + return { + ...utxo, + computed: { + timestamp, + progress, + }, + } } + + return null }) - .reduce((acc, utxo) => (utxo ? [...acc, utxo] : acc), []) - .sort( - (a, b) => a.utxo.lock.content.timestamp - b.utxo.lock.content.timestamp, + .filter(Boolean) + .sort((a, b) => a.computed.timestamp - b.computed.timestamp) + + const totalLocked = + lockedBalance || + updatedUtxosList.reduce( + (sum, u) => sum + Number(u.utxo.value.amount.decimal), + 0, ) return ( - <> -
    - {fetchingUtxos ? ( -
    - +
    + {fetchingUtxos ? ( +
    + +
    + ) : ( + <> +
    +
    + +
    +
    +

    Locked coins

    +

    + These coins are time-locked and become spendable automatically + once their unlock block height is reached on the Mintlayer + chain. +

    +
    +
    + +
    +
    +

    Total locked

    +

    + {totalLocked} + ML +

    +
    + {updatedUtxosList.length > 0 && ( +
    + {updatedUtxosList.length} ACTIVE LOCK + {updatedUtxosList.length > 1 ? 'S' : ''} +
    + )} +
    + +
      + {updatedUtxosList.map((utxo) => ( + + ))} +
    + +
    + Unlock times are estimates based on the current block production + rate.
    - ) : ( -
AddressStatusBalances + ADDRESS + + STATUS + + BALANCE +
No addresses found
-
- +
+ - - {ML.formatAddress(address.id, 18)} - - - + - - - - - - {address.used ? 'Used' : 'Unused'} - - -
- - {address.coin_balance.available || '0.00'} {ticker} + {address.used ? 'Used' : 'Unused'} +
+ {hasBalance ? ( + + {address.coin_balance.available}{' '} + {ticker} + + ) : address.used && Number(address.coin_balance.available) === 0 ? ( + + 0 {ticker} + + ) : ( + + )} {address.coin_balance.locked > 0 && ( - + (Locked: {address.coin_balance.locked} {ticker}) )} {hasTokens && ( -
+
- {/* Tokens list - shown when expanded */} {tokensExpanded && ( -
+
{address.tokens.map((token, tokenIndex) => (
- - {token.amount.decimal || '0.00'} + + {formatTokenAmount(token.amount.decimal)} - + ({ML.formatAddress(token.token_id, 12)})
@@ -109,14 +114,22 @@ const AddressListItem = ({ address, index }) => { )}
)} -
-
+ +
- - - - - - - - {lockedUtxos && - updatedUtxosList.map((utxo, index) => ( - - ))} - -
DateAmount
- )} -
- + + )} +
) } diff --git a/src/components/composed/LockedBalanceList/LockedBalanceList.module.css b/src/components/composed/LockedBalanceList/LockedBalanceList.module.css new file mode 100644 index 00000000..d228c939 --- /dev/null +++ b/src/components/composed/LockedBalanceList/LockedBalanceList.module.css @@ -0,0 +1,118 @@ +.wrapper { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + flex: 1; + min-height: 0; +} + +.header { + display: flex; + align-items: flex-start; + gap: 12px; + flex-shrink: 0; +} + +.headerIcon { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 12px; + background: rgba(var(--mojito-green), 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.headerIcon svg { + width: 20px; + height: 20px; +} + +.headerText h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: rgb(var(--color-black)); +} + +.headerText p { + margin: 4px 0 0; + font-size: 13px; + color: rgb(var(--color-dark-gray)); + line-height: 1.4; +} + +.summary { + background: rgb(var(--color-white)); + border-radius: 16px; + padding: 16px 20px; + display: flex; + align-items: center; + justify-content: space-between; + box-shadow: var(--shadow-sm); + min-height: max-content; +} + +.summaryLabel { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: rgb(var(--color-dark-gray)); + text-transform: uppercase; + margin: 0; +} + +.summaryValue { + font-size: 24px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 4px 0 0; +} + +.summaryValue span { + font-size: 14px; + font-weight: 500; + color: rgb(var(--color-dark-gray)); + margin-left: 4px; +} + +.badge { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + color: rgb(var(--mojito-orange)); + border: 1px solid rgba(var(--mojito-orange), 0.3); + background: rgba(var(--mojito-orange), 0.06); + border-radius: 20px; + padding: 6px 12px; + white-space: nowrap; +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; + list-style: none; + margin: 0; + padding: 0; + flex: 1; + overflow: auto; +} + +.footer { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: rgb(var(--color-dark-gray)); + padding: 0 4px; +} + +.loadingWrapper { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} diff --git a/src/components/composed/LockedBalanceList/LockedBalanceList.test.js b/src/components/composed/LockedBalanceList/LockedBalanceList.test.js new file mode 100644 index 00000000..170e3c88 --- /dev/null +++ b/src/components/composed/LockedBalanceList/LockedBalanceList.test.js @@ -0,0 +1,158 @@ +import { render, screen } from '@testing-library/react' +import { MintlayerContext } from '@Contexts' +import LockedBalanceList from './LockedBalanceList' + +const makeUtxo = (sourceId, index, lockType, lockContent, amount) => ({ + outpoint: { source_id: sourceId, index }, + utxo: { + type: 'LockThenTransfer', + lock: { type: lockType, content: lockContent }, + value: { amount: { decimal: String(amount) } }, + }, +}) + +const baseTx = { + txid: 'tx1', + confirmations: 50, + date: '1700000000', +} + +const defaultContext = { + lockedUtxos: [], + lockedBalance: 0, + transactions: [], + fetchingUtxos: false, + currentHeight: 1000, +} + +const renderWith = (ctx) => + render( + + + , + ) + +describe('LockedBalanceList', () => { + it('shows loading when fetchingUtxos is true', () => { + renderWith({ fetchingUtxos: true }) + + expect(screen.queryByText('Locked coins')).not.toBeInTheDocument() + expect(document.querySelector('[class*="loadingWrapper"]')).toBeTruthy() + }) + + it('renders header and footer when not loading', () => { + renderWith({}) + + expect(screen.getByText('Locked coins')).toBeInTheDocument() + expect(screen.getByText(/Unlock times are estimates/)).toBeInTheDocument() + }) + + it('shows total locked from lockedBalance context value', () => { + renderWith({ lockedBalance: 500 }) + + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByText('ML')).toBeInTheDocument() + }) + + it('calculates total from UTXOs when lockedBalance is falsy', () => { + const utxo = makeUtxo('tx1', 0, 'ForBlockCount', 100, 25.5) + renderWith({ + lockedBalance: 0, + lockedUtxos: [utxo], + transactions: [baseTx], + }) + + const summary = document.querySelector('[class*="summaryValue"]') + expect(summary).toHaveTextContent('25.5') + }) + + it('renders ForBlockCount UTXOs as list items', () => { + const utxo = makeUtxo('tx1', 0, 'ForBlockCount', 100, 10) + renderWith({ + lockedUtxos: [utxo], + transactions: [baseTx], + }) + + const items = document.querySelectorAll('li') + expect(items).toHaveLength(1) + }) + + it('renders UntilTime UTXOs as list items', () => { + const utxo = makeUtxo('tx2', 0, 'UntilTime', 1700050000, 20) + renderWith({ lockedUtxos: [utxo] }) + + const items = document.querySelectorAll('li') + expect(items).toHaveLength(1) + }) + + it('filters out UTXOs with no matching transaction', () => { + const utxo = makeUtxo('unknown_tx', 0, 'ForBlockCount', 100, 10) + renderWith({ + lockedUtxos: [utxo], + transactions: [baseTx], + }) + + const items = document.querySelectorAll('li') + expect(items).toHaveLength(0) + }) + + it('filters out UTXOs with negative blocksToUnlock', () => { + const utxo = makeUtxo('tx1', 0, 'ForBlockCount', 100, 10) + const tx = { ...baseTx, confirmations: 200 } + renderWith({ + lockedUtxos: [utxo], + transactions: [tx], + }) + + const items = document.querySelectorAll('li') + expect(items).toHaveLength(0) + }) + + it('filters out UTXOs with unknown lock type', () => { + const utxo = makeUtxo('tx1', 0, 'UnknownType', 100, 10) + renderWith({ + lockedUtxos: [utxo], + transactions: [baseTx], + }) + + const items = document.querySelectorAll('li') + expect(items).toHaveLength(0) + }) + + it('shows singular badge for 1 item', () => { + const utxo = makeUtxo('tx1', 0, 'ForBlockCount', 100, 10) + renderWith({ + lockedUtxos: [utxo], + transactions: [baseTx], + }) + + expect(screen.getByText(/1 ACTIVE LOCK$/)).toBeInTheDocument() + }) + + it('shows plural badge for multiple items', () => { + const utxo1 = makeUtxo('tx1', 0, 'ForBlockCount', 100, 10) + const utxo2 = makeUtxo('tx1', 1, 'ForBlockCount', 200, 20) + renderWith({ + lockedUtxos: [utxo1, utxo2], + transactions: [baseTx], + }) + + expect(screen.getByText(/2 ACTIVE LOCKS/)).toBeInTheDocument() + }) + + it('hides badge when list is empty', () => { + renderWith({}) + + expect(screen.queryByText(/ACTIVE LOCK/)).not.toBeInTheDocument() + }) + + it('sorts UTXOs by timestamp ascending', () => { + const utxo1 = makeUtxo('tx1', 0, 'UntilTime', 1700090000, 10) + const utxo2 = makeUtxo('tx2', 0, 'UntilTime', 1700010000, 20) + renderWith({ lockedUtxos: [utxo1, utxo2] }) + + const amounts = document.querySelectorAll('li p[class*="cardAmount"]') + expect(amounts[0]).toHaveTextContent('20') + expect(amounts[1]).toHaveTextContent('10') + }) +}) diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.css b/src/components/composed/LockedBalanceList/LockedBalanceListItem.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.js b/src/components/composed/LockedBalanceList/LockedBalanceListItem.js index 56ef4746..a6ba6285 100644 --- a/src/components/composed/LockedBalanceList/LockedBalanceListItem.js +++ b/src/components/composed/LockedBalanceList/LockedBalanceListItem.js @@ -1,30 +1,48 @@ -// import { useMemo } from 'react' import { format } from 'date-fns' +import { ReactComponent as LockIcon } from '@Assets/images/icon-lock.svg' -import './LockedBalanceListItem.css' +import styles from './LockedBalanceListItem.module.css' -const displayDate = (utxo) => { - if (utxo.utxo.lock.type === 'ForBlockCount') { - return `~ ${format( - new Date(utxo.utxo.lock.content.timestamp * 1000), - 'dd/MM/yyyy HH:mm', - )} (Block height: ${utxo.utxo.lock.content.unlockHeight})` - } else if (utxo.utxo.lock.type === 'UntilTime') { - return format( - new Date(utxo.utxo.lock.content.timestamp * 1000), - 'dd/MM/yyyy HH:mm', - ) - } -} - -const LockedBalanceListItem = ({ index, utxo }) => { +const LockedBalanceListItem = ({ utxo }) => { return ( - - {displayDate(utxo)} - - {utxo.utxo.value.amount.decimal} ML - - +
  • +
    +
    + +
    +
    +

    + ~{' '} + {format( + new Date(utxo.computed.timestamp * 1000), + 'dd/MM/yyyy · HH:mm', + )} +

    +
    + {utxo.computed.unlockHeight && ( + + Block {utxo.computed.unlockHeight.toLocaleString()} + + )} + {utxo.computed.blocksToUnlock != null && ( + + unlocks in ~{utxo.computed.blocksToUnlock} blocks + + )} +
    +
    +
    +

    + {utxo.utxo.value.amount.decimal} + ML +

    +
    +
    +
    +
  • ) } diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css b/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css new file mode 100644 index 00000000..b0b6baf0 --- /dev/null +++ b/src/components/composed/LockedBalanceList/LockedBalanceListItem.module.css @@ -0,0 +1,93 @@ +.card { + background: rgb(var(--color-white)); + border-radius: 16px; + padding: 16px; + box-shadow: var(--shadow-sm); + min-height: max-content; +} + +.cardTop { + display: flex; + align-items: flex-start; + gap: 12px; + min-height: max-content; +} + +.cardIcon { + width: 36px; + height: 36px; + min-width: 36px; + border-radius: 50%; + background: rgba(var(--mojito-orange), 0.2); + display: flex; + align-items: center; + justify-content: center; +} + +.cardIcon svg { + width: 18px; + height: 18px; +} + +.cardInfo { + flex: 1; +} + +.cardDate { + font-size: 14px; + font-weight: 600; + color: rgb(var(--color-black)); + margin: 0; +} + +.cardMeta { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} + +.blockBadge { + font-size: 11px; + font-weight: 600; + color: rgb(var(--color-dark-gray)); + background: rgb(var(--color-extra-light-gray)); + border-radius: 6px; + padding: 3px 8px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.blocksLeft { + font-size: 12px; + color: rgb(var(--color-dark-gray)); +} + +.cardAmount { + font-size: 20px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 12px 0 0 48px; +} + +.cardAmount span { + font-size: 13px; + font-weight: 500; + color: rgb(var(--color-dark-gray)); + margin-left: 3px; +} + +.progressBar { + height: 4px; + border-radius: 2px; + background: rgb(var(--color-medium-gray)); + margin: 8px 0 0 48px; + overflow: hidden; +} + +.progressFill { + height: 100%; + border-radius: 2px; + background: rgb(var(--mojito-green)); +} diff --git a/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js b/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js new file mode 100644 index 00000000..de25b922 --- /dev/null +++ b/src/components/composed/LockedBalanceList/LockedBalanceListItem.test.js @@ -0,0 +1,94 @@ +import { render, screen } from '@testing-library/react' +import LockedBalanceListItem from './LockedBalanceListItem' + +const forBlockCountUtxo = { + outpoint: { source_id: 'tx1', index: 0 }, + utxo: { + type: 'LockThenTransfer', + lock: { type: 'ForBlockCount', content: 100 }, + value: { amount: { decimal: '42.5' } }, + }, + computed: { + blocksToUnlock: 50, + unlockHeight: 1050, + timestamp: 1700000000, + progress: 0.5, + }, +} + +const untilTimeUtxo = { + outpoint: { source_id: 'tx2', index: 0 }, + utxo: { + type: 'LockThenTransfer', + lock: { type: 'UntilTime', content: 1700050000 }, + value: { amount: { decimal: '100' } }, + }, + computed: { + timestamp: 1700050000, + progress: 0.5, + }, +} + +const renderItem = (utxo) => + render( +
      + +
    , + ) + +describe('LockedBalanceListItem', () => { + it('renders amount with ML suffix', () => { + renderItem(forBlockCountUtxo) + + expect(screen.getByText('42.5')).toBeInTheDocument() + expect(screen.getByText('ML')).toBeInTheDocument() + }) + + it('renders formatted date', () => { + renderItem(forBlockCountUtxo) + + expect(screen.getByText(/14\/11\/2023/)).toBeInTheDocument() + }) + + it('renders block badge for ForBlockCount', () => { + renderItem(forBlockCountUtxo) + + expect(screen.getByText(/Block 1,050/)).toBeInTheDocument() + }) + + it('renders blocks left for ForBlockCount', () => { + renderItem(forBlockCountUtxo) + + expect(screen.getByText(/unlocks in ~50 blocks/)).toBeInTheDocument() + }) + + it('does not render block badge for UntilTime', () => { + renderItem(untilTimeUtxo) + + expect(screen.queryByText(/Block /)).not.toBeInTheDocument() + }) + + it('does not render blocks left for UntilTime', () => { + renderItem(untilTimeUtxo) + + expect(screen.queryByText(/unlocks in/)).not.toBeInTheDocument() + }) + + it('sets progress bar width from progress value', () => { + renderItem(forBlockCountUtxo) + + const fill = document.querySelector('[class*="progressFill"]') + expect(fill.style.width).toBe('50%') + }) + + it('handles progress at 100%', () => { + const utxo = { + ...forBlockCountUtxo, + computed: { ...forBlockCountUtxo.computed, progress: 1 }, + } + renderItem(utxo) + + const fill = document.querySelector('[class*="progressFill"]') + expect(fill.style.width).toBe('100%') + }) +}) diff --git a/src/components/composed/Navigation/Navigation.css b/src/components/composed/Navigation/Navigation.css deleted file mode 100644 index 4975d549..00000000 --- a/src/components/composed/Navigation/Navigation.css +++ /dev/null @@ -1,75 +0,0 @@ -.bottom-menu-item { - display: flex; - align-items: center; - padding: 10px 5px; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; -} - -.navigation-item { - position: relative; - display: flex; - flex-direction: column; - justify-content: center; - border-radius: 4px; - cursor: pointer; - transition: all 0.2s ease; -} - -.bottom-menu-item:last-child, -.navigation-item:last-child { - border-bottom: none; -} - -.label-wrapper { - display: flex; - align-items: center; - padding: 10px 5px; - border-radius: 4px; -} - -.bottom-menu-item svg, -.navigation-item svg { - width: 21px; - height: 20px; - margin-right: 10px; -} - -.slider-version { - display: flex; - margin-top: 5px; - padding-left: 5px; - font-size: 0.9rem; -} - -.navigation-item-open { - background: rgba(var(--color-light-purple), 0.4); - padding: 5px; - transition: all 0.2s ease; -} - -.navigation-item .navigation-triangle { - position: absolute; - top: 16px; - right: 10px; - width: 8px; - height: 8px; - margin: 0; - transform: rotate(180deg); - transition: all 0.2s ease; -} - -.navigation-item .navigation-triangle g { - fill: rgb(var(--color-white)); -} - -.navigation-item .navigation-triangle-open { - transform: none; - top: 21px; -} - -.bottom-menu-item:hover, -.navigation-item:hover { - background-color: rgba(var(--color-light-purple), 0.4); -} diff --git a/src/components/composed/Navigation/Navigation.module.css b/src/components/composed/Navigation/Navigation.module.css new file mode 100644 index 00000000..ffb21e98 --- /dev/null +++ b/src/components/composed/Navigation/Navigation.module.css @@ -0,0 +1,48 @@ +.navigationList { + display: flex; + flex-direction: column; + flex: 1; + gap: 3px; +} + +.navigationItem, +.bottomMenuItem { + position: relative; + display: flex; + align-items: center; + border-radius: 16px; + padding: 10px 14px; + cursor: pointer; + transition: all 0.2s ease; + cursor: pointer; +} + +.labelWrapper { + display: flex; + align-items: center; + font-size: 14px; +} + +.bottomMenuItem svg, +.navigationItem svg { + width: 18px; + height: 18px; + margin-right: 10px; +} + +.sliderVersion { + display: flex; + margin-top: 5px; + padding-left: 15px; + font-size: 11px; +} + +.navigationItemActive { + background: rgb(var(--mojito-green-soft)); + color: rgb(var(--mojito-green)); +} + +.navigationItem:hover, +.bottomMenuItem:hover { + background: rgb(var(--mojito-green-soft)); +} diff --git a/src/components/composed/Navigation/Navigation.test.js b/src/components/composed/Navigation/Navigation.test.js index 194dc833..cd1044d1 100644 --- a/src/components/composed/Navigation/Navigation.test.js +++ b/src/components/composed/Navigation/Navigation.test.js @@ -1,7 +1,7 @@ import React from 'react' import { render, screen, fireEvent } from '@testing-library/react' import { MemoryRouter } from 'react-router' -import Navigation from './Navigation' +import Navigation from './Navigation.tsx' import { AccountContext, MintlayerContext } from '@Contexts' // Mock the chrome object diff --git a/src/components/composed/Navigation/Navigation.js b/src/components/composed/Navigation/Navigation.tsx similarity index 62% rename from src/components/composed/Navigation/Navigation.js rename to src/components/composed/Navigation/Navigation.tsx index 66858ec9..f0447098 100644 --- a/src/components/composed/Navigation/Navigation.js +++ b/src/components/composed/Navigation/Navigation.tsx @@ -1,29 +1,42 @@ /* eslint-disable no-undef */ -import React, { useContext, useEffect, useState } from 'react' +import { ReactNode, useContext, useEffect, useState } from 'react' import { useNavigate, useLocation } from 'react-router' -import { ReactComponent as LogoutImg } from '@Assets/images/logout.svg' +import { ReactComponent as LogoutImg } from '@Assets/images/icon-logout.svg' import { ReactComponent as ExpandImg } from '@Assets/images/icon-expand.svg' -import { ReactComponent as SettingsImg } from '@Assets/images/settings.svg' +import { ReactComponent as SettingsImg } from '@Assets/images/icon-settings.svg' import { ReactComponent as LoginImg } from '@Assets/images/icon-login.svg' import { ReactComponent as AddWalletImg } from '@Assets/images/icon-add-wallet.svg' import { ReactComponent as HomeImg } from '@Assets/images/icon-home.svg' -// import { ReactComponent as WalletIcon } from '@Assets/images/icon-wallet.svg' -import { ReactComponent as TriangleIcon } from '@Assets/images/icon-triangle.svg' +import { ReactComponent as BtcLogo } from '@Assets/images/btc-logo.svg' +import { ReactComponent as MlLogo } from '@Assets/images/logo.svg' import { APP_VERSION } from '@Version' import { AccountContext, MintlayerContext } from '@Contexts' -// import { AppInfo } from '@Constants' -import NestedNavigation from './NestedNavigation' +import styles from './Navigation.module.css' -import './Navigation.css' +interface NavigationItem { + id: number + label: string + icon?: ReactNode + link?: string + type?: string + content?: NavigationItem[] +} + +interface NavigationProps { + customNavigation?: NavigationItem[] + toggleMenu?: boolean +} -const Navigation = ({ customNavigation }) => { +const Navigation = ({ + customNavigation, + toggleMenu = true, +}: NavigationProps) => { const [unlocked, setUnlocked] = useState(false) - const [navigationItemID, setNavigationItemID] = useState(null) - const [nestedItemID, setNestedItemID] = useState(null) + const [navigationItemID, setNavigationItemID] = useState(null) const navigate = useNavigate() const location = useLocation() const { @@ -42,34 +55,23 @@ const Navigation = ({ customNavigation }) => { }, [location.pathname]) const toggleSliderMenu = () => { + if (!toggleMenu) return setSliderMenuOpen(!sliderMenuOpen) } - const onNavigationItemClick = (item) => { + const onNavigationItemClick = (item: NavigationItem) => { if (item.type !== 'menu') { - navigate(item.link) + navigate(item.link!) toggleSliderMenu() } else { setNavigationItemID(navigationItemID === item.id ? null : item.id) } - return - } - - const onNestedItemClick = (item) => { - if (item.type !== 'menu') { - navigate(item.link) - toggleSliderMenu() - } else { - setNestedItemID(nestedItemID === item.id ? null : item.id) - } - return } const expandHandler = () => { window.open( typeof browser !== 'undefined' - ? // eslint-disable-next-line no-undef - browser.runtime.getURL('popup.html') + ? browser.runtime.getURL('popup.html') : chrome.runtime.getURL('popup.html'), '_blank', ) @@ -82,22 +84,29 @@ const Navigation = ({ customNavigation }) => { toggleSliderMenu() } - const loggedNavigationList = [ + const loggedNavigationList: NavigationItem[] = [ { id: 1, label: 'Dashboard', icon: , link: '/dashboard', }, - // { - // id: 2, - // label: 'Wallets', - // icon: , - // type: 'menu', - // content: AppInfo.WALLETS_NAVIGATION, - // }, + + { + id: 2, + label: 'Bitcoin Wallet', + icon: , + link: '/wallet/Bitcoin', + }, { id: 3, + label: 'Mintlayer Wallet', + icon: , + link: '/wallet/Mintlayer', + }, + + { + id: 4, label: 'Settings', icon: , link: '/settings', @@ -105,7 +114,7 @@ const Navigation = ({ customNavigation }) => { ...(process.env.REACT_APP_CONFIG_NAME !== 'production' ? [ { - id: 4, + id: 5, label: 'Connection Page', icon: , link: '/connect', @@ -115,7 +124,7 @@ const Navigation = ({ customNavigation }) => { ...(process.env.REACT_APP_CONFIG_NAME !== 'production' ? [ { - id: 5, + id: 6, label: 'Test Sign Transaction', icon: , link: '/wallet/Mintlayer/sign-external-transaction', @@ -125,7 +134,7 @@ const Navigation = ({ customNavigation }) => { ...(process.env.REACT_APP_CONFIG_NAME !== 'production' ? [ { - id: 6, + id: 7, label: 'Test Sign Bitcoin Transaction', icon: , link: '/wallet/Bitcoin/sign-transaction', @@ -135,7 +144,7 @@ const Navigation = ({ customNavigation }) => { ...(process.env.REACT_APP_CONFIG_NAME !== 'production' ? [ { - id: 7, + id: 8, label: 'Test Sign Challenge', icon: , link: '/wallet/Mintlayer/sign-challenge', @@ -144,7 +153,7 @@ const Navigation = ({ customNavigation }) => { : []), ] - const navigationList = [ + const navigationList: NavigationItem[] = [ { id: 1, label: 'Login', @@ -168,6 +177,11 @@ const Navigation = ({ customNavigation }) => { }, ] + const isActive = (item: NavigationItem) => { + if (!item.link) return false + return location.pathname.startsWith(item.link) + } + const navList = customNavigation ? customNavigation : unlocked @@ -176,58 +190,47 @@ const Navigation = ({ customNavigation }) => { return ( <> -
      +
        {navList.map((item) => (
      • { + onNavigationItemClick(item) + }} > -
        { - onNavigationItemClick(item) - }} - > +
        {item.icon && item.icon} {item.label}
        - - {item.type === 'menu' && navigationItemID === item.id && ( - - )} - {item.type === 'menu' && ( - - )}
      • ))}
      -
        +
          {!isExtended && (
        • - Expand view +
          + Expand view +
        • )} {unlocked && (
        • - - Logout +
          + + Logout +
        • )} - v{APP_VERSION} + v{APP_VERSION}
        ) diff --git a/src/components/composed/Navigation/NestedNavigation.js b/src/components/composed/Navigation/NestedNavigation.js deleted file mode 100644 index 069b2d66..00000000 --- a/src/components/composed/Navigation/NestedNavigation.js +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useContext } from 'react' -import { useNavigate } from 'react-router' -import { ReactComponent as TriangleIcon } from '@Assets/images/icon-triangle.svg' - -import { AccountContext } from '@Contexts' - -import './NestedNavigation.css' - -const NestedNavigation = ({ item, onNestedItemClick, nestedItemID }) => { - const { sliderMenuOpen, setSliderMenuOpen } = useContext(AccountContext) - const navigate = useNavigate() - const toggleSliderMenu = () => { - setSliderMenuOpen(!sliderMenuOpen) - } - return ( -
          - {item.content.map((nestedItem) => ( -
        • -
          { - onNestedItemClick(nestedItem) - }} - > - {nestedItem.icon && nestedItem.icon} - {nestedItem.label} -
          - - {nestedItem.type === 'menu' && nestedItemID === nestedItem.id && ( -
            - {nestedItem.actions.map((action) => ( -
          • { - navigate(action.link) - toggleSliderMenu() - }} - > - {action.name} -
          • - ))} -
          - )} -
        • - ))} -
        - ) -} - -export default NestedNavigation diff --git a/src/components/composed/Navigation/NestedNavigation.css b/src/components/composed/Navigation/NestedNavigation.module.css similarity index 77% rename from src/components/composed/Navigation/NestedNavigation.css rename to src/components/composed/Navigation/NestedNavigation.module.css index 195b4469..2f81df5d 100644 --- a/src/components/composed/Navigation/NestedNavigation.css +++ b/src/components/composed/Navigation/NestedNavigation.module.css @@ -1,9 +1,9 @@ -.nested-menu { +.nestedMenu { margin-top: 10px; transition: all 0.2s ease; } -.nested-menu-item { +.nestedMenuItem { position: relative; margin-bottom: 1px; border-radius: 4px; @@ -13,11 +13,11 @@ transition: all 0.2s ease; } -.nested-menu-item-open { +.nestedMenuItemOpen { padding: 5px; } -.nested-label-wrapper { +.nestedLabelWrapper { display: flex; align-items: center; padding: 10px 15px; @@ -25,23 +25,23 @@ border-radius: 4px; } -.nested-label-wrapper:hover { +.nestedLabelWrapper:hover { color: rgb(var(--color-white)); background: rgba(var(--color-purple), 0.7); transition: all 0.2s ease; } -.nested-menu-item-open .nested-label-wrapper { +.nestedMenuItemOpen .nestedLabelWrapper { color: rgb(var(--color-white)); background: rgba(var(--color-purple), 0.7); transition: all 0.2s ease; } -.nested-item-menu { +.nestedItemMenu { margin-top: 5px; } -.nested-item-menu-item { +.nestedItemMenuItem { display: flex; padding: 10px 15px; margin-bottom: 1px; @@ -52,12 +52,12 @@ transition: all 0.2s ease; } -.nested-item-menu-item:hover { +.nestedItemMenuItem:hover { color: rgb(var(--color-white)); background-color: rgba(153, 196, 153, 0.8); } -.nested-item-menu .navigation-triangle { +.navigationTriangle { position: absolute; top: 16px; right: 10px; @@ -68,7 +68,7 @@ transition: all 0.2s ease; } -.nested-item-menu .navigation-triangle-open { +.navigationTriangleOpen { transform: none; top: 21px; } diff --git a/src/components/composed/Navigation/NestedNavigation.tsx b/src/components/composed/Navigation/NestedNavigation.tsx new file mode 100644 index 00000000..656fdfe1 --- /dev/null +++ b/src/components/composed/Navigation/NestedNavigation.tsx @@ -0,0 +1,90 @@ +import { ReactNode, useContext } from 'react' +import { useNavigate } from 'react-router' +import { ReactComponent as TriangleIcon } from '@Assets/images/icon-triangle.svg' + +import { AccountContext } from '@Contexts' + +import styles from './NestedNavigation.module.css' + +interface NestedAction { + id: number + name: string + link: string +} + +interface NestedItem { + id: number + label: string + icon?: ReactNode + link?: string + type?: string + actions?: NestedAction[] +} + +interface NavigationItem { + id: number + label: string + icon?: ReactNode + link?: string + type?: string + content: NestedItem[] +} + +interface NestedNavigationProps { + item: NavigationItem + onNestedItemClick: (item: NestedItem) => void + nestedItemID: number | null +} + +const NestedNavigation = ({ + item, + onNestedItemClick, + nestedItemID, +}: NestedNavigationProps) => { + const { sliderMenuOpen, setSliderMenuOpen } = useContext(AccountContext) + const navigate = useNavigate() + const toggleSliderMenu = () => { + setSliderMenuOpen(!sliderMenuOpen) + } + return ( +
          + {item.content.map((nestedItem) => ( +
        • +
          { + onNestedItemClick(nestedItem) + }} + > + {nestedItem.icon && nestedItem.icon} + {nestedItem.label} +
          + + {nestedItem.type === 'menu' && nestedItemID === nestedItem.id && ( +
            + {nestedItem.actions?.map((action) => ( +
          • { + navigate(action.link) + toggleSliderMenu() + }} + > + {action.name} +
          • + ))} +
          + )} +
        • + ))} +
        + ) +} + +export default NestedNavigation diff --git a/src/components/composed/PopUp/Popup.css b/src/components/composed/PopUp/Popup.css deleted file mode 100644 index f124c52c..00000000 --- a/src/components/composed/PopUp/Popup.css +++ /dev/null @@ -1,107 +0,0 @@ -.backdrop { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgb(var(--color-extra-light-gray)); - z-index: 9999; - display: flex; - justify-content: center; - align-items: center; - - animation-name: BackdropAppierance; - animation-duration: 0.3s; - animation-timing-function: ease; - cursor: default; -} - -.backdropClosing { - animation-name: BackdropDisappierance; - animation-duration: 1.3s; - animation-timing-function: ease; -} - -.popup { - position: absolute; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - width: 88.4%; - height: 94%; - padding: 40px; - background: rgb(var(--color-white)); - border-radius: 15px 15px 0 0; - - animation-name: slideUp; - animation-duration: 0.7s; - animation-timing-function: ease; - cursor: default; -} - -.popupClosing { - animation-name: slideDown; - animation-duration: 0.7s; - animation-timing-function: ease; -} - -.popupCloseButton { - position: absolute; - background-color: transparent; - top: 10px; - right: 10px; - width: 35px; - height: 35px; - padding: 0; -} - -.popupCloseButton:hover { - background-color: transparent; - opacity: 0.6; -} - -.popupCloseButton:hover svg path { - stroke: rgb(var(--color-black)); -} - -.popupCloseButton svg { - height: 50%; - width: 50%; -} - -@keyframes slideUp { - 0% { - transform: translateY(100%); - } - 100% { - transform: translateY(0%); - } -} - -@keyframes slideDown { - 0% { - transform: translateY(0%); - } - 100% { - transform: translateY(100%); - } -} - -@keyframes BackdropAppierance { - 0% { - opacity: 0; - } - 100% { - opacity: 1; - } -} - -@keyframes BackdropDisappierance { - 0% { - opacity: 1; - } - 100% { - opacity: 0; - } -} diff --git a/src/components/composed/PopUp/Popup.js b/src/components/composed/PopUp/Popup.js index 0149c8bf..a369bfee 100644 --- a/src/components/composed/PopUp/Popup.js +++ b/src/components/composed/PopUp/Popup.js @@ -5,10 +5,9 @@ import { Button } from '@BasicComponents' import { useOnClickOutside } from '@Hooks' import { ReactComponent as IconClose } from '@Assets/images/icon-close.svg' -import './Popup.css' +import styles from './Popup.module.css' const Popup = ({ children, setOpen, allowClosing = true }) => { - const closeButtonExtraStyles = ['popupCloseButton'] const [popupClosing, setPopupClosing] = useState(false) const closeButtonClickHandler = () => { @@ -16,29 +15,27 @@ const Popup = ({ children, setOpen, allowClosing = true }) => { setPopupClosing(true) setTimeout(() => { setOpen(false) - }, 700) + }, 300) } const popupRef = useRef(null) useOnClickOutside(popupRef, closeButtonClickHandler) - const mainElement = document.querySelector('main') - ? document.querySelector('main') - : document.body + const portalTarget = document.getElementById('root') || document.body return ReactDOM.createPortal(
        {allowClosing && (
        , - mainElement, + portalTarget, ) } diff --git a/src/components/composed/PopUp/Popup.module.css b/src/components/composed/PopUp/Popup.module.css new file mode 100644 index 00000000..3d706c0b --- /dev/null +++ b/src/components/composed/PopUp/Popup.module.css @@ -0,0 +1,129 @@ +.backdrop { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(var(--color-black), 0.25); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + z-index: 9999; + animation-name: BackdropAppierance; + animation-duration: 0.25s; + animation-timing-function: ease; + cursor: default; +} + +.backdropClosing { + animation-name: BackdropDisappierance; + animation-duration: 0.3s; + animation-timing-function: ease; + animation-fill-mode: forwards; +} + +.popup { + position: absolute; + top: 14px; + right: 14px; + bottom: 14px; + left: 14px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 68px 16px 14px; + background: rgb(var(--color-white)); + border-radius: 20px; + box-shadow: 0 20px 60px -10px rgba(var(--color-black), 0.25); + overflow: hidden; + animation-name: popupAppear; + animation-duration: 0.3s; + animation-timing-function: ease; + cursor: default; + + @media screen and (min-width: 801px) { + top: 40px; + right: 40px; + bottom: 40px; + left: 40px; + padding: 73px 32px 20px; + } +} + +.popupClosing { + animation-name: popupDisappear; + animation-duration: 0.3s; + animation-timing-function: ease; + animation-fill-mode: forwards; +} + +.popupCloseButton { + position: absolute; + top: 20px; + right: 20px; + width: 36px; + height: 36px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(var(--color-black), 0.05); + border-radius: 10px; +} + +.popupCloseButton:hover { + background-color: rgba(var(--color-black), 0.1); +} + +.popupCloseButton svg { + width: 14px; + height: 14px; +} + +.popupCloseButton svg path { + stroke: rgba(var(--color-black), 0.6); +} + +.popupCloseButton:hover svg path { + stroke: rgb(var(--color-black)); +} + +@keyframes popupAppear { + 0% { + transform: scale(0.96); + opacity: 0; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +@keyframes popupDisappear { + 0% { + transform: scale(1); + opacity: 1; + } + 100% { + transform: scale(0.96); + opacity: 0; + } +} + +@keyframes BackdropAppierance { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +@keyframes BackdropDisappierance { + 0% { + opacity: 1; + } + 100% { + opacity: 0; + } +} diff --git a/src/components/composed/PriceChart/PriceChart.css b/src/components/composed/PriceChart/PriceChart.css index 2db6ffb2..e7388a96 100644 --- a/src/components/composed/PriceChart/PriceChart.css +++ b/src/components/composed/PriceChart/PriceChart.css @@ -1,15 +1,16 @@ .crypto-stats { display: flex; flex-direction: column; - width: 153px; + width: 120px; + justify-content: center; } .crypto-stats .crypto-stats-numbers { display: flex; justify-content: flex-end; align-items: flex-end; - gap: 10px; - height: 30px; + gap: 6px; + height: 20px; } .crypto-stats .crypto-stats-numbers > * { @@ -24,16 +25,32 @@ } .crypto-stats .crypto-stats-numbers > strong { - font-size: 1.5rem; + font-size: 1.1rem; font-weight: 600; } .crypto-stats .crypto-stats-numbers > span { - font-size: 1.125rem; + font-size: 0.85rem; font-weight: light; } .crypto-stats svg { width: 100%; - height: 40px; + height: 28px; +} + +.crypto-stats .chart-placeholder { + width: 100%; + height: 28px; + border-radius: 4px; + animation: skeleton-loading 1s linear infinite alternate; +} + +@keyframes skeleton-loading { + 0% { + background: rgba(var(--color-main-green), 0.1); + } + 100% { + background: rgba(var(--color-main-green), 0.2); + } } diff --git a/src/components/composed/PriceChart/PriceChart.js b/src/components/composed/PriceChart/PriceChart.js index add04fcd..b85e3ee2 100644 --- a/src/components/composed/PriceChart/PriceChart.js +++ b/src/components/composed/PriceChart/PriceChart.js @@ -8,6 +8,7 @@ import './PriceChart.css' const PriceChart = ({ data, item }) => { const { networkType } = useContext(SettingsContext) const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET + const isToken = item.name !== 'Mintlayer' && item.name !== 'Bitcoin' const color = AppInfo.COLOR_LIST[item.symbol.toLowerCase()] return ( @@ -22,15 +23,19 @@ const PriceChart = ({ data, item }) => { )}
    - {(!isTestnet || !data || !data.length) && ( - - )} + {!isTestnet && + !isToken && + (data && data.length ? ( + + ) : ( +
    + ))}
    ) } diff --git a/src/components/composed/ProgressTracker/ProgressTracker.css b/src/components/composed/ProgressTracker/ProgressTracker.css deleted file mode 100644 index 876c8009..00000000 --- a/src/components/composed/ProgressTracker/ProgressTracker.css +++ /dev/null @@ -1,252 +0,0 @@ -/* .progressTracker { - display: flex; - flex-direction: row; - gap: 20px; - justify-content: space-between; - margin: 2rem 0; - color: rgb(var(--color-lightest-blue)); -} - -.step { - position: relative; - flex-grow: 1; - font-size: 0.75rem; - font-weight: bold; - list-style: none; - padding: 0.5rem 0 0; - text-align: center; - width: 22%; - min-width: 22%; -} - -.step.active { - color: rgb(var(--color-medium-green)); -} - -.stepper-bar { - height: 8px; - margin-bottom: 17px; - background: rgb(var(--color-dark-blue)); - border-radius: 30px; -} - -.stepper-bar::after, -.step.active .stepper-bar::after { - content: ''; - box-sizing: border-box; - width: 0; - height: 8px; - background: rgb(var(--color-dark-blue)); - position: absolute; - top: 8px; - left: 0; - border-radius: 30px; - animation: animBw 0.3s linear forwards; -} - -.stepper-bar::before, -.step.active .stepper-bar::before { - content: ''; - box-sizing: border-box; - width: 0; - height: 8px; - background: rgb(var(--color-medium-green)); - position: absolute; - top: 8px; - left: 0; - border-radius: 30px; - animation: animBw 0.3s linear forwards; -} - -.step.active .stepper-bar { - color: rgb(var(--color-medium-green)); -} - -.step.active .stepper-bar::after { - animation: animFw 0.3s linear forwards; - background: rgb(var(--color-medium-green)); -} - -.step.active .stepper-bar::before { - background: rgb(var(--color-dark-blue)); -} - - -@keyframes animFw2 { - 100% { - width: 100%; - } - 0% { - width: 0; - } -} - -@keyframes animBw2 { - 100% { - width: 100%; - } - 0% { - width: 0; - } -} - - - -@keyframes animFw { - 0% { - width: 0; - } - 100% { - width: 100%; - } -} - -@keyframes animBw { - 0% { - width: 0; - } - 100% { - width: 100%; - } -} - - */ - -.progressTracker { - display: flex; - flex-direction: row; - gap: 20px; - justify-content: space-between; - margin: 2rem 0; - color: rgb(var(--color-black)); -} - -.step { - position: relative; - flex-grow: 1; - font-size: 0.75rem; - font-weight: bold; - list-style: none; - padding: 0.5rem 0 0; - text-align: center; - width: 22%; - min-width: 22%; -} - -.step.active { - color: rgb(var(--color-main-green)); -} - -.stepper-bar { - height: 8px; - margin-bottom: 17px; - background: rgb(var(--color-light-gray)); - border-radius: 30px; -} - -.stepper-bar::after, -.step.active .stepper-bar::after { - content: ''; - box-sizing: border-box; - width: 0; - height: 8px; - background: rgb(var(--color-light-gray)); - position: absolute; - top: 8px; - left: 0; - border-radius: 30px; - animation: animBw 0.3s linear forwards; -} - -.stepper-bar::before, -.step.active .stepper-bar::before { - content: ''; - box-sizing: border-box; - width: 0; - height: 8px; - background: rgb(var(--color-main-green)); - position: absolute; - top: 8px; - left: 0; - border-radius: 30px; - animation: animBw 0.3s linear forwards; -} - -/* .backward .stepper-bar::after { - animation: none; - width: 0; -} - -.backward .stepper-bar::before { - animation: animBackward 10s linear forwards; - background: rgb(var(--color-medium-green)); - width: 100%; -} */ - -.backward .stepper-bar::after { - animation: none; - width: 0; -} - -.backward .stepper-bar::before { - animation: animBackward 0.3s linear forwards; - background: rgb(var(--color-main-green)); - width: 100%; -} - -.step.active .stepper-bar { - color: rgb(var(--color-main-green)); -} - -.step.active .stepper-bar::after { - animation: animFw 0.3s linear forwards; - background: rgb(var(--color-main-green)); -} - -.step.active .stepper-bar::before { - background: rgb(var(--color-light-gray)); -} - -/* .step.active.backward .stepper-bar::after { - animation: test 5s linear forwards; - background: rgb(var(--color-medium-green)); - width: 100%; -} */ - -.step.active.backward .stepper-bar::after { - animation: animBackward 0.3s linear forwards; - background: rgb(var(--color-light-gray)); - width: 0; -} - -.step.active.backward .stepper-bar::before { - background: rgb(var(--color-main-green)); - width: 100%; -} - -@keyframes animFw { - 0% { - width: 0; - } - 100% { - width: 100%; - } -} - -@keyframes animBw { - 100% { - width: 100%; - } - 0% { - width: 0%; - } -} - -@keyframes animBackward { - 0% { - width: 100%; - } - 100% { - width: 0; - } -} diff --git a/src/components/composed/ProgressTracker/ProgressTracker.js b/src/components/composed/ProgressTracker/ProgressTracker.js deleted file mode 100644 index 13b7f301..00000000 --- a/src/components/composed/ProgressTracker/ProgressTracker.js +++ /dev/null @@ -1,32 +0,0 @@ -// import React, { useEffect, useState } from 'react' - -import './ProgressTracker.css' - -const defaultSteps = [ - { name: 'Step 1' }, - { name: 'Step 2', active: true }, - { name: 'Step 3' }, -] - -const ProgressTracker = ({ steps = defaultSteps, direction }) => { - return ( -
      - {steps.map((step, index) => ( -
    1. -
      - {step.name} -
    2. - ))} -
    - ) -} - -export default ProgressTracker diff --git a/src/components/composed/ProgressTracker/ProgressTracker.module.css b/src/components/composed/ProgressTracker/ProgressTracker.module.css new file mode 100644 index 00000000..17d9051f --- /dev/null +++ b/src/components/composed/ProgressTracker/ProgressTracker.module.css @@ -0,0 +1,80 @@ +.progressTracker { + display: flex; + gap: 20px; + justify-content: space-between; + width: 100%; + min-height: max-content; + margin: 2rem 0; + color: rgb(var(--color-black)); +} + +.step { + flex-grow: 1; + font-size: 11px; + font-weight: bold; + list-style: none; + padding: 0.5rem 0 0; + text-align: center; +} + +.step.active { + color: rgb(var(--mojito-green)); +} + +.stepperBar { + position: relative; + height: 4px; + margin-bottom: 7px; + background: rgb(var(--color-light-gray)); + border-radius: 30px; + overflow: hidden; +} + +.stepperBar::after { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 0; + background: rgb(var(--color-main-green)); + border-radius: inherit; +} + +.step.completed .stepperBar::after { + width: 100%; +} + +.step.active.forward .stepperBar::after { + left: 0; + animation: fill 0.3s linear forwards; +} + +.step.active.backward .stepperBar::after { + left: 0; + width: 100%; +} + +.step.leaving.backward .stepperBar::after { + left: 0; + width: 100%; + animation: empty 0.3s linear forwards; +} + +@keyframes fill { + from { + width: 0; + } + to { + width: 100%; + } +} + +@keyframes empty { + from { + width: 100%; + } + to { + width: 0; + } +} diff --git a/src/components/composed/ProgressTracker/ProgressTracker.test.js b/src/components/composed/ProgressTracker/ProgressTracker.test.js index d71cbc0f..696b18e1 100644 --- a/src/components/composed/ProgressTracker/ProgressTracker.test.js +++ b/src/components/composed/ProgressTracker/ProgressTracker.test.js @@ -2,7 +2,13 @@ import { render, screen } from '@testing-library/react' import ProgressTracker from './ProgressTracker' test('Render ProgressTracker component', () => { - render() + const steps = [ + { name: 'Step 1' }, + { name: 'Step 2', active: true }, + { name: 'Step 3' }, + ] + + render() const progressTrackerComponent = screen.getByTestId( 'progress-tracker-container', ) diff --git a/src/components/composed/ProgressTracker/ProgressTracker.tsx b/src/components/composed/ProgressTracker/ProgressTracker.tsx new file mode 100644 index 00000000..6c9d7c95 --- /dev/null +++ b/src/components/composed/ProgressTracker/ProgressTracker.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' + +import styles from './ProgressTracker.module.css' + +interface Step { + name: string + active?: boolean +} + +interface ProgressTrackerProps { + steps: Step[] + direction?: string +} + +const ProgressTracker = ({ steps }: ProgressTrackerProps) => { + const activeIndex = steps.findIndex((step) => step.active) + const [prevActiveIndex, setPrevActiveIndex] = useState(activeIndex) + const [leavingIndex, setLeavingIndex] = useState(-1) + + if (prevActiveIndex !== activeIndex) { + setLeavingIndex(prevActiveIndex) + setPrevActiveIndex(activeIndex) + } + + const isForward = leavingIndex < 0 || activeIndex > leavingIndex + + return ( +
      + {steps.map((step, index) => { + const isCompleted = index < activeIndex + const isLeaving = + index === leavingIndex && leavingIndex !== activeIndex && !isCompleted + const dirClass = isForward ? styles.forward : styles.backward + + const classList = [styles.step] + if (isCompleted) classList.push(styles.completed) + if (step.active) classList.push(styles.active, dirClass) + if (isLeaving) classList.push(styles.leaving, dirClass) + + return ( +
    1. +
      + {step.name} +
    2. + ) + })} +
    + ) +} + +export default ProgressTracker diff --git a/src/components/composed/SendPageHeader/SendPageHeader.js b/src/components/composed/SendPageHeader/SendPageHeader.js new file mode 100644 index 00000000..20530600 --- /dev/null +++ b/src/components/composed/SendPageHeader/SendPageHeader.js @@ -0,0 +1,23 @@ +import React from 'react' + +import styles from './SendPageHeader.module.css' + +const SendPageHeader = ({ ticker, networkName, isTestnet }) => { + return ( +
    +

    + Send {ticker} +

    +

    + Transferring from your{' '} + + {networkName} + {isTestnet ? ' (Testnet)' : ''} + {' '} + balance. +

    +
    + ) +} + +export default SendPageHeader diff --git a/src/components/composed/SendPageHeader/SendPageHeader.module.css b/src/components/composed/SendPageHeader/SendPageHeader.module.css new file mode 100644 index 00000000..ea2d08e7 --- /dev/null +++ b/src/components/composed/SendPageHeader/SendPageHeader.module.css @@ -0,0 +1,29 @@ +.header { + padding: 10px 0 20px; +} + +.title { + font-size: 22px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0; + + @media screen and (min-width: 801px) { + font-size: 28px; + } +} + +.ticker { + font-size: 22px; + color: rgb(var(--color-main-green)); + + @media screen and (min-width: 801px) { + font-size: 28px; + } +} + +.subtitle { + font-size: 13px; + color: rgba(var(--color-black), 0.5); + margin: 4px 0 0; +} diff --git a/src/components/composed/Sidebar/Sidebar.module.css b/src/components/composed/Sidebar/Sidebar.module.css new file mode 100644 index 00000000..ca65a5fa --- /dev/null +++ b/src/components/composed/Sidebar/Sidebar.module.css @@ -0,0 +1,159 @@ +.sidebar { + display: none; + + @media screen and (min-width: 801px) { + display: flex; + flex-direction: column; + width: 260px; + height: 100%; + flex-shrink: 0; + padding: 24px 16px; + background: rgb(var(--color-white)); + border-right: 1px solid rgba(var(--color-black), 0.08); + } +} + +.top { + display: flex; + flex-direction: column; +} + +.logoRow { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 12px; + margin-bottom: 20px; +} + +.logoIcon { + width: 24px; + height: 24px; +} + +.logoText { + font-size: 20px; + font-weight: 700; + color: rgb(var(--color-black)); +} + +.accountCard { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + margin-bottom: 16px; + border-radius: 12px; + background: rgba(var(--mojito-green), 0.08); +} + +.avatar { + width: 36px; + height: 36px; + border-radius: 50%; + flex-shrink: 0; +} + +.accountInfo { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1; +} + +.accountName { + font-size: 13px; + font-weight: 600; + color: rgb(var(--color-black)); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.accountAddress { + font-size: 11px; + color: rgba(var(--color-black), 0.5); + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; +} + +.copyBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + background: transparent; + border-radius: 8px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.2s; +} + +.copyBtn:hover { + background: rgba(var(--color-black), 0.06); +} + +.copyIcon { + width: 14px; + height: 14px; +} + +.nav { + display: flex; + flex-direction: column; + flex: 1; + overflow: auto; +} + +.navList { + list-style: none; + display: flex; + flex-direction: column; + gap: 2px; +} + +.navItem { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 12px; + cursor: pointer; + transition: background 0.2s; + color: rgb(var(--color-black)); + font-size: 15px; + font-weight: 500; +} + +.navItem:hover { + background: rgba(var(--color-black), 0.04); +} + +.navItemActive { + background: rgba(var(--mojito-green), 0.1); + color: rgb(var(--mojito-green)); +} + +.navItemActive:hover { + background: rgba(var(--mojito-green), 0.14); +} + +.navIcon { + width: 22px; + height: 22px; + flex-shrink: 0; +} + +.navItemActive .navIcon path, +.navItemActive .navIcon line, +.navItemActive .navIcon polyline, +.navItemActive .navIcon circle { + stroke: rgb(var(--mojito-green)); +} + +.navLabel { + white-space: nowrap; +} diff --git a/src/components/composed/Sidebar/Sidebar.tsx b/src/components/composed/Sidebar/Sidebar.tsx new file mode 100644 index 00000000..f6fcf1a7 --- /dev/null +++ b/src/components/composed/Sidebar/Sidebar.tsx @@ -0,0 +1,96 @@ +import { useContext, useState } from 'react' + +import { Navigation, UpdateButton } from '@ComposedComponents' + +import { ReactComponent as LogoIcon } from '@Assets/images/logo.svg' +import { ReactComponent as CopyIcon } from '@Assets/images/icon-copy.svg' +import { ReactComponent as SuccessIcon } from '@Assets/images/icon-success.svg' + +import { AccountContext } from '@Contexts' + +import styles from './Sidebar.module.css' + +const AVATAR_GRADIENTS = [ + 'linear-gradient(135deg, #a8e6cf, #f9e79f, #f5b041)', + 'linear-gradient(135deg, #89CFF0, #B19CD9)', + 'linear-gradient(135deg, #f5af19, #f12711)', + 'linear-gradient(135deg, #43e97b, #38f9d7)', + 'linear-gradient(135deg, #fa709a, #fee140)', + 'linear-gradient(135deg, #a18cd1, #fbc2eb)', +] + +const Sidebar = () => { + const { accountName, addresses, isAccountUnlocked, accountID } = + useContext(AccountContext) + const [copied, setCopied] = useState(false) + + const unlocked = isAccountUnlocked() + + if (!unlocked) return null + + const mlAddress = + addresses.mlAddresses && + addresses.mlAddresses.mlReceivingAddresses && + addresses.mlAddresses.mlReceivingAddresses[0] + + const shortenAddress = (addr: string) => { + if (!addr) return '' + return addr.length > 16 ? `${addr.slice(0, 8)}...${addr.slice(-5)}` : addr + } + + const handleCopy = () => { + if (mlAddress) { + navigator.clipboard.writeText(mlAddress) + setCopied(true) + setTimeout(() => setCopied(false), 1200) + } + } + + const avatarGradient = accountID + ? AVATAR_GRADIENTS[ + (typeof accountID === 'string' ? accountID.charCodeAt(0) : accountID) % + AVATAR_GRADIENTS.length + ] + : AVATAR_GRADIENTS[0] + + return ( + + ) +} + +export default Sidebar diff --git a/src/components/composed/SliderMenu/SliderMenu.js b/src/components/composed/SliderMenu/SliderMenu.js deleted file mode 100644 index f8e7e826..00000000 --- a/src/components/composed/SliderMenu/SliderMenu.js +++ /dev/null @@ -1,54 +0,0 @@ -/* eslint-disable no-undef */ -import React, { useState, useEffect, useRef } from 'react' -import ReactDOM from 'react-dom' -import { Button } from '@BasicComponents' -import { ReactComponent as IconClose } from '@Assets/images/icon-close.svg' -import { useOnClickOutside } from '@Hooks' -import './SliderMenu.css' - -const SliderMenu = ({ children, isOpen, onClose }) => { - const [isVisible, setIsVisible] = useState(false) - const closeButtonExtraStyles = ['slider-menu-close-button'] - - useEffect(() => { - if (isOpen) { - setIsVisible(true) - } else { - const timer = setTimeout(() => setIsVisible(false), 300) - return () => clearTimeout(timer) - } - }, [isOpen]) - - const sliderRef = useRef(null) - useOnClickOutside(sliderRef, onClose) - - const mainElement = document.querySelector('main') - ? document.querySelector('main') - : document.body - - return ReactDOM.createPortal( - isVisible && ( -
    -
    - -
    {children}
    -
    -
    - ), - mainElement, - ) -} - -export default SliderMenu diff --git a/src/components/composed/SliderMenu/SliderMenu.css b/src/components/composed/SliderMenu/SliderMenu.module.css similarity index 69% rename from src/components/composed/SliderMenu/SliderMenu.css rename to src/components/composed/SliderMenu/SliderMenu.module.css index 09c6ebf3..954d7a38 100644 --- a/src/components/composed/SliderMenu/SliderMenu.css +++ b/src/components/composed/SliderMenu/SliderMenu.module.css @@ -1,4 +1,4 @@ -.backdrop-slider-menu { +.backdrop { position: absolute; top: 0; left: 0; @@ -12,13 +12,13 @@ cursor: default; } -.slider-menu { +.sliderMenu { position: absolute; top: 0; right: 0; height: 100%; width: 300px; - padding: 50px 30px 40px; + padding: 65px 30px 40px; background: rgb(var(--color-white)); box-shadow: -2px 0 5px rgba(0, 0, 0, 0.1); transform: translateX(100%); @@ -26,40 +26,46 @@ z-index: 99999; } -.slider-menu.open { +.open { transform: translateX(0); + transition: transform 0.3s ease-out; } -.slider-menu.close { +.close { transform: translateX(100%); + transition: transform 0.3s ease-in; } -.slider-menu-content { +.content { display: flex; flex-direction: column; justify-content: space-between; height: 100%; } -.slider-menu-close-button { +.closeButton { position: absolute; - top: 20px; - right: 20px; - width: 25px; - height: 25px; + top: 25px; + right: 30px; + width: 22px; + height: 22px; padding: 0; background-color: transparent !important; } -.slider-menu-close-button svg { +.closeButton svg { width: 68%; height: 68%; } -.slider-menu-close-button:hover { +.closeButton svg path { + stroke: rgb(var(--color-black)); +} + +.closeButton:hover { background-color: transparent; } -.slider-menu-close-button:hover svg path { +.closeButton:hover svg path { stroke: rgb(var(--color-black)); } diff --git a/src/components/composed/SliderMenu/SliderMenu.test.js b/src/components/composed/SliderMenu/SliderMenu.test.js index 641e19c6..080043b8 100644 --- a/src/components/composed/SliderMenu/SliderMenu.test.js +++ b/src/components/composed/SliderMenu/SliderMenu.test.js @@ -49,7 +49,7 @@ describe('SliderMenu', () => { expect(onCloseMock).toHaveBeenCalledTimes(1) }) - test('calls onClose when clicking outside the slider menu', () => { + test('calls onClose when clicking the backdrop', () => { render( { , ) - fireEvent.mouseDown(document) + fireEvent.click(screen.getByTestId('backdrop')) expect(onCloseMock).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/composed/SliderMenu/SliderMenu.tsx b/src/components/composed/SliderMenu/SliderMenu.tsx new file mode 100644 index 00000000..18e89f0e --- /dev/null +++ b/src/components/composed/SliderMenu/SliderMenu.tsx @@ -0,0 +1,79 @@ +import React, { + ReactNode, + useState, + useEffect, + useRef, + useCallback, +} from 'react' +import ReactDOM from 'react-dom' +import { Button } from '@BasicComponents' +import { ReactComponent as IconClose } from '@Assets/images/icon-close.svg' + +import styles from './SliderMenu.module.css' + +interface SliderMenuProps { + children: ReactNode + isOpen: boolean + onClose: () => void +} + +const SliderMenu = ({ children, isOpen, onClose }: SliderMenuProps) => { + const [isVisible, setIsVisible] = useState(false) + const [prevIsOpen, setPrevIsOpen] = useState(false) + + if (isOpen !== prevIsOpen) { + setPrevIsOpen(isOpen) + if (isOpen) { + setIsVisible(true) + } + } + + useEffect(() => { + if (!isOpen && isVisible) { + const timer = setTimeout(() => setIsVisible(false), 300) + return () => clearTimeout(timer) + } + }, [isOpen, isVisible]) + + const sliderRef = useRef(null) + + const handleBackdropClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose() + } + }, + [onClose], + ) + + const portalTarget = document.getElementById('root') || document.body + + const sliderMenuClass = `${styles.sliderMenu} ${isOpen ? styles.open : styles.close}` + + return ReactDOM.createPortal( + isVisible && ( +
    +
    + +
    {children}
    +
    +
    + ), + portalTarget, + ) +} + +export default SliderMenu diff --git a/src/components/composed/SwapInterface/SelectTokenSwap.css b/src/components/composed/SwapInterface/SelectTokenSwap.css deleted file mode 100644 index f111323c..00000000 --- a/src/components/composed/SwapInterface/SelectTokenSwap.css +++ /dev/null @@ -1,38 +0,0 @@ -.swap-select-wrapper { - position: relative; - width: 66%; -} - -.swap-token-select { - display: flex; - align-items: center; - gap: 8px; - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - position: relative; - width: 100%; - font-weight: bold; - font-size: 20px; - border-radius: 36px; - padding: 11px 26px 11px 16px; - border: 1px solid #e0e0e0; - background: transparent; - color: #222; - cursor: pointer; -} - -.swap-token-select::-ms-expand { - display: none; -} - -.swap-token-select:hover { - outline: none; - border: 1px solid rgba(var(--color-light-green), 0.5); -} - -.swap-token-symbol { - margin-left: 8px; - font-weight: 600; - color: #222; -} diff --git a/src/components/composed/SwapInterface/SelectTokenSwap.js b/src/components/composed/SwapInterface/SelectTokenSwap.js index c51c7a99..9052029d 100644 --- a/src/components/composed/SwapInterface/SelectTokenSwap.js +++ b/src/components/composed/SwapInterface/SelectTokenSwap.js @@ -2,16 +2,16 @@ import { SwapTokenLogo } from '@BasicComponents' import { ML } from '@Helpers' import { ReactComponent as ChevronDownIcon } from '@Assets/images/icon-chevron-down.svg' -import './SelectTokenSwap.css' +import styles from './SelectTokenSwap.module.css' const SelectTokenSwap = ({ token, onClick }) => { return (
    @@ -24,7 +24,7 @@ const SelectTokenSwap = ({ token, onClick }) => { : 'ML (Mintlayer)'}
    diff --git a/src/components/composed/SwapInterface/SelectTokenSwap.module.css b/src/components/composed/SwapInterface/SelectTokenSwap.module.css new file mode 100644 index 00000000..916c0692 --- /dev/null +++ b/src/components/composed/SwapInterface/SelectTokenSwap.module.css @@ -0,0 +1,44 @@ +.selectWrapper { + position: relative; + width: 66%; +} + +.tokenSelect { + display: flex; + align-items: center; + gap: 10px; + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + position: relative; + width: 100%; + font-weight: 700; + font-size: 18px; + border-radius: 21px; + padding: 8px 30px 8px 13px; + border: 1px solid rgba(var(--color-black), 0.06); + background: rgb(var(--color-white)); + color: rgb(var(--color-black)); + cursor: pointer; + transition: border-color 0.15s ease; +} + +.tokenSelect::-ms-expand { + display: none; +} + +.tokenSelect:hover { + outline: none; + border-color: rgba(var(--color-light-green), 0.5); +} + +.chevronIcon { + pointer-events: none; + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + width: 1em; + height: 1em; + color: rgba(var(--color-black), 0.4); +} diff --git a/src/components/composed/SwapInterface/SelectTokenSwap.test.js b/src/components/composed/SwapInterface/SelectTokenSwap.test.js index 3cc3ef3d..bb0c947e 100644 --- a/src/components/composed/SwapInterface/SelectTokenSwap.test.js +++ b/src/components/composed/SwapInterface/SelectTokenSwap.test.js @@ -70,11 +70,9 @@ describe('SelectTokenSwap', () => { onClick={mockOnClick} />, ) - expect(screen.getByTestId('select-token-swap')).toHaveClass( - 'swap-select-wrapper', - ) + expect(screen.getByTestId('select-token-swap')).toHaveClass('selectWrapper') expect(screen.getByTestId('select-token-swap-content')).toHaveClass( - 'swap-token-select', + 'tokenSelect', ) }) diff --git a/src/components/composed/SwapInterface/SwapInterface.css b/src/components/composed/SwapInterface/SwapInterface.css deleted file mode 100644 index 7dfce91c..00000000 --- a/src/components/composed/SwapInterface/SwapInterface.css +++ /dev/null @@ -1,213 +0,0 @@ -.swap-interface { - display: flex; - flex-direction: column; - width: 100%; - background: #f5f8f7; - border-radius: 18px; - gap: 6px; - min-height: max-content; - padding: 12px 68px; - color: #222; - box-shadow: none; - - @media screen and (min-width: 801px) { - gap: 16px; - padding: 24px 44px; - } -} - -.swap-row { - display: flex; - flex-direction: column; - gap: 10px; -} - -.inputs-wrapper { - display: flex; - align-items: center; - gap: 5px; -} - -.swap-balance { - margin-left: auto; - opacity: 0.7; - font-size: 14px; - color: #888; -} - -.swap-amount-input { - width: 34%; - height: 100%; - padding: 12px 16px; - font-size: 20px; - border-radius: 36px; - border: 1px solid #e0e0e0; - background: transparent; - color: #222; -} - -.swap-amount-input:hover { - outline: none; - border: 1px solid rgba(var(--color-light-green), 0.5); -} - -.swap-arrow-row { - display: flex; - justify-content: center; -} - -.swap-arrow-btn { - background: #e0f7ef; - border: none; - border-radius: 50%; - width: 40px; - height: 40px; - color: #7fd6b0; - font-size: 24px; - cursor: pointer; - transition: background 0.2s; -} - -.swap-arrow-btn:hover { - background: #b2f5d6; -} - -.swap-link { - color: #6fffb0; - text-decoration: underline; -} - -.swap-submit-btn { - width: 100%; - padding: 16px; - border-radius: 12px; - background: #6fffb0; - color: #222; - font-weight: 600; - font-size: 18px; - border: none; - margin-bottom: 16px; - cursor: pointer; - opacity: 1; - transition: opacity 0.2s; -} - -.swap-submit-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.swap-amount-input::-webkit-outer-spin-button, -.swap-amount-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.swap-amount-input[type='number'] { - appearance: textfield; -} - -.from-token-balance { - color: #888; - padding-left: 18px; -} - -.icon-arrow-swap { - width: 20px; - height: 20px; -} - -.icon-chevron-down { - pointer-events: none; - position: absolute; - right: 10px; - top: 50%; - transform: translateY(-50%); - width: 1em; - height: 1em; - color: #000; -} - -.swap-arrow-button { - cursor: default; - padding: 0; - width: 40px; - height: 40px; -} - -.token-popup-swap { - width: 100%; - max-height: 88%; -} - -.find-order-button { - width: 34%; - height: 100%; -} - -.find-order-button-icon { - width: 18px; - height: 18px; - max-width: 18px; - max-height: 18px; - margin-left: 10px; -} - -.find-order-button:hover .find-order-button-icon { - animation: moveArrowRight 0.3s ease-in-out; -} - -/* Tokens list styles */ - -.token-popup-swap { - display: flex; - flex-direction: column; - gap: 10px; - width: 100%; - height: 88%; -} - -.token-popup-swap h2 { - font-size: 1.2rem; - font-weight: bold; - min-height: max-content; -} - -.swap-token-search-input { - width: 100%; - padding: 10px 16px; - border-radius: 36px; - border: 1px solid #e0e0e0; - background: transparent; - color: #222; - font-size: 16px; - margin-bottom: 10px; -} - -.token-popup-swap ul { - display: flex; - flex-direction: column; - overflow-y: auto; - flex-grow: 1; - padding: 7px; - gap: 5px; -} - -.token-popup-swap ul li { - display: flex; - align-items: center; - padding: 10px 15px; - gap: 10px; - border-radius: 10px; - background: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); - min-height: 54px; - word-break: break-all; - cursor: pointer; - transition: all 0.3s ease-in-out; -} - -.token-popup-swap ul li:hover { - transform: scale(1.02); - border: 1px solid rgba(var(--color-light-green), 0.5); -} diff --git a/src/components/composed/SwapInterface/SwapInterface.js b/src/components/composed/SwapInterface/SwapInterface.js index 42b612a4..b9da5b81 100644 --- a/src/components/composed/SwapInterface/SwapInterface.js +++ b/src/components/composed/SwapInterface/SwapInterface.js @@ -8,7 +8,7 @@ import { ReactComponent as SearchIcon } from '@Assets/images/icon-search.svg' import SwapPopupContent from './SwapPopupContent' import SelectTokenSwap from './SelectTokenSwap' -import './SwapInterface.css' +import styles from './SwapInterface.module.css' const SwapInterface = () => { const { tokenBalances, balance, allNetworkTokensData, fetchOrdersPairInfo } = @@ -94,12 +94,12 @@ const SwapInterface = () => { return (
    -
    -

    Swap From

    -
    +
    +

    Swap From

    +
    { placeholder="0" value={amount} onChange={handleInputChange} - className="swap-amount-input" + className={styles.amountInput} id="swap-amount-input" />
    -

    +

    Balance: {fromToken.balance} {fromToken.token_ticker}

    -
    -
    -
    -

    Swap To

    -
    +
    +

    Swap To

    +
    {
    diff --git a/src/components/composed/SwapInterface/SwapInterface.module.css b/src/components/composed/SwapInterface/SwapInterface.module.css new file mode 100644 index 00000000..7dd6d42d --- /dev/null +++ b/src/components/composed/SwapInterface/SwapInterface.module.css @@ -0,0 +1,139 @@ +.form { + display: flex; + flex-direction: column; + width: 100%; + background: rgb(var(--color-white)); + border-radius: 16px; + gap: 4px; + min-height: max-content; + padding: 20px 24px; + color: rgb(var(--color-black)); + box-shadow: none; +} + +@media screen and (min-width: 801px) { + .form { + gap: 12px; + padding: 28px 36px; + } +} + +.row { + display: flex; + flex-direction: column; + gap: 10px; +} + +.label { + font-size: 13px; + font-weight: 600; + letter-spacing: 0.8px; + color: rgba(var(--color-light-green), 1); + text-transform: uppercase; + padding-left: 5px; +} + +.inputsWrapper { + display: flex; + align-items: center; + gap: 8px; +} + +.amountInput { + width: 32%; + height: 100%; + padding: 12px 16px; + font-size: 20px; + font-weight: 700; + border-radius: 36px; + border: 1px solid rgba(var(--color-black), 0.08); + background: rgb(var(--color-white)); + color: rgb(var(--color-black)); +} + +.amountInput:focus { + outline: none; + border: 1px solid rgba(var(--color-light-green), 0.5); +} + +.amountInput:hover { + outline: none; + border: 1px solid rgba(var(--color-light-green), 0.5); +} + +.amountInput::-webkit-outer-spin-button, +.amountInput::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.amountInput[type='number'] { + appearance: textfield; +} + +.balance { + color: rgba(var(--color-black), 0.35); + padding-left: 18px; + font-size: 14px; +} + +.arrowRow { + display: flex; + justify-content: center; + padding: 4px 0; +} + +.arrowIcon { + width: 20px; + height: 20px; +} + +/* Arrow button */ +.arrowButton { + cursor: default; + padding: 0; + width: 42px; + height: 42px; + border-radius: 50%; + background: rgba(var(--color-main-green), 1); +} + +.arrowButton:hover { + background: rgba(var(--color-main-green), 0.85); +} + +.arrowButton svg path { + stroke: rgb(var(--color-white)); +} + +.arrowButton:hover svg path { + stroke: rgb(var(--color-white)); +} + +/* Find orders button */ +.findButton { + width: 32%; + height: 100%; + font-weight: 600; + font-size: 16px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + border-radius: 36px; +} + +.findButton:hover svg path { + stroke: unset; +} + +.findOrderIcon { + width: 18px; + height: 18px; + max-width: 18px; + max-height: 18px; +} + +.findButton:hover .findOrderIcon { + animation: moveArrowRight 0.3s ease-in-out; +} diff --git a/src/components/composed/SwapInterface/SwapPopupContent.css b/src/components/composed/SwapInterface/SwapPopupContent.css deleted file mode 100644 index e69de29b..00000000 diff --git a/src/components/composed/SwapInterface/SwapPopupContent.js b/src/components/composed/SwapInterface/SwapPopupContent.js index 4b3592ef..fa8f9c65 100644 --- a/src/components/composed/SwapInterface/SwapPopupContent.js +++ b/src/components/composed/SwapInterface/SwapPopupContent.js @@ -2,6 +2,8 @@ import React, { useState } from 'react' import { SwapTokenLogo } from '@BasicComponents' import { ML } from '@Helpers' +import styles from './SwapPopupContent.module.css' + const SwapPopupContent = ({ tokens, coin, handleTokenChange, mode }) => { const [search, setSearch] = useState('') @@ -15,7 +17,7 @@ const SwapPopupContent = ({ tokens, coin, handleTokenChange, mode }) => { return (

    {title}

    @@ -24,14 +26,17 @@ const SwapPopupContent = ({ tokens, coin, handleTokenChange, mode }) => { placeholder="Search by symbol or token id" value={search} onChange={(e) => setSearch(e.target.value)} - className="swap-token-search-input" + className={styles.searchInput} /> -
      +
      • { handleTokenChange(coin) }} - className="swap-token-item" + className={styles.tokenItem} key={coin.coin} > @@ -43,7 +48,7 @@ const SwapPopupContent = ({ tokens, coin, handleTokenChange, mode }) => { onClick={() => { handleTokenChange(token) }} - className="swap-token-item" + className={styles.tokenItem} > { />, ) - expect(screen.getByTestId('swap-popup-content')).toHaveClass( - 'token-popup-swap', - ) + expect(screen.getByTestId('swap-popup-content')).toHaveClass('popup') expect( screen.getByPlaceholderText('Search by symbol or token id'), - ).toHaveClass('swap-token-search-input') + ).toHaveClass('searchInput') }) it('search input has correct attributes', () => { @@ -319,7 +317,7 @@ describe('SwapPopupContent', () => { 'Search by symbol or token id', ) expect(searchInput).toHaveAttribute('type', 'text') - expect(searchInput).toHaveClass('swap-token-search-input') + expect(searchInput).toHaveClass('searchInput') }) it('renders list structure correctly', () => { diff --git a/src/components/composed/TextField/TextField.css b/src/components/composed/TextField/TextField.module.css similarity index 57% rename from src/components/composed/TextField/TextField.css rename to src/components/composed/TextField/TextField.module.css index c78b7e35..8881944c 100644 --- a/src/components/composed/TextField/TextField.css +++ b/src/components/composed/TextField/TextField.module.css @@ -4,12 +4,11 @@ } .inputLabel.alternate { - font-size: 1.5rem; + font-size: 1rem; text-align: center; } .inputLabel.alternate strong { - font-size: 1.5rem; display: block; margin-top: 10px; color: rgb(var(--color-main-green)); @@ -22,3 +21,22 @@ .inputLabel.inputLabelRight { text-align: right; } + +.errorIcon { + display: none; +} + +.errorText { + line-height: 1.3; +} + +@keyframes errorSlideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/composed/TextField/TextField.test.js b/src/components/composed/TextField/TextField.test.js index 83487313..13c0fecc 100644 --- a/src/components/composed/TextField/TextField.test.js +++ b/src/components/composed/TextField/TextField.test.js @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react' -import TextField from './TextField' +import TextField from './TextField.tsx' const ONCHANGEHANDLESAMPLE = () => {} diff --git a/src/components/composed/TextField/TextField.js b/src/components/composed/TextField/TextField.tsx similarity index 53% rename from src/components/composed/TextField/TextField.js rename to src/components/composed/TextField/TextField.tsx index 1f7a41d2..538adc74 100644 --- a/src/components/composed/TextField/TextField.js +++ b/src/components/composed/TextField/TextField.tsx @@ -1,10 +1,26 @@ -import React, { useId, useEffect, useState } from 'react' +import { ReactNode, useId, useEffect, useState, ChangeEvent } from 'react' import { Input, Error } from '@BasicComponents' import { VerticalGroup } from '@LayoutComponents' -import { useStyleClasses } from '@Hooks' -import './TextField.css' +import styles from './TextField.module.css' + +interface TextFieldProps { + label?: ReactNode + labelPosition?: 'center' | 'left' | 'right' + placeHolder?: string + alternate?: boolean + password?: boolean + value?: string + onChangeHandle?: (value: string) => void + validity?: boolean | null + pattern?: string + extraStyleClasses?: string[] + errorMessages?: string | string[] | null + pristinity?: boolean + focus?: boolean + bigGap?: boolean +} const TextField = ({ label, @@ -20,45 +36,38 @@ const TextField = ({ errorMessages, pristinity = true, focus = true, - bigGap = true, -}) => { + bigGap = false, +}: TextFieldProps) => { const inputId = useId() - - const { styleClasses, addStyleClass, removeStyleClass } = - useStyleClasses('inputLabel') const [isPristine, setIsPristine] = useState(true) - const [fieldValidity, setFieldValidity] = useState(null) - - useEffect(() => { - alternate ? addStyleClass('alternate') : removeStyleClass('alternate') - }, [alternate, addStyleClass, removeStyleClass]) + const [fieldValidity, setFieldValidity] = useState(null) useEffect(() => { - if (isPristine || validity === null) return + if (isPristine || validity === null || validity === undefined) return validity ? setFieldValidity('valid') : setFieldValidity('invalid') }, [validity, isPristine]) - useEffect(() => { - labelPosition === 'left' - ? addStyleClass('inputLabelLeft') - : removeStyleClass('inputLabelLeft') - labelPosition === 'right' - ? addStyleClass('inputLabelRight') - : removeStyleClass('inputLabelRight') - }, [labelPosition, addStyleClass, removeStyleClass]) - useEffect(() => { setIsPristine(pristinity) }, [pristinity]) const setPristineState = () => setIsPristine(false) + const labelClasses = [ + styles.inputLabel, + alternate && styles.alternate, + labelPosition === 'left' && styles.inputLabelLeft, + labelPosition === 'right' && styles.inputLabelRight, + ] + .filter(Boolean) + .join(' ') + return ( {label && ( diff --git a/src/components/containers/CreateAccount/WordsListDescription.css b/src/components/containers/CreateAccount/WordsListDescription.css index ded55ce9..1db7bc55 100644 --- a/src/components/containers/CreateAccount/WordsListDescription.css +++ b/src/components/containers/CreateAccount/WordsListDescription.css @@ -1,15 +1,15 @@ .words-list-description { - font-size: 24px; + font-size: 22px; margin-bottom: 20px; } .words-list-description:nth-child(2) { - font-size: 24px; + font-size: 22px; margin: 0; } .word-list-highlighted { - color: rgb(var(--color-darker-green)); - font-size: 1.5rem; + color: rgb(var(--mojito-green)); + font-size: 22px; margin: 0; } diff --git a/src/components/containers/Dashboard/CryptoList.css b/src/components/containers/Dashboard/CryptoList.css index 925fcdd7..cbc24e57 100644 --- a/src/components/containers/Dashboard/CryptoList.css +++ b/src/components/containers/Dashboard/CryptoList.css @@ -21,6 +21,9 @@ font-weight: bold; } .crypto-list { + display: flex; + flex-direction: column; + gap: 0.4rem; height: 240px; overflow-y: auto; overflow-x: hidden; @@ -36,13 +39,16 @@ position: relative; display: flex; justify-content: space-between; - padding: 12px 30px 12px 18px; - margin-bottom: 0.75rem; + align-items: center; + padding: 12px 29px 12px 8px; cursor: pointer; transition: all 0.3s ease-in-out; background-color: rgb(var(--color-gray)); border: 1px solid rgba(var(--color-light-green), 0.2); - border-radius: 45px; + border-radius: 35px; + max-height: 72px; + min-height: 72px; + box-sizing: border-box; } .logo-wrapper { @@ -54,12 +60,15 @@ } .crypto-item .name-values { + display: flex; + flex-direction: column; + justify-content: center; width: 393px; - margin: 0.3rem 0 0.3rem 1.875rem; + margin: 0 0 0 1rem; } .crypto-item .name-values h5 { - font-size: 1.5rem; + font-size: 1.2rem; font-weight: bold; } @@ -95,7 +104,7 @@ .crypto-item .name-values .values dt, .crypto-item .name-values .values dd { - font-size: 1.125rem; + font-size: 0.95rem; display: block; } @@ -111,20 +120,42 @@ .crypto-item .name-values .values dd { font-weight: 600; - line-height: 2.3rem; + line-height: 1.6rem; margin-bottom: auto; } .crypto-item svg { - height: 72px; - width: 72px; + height: 60px; + width: 60px; +} + +.crypto-item .logo-round { + height: 60px; + width: 60px; + min-width: 60px; +} + +.crypto-item .token-logo-round { + height: 60px; + width: 60px; + min-width: 60px; + min-height: 60px; + font-size: 18px; +} + +.crypto-item .token-logo-round img { + right: -4px; + bottom: -2px; + height: 10px; + width: 10px; + padding: 3px; } .crypto-item .connect-logo { background: rgb(var(--color-green)); border-radius: 50%; - height: 72px; - width: 72px; + height: 60px; + width: 60px; } .crypto-item .connect-logo img { diff --git a/src/components/containers/Dashboard/CryptoList.js b/src/components/containers/Dashboard/CryptoList.js index b68c426e..5fd387aa 100644 --- a/src/components/containers/Dashboard/CryptoList.js +++ b/src/components/containers/Dashboard/CryptoList.js @@ -55,7 +55,7 @@ export const CryptoItem = ({ onClickItem, item }) => { return ( <> {fetchingBalances ? ( - + ) : (
      • ({ - value: (crypto.balance * crypto.exchangeRate).toFixed(2), - asset: crypto.name, - color: AppInfo.COLOR_LIST[crypto.symbol.toLowerCase()], - valueSymbol: fiatSymbol, - })) + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET + const hasBalance = totalBalance > 0 && !isTestnet + + const [integerPart, decimalPart] = totalBalanceInFiat.split( + AppInfo.decimalSeparator, + ) + + const data = hasBalance + ? cryptos.map((crypto) => ({ + value: (crypto.balance * crypto.exchangeRate).toFixed(2), + asset: crypto.name, + color: AppInfo.COLOR_LIST[crypto.symbol.toLowerCase()], + valueSymbol: fiatSymbol, + })) + : [{ value: 1, asset: '', color: AppInfo.COLOR_LIST.ml, valueSymbol: '' }] return ( <> @@ -36,15 +46,23 @@ const CryptoSharesChart = ({ />

    + {accountName} {balanceLoading ? ( - '' + ) : ( - <> - {totalBalanceInFiat} - {fiatSymbol} - + + $ + {integerPart} + {decimalPart !== undefined && ( + <> + + {AppInfo.decimalSeparator} + {decimalPart} + + + )} + )} - {accountName}

    diff --git a/src/components/containers/Dashboard/DashboardSkeleton.module.css b/src/components/containers/Dashboard/DashboardSkeleton.module.css new file mode 100644 index 00000000..27597755 --- /dev/null +++ b/src/components/containers/Dashboard/DashboardSkeleton.module.css @@ -0,0 +1,48 @@ +.statItemSkeleton { + pointer-events: none; + width: 210px; + height: 112px; +} + +.statItemSkeleton dt, +.statItemSkeleton dd { + width: 100%; +} + +.skeletonLine { + display: block; + border-radius: 4px; + animation: skeleton-dash-loading 1s linear infinite alternate; +} + +.skeletonLineWide { + width: 70%; + height: 1.4rem; + margin-top: 4px; +} + +.skeletonLineNarrow { + width: 40%; + height: 0.55rem; +} + +.balanceSkeleton { + display: block; + margin-top: 4px; +} + +.skeletonLineBalance { + width: 140px; + height: 2.2rem; + border-radius: 6px; + margin: 0 auto; +} + +@keyframes skeleton-dash-loading { + 0% { + background: rgba(var(--color-dark-gray), 0.08); + } + 100% { + background: rgba(var(--color-dark-gray), 0.16); + } +} diff --git a/src/components/containers/Dashboard/DashboardSkeleton.tsx b/src/components/containers/Dashboard/DashboardSkeleton.tsx new file mode 100644 index 00000000..35252819 --- /dev/null +++ b/src/components/containers/Dashboard/DashboardSkeleton.tsx @@ -0,0 +1,29 @@ +import React from 'react' +import styles from './DashboardSkeleton.module.css' + +const StatisticsSkeleton: React.FC = () => ( +
      +
    • +
      +
      +
    • +
    • +
      +
      +
    • +
    +) + +const BalanceSkeleton: React.FC = () => ( + + + +) + +export { StatisticsSkeleton, BalanceSkeleton } diff --git a/src/components/containers/Dashboard/Statistics.css b/src/components/containers/Dashboard/Statistics.css index 8e0af398..02bb6ba9 100644 --- a/src/components/containers/Dashboard/Statistics.css +++ b/src/components/containers/Dashboard/Statistics.css @@ -8,46 +8,82 @@ align-items: center; justify-content: flex-end; height: 100%; + width: 100%; } .stats-list ul { display: flex; - justify-content: space-evenly; - gap: 5px; + flex-direction: column; + gap: 0; + padding: 0; + margin: 0; + list-style: none; + height: 100%; + width: 100%; + position: relative; +} + +.stats-list ul::before { + content: ''; + position: absolute; + left: 0; + top: 24%; + bottom: 24%; + width: 1px; + background: rgb(var(--color-light-gray)); + opacity: 0.5; } .stat-item { display: flex; flex-direction: column; - width: 50%; - text-align: center; - border-radius: 20px; + text-align: left; + padding: 24px 24px 22px 48px; + border-left: none; + background: none; + border-radius: 0; + flex: 1; justify-content: center; - padding: 7px 12px; - min-width: max-content; + position: relative; +} + +.stat-item + .stat-item::before { + content: ''; + position: absolute; + top: 0; + left: 46px; + right: 0; + height: 1px; + background: rgb(var(--color-light-gray)); + opacity: 0.5; } .stat-item.stats-positive { - background: rgb(var(--color-light-green), 0.1); + background: none; } .stat-item.stats-negative { - background: rgb(var(--color-red), 0.1); + background: none; } -.stat-item dt { - font-weight: 600; - font-size: 1.5rem; +.stat-item dd { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.12em; + color: rgb(var(--color-dark-gray)); + margin: 0 0 4px 0; + order: -1; } -.stat-item dd { - font-weight: thin; - font-size: 1.125rem; - margin: 0; +.stat-item dt { + font-weight: 700; + font-size: 2.7rem; + line-height: 1.1; } .stats-positive dt { - color: rgb(var(--color-light-green)); + color: rgb(var(--color-stats-green)); } .stats-negative dt { @@ -55,5 +91,7 @@ } .stats-list .stat-unit { - font-size: 1rem; + font-size: 1.1rem; + font-weight: 500; + opacity: 0.5; } diff --git a/src/components/containers/Dashboard/Statistics.js b/src/components/containers/Dashboard/Statistics.js index 9532489a..10b4d7a4 100644 --- a/src/components/containers/Dashboard/Statistics.js +++ b/src/components/containers/Dashboard/Statistics.js @@ -1,38 +1,40 @@ +import { useContext } from 'react' import { VerticalGroup } from '@LayoutComponents' +import { MintlayerContext } from '@Contexts' +import { StatisticsSkeleton } from './DashboardSkeleton' import './Statistics.css' -const Statistics = ({ stats = [], highestBalance, totalBalance }) => { +const Statistics = ({ stats = [] }) => { + const { balanceLoading } = useContext(MintlayerContext) + return ( <>
    -
      - {stats && - stats.map((stat) => ( + {balanceLoading ? ( + + ) : ( +
        + {stats.map((stat) => (
      • = 0 - ? 'stats-positive' - : 'stats-negative' - : 'stats-positive' + parseFloat(stat.value) >= 0 + ? 'stats-positive' + : 'stats-negative' }`} >
        - {totalBalance > 0 && ( - <> - {parseFloat(stat.value) >= 0 ? '+' : '-'} - {Math.abs(parseFloat(stat.value))} - {stat.unit} - - )} + {parseFloat(stat.value) >= 0 ? '+' : '-'} + {Math.abs(parseFloat(stat.value))} + {stat.unit}
        - {totalBalance > 0 &&
        {stat.name}
        } +
        {stat.name}
      • ))} -
      +
    + )}
    diff --git a/src/components/containers/DeleteAccount/DeleteAccount.css b/src/components/containers/DeleteAccount/DeleteAccount.css deleted file mode 100644 index 4dff6c8a..00000000 --- a/src/components/containers/DeleteAccount/DeleteAccount.css +++ /dev/null @@ -1,16 +0,0 @@ -.remove-title { - font-size: 1.5rem; -} - -.remove-paragraph { - font-size: 1.12rem; -} - -.highlighted { - color: rgb(var(--color-red)); - font-weight: 600; -} - -.popup-delete-button { - min-width: 120px; -} diff --git a/src/components/containers/DeleteAccount/DeleteAccount.js b/src/components/containers/DeleteAccount/DeleteAccount.js deleted file mode 100644 index 3e20bbc5..00000000 --- a/src/components/containers/DeleteAccount/DeleteAccount.js +++ /dev/null @@ -1,96 +0,0 @@ -import { useState, useContext } from 'react' - -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { Button } from '@BasicComponents' -import { Login } from '@ContainerComponents' - -import { Account } from '@Entities' -import { AccountContext, MintlayerContext } from '@Contexts' -import { useNavigate } from 'react-router' - -import './DeleteAccount.css' - -const DeleteAccount = () => { - const [step, setStep] = useState(1) - const { - logout, - verifyAccountsExistence, - deletingAccount, - setRemoveAccountPopupOpen, - } = useContext(AccountContext) - const { setAllDataFetching } = useContext(MintlayerContext) - const nextButonClickHandler = () => setStep(2) - const buttonExtraStyleClasses = ['popup-delete-button'] - const buttonCancelExtraStyleClasses = ['popup-delete-button delete-cancel'] - const navigate = useNavigate() - - const deleteAccountHandler = async (addresses, accountId) => { - try { - await Account.deleteAccount(accountId) - await verifyAccountsExistence() - navigate('/') - setAllDataFetching(false) - logout() - setRemoveAccountPopupOpen(false) - } catch (e) { - console.error(e) - } - } - - const onCancel = () => { - setRemoveAccountPopupOpen(false) - } - - return ( - - {step === 1 && ( - - -

    - Are you sure you want to permanently delete your wallet? -

    -

    - All local data associated with this wallet will be permanently - lost. -

    -

    - This action cannot be undone. -

    -

    - Please make sure that you have securely saved your seed phrase - before proceeding. -

    -

    - Please confirm that you wish to proceed. -

    -
    - - - - -
    - )} - {step === 2 && ( - - )} -
    - ) -} - -export default DeleteAccount diff --git a/src/components/containers/DeleteAccount/DeleteAccount.module.css b/src/components/containers/DeleteAccount/DeleteAccount.module.css new file mode 100644 index 00000000..4e5249f8 --- /dev/null +++ b/src/components/containers/DeleteAccount/DeleteAccount.module.css @@ -0,0 +1,155 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; + max-width: 520px; + margin: 0 auto; +} + +.warningBadge { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + border-radius: 20px; + background: rgba(var(--color-red), 0.1); + border: 1px solid rgba(var(--color-red), 0.2); + margin-bottom: 20px; +} + +.warningBadge svg { + width: 28px; + height: 28px; + color: rgb(var(--color-red)); +} + +.title { + font-size: 20px; + font-weight: 700; + text-align: center; + color: rgb(var(--color-black)); + margin-bottom: 8px; +} + +.subtitle { + font-size: 14px; + color: rgb(var(--color-dark-gray)); + text-align: center; + margin-bottom: 24px; +} + +.warningBox { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + padding: 16px; + border-radius: 16px; + background: rgba(var(--color-red), 0.08); + margin-bottom: 24px; +} + +.warningBoxIcon { + flex-shrink: 0; + width: 22px; + height: 22px; + color: rgb(var(--color-red)); + margin-top: 1px; +} + +.warningBoxContent strong { + display: block; + font-size: 14px; + font-weight: 700; + color: rgb(var(--color-red)); + margin-bottom: 4px; +} + +.warningBoxContent span { + font-size: 13px; + color: rgb(var(--color-red)); + opacity: 0.85; +} + +.checkboxList { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + margin-bottom: 24px; +} + +.checkboxItem { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: 16px; + background: rgb(var(--color-gray)); + cursor: pointer; + transition: background 0.2s ease; +} + +.checkboxItem input { + appearance: none; + flex-shrink: 0; + width: 20px; + height: 20px; + border: 2px solid rgb(var(--color-light-gray)); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; +} + +.checkboxItem input:checked { + background: rgb(var(--color-red)); + border-color: rgb(var(--color-red)); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"); + background-size: 14px; + background-position: center; + background-repeat: no-repeat; +} + +.checkboxItem span { + font-size: 14px; + color: rgb(var(--color-black)); +} + +.buttonRow { + display: flex; + gap: 12px; + width: 100%; +} + +.cancelButton { + flex: 1; +} + +.deleteButton { + flex: 1; + background-color: rgb(var(--color-red)); +} + +.deleteButton:hover, +.deleteButton:focus { + background-color: rgb(var(--color-red)); + opacity: 0.9; +} + +.deleteButtonInner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.deleteButtonInner svg { + width: 16px; + height: 16px; +} + +.labelRow h1 { + margin-bottom: 30px; +} diff --git a/src/components/containers/DeleteAccount/DeleteAccount.test.js b/src/components/containers/DeleteAccount/DeleteAccount.test.js index 74b35517..0d9cc902 100644 --- a/src/components/containers/DeleteAccount/DeleteAccount.test.js +++ b/src/components/containers/DeleteAccount/DeleteAccount.test.js @@ -32,14 +32,10 @@ describe('DeleteAccount', () => { , , ) - expect( - screen.getByText( - 'Are you sure you want to permanently delete your wallet?', - ), - ).toBeInTheDocument() + expect(screen.getByText('Delete wallet permanently?')).toBeInTheDocument() }) - it('changes step on Continue click', () => { + it('changes step on Delete wallet click after confirming checkboxes', () => { render( @@ -50,7 +46,10 @@ describe('DeleteAccount', () => { , , ) - fireEvent.click(screen.getByText('Continue')) + const checkboxes = screen.getAllByRole('checkbox') + fireEvent.click(checkboxes[0]) + fireEvent.click(checkboxes[1]) + fireEvent.click(screen.getByText('Delete wallet')) expect(screen.getByText('Delete Wallet')).toBeInTheDocument() }) @@ -69,7 +68,7 @@ describe('DeleteAccount', () => { expect(mockContext.setRemoveAccountPopupOpen).toHaveBeenCalledWith(false) }) - it('calls deleteAccountHandler on form submit', async () => { + it('disables delete button until both checkboxes are checked', () => { render( @@ -80,16 +79,14 @@ describe('DeleteAccount', () => { , , ) - fireEvent.click(screen.getByText('Continue')) - fireEvent.submit(screen.getByText('Delete Wallet')) + const deleteBtn = screen.getByText('Delete wallet').closest('button') + expect(deleteBtn).toBeDisabled() - // await waitFor(() => expect(Account.deleteAccount).toHaveBeenCalledWith('1')) - // await waitFor(() => - // expect(mockContext.verifyAccountsExistence).toHaveBeenCalled(), - // ) - // await waitFor(() => expect(mockContext.logout).toHaveBeenCalled()) - // await waitFor(() => - // expect(mockContext.setRemoveAccountPopupOpen).toHaveBeenCalledWith(false), - // ) + const checkboxes = screen.getAllByRole('checkbox') + fireEvent.click(checkboxes[0]) + expect(deleteBtn).toBeDisabled() + + fireEvent.click(checkboxes[1]) + expect(deleteBtn).not.toBeDisabled() }) }) diff --git a/src/components/containers/DeleteAccount/DeleteAccount.tsx b/src/components/containers/DeleteAccount/DeleteAccount.tsx new file mode 100644 index 00000000..9431ea5c --- /dev/null +++ b/src/components/containers/DeleteAccount/DeleteAccount.tsx @@ -0,0 +1,177 @@ +import { useState, useContext, ChangeEvent, ReactNode } from 'react' + +import { CenteredLayout } from '@LayoutComponents' +import { Button } from '@BasicComponents' +import { Login } from '@ContainerComponents' + +import { Account } from '@Entities' +import { AccountContext, MintlayerContext } from '@Contexts' +import { useNavigate } from 'react-router' + +import styles from './DeleteAccount.module.css' + +const DeleteAccount = () => { + const [step, setStep] = useState(1) + const [seedSaved, setSeedSaved] = useState(false) + const [understandIrreversible, setUnderstandIrreversible] = useState(false) + const { + logout, + verifyAccountsExistence, + deletingAccount, + setRemoveAccountPopupOpen, + } = useContext(AccountContext) + const { setAllDataFetching } = useContext(MintlayerContext) + const navigate = useNavigate() + + const canProceed = seedSaved && understandIrreversible + + const nextButonClickHandler = () => setStep(2) + + const deleteAccountHandler = async ( + addresses: unknown, + accountId: string | number, + ) => { + try { + await Account.deleteAccount(accountId as string) + await verifyAccountsExistence() + navigate('/') + setAllDataFetching(false) + logout() + setRemoveAccountPopupOpen(false) + } catch (e) { + console.error(e) + } + } + + const onCancel = () => { + setRemoveAccountPopupOpen(false) + } + + const label = (): ReactNode => ( +
    +
    +

    {deletingAccount.name}

    +
    +

    Enter your password to delete this wallet

    +
    + ) + + return ( + + {step === 1 && ( +
    +
    + + + +
    + +

    Delete wallet permanently?

    +

    + All local data associated with this wallet will be permanently lost. +

    + +
    + + + +
    + This action cannot be undone + + Make sure you have securely saved your seed phrase before + proceeding. + +
    +
    + +
    + + +
    + +
    + + +
    +
    + )} + {step === 2 && ( + + )} +
    + ) +} + +export default DeleteAccount diff --git a/src/components/containers/Login/AccountCard.module.css b/src/components/containers/Login/AccountCard.module.css new file mode 100644 index 00000000..3a5b3250 --- /dev/null +++ b/src/components/containers/Login/AccountCard.module.css @@ -0,0 +1,90 @@ +.card { + display: flex; + align-items: center; + gap: 14px; + min-height: 72px; + padding: 16px 18px; + border-radius: 16px; + border: 1px solid rgba(var(--color-black), 0.1); + background: rgb(var(--color-white)); + cursor: pointer; + transition: + border-color 0.2s, + box-shadow 0.2s; + + @media screen and (min-width: 801px) { + padding: 18px 22px; + border-radius: 18px; + } +} + +.card:hover { + border-color: rgba(var(--mojito-green), 1); + box-shadow: 0 2px 12px rgba(var(--color-black), 0.06); +} + +.avatar { + width: 48px; + height: 48px; + border-radius: 50%; + flex-shrink: 0; + + @media screen and (min-width: 801px) { + width: 56px; + height: 56px; + } +} + +.cardInfo { + flex: 1; + min-width: 0; +} + +.cardName { + font-size: 15px; + font-weight: 600; + color: rgb(var(--color-black)); + margin: 0; + + @media screen and (min-width: 801px) { + font-size: 17px; + } +} + +.deleteButton { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 8px; + border: none; + background: transparent; + cursor: pointer; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.2s; +} + +.card:hover .deleteButton { + opacity: 1; +} + +.deleteIcon { + width: 13px; + height: 13px; + fill: rgba(var(--color-black), 0.3); + transition: fill 0.2s; +} + +.deleteButton:hover .deleteIcon { + fill: rgb(var(--color-red)); +} + +.chevron { + width: 18px; + height: 18px; + flex-shrink: 0; + color: rgba(var(--color-black), 0.25); + transform: rotate(-90deg); +} diff --git a/src/components/containers/Login/AccountCard.test.js b/src/components/containers/Login/AccountCard.test.js new file mode 100644 index 00000000..aa0f316c --- /dev/null +++ b/src/components/containers/Login/AccountCard.test.js @@ -0,0 +1,54 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import AccountCard from './AccountCard.tsx' + +const account = { id: '1', name: 'My Wallet' } +const gradient = 'linear-gradient(135deg, #a8e6cf, #f9e79f)' + +describe('AccountCard', () => { + it('renders account name', () => { + render( +
      + +
    , + ) + expect(screen.getByText('My Wallet')).toBeInTheDocument() + }) + + it('calls onSelect when clicked', () => { + const onSelect = jest.fn() + render( +
      + +
    , + ) + fireEvent.click(screen.getByTestId('carousel-item')) + expect(onSelect).toHaveBeenCalledWith(account) + }) + + it('calls onDelete when delete button clicked', () => { + const onSelect = jest.fn() + const onDelete = jest.fn() + render( +
      + +
    , + ) + fireEvent.click(screen.getByTestId('delete-wallet-button')) + expect(onDelete).toHaveBeenCalled() + }) +}) diff --git a/src/components/containers/Login/AccountCard.tsx b/src/components/containers/Login/AccountCard.tsx new file mode 100644 index 00000000..0cb057b2 --- /dev/null +++ b/src/components/containers/Login/AccountCard.tsx @@ -0,0 +1,51 @@ +import React from 'react' +import { ReactComponent as ChevronIcon } from '@Assets/images/icon-chevron-down.svg' +import { ReactComponent as IconBin } from '@Assets/images/icon-bin.svg' + +import styles from './AccountCard.module.css' + +interface Account { + id: string | number + name: string +} + +interface AccountCardProps { + account: Account + gradient: string + onSelect: (account: Account) => void + onDelete: (e: React.MouseEvent, account: Account) => void +} + +const AccountCard = ({ + account, + gradient, + onSelect, + onDelete, +}: AccountCardProps) => { + return ( +
  • onSelect(account)} + data-testid="carousel-item" + > +
    +
    +

    {account.name}

    +
    + + +
  • + ) +} + +export default AccountCard diff --git a/src/components/containers/Login/Login.css b/src/components/containers/Login/Login.css deleted file mode 100644 index eaa02ef6..00000000 --- a/src/components/containers/Login/Login.css +++ /dev/null @@ -1,41 +0,0 @@ -.list-accounts { - justify-content: center; - align-items: center; - align-content: center; - display: flex; - flex-direction: column; - animation: fadeIn 0.3s ease-in-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - } - to { - opacity: 1; - } -} -.subtitle { - margin-top: 2rem; - margin-bottom: 0.5rem; - font-size: 24px; - font-weight: lighter; -} -.content { - width: 100%; - display: flex; - justify-content: center; - align-items: center; -} - -.add-wallet-button-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.add-wallet-button:hover .add-wallet-button-icon { - animation: moveArrowUpRight 0.3s ease-in-out; -} diff --git a/src/components/containers/Login/Login.js b/src/components/containers/Login/Login.js deleted file mode 100644 index 123e6a64..00000000 --- a/src/components/containers/Login/Login.js +++ /dev/null @@ -1,43 +0,0 @@ -import React from 'react' - -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' -import { Button } from '@BasicComponents' -import { Carousel } from '@ComposedComponents' - -import './Login.css' - -const Login = ({ accounts, onSelect, onCreate }) => { - const onSelectAccount = (account) => onSelect && onSelect(account) - const onCreateAccount = () => onCreate && onCreate() - - return ( -
    -

    - Available wallet{accounts.length > 1 ? 's' : ''} -

    - -
    - -
    - - - -
    -
    - ) -} - -export default Login diff --git a/src/components/containers/Login/Login.module.css b/src/components/containers/Login/Login.module.css new file mode 100644 index 00000000..d016fe87 --- /dev/null +++ b/src/components/containers/Login/Login.module.css @@ -0,0 +1,93 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + animation: fadeIn 0.3s ease-in-out; + width: 100%; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.heading { + font-size: 22px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 20px 0 6px; + + @media screen and (min-width: 801px) { + font-size: 28px; + margin: 30px 0 8px; + } +} + +.subtitle { + font-size: 13px; + color: rgba(var(--color-black), 0.5); + margin: 0 0 24px; + + @media screen and (min-width: 801px) { + font-size: 15px; + margin: 0 0 32px; + } +} + +.list { + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + max-width: 400px; + min-height: 312px; + flex: 1; + overflow: auto; + + @media screen and (min-width: 801px) { + gap: 14px; + max-width: 460px; + } +} + +.addButton { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + max-width: 400px; + padding: 16px; + margin-top: 40px; + border-radius: 16px; + border: 1.5px dashed rgba(var(--color-black), 0.15); + background: transparent; + cursor: pointer; + font-size: 14px; + color: rgba(var(--color-black), 0.45); + transition: + border-color 0.2s, + color 0.2s; + + @media screen and (min-width: 801px) { + max-width: 460px; + padding: 18px; + border-radius: 18px; + font-size: 15px; + } +} + +.addButton:hover { + border: 1.5px dashed rgba(var(--mojito-green), 1); + color: rgba(var(--mojito-green), 1); +} + +.addIcon { + font-size: 18px; + font-weight: 300; + line-height: 1; +} diff --git a/src/components/containers/Login/Login.test.js b/src/components/containers/Login/Login.test.js index 6dccdea9..387f7742 100644 --- a/src/components/containers/Login/Login.test.js +++ b/src/components/containers/Login/Login.test.js @@ -1,5 +1,5 @@ import { fireEvent, render, screen } from '@testing-library/react' -import ListAccounts from './Login' +import ListAccounts from './Login.tsx' import { AccountContext } from '@Contexts' const data = { @@ -14,6 +14,7 @@ const mockContext = { verifyAccountsExistence: jest.fn(), deletingAccount: { id: '1', addresses: ['address1'] }, setRemoveAccountPopupOpen: jest.fn(), + setDeletingAccount: jest.fn(), } test('Renders List Accounts page', () => { @@ -46,6 +47,6 @@ test('Render button onCreate', () => { , ) - fireEvent.click(screen.getByText('Add Wallet')) + fireEvent.click(screen.getByTestId('add-wallet-button')) expect(data.onCreate).toHaveBeenCalled() }) diff --git a/src/components/containers/Login/Login.tsx b/src/components/containers/Login/Login.tsx new file mode 100644 index 00000000..cad5a7ce --- /dev/null +++ b/src/components/containers/Login/Login.tsx @@ -0,0 +1,71 @@ +import React, { useContext } from 'react' +import { AccountContext } from '@Contexts' + +import AccountCard from './AccountCard' +import styles from './Login.module.css' + +interface Account { + id: string | number + name: string +} + +interface LoginProps { + accounts: Account[] + onSelect?: (account: Account) => void + onCreate?: () => void +} + +const AVATAR_GRADIENTS = [ + 'linear-gradient(135deg, #a8e6cf, #f9e79f, #f5b041)', + 'linear-gradient(135deg, #89CFF0, #B19CD9)', + 'linear-gradient(135deg, #f5af19, #f12711)', + 'linear-gradient(135deg, #43e97b, #38f9d7)', + 'linear-gradient(135deg, #fa709a, #fee140)', + 'linear-gradient(135deg, #a18cd1, #fbc2eb)', +] + +const Login = ({ accounts, onSelect, onCreate }: LoginProps) => { + const { setRemoveAccountPopupOpen, setDeletingAccount } = + useContext(AccountContext) + const onSelectAccount = (account: Account) => onSelect && onSelect(account) + const onCreateAccount = () => onCreate && onCreate() + + const onDeleteAccount = (e: React.MouseEvent, account: Account) => { + e.stopPropagation() + setDeletingAccount(account) + setRemoveAccountPopupOpen(true) + } + + return ( +
    +

    Choose an account

    +

    Select which wallet to unlock

    + +
      + {accounts.map((account, index) => ( + + ))} +
    + + +
    + ) +} + +export default Login diff --git a/src/components/containers/Login/SetPassword.css b/src/components/containers/Login/SetPassword.css deleted file mode 100644 index 5c3d42ec..00000000 --- a/src/components/containers/Login/SetPassword.css +++ /dev/null @@ -1,41 +0,0 @@ -.content { - margin-top: 2.5rem; - justify-content: space-between; - align-items: center; - align-content: center; - display: flex; - flex-direction: column; - height: 100%; -} - -.loading-big { - width: 190px; - height: 190px; -} - -.loading-big::after { - width: 160px; - height: 160px; - border: 15px solid #fff; - border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) - transparent; -} - -.loadingText { - margin-bottom: 60px; - font-size: 24px; - font-weight: 400; - color: rgb(var(--color-black)); -} - -.login-button-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.login-password-submit:hover .login-button-icon { - animation: moveArrowRight 0.3s ease-in-out; -} diff --git a/src/components/containers/Login/SetPassword.module.css b/src/components/containers/Login/SetPassword.module.css new file mode 100644 index 00000000..e513a436 --- /dev/null +++ b/src/components/containers/Login/SetPassword.module.css @@ -0,0 +1,105 @@ +.shieldBadge { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + height: 60px; + margin: 0 auto 20px; + border-radius: 20px; + background: rgb(var(--mojito-green-soft)); + border: 1px solid rgba(30, 187, 129, 0.2); +} + +.shieldBadge svg { + width: 36px; + height: 36px; + color: rgb(var(--mojito-green)); +} + +.content { + margin-top: 2.5rem; + justify-content: space-between; + align-items: center; + align-content: center; + display: flex; + flex-direction: column; + height: 100%; +} + +.form { + width: 340px; + + @media (min-width: 801px) { + width: 400px; + } +} + +.loadingWrapper { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; +} + +.loadingBig { + width: 190px; + height: 190px; +} + +.loadingBig::after { + width: 160px; + height: 160px; + border: 15px solid #fff; + border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) + transparent; +} + +.loadingText { + margin-bottom: 60px; + font-size: 24px; + font-weight: 400; + text-align: center; + color: rgb(var(--color-black)); +} + +.loginButtonIcon { + width: 13px; + height: 13px; + max-width: 13px; + max-height: 13px; + margin-left: 10px; +} + +.loginPasswordSubmit { + width: 100%; + margin-top: 5px; +} + +.loginPasswordSubmit:hover .loginButtonIcon { + animation: moveArrowRight 0.3s ease-in-out; +} + +.labelRow { + display: flex; + flex-direction: column; + align-items: center; + gap: 30px; +} + +.labelRow h1 { + font-size: 22px; + margin-bottom: 5px; + font-weight: 600; + color: rgb(var(--color-black)); +} + +.labelRow h2 { + font-size: 18px; + font-weight: 400; +} + +.labelRow p { + font-size: 14px; + color: rgb(var(--color-dark-gray)); +} diff --git a/src/components/containers/Login/SetPassword.test.js b/src/components/containers/Login/SetPassword.test.js index 7ebeacdf..82269305 100644 --- a/src/components/containers/Login/SetPassword.test.js +++ b/src/components/containers/Login/SetPassword.test.js @@ -3,7 +3,7 @@ import { MemoryRouter } from 'react-router' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { AccountProvider, SettingsProvider } from '@Contexts' -import SetPassword from './SetPassword' +import SetPassword from './SetPassword.tsx' const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) @@ -58,7 +58,7 @@ const setup = ({ data = _data } = {}) => { , ) - const title = screen.getByText('Password for') + const title = screen.getByText('Welcome back') const account = screen.getByText(data.account.name) const password = screen.getByPlaceholderText('Password') const loginButton = screen.getByTestId('login-password-submit') diff --git a/src/components/containers/Login/SetPassword.js b/src/components/containers/Login/SetPassword.tsx similarity index 51% rename from src/components/containers/Login/SetPassword.js rename to src/components/containers/Login/SetPassword.tsx index d3930e02..6b133c54 100644 --- a/src/components/containers/Login/SetPassword.js +++ b/src/components/containers/Login/SetPassword.tsx @@ -1,77 +1,118 @@ -import { useState } from 'react' +import { useState, FormEvent, ReactNode } from 'react' import { useLocation } from 'react-router' import { Button } from '@BasicComponents' import { Loading, TextField } from '@ComposedComponents' import { VerticalGroup, CenteredLayout } from '@LayoutComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' +import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' -import './SetPassword.css' +import styles from './SetPassword.module.css' + +interface Account { + id: string | number + name: string +} + +interface CheckPasswordResult { + addresses?: unknown + [key: string]: unknown +} + +interface SetPasswordProps { + onChangePassword?: (value: string) => void + onSubmit?: (addresses: unknown, id: string | number, name: string) => void + checkPassword: ( + id: string | number, + password: string, + ) => Promise + selectedAccount?: Account + buttonTitle?: string + customLabel?: string | ReactNode +} const SetPassword = ({ onChangePassword, onSubmit, checkPassword, selectedAccount, - buttonTitle = 'Log In', -}) => { + buttonTitle = 'Unlock wallet', + customLabel, +}: SetPasswordProps) => { const location = useLocation() - const account = selectedAccount ? selectedAccount : location.state.account - const loadingExtraClasses = ['loading-big'] + const account: Account = selectedAccount + ? selectedAccount + : location.state.account const [accountPasswordValue, setAccountPasswordValue] = useState('') - const [accountPasswordValid, setAccountPasswordValid] = useState(null) - const [accountPasswordPritinity, setAccountPasswordPritinity] = useState(true) + const [accountPasswordValid, setAccountPasswordValid] = useState< + boolean | null + >(null) + const [accountPasswordPristinity, setAccountPasswordPristinity] = + useState(true) const [accountPasswordErrorMessage, setAccountPasswordErrorMessage] = - useState(null) + useState(null) const [unlockingAccount, setUnlockingAccount] = useState(false) const passwordFieldValidity = async () => { try { const accountData = await checkPassword(account.id, accountPasswordValue) return accountData - } catch (e) { + } catch { return false } } - const accountPasswordChangeHandler = (value) => { + const accountPasswordChangeHandler = (value: string) => { setAccountPasswordValue(value) onChangePassword && onChangePassword(value) } - const label = () => ( - <> - Password for {account.name} - - ) + const label = (): ReactNode => + customLabel ? ( + customLabel + ) : ( +
    +
    +

    {account.name}

    +

    Welcome back

    +
    + +

    Enter your password to unlock

    +
    + ) - const submitHandler = (e) => { + const submitHandler = (e: FormEvent) => { e.preventDefault() e.stopPropagation() - setAccountPasswordPritinity(false) + setAccountPasswordPristinity(false) setUnlockingAccount(true) passwordFieldValidity().then((validated) => { - const addresses = validated.addresses - if (!addresses) { + if (!validated || !validated.addresses) { setAccountPasswordValid(false) setUnlockingAccount(false) setAccountPasswordErrorMessage('Incorrect password') return } - onSubmit(addresses, account.id, account.name) + onSubmit && onSubmit(validated.addresses, account.id, account.name) }) } return (
    -
    +
    - - + + {!unlockingAccount ? ( <> +
    + +
    ) : ( - <> -

    +
    +

    {' '} Just a sec, we are validating your password...{' '}

    - - + +
    )} diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.js b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.js new file mode 100644 index 00000000..64242902 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.js @@ -0,0 +1,173 @@ +import React, { useState, useRef } from 'react' + +import { Error } from '@BasicComponents' +import { CenteredLayout, VerticalGroup } from '@LayoutComponents' +import { ReactComponent as IconUpload } from '@Assets/images/icon-upload.svg' +import { ReactComponent as IconDocument } from '@Assets/images/icon-document.svg' +import { AppInfo } from '@Constants' + +import styles from './FileUpload.module.css' + +const requiredKeys = { + id: 'number', + iv: { + btcIv: 'string', + mlTestnetPrivKeyIv: 'string', + mlMainnetPrivKeyIv: 'string', + }, + name: 'string', + salt: 'string', + tag: { + btcTag: 'string', + mlTestnetPrivKeyTag: 'string', + mlMainnetPrivKeyTag: 'string', + }, + seed: { + btcEncryptedSeed: 'string', + encryptedMlMainnetPrivateKey: 'string', + encryptedMlTestnetPrivateKey: 'string', + }, + walletType: 'string', + walletsToCreate: ['string'], +} + +const validateKeys = (json, required) => { + return Object.keys(required).every((key) => { + if (!(key in json)) return false + const requiredType = required[key] + const valueType = typeof json[key] + + if (Array.isArray(requiredType)) { + return ( + Array.isArray(json[key]) && + json[key].every((item) => typeof item === requiredType[0]) + ) + } + + if (valueType === 'object' && !Array.isArray(json[key])) { + return validateKeys(json[key], requiredType) + } + + return valueType === requiredType + }) +} + +const FileUpload = ({ + fileContent, + setFileContent, + errorMessage, + setErrorMessage, +}) => { + const fileInputRef = useRef(null) + const [fileName, setFileName] = useState('') + const [isDragOver, setIsDragOver] = useState(false) + + const processFile = (file) => { + if (file.size > AppInfo.MAX_UPLOAD_FILE_SIZE) { + setErrorMessage('The file size exceeds the maximum limit of 2 KB.') + return + } + setFileName(file.name) + const reader = new FileReader() + reader.onload = (e) => { + try { + const content = e.target.result + const json = JSON.parse(content) + const isValid = validateKeys(json, requiredKeys) + + if (isValid) { + setFileContent(json) + setErrorMessage('') + } else { + setErrorMessage( + 'The JSON file does not contain all required keys or has invalid values.', + ) + } + } catch { + setErrorMessage('Invalid JSON file.') + } + } + reader.readAsText(file) + } + + const handleFileChange = (event) => { + const file = event.target.files[0] + if (file) processFile(file) + } + + const handleDrop = (e) => { + e.preventDefault() + setIsDragOver(false) + const file = e.dataTransfer.files[0] + if (file) processFile(file) + } + + const handleDragOver = (e) => { + e.preventDefault() + setIsDragOver(true) + } + + const handleDragLeave = () => { + setIsDragOver(false) + } + + const handleDropzoneClick = () => { + fileInputRef.current.click() + } + + const dropzoneClasses = [styles.dropzone] + if (isDragOver) dropzoneClasses.push(styles.dropzoneActive) + if (fileContent) dropzoneClasses.push(styles.dropzoneUploaded) + + return ( + + +

    Select backup file

    +

    + Choose the JSON file exported from Mojito Wallet +

    +
    + {fileContent ? ( + <> +
    + +
    +

    {fileName}

    +

    Ready to import

    +

    + Click to choose a different file +

    + + ) : ( + <> +
    + +
    +

    + Drag & drop or click to upload +

    +

    JSON backup file

    + + )} +
    + + {errorMessage && } +
    +
    + ) +} + +export default FileUpload diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css new file mode 100644 index 00000000..51deec01 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/FileUpload.module.css @@ -0,0 +1,117 @@ +.title { + margin-bottom: 0.5rem; + font-size: 1.5rem; + font-weight: 700; + text-align: center; + color: rgb(var(--color-black)); +} + +.subtitle { + font-size: 1rem; + text-align: center; + color: rgb(var(--color-dark-gray)); + margin-bottom: 1.5rem; +} + +.dropzone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-width: 440px; + gap: 1rem; + min-height: 200px; + padding: 2.5rem 2rem; + border: 2px dashed rgb(var(--color-light-gray)); + border-radius: 16px; + background: rgb(var(--color-gray)); + cursor: pointer; + transition: + border-color 0.2s, + background 0.2s; +} + +.dropzone:hover { + border-color: rgb(var(--mojito-green)); + background: rgb(var(--mojito-green-50)); +} + +.dropzoneActive { + border-color: rgb(var(--mojito-green)); + background: rgb(var(--mojito-green-50)); +} + +.dropzoneUploaded { + border-color: rgb(var(--mojito-green)); + background: rgb(var(--mojito-green-50)); +} + +.iconWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 50%; + background: rgb(var(--color-extra-light-gray)); +} + +.uploadIcon { + width: 24px; + height: 24px; + color: rgb(var(--color-dark-gray)); +} + +.dropzoneText { + font-size: 1rem; + font-weight: 600; + color: rgb(var(--color-black)); + text-align: center; +} + +.dropzoneHint { + font-size: 0.875rem; + color: rgb(var(--color-dark-gray)); + text-align: center; +} + +.uploadedFileName { + font-size: 1rem; + font-weight: 600; + color: rgb(var(--color-black)); + text-align: center; +} + +.uploadedStatus { + font-size: 0.875rem; + color: rgb(var(--mojito-green)); + font-weight: 500; + text-align: center; +} + +.uploadedChange { + font-size: 0.8125rem; + color: rgb(var(--color-dark-gray)); + text-align: center; +} + +.uploadedIconWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: 12px; + background: rgb(var(--color-extra-light-gray)); + border: 1px solid rgba(var(--mojito-green), 0.3); +} + +.uploadedIcon { + width: 24px; + height: 24px; + color: rgb(var(--mojito-green)); +} + +.hidden { + display: none; +} diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.css b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.css deleted file mode 100644 index aae86b4c..00000000 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.css +++ /dev/null @@ -1,49 +0,0 @@ -.account-form-json { - margin-top: 4em; -} - -.restore-wallet-title { - margin-bottom: 1rem; - font-size: 1.5rem; - font-weight: 500; - text-align: center; -} - -.restore-wallet-description { - font-size: 1.2rem; - text-align: center; -} - -.restore-wallet-item { - display: flex; - width: 500px; -} - -.restore-wallet-details-title, -.restore-wallet-details-content { - padding: 15px; - background: rgba(var(--color-light-green), 0.2); - word-break: break-word; - border-radius: 10px; -} - -.restore-wallet-details-title { - margin-right: 10px; - width: 25%; -} - -.restore-wallet-details-content { - flex-grow: 1; -} - -.restore-upload-button { - margin-top: 1rem; -} - -.restore-file-submit-icon { - margin-left: 10px; -} - -.restore-file-submit-button:hover:not(:disabled) .restore-file-submit-icon { - animation: moveArrowRight 0.3s ease-in-out; -} diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.js b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.js index f62b8619..6f898d35 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.js +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.js @@ -1,33 +1,25 @@ -import React, { useState, useRef } from 'react' +import React, { useState, useEffect, useContext } from 'react' import { useNavigate } from 'react-router' -import { Button, Error } from '@BasicComponents' +import { Button } from '@BasicComponents' import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { ProgressTracker, Header } from '@ComposedComponents' +import { ProgressTracker } from '@ComposedComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' import { Account } from '@Entities' -import { AppInfo } from '@Constants' +import { AccountContext } from '@Contexts' -import './RestoreAccountJson.css' +import FileUpload from './FileUpload' +import WalletDetails from './WalletDetails' +import RestoreSuccess from './RestoreSuccess' -const RestoreWalletDetailsItem = ({ label, value }) => { - const content = Array.isArray(value) ? value.join(', ').toUpperCase() : value - return ( -
    -

    {label}:

    -

    {content}

    -
    - ) -} +import styles from './RestoreAccountJson.module.css' const RestoreAccountJson = () => { const navigate = useNavigate() + const { setCustomBackAction } = useContext(AccountContext) const [step, setStep] = useState(1) - const fileInputRef = useRef(null) const [errorMessage, setErrorMessage] = useState('') const [fileContent, setFileContent] = useState(null) - const [fileName, setFileName] = useState('') - const uploadButtonExtraClasses = ['restore-upload-button'] const steps = [ { name: 'Backup file', active: step === 1 }, @@ -35,12 +27,6 @@ const RestoreAccountJson = () => { { name: 'Finish', active: step === 3 }, ] - const walletDetails = [ - { label: 'Name', value: fileContent?.name }, - { label: 'ID', value: fileContent?.id }, - { label: 'Wallets', value: fileContent?.walletsToCreate }, - ] - const handleSubmit = (e) => { e.preventDefault() if (step === 1 && fileContent && !errorMessage) { @@ -50,7 +36,7 @@ const RestoreAccountJson = () => { try { Account.restoreAccountFromJSON(fileContent) setStep(step + 1) - } catch (error) { + } catch { setErrorMessage('Error restoring account from JSON file.') } } @@ -60,182 +46,51 @@ const RestoreAccountJson = () => { } const isSubmitButtonDisabled = step === 1 && (!fileContent || errorMessage) - const uploadButtonContent = !fileContent - ? 'Upload JSON file' - : `Uploaded: ${fileName}` - const submitButtonContent = step === 3 ? 'Finish' : 'Next' + const submitButtonTitles = { 2: 'Restore wallet', 3: 'Go to login' } + const submitButtonContent = submitButtonTitles[step] || 'Next' const customBackAction = () => { navigate('/') } - const handleFileChange = (event) => { - const file = event.target.files[0] - if (file) { - if (file.size > AppInfo.MAX_UPLOAD_FILE_SIZE) { - setErrorMessage('The file size exceeds the maximum limit of 2 KB.') - return - } - setFileName(file.name) - const reader = new FileReader() - reader.onload = (e) => { - try { - const content = e.target.result - const json = JSON.parse(content) - - // Define the required keys and their expected types - const requiredKeys = { - id: 'number', - iv: { - btcIv: 'string', - mlTestnetPrivKeyIv: 'string', - mlMainnetPrivKeyIv: 'string', - }, - name: 'string', - salt: 'string', - tag: { - btcTag: 'string', - mlTestnetPrivKeyTag: 'string', - mlMainnetPrivKeyTag: 'string', - }, - seed: { - btcEncryptedSeed: 'string', - encryptedMlMainnetPrivateKey: 'string', - encryptedMlTestnetPrivateKey: 'string', - }, - walletType: 'string', - walletsToCreate: ['string'], - } - - const validateKeys = (json, requiredKeys) => { - return Object.keys(requiredKeys).every((key) => { - if (!(key in json)) { - return false - } - const requiredType = requiredKeys[key] - const valueType = typeof json[key] - - if (Array.isArray(requiredType)) { - return ( - Array.isArray(json[key]) && - json[key].every((item) => typeof item === requiredType[0]) - ) - } - - if (valueType === 'object' && !Array.isArray(json[key])) { - return validateKeys(json[key], requiredType) - } - - return valueType === requiredType - }) - } - - const isValid = validateKeys(json, requiredKeys) - - if (isValid) { - setFileContent(json) - setErrorMessage('') - } else { - setErrorMessage( - 'The JSON file does not contain all required keys or has invalid values.', - ) - } - } catch (error) { - setErrorMessage('Invalid JSON file.') - } - } - reader.readAsText(file) - } - } - - const handleUploadButtonClick = () => { - fileInputRef.current.click() - } + useEffect(() => { + setCustomBackAction(() => customBackAction) + return () => setCustomBackAction(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) return (
    -
    {step === 1 && ( - - -

    - Select your backup file -

    -

    - Please select the backup file you want to restore your wallet - from. -

    - - - - - {errorMessage && } -
    -
    - )} - {step === 2 && ( - - -

    Wallet details

    - {walletDetails.map((item, index) => ( - - ))} -
    -
    + )} + {step === 2 && } - {step === 3 && ( - - -

    Congraduation!

    -

    - You have successfully restored your wallet. Please go to the - login page to access your account. -

    -

    - Remember to keep your recovery details safe and secure. -

    -
    -
    - )} + {step === 3 && }
    diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.module.css new file mode 100644 index 00000000..d0f6b7c9 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.module.css @@ -0,0 +1,15 @@ +.accountForm { + margin-top: 2rem; + @media (min-width: 801px) { + margin-top: 4rem; + } +} + +.submitIcon { + margin-left: 10px; + width: 15px; +} + +.submitButton:hover:not(:disabled) .submitIcon { + animation: moveArrowRight 0.3s ease-in-out; +} diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.test.js b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.test.js index e1f0b84c..d7c2ace5 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.test.js +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreAccountJson.test.js @@ -59,13 +59,13 @@ describe('RestoreAccountJson', () => { , ) - expect(screen.getByText('Select your backup file')).toBeInTheDocument() + expect(screen.getByText('Select backup file')).toBeInTheDocument() expect( - screen.getByText( - 'Please select the backup file you want to restore your wallet from.', - ), + screen.getByText('Choose the JSON file exported from Mojito Wallet'), + ).toBeInTheDocument() + expect( + screen.getByText('Drag & drop or click to upload'), ).toBeInTheDocument() - expect(screen.getByText('Upload JSON file')).toBeInTheDocument() expect(screen.getByText('Next')).toBeInTheDocument() }) @@ -118,19 +118,17 @@ describe('RestoreAccountJson', () => { expect(screen.getByText('Next')).toBeInTheDocument() - await expect( - screen.findByText('Uploaded: valid.json'), - ).resolves.toBeInTheDocument() + await expect(screen.findByText('valid.json')).resolves.toBeInTheDocument() fireEvent.click(screen.getByText('Next')) - expect(screen.getAllByText('Wallet details')).toHaveLength(2) - expect(screen.getByText('Name:')).toBeInTheDocument() - expect(screen.getByText('ID:')).toBeInTheDocument() - expect(screen.getByText('Wallets:')).toBeInTheDocument() + expect(screen.getByText('Confirm wallet details')).toBeInTheDocument() + expect(screen.getByText('Wallet Name')).toBeInTheDocument() + expect(screen.getByText('Wallet ID')).toBeInTheDocument() + expect(screen.getByText('Assets')).toBeInTheDocument() expect(screen.getByText('Test Wallet')).toBeInTheDocument() - expect(screen.getByText('1')).toBeInTheDocument() + expect(screen.getByText('#1')).toBeInTheDocument() expect(screen.getByText('WALLET1, WALLET2')).toBeInTheDocument() }) @@ -156,24 +154,17 @@ describe('RestoreAccountJson', () => { expect(screen.getByText('Next')).toBeInTheDocument() - await expect( - screen.findByText('Uploaded: valid.json'), - ).resolves.toBeInTheDocument() + await expect(screen.findByText('valid.json')).resolves.toBeInTheDocument() fireEvent.click(screen.getByText('Next')) - fireEvent.click(screen.getByText('Next')) + fireEvent.click(screen.getByText('Restore wallet')) - expect(screen.getByText('Congraduation!')).toBeInTheDocument() - expect( - screen.getByText( - 'You have successfully restored your wallet. Please go to the login page to access your account.', - ), - ).toBeInTheDocument() + expect(screen.getByText('Wallet restored!')).toBeInTheDocument() expect( screen.getByText( - 'Remember to keep your recovery details safe and secure.', + 'Your wallet has been successfully restored from the backup file.', ), ).toBeInTheDocument() - expect(screen.getAllByText('Finish')).toHaveLength(2) + expect(screen.getByText('Go to login')).toBeInTheDocument() }) }) diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.js b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.js new file mode 100644 index 00000000..9163e8df --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.js @@ -0,0 +1,24 @@ +import React from 'react' + +import { CenteredLayout, VerticalGroup } from '@LayoutComponents' +import { ReactComponent as IconSuccess } from '@Assets/images/icon-success.svg' + +import styles from './RestoreSuccess.module.css' + +const RestoreSuccess = () => { + return ( + + +
    + +
    +

    Wallet restored!

    +

    + Your wallet has been successfully restored from the backup file. +

    +
    +
    + ) +} + +export default RestoreSuccess diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css new file mode 100644 index 00000000..a8991e36 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/RestoreSuccess.module.css @@ -0,0 +1,30 @@ +.title { + font-size: 1.75rem; + font-weight: 700; + text-align: center; + color: rgb(var(--color-black)); +} + +.description { + font-size: 1rem; + text-align: center; + color: rgb(var(--color-dark-gray)); + line-height: 1.5; +} + +.iconCircle { + display: flex; + align-items: center; + justify-content: center; + width: 120px; + height: 120px; + border-radius: 50%; + background: rgb(var(--mojito-green-50)); + margin: 0 auto; +} + +.checkIcon { + width: 40px; + height: 40px; + color: rgb(var(--mojito-green)); +} diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.js b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.js new file mode 100644 index 00000000..d1da5f33 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.js @@ -0,0 +1,62 @@ +import React from 'react' + +import { CenteredLayout, VerticalGroup } from '@LayoutComponents' +import { ReactComponent as IconAccount } from '@Assets/images/icon-account.svg' +import { ReactComponent as IconWallet } from '@Assets/images/icon-wallet.svg' +import { ReactComponent as IconInbox } from '@Assets/images/icon-inbox.svg' +import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' + +import styles from './WalletDetails.module.css' + +const WalletDetails = ({ fileContent }) => { + const details = [ + { label: 'Wallet Name', value: fileContent?.name, icon: }, + { label: 'Wallet ID', value: `#${fileContent?.id}`, icon: }, + { + label: 'Assets', + value: fileContent?.walletsToCreate, + icon: , + }, + ] + + return ( + + +

    Confirm wallet details

    +

    + Review the information from your backup file +

    +
    + {details.map((item, index) => { + const content = Array.isArray(item.value) + ? item.value.join(', ').toUpperCase() + : item.value + return ( +
    +
    {item.icon}
    +
    +

    {item.label}

    +

    {content}

    +
    +
    + ) + })} +
    +
    +
    + +
    +

    + You'll need your password to unlock this wallet after + restoring. +

    +
    +
    +
    + ) +} + +export default WalletDetails diff --git a/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css new file mode 100644 index 00000000..26a3f6d4 --- /dev/null +++ b/src/components/containers/RestoreAccount/RestoreAccountJson/WalletDetails.module.css @@ -0,0 +1,106 @@ +.title { + font-size: 1.5rem; + font-weight: 700; + text-align: center; + color: rgb(var(--color-black)); + + @media (min-width: 801px) { + margin-bottom: 0.5rem; + } +} + +.subtitle { + font-size: 1rem; + text-align: center; + color: rgb(var(--color-dark-gray)); + + @media (min-width: 801px) { + margin-bottom: 1.5rem; + } +} + +.detailsCard { + background: rgb(var(--color-gray)); + border: 1px solid rgb(var(--color-medium-gray)); + border-radius: 16px; + overflow: hidden; + min-width: 440px; +} + +.detailsRow { + display: flex; + align-items: center; + gap: 1rem; + padding: 1.25rem 1.5rem; +} + +.detailsRow + .detailsRow { + border-top: 1px solid rgb(var(--color-extra-light-gray)); +} + +.detailsIcon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 12px; + background: rgb(var(--mojito-green-50)); + color: rgb(var(--mojito-green)); + flex-shrink: 0; +} + +.detailsIcon svg { + width: 20px; + height: 20px; +} + +.detailsLabel { + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: rgb(var(--color-dark-gray)); + margin-bottom: 0.125rem; +} + +.detailsValue { + font-size: 1rem; + font-weight: 600; + color: rgb(var(--color-black)); + word-break: break-word; +} + +.infoBanner { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1.25rem; + background: rgb(var(--mojito-green-50)); + border: 1px solid rgba(30, 187, 129, 0.2); + border-radius: 12px; + + @media (min-width: 801px) { + margin-top: 1rem; + } +} + +.infoBannerIcon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + color: rgb(var(--mojito-green)); +} + +.infoBannerIcon svg { + width: 20px; + height: 20px; +} + +.infoBannerText { + font-size: 0.875rem; + color: rgb(var(--color-dark-gray)); +} diff --git a/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.css b/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.css index 9f45a11c..f849c00d 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.css +++ b/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.css @@ -7,19 +7,51 @@ } .account-form-description { - margin-top: 204px; + margin-top: 100px; } .account-form-address-type { margin-top: 2.5em; } +.restore-mnemonic-title { + font-size: 20px; + margin-bottom: 20px; +} + +.itemWrapper { + width: 400px; +} + .account-input { padding: 1.2rem 1.7rem; } +.words-description-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.words-description-icon { + display: flex; + align-items: center; + justify-content: center; + width: 80px; + height: 80px; + border-radius: 20px; + background: rgba(var(--mojito-green), 0.1); +} + +.words-description-icon svg { + width: 40px; + height: 40px; + color: rgb(var(--mojito-green)); +} + .words-description { - font-size: 24px; + font-size: 22px; text-align: center; } diff --git a/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.js b/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.js index 9a196d95..a753a827 100644 --- a/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.js +++ b/src/components/containers/RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic.js @@ -1,19 +1,20 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect, useContext } from 'react' import { useNavigate } from 'react-router' -import { Expressions } from '@Constants' +import { AppInfo, Expressions } from '@Constants' import { BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { AccountContext } from '@Contexts' import { Button, Error } from '@BasicComponents' import { CenteredLayout, VerticalGroup } from '@LayoutComponents' import { ProgressTracker, - Header, TextField, RestoreSeedField, } from '@ComposedComponents' import { ReactComponent as IconArrowRight } from '@Assets/images/icon-arrow-right.svg' +import { ReactComponent as IconDocumentFilled } from '@Assets/images/icon-document-filled.svg' import './RestoreAccountMnemonic.css' @@ -44,6 +45,7 @@ const RestoreAccountMnemonic = ({ const btcAddressType = BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT const navigate = useNavigate() + const { setCustomBackAction } = useContext(AccountContext) const isSeedValid = (words, DefaultWordList = []) => { if (words.length !== 12 && words.length !== 24) { @@ -59,20 +61,13 @@ const RestoreAccountMnemonic = ({ return isWordListValid && validateMnemonicFn(wordsFields.join(' ')) }, [wordsFields, defaultBTCWordList, validateMnemonicFn]) - const accountNameErrorMessage = useMemo(() => { - return !accountNameValid - ? 'The wallet name should have at least 4 characteres.' - : null - }, [accountNameValid]) - - const accountPasswordErrorMessage = useMemo(() => { - return !accountPasswordValid - ? [ - 'Your password should have at least 8 characteres.', - 'Also it should have a lowercase letter, an uppercase letter, a digit, and a special char like: /\\*()&^%$#@-_=+\'"?!:;<>~`', - ] - : null - }, [accountPasswordValid]) + const accountNameErrorMessage = !accountNameValid + ? AppInfo.WALLET_NAME_ERROR + : null + + const accountPasswordErrorMessage = !accountPasswordValid + ? AppInfo.WALLET_PASSWORD_ERROR + : null const getMnemonics = () => wordsFields.join(' ').trim() @@ -94,6 +89,13 @@ const RestoreAccountMnemonic = ({ } const goToPrevStep = () => (step < 2 ? navigate(-1) : setStep(step - 1)) + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + setCustomBackAction(() => goToPrevStep) + return () => setCustomBackAction(null) + }, [step]) + /* eslint-enable react-hooks/exhaustive-deps */ + const steps = [ { name: 'Wallet Name', active: step === 1 }, { name: 'Wallet Password', active: step === 2 }, @@ -162,7 +164,6 @@ const RestoreAccountMnemonic = ({ return (
    -
    {step === 1 && ( - +
    + + Create a name for your wallet +

    + } + extraStyleClasses={inputExtraclasses} + errorMessages={accountNameErrorMessage} + pristinity={accountNamePristinity} + alternate + /> +
    )} {step === 2 && ( - +
    + + Create a password for your wallet + + } + placeHolder={'Password'} + extraStyleClasses={inputExtraclasses} + errorMessages={accountPasswordErrorMessage} + pristinity={accountPasswordPristinity} + alternate + /> +
    )} {step === 3 && ( - +
    +
    + +

    - +

    )} {step === 4 && ( <> @@ -230,7 +246,9 @@ const RestoreAccountMnemonic = ({ - - - - ) - ) : ( - -

    Your transaction was sent.

    -

    Txid: {transactionTxid}

    - - - -
    - )} - - )}
    ) } diff --git a/src/components/containers/SendTransaction/SendBtcTransaction.module.css b/src/components/containers/SendTransaction/SendBtcTransaction.module.css new file mode 100644 index 00000000..8507c3f9 --- /dev/null +++ b/src/components/containers/SendTransaction/SendBtcTransaction.module.css @@ -0,0 +1,27 @@ +.transactionForm { + display: flex; + flex-direction: column; + gap: 16px; +} + +.transactionForm input.invalid, +.transactionForm input.valid { + padding: 1.8rem 1.685rem; +} + +.loadingCenter { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} + +.resultTitle { + word-wrap: break-word; +} + +.loadingText { + font-size: 24px; + font-weight: 400; + text-align: center; +} diff --git a/src/components/containers/SendTransaction/SendMlTransaction.css b/src/components/containers/SendTransaction/SendMlTransaction.css deleted file mode 100644 index f6cde816..00000000 --- a/src/components/containers/SendTransaction/SendMlTransaction.css +++ /dev/null @@ -1,79 +0,0 @@ -.form-field { - display: flex; - flex-direction: row; - height: 7.6rem; -} - -.form-field .address-field { - font-size: 1.4rem; -} - -.form-field input { - height: 3rem; -} - -.form-field button { - height: 3.75rem; -} - -.form-field label { - font-size: 1.5rem; -} - -.form-field > * { - align-self: center; -} - -.form-field > *:first-child { - width: 9rem; -} - -.form-field:nth-child(2) > *:first-child, -.form-field:nth-child(3) > *:first-child { - align-self: unset; - margin-top: 1.75rem; -} - -.send-transaction-button { - margin-top: 0.8rem; -} - -.transaction-form input.invalid, -.transaction-form input.valid { - padding: 1.8rem 1.685rem; -} - -.result-title { - word-wrap: break-word; -} - -.loading-text { - font-size: 24px; - font-weight: 400; - text-align: center; -} - -.nft-transaction-info { - display: flex; - align-items: center; - gap: 3.4rem; - margin: 15px 0; -} - -.nft-transaction-info h2 { - font-size: 1.5rem; - font-weight: 400; - min-width: max-content; -} - -.nft-transaction-info p { - font-size: 1.4rem; - word-break: break-all; -} - -.loading-center { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); -} diff --git a/src/components/containers/SendTransaction/SendMlTransaction.js b/src/components/containers/SendTransaction/SendMlTransaction.js index 5884bd60..deb7488a 100644 --- a/src/components/containers/SendTransaction/SendMlTransaction.js +++ b/src/components/containers/SendTransaction/SendMlTransaction.js @@ -1,22 +1,24 @@ import React, { useEffect, useState, useContext } from 'react' import Decimal from 'decimal.js' +import { ReactComponent as MlLogo } from '@Assets/images/logo.svg' import { Button } from '@BasicComponents' -import { Loading } from '@ComposedComponents' +import { Loading, WalletCard } from '@ComposedComponents' import { CenteredLayout } from '@LayoutComponents' import { Format, NumbersHelper } from '@Helpers' -import { AccountContext } from '@Contexts' +import { AccountContext, SettingsContext } from '@Contexts' import { AppInfo } from '@Constants' import FeesField from './FeesField' import AddressField from './AddressField' import AmountField from './AmountField' -import './SendMlTransaction.css' +import styles from './SendMlTransaction.module.css' import { Error } from '@BasicComponents' const SendMlTransaction = ({ totalFeeCrypto, feeLoading, + feeError, transactionData, exchangeRate = 0, maxValueInToken, @@ -29,6 +31,8 @@ const SendMlTransaction = ({ walletType, }) => { const { balanceLoading } = useContext(AccountContext) + const { networkType } = useContext(SettingsContext) + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET const [amountInCrypto, setAmountInCrypto] = useState('0.00') const [originalAmount, setOriginalAmount] = useState('0,00') const [addressTo, setAddressTo] = useState('') @@ -169,15 +173,21 @@ const SendMlTransaction = ({ : 'Send' return ( -
    +
    {balanceLoading || sendingTransaction ? ( -
    +
    ) : ( <> + + {transactionMode === AppInfo.ML_TRANSACTION_MODES.NFT_SEND && ( -
    +

    Nft Id:

    {transactionData.tokenId @@ -202,7 +212,7 @@ const SendMlTransaction = ({ exchangeRate={exchangeRate} maxValueInToken={maxValueInToken} setAmountValidity={setAmountValidity} - errorMessage={passErrorMessage} + errorMessage={feeError || passErrorMessage} totalFeeInCrypto={totalFeeCrypto} transactionMode={transactionMode} /> @@ -212,6 +222,7 @@ const SendMlTransaction = ({ value={feeLoading ? 'calculating fee...' : totalFeeCrypto} walletType={walletType} setFeeValidity={true} + loading={feeLoading} /> {txErrorMessage ? ( diff --git a/src/components/containers/SendTransaction/SendMlTransaction.module.css b/src/components/containers/SendTransaction/SendMlTransaction.module.css new file mode 100644 index 00000000..49a75249 --- /dev/null +++ b/src/components/containers/SendTransaction/SendMlTransaction.module.css @@ -0,0 +1,46 @@ +.transactionForm { + position: relative; + display: flex; + flex-direction: column; + gap: 16px; +} + +.transactionForm input.invalid, +.transactionForm input.valid { + padding: 1.8rem 1.685rem; +} + +.resultTitle { + word-wrap: break-word; +} + +.loadingText { + font-size: 24px; + font-weight: 400; + text-align: center; +} + +.nftTransactionInfo { + display: flex; + align-items: center; + gap: 3.4rem; + margin: 15px 0; +} + +.nftTransactionInfo h2 { + font-size: 1.5rem; + font-weight: 400; + min-width: max-content; +} + +.nftTransactionInfo p { + font-size: 1.4rem; + word-break: break-all; +} + +.loadingCenter { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} diff --git a/src/components/containers/SendTransaction/SendTransaction.test.js b/src/components/containers/SendTransaction/SendTransaction.test.js index 915904e7..0a18da67 100644 --- a/src/components/containers/SendTransaction/SendTransaction.test.js +++ b/src/components/containers/SendTransaction/SendTransaction.test.js @@ -1,4 +1,5 @@ import { render, screen, act, fireEvent } from '@testing-library/react' +import { MemoryRouter } from 'react-router' import SendBtcTransaction from './SendBtcTransaction' @@ -19,21 +20,22 @@ const TRANSACTIONDATASAMPLE = { test('Send Transaction', async () => { await act(async () => { render( - - - - - {}} - calculateTotalFee={() => {}} - walletType={{ name: 'Mintlayer' }} - /> - - , - - - , + + + + + + {}} + calculateTotalFee={() => {}} + walletType={{ name: 'Mintlayer' }} + /> + + + + + , ) }) diff --git a/src/components/containers/SendTransaction/SendTransactionConfirmation.css b/src/components/containers/SendTransaction/SendTransactionConfirmation.css deleted file mode 100644 index 09e9f877..00000000 --- a/src/components/containers/SendTransaction/SendTransactionConfirmation.css +++ /dev/null @@ -1,51 +0,0 @@ -.descriptionList { - margin-top: 1.2rem; -} - -dt { - font-size: 1.5rem; - font-weight: 300; -} - -dd { - font-weight: 300; - margin-bottom: 1.1rem; - line-break: anywhere; -} - -dd, -dd > strong { - font-size: 1.5rem; -} - -dd:nth-child(2) > strong { - font-size: 1.25rem; -} - -dd > strong { - font-weight: bold; - margin-right: 0.3rem; -} - -dd span { - font-weight: 300; - margin-left: 0.4rem; -} - -dd span > strong { - font-weight: bold; - margin-right: 0.3rem; -} - -dd span, -dd span * { - font-size: 1.12rem; -} - -.pool-note-message { - font-size: 1.2rem; - font-weight: 600; - color: rgb(var(--color-red)); - word-wrap: break-word; - line-break: normal; -} diff --git a/src/components/containers/SendTransaction/SendTransactionConfirmation.js b/src/components/containers/SendTransaction/SendTransactionConfirmation.js deleted file mode 100644 index 016a2640..00000000 --- a/src/components/containers/SendTransaction/SendTransactionConfirmation.js +++ /dev/null @@ -1,89 +0,0 @@ -import React, { useContext } from 'react' -import { Button, Error } from '@BasicComponents' -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { SettingsContext } from '@Contexts' -import { AppInfo } from '@Constants' - -import './SendTransactionConfirmation.css' - -const SendFundConfirmation = ({ - address, - amountInFiat, - amountInCrypto, - cryptoName, - fiatName, - txErrorMessage, - totalFeeFiat, - totalFeeCrypto, - fee, - onConfirm, - onCancel, - walletType, - poolData, -}) => { - const { networkType } = useContext(SettingsContext) - const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const amountFiat = isTestnet ? '0,00' : amountInFiat - const feeFiat = isTestnet ? '0,00' : totalFeeFiat - - const isLowReward = - (poolData && - poolData[0].cost_per_block.decimal > - AppInfo.APPROPRIATE_COST_PER_BLOCK) || - (poolData && - parseFloat(poolData[0].margin_ratio_per_thousand) > - AppInfo.APPROPRIATE_MARGIN_RATIO_PER_THOUSAND) - const rewardMessage = - 'The pool you are using has a high cost per block and/or margin ratio. This may result in lower rewards.' - - return ( - - -

    -
    Send to:
    -
    - {address} -
    - -
    Amount:
    -
    - {amountInCrypto} - {cryptoName} - - - ({amountFiat} - {fiatName}) - -
    - -
    Total fee:
    -
    - {totalFeeCrypto} - {walletType.name === 'Bitcoin' ? 'BTC' : 'ML'} - - ({feeFiat} - {fiatName}) - - {walletType.name !== 'Mintlayer' && ( - - ({fee} - sat/B) - - )} -
    - {poolData && isLowReward && ( -
    Please note: {rewardMessage}
    - )} -
    - {txErrorMessage ? : <>} - - - - - - - - ) -} - -export default SendFundConfirmation diff --git a/src/components/containers/SendTransaction/SendTransactionConfirmation.test.js b/src/components/containers/SendTransaction/SendTransactionConfirmation.test.js deleted file mode 100644 index e9aac202..00000000 --- a/src/components/containers/SendTransaction/SendTransactionConfirmation.test.js +++ /dev/null @@ -1,95 +0,0 @@ -import { render, screen, fireEvent } from '@testing-library/react' - -import SendFundConfirmation from './SendTransactionConfirmation' -import { SettingsProvider, AccountProvider } from '@Contexts' - -const _data = { - address: '43c5n73485v73894cm43mr98', - amountInFiat: 888888.88, - amountInCrypto: 12.24983, - cryptoName: 'BTC', - fiatName: 'USD', - totalFeeFiat: '1.20', - totalFeeCrypto: '0.00000456', - fee: 2, - onConfirm: jest.fn(), - onCancel: jest.fn(), - walletType: { - name: 'Bitcoin', - ticker: 'BTC', - chain: 'bitcoin', - tokenId: null, - }, -} - -beforeEach(() => { - _data.onConfirm.mockClear() - _data.onCancel.mockClear() -}) - -const setup = ({ data = _data } = {}) => { - render( - - - - - , - ) - const address = screen.getByText(data.address) - const amountInFiat = screen.getByText(data.amountInFiat) - const amountInCrypto = screen.getByText(data.amountInCrypto) - const totalFeeFiat = screen.getByText(data.totalFeeFiat) - const totalFeeCrypto = screen.getByText(data.totalFeeCrypto) - const fee = screen.getByText(data.fee) - const confirmButton = screen.getByText('Confirm') - const cancelButton = screen.getByText('Cancel') - - return { - address, - amountInFiat, - amountInCrypto, - totalFeeFiat, - totalFeeCrypto, - fee, - confirmButton, - cancelButton, - } -} - -test('Renders SendFundConfirmation page', () => { - const { - address, - amountInFiat, - amountInCrypto, - totalFeeFiat, - totalFeeCrypto, - fee, - confirmButton, - cancelButton, - } = setup() - - expect(address).toBeInTheDocument() - expect(amountInFiat).toBeInTheDocument() - expect(amountInCrypto).toBeInTheDocument() - expect(totalFeeFiat).toBeInTheDocument() - expect(totalFeeCrypto).toBeInTheDocument() - expect(fee).toBeInTheDocument() - expect(confirmButton).toBeInTheDocument() - expect(cancelButton).toBeInTheDocument() -}) - -test('Renders SendFundConfirmation and confirm', async () => { - const { confirmButton } = setup() - - fireEvent.click(confirmButton) - expect(_data.onConfirm).toHaveBeenCalled() - expect(_data.onCancel).not.toHaveBeenCalled() -}) - -test('Renders SendFundConfirmation and cancel', async () => { - const { cancelButton } = setup() - - fireEvent.click(cancelButton) - expect(_data.onConfirm).not.toHaveBeenCalled() - expect(_data.onCancel).toHaveBeenCalled() -}) diff --git a/src/components/containers/SendTransaction/TransactionField.css b/src/components/containers/SendTransaction/TransactionField.css deleted file mode 100644 index ea461eef..00000000 --- a/src/components/containers/SendTransaction/TransactionField.css +++ /dev/null @@ -1,3 +0,0 @@ -.form-field { - position: relative; -} diff --git a/src/components/containers/SendTransaction/TransactionField.js b/src/components/containers/SendTransaction/TransactionField.js deleted file mode 100644 index a151bf23..00000000 --- a/src/components/containers/SendTransaction/TransactionField.js +++ /dev/null @@ -1,7 +0,0 @@ -import './TransactionField.css' - -const TransactionField = ({ children }) => ( -
    {children}
    -) - -export default TransactionField diff --git a/src/components/containers/SendTransaction/errorMessages.css b/src/components/containers/SendTransaction/errorMessages.css deleted file mode 100644 index 9254ec9e..00000000 --- a/src/components/containers/SendTransaction/errorMessages.css +++ /dev/null @@ -1,11 +0,0 @@ -.error-message { - color: rgb(var(--color-red)); - font-weight: bold; - left: 9.3rem; - top: 6.475rem; - position: absolute; -} - -.delegation-description { - padding: 0 0 0 96px; -} diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPI.css b/src/components/containers/Settings/SettingsAPI/SettingsAPI.css deleted file mode 100644 index a9edbaa1..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPI.css +++ /dev/null @@ -1,8 +0,0 @@ -.settings-api { - display: flex; - flex-direction: column; -} - -.api-description { - margin-bottom: 15px; -} diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPI.js b/src/components/containers/Settings/SettingsAPI/SettingsAPI.js deleted file mode 100644 index 10ca904b..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPI.js +++ /dev/null @@ -1,182 +0,0 @@ -import { useState, useEffect } from 'react' -import { VerticalGroup } from '@LayoutComponents' - -import { EnvVars, AppInfo } from '@Constants' -import { LocalStorageService } from '@Storage' -import SettingsApiItem from './SettingsAPIItem.js' -import { Mintlayer, Electrum } from '@APIs' - -import './SettingsAPI.css' - -const SettingsAPI = () => { - const [mintlayerTestnetFieldValue, setMintlayerTestnetFieldValue] = - useState('') - const [mintlayerMainnetFieldValue, setMintlayerMainnetFieldValue] = - useState('') - const [bitconinTestnetFieldValue, setBitconinTestnetFieldValue] = useState('') - const [bitconinMainnetFieldValue, setBitconinMainnetFieldValue] = useState('') - const [currentServers, setCurrentServers] = useState({}) - - const mintlayerDefauldTestnetServer = EnvVars.TESTNET_MINTLAYER_SERVERS - const mintlayerDefauldMainnetServer = EnvVars.MAINNET_MINTLAYER_SERVERS - const bitconinDefauldTestnetServer = EnvVars.TESTNET_ELECTRUM_SERVERS - const bitconinDefauldMainnetServer = EnvVars.MAINNET_ELECTRUM_SERVERS - - useEffect(() => { - const getCurrentServer = () => { - const customServersFromStore = - LocalStorageService.getItem(AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS) || - {} // Ensure it's an object even if null/undefined is returned - - const currentServers = { - mintlayer_testnet: - customServersFromStore.mintlayer_testnet || - mintlayerDefauldTestnetServer, - mintlayer_mainnet: - customServersFromStore.mintlayer_mainnet || - mintlayerDefauldMainnetServer, - bitcoin_testnet: - customServersFromStore.bitcoin_testnet || - bitconinDefauldTestnetServer, - bitcoin_mainnet: - customServersFromStore.bitcoin_mainnet || - bitconinDefauldMainnetServer, - } - - setCurrentServers(currentServers) - } - getCurrentServer() - }, [ - bitconinDefauldMainnetServer, - bitconinDefauldTestnetServer, - mintlayerDefauldMainnetServer, - mintlayerDefauldTestnetServer, - ]) - - const submitHandler = async (data) => { - const customServersFromStore = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - const customServers = customServersFromStore ? customServersFromStore : {} - const key = `${data.wallet}_${data.networkType}` - - try { - let endpoint - if (data.wallet === 'mintlayer') { - endpoint = Mintlayer.MINTLAYER_ENDPOINTS.GET_CHAIN_TIP - } else if (data.wallet === 'bitcoin') { - endpoint = Electrum.ELECTRUM_ENDPOINTS.GET_LAST_BLOCK_HEIGHT - } else { - throw new Error('Unsupported wallet type') - } - - const response = await fetch(data.data + endpoint) - if (!response.ok) { - throw new Error(`Invalid response from ${data.wallet} server`) - } - - customServers[key] = data.data - - LocalStorageService.setItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - customServers, - ) - return true - } catch (error) { - console.error(`Invalid ${data.wallet} ${data.networkType} server:`, error) - return false - } - } - - const resetHandler = (data) => { - const customServersFromStore = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - const key = `${data.wallet}_${data.networkType}` - const storageKey = customServersFromStore[key] - - if (storageKey) { - delete customServersFromStore[key] - LocalStorageService.setItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - customServersFromStore, - ) - setMintlayerTestnetFieldValue('') - setMintlayerMainnetFieldValue('') - setBitconinTestnetFieldValue('') - setBitconinMainnetFieldValue('') - } else { - console.log('Invalid wallet or network type.') - } - } - - const inputsList = [ - { - wallet: 'mintlayer', - networkType: 'testnet', - cuurrentServer: currentServers.mintlayer_testnet, - inputValue: mintlayerTestnetFieldValue, - setInputValue: setMintlayerTestnetFieldValue, - }, - { - wallet: 'mintlayer', - networkType: 'mainnet', - cuurrentServer: currentServers.mintlayer_mainnet, - inputValue: mintlayerMainnetFieldValue, - setInputValue: setMintlayerMainnetFieldValue, - }, - { - wallet: 'bitcoin', - networkType: 'testnet', - cuurrentServer: currentServers.bitcoin_testnet, - inputValue: bitconinTestnetFieldValue, - setInputValue: setBitconinTestnetFieldValue, - }, - { - wallet: 'bitcoin', - networkType: 'mainnet', - cuurrentServer: currentServers.bitcoin_mainnet, - inputValue: bitconinMainnetFieldValue, - setInputValue: setBitconinMainnetFieldValue, - }, - ] - - return ( -
    -
    - -

    API SERVERS

    -

    - Here you can define the API server for each wallet and network type. - If you leave the field empty, the default server will be used. To - reset the API server to default, click the reset button. -

    -

    - Plese note that the API server is used for the transaction and if - you are using a custom server, make sure it is a safe and reliable - server -

    -
    -
    - {inputsList.map((item, index) => ( - - ))} -
    - ) -} - -export default SettingsAPI diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPI.test.js b/src/components/containers/Settings/SettingsAPI/SettingsAPI.test.js deleted file mode 100644 index d0dadf48..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPI.test.js +++ /dev/null @@ -1,170 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import SettingsAPI from './SettingsAPI' -import { AppInfo } from '@Constants' -import { LocalStorageService } from '@Storage' - -import { localStorageMock } from 'src/tests/mock/localStorage/localStorage' - -const initialCustomServers = { - mintlayer_testnet: 'https://m-testnet.com', - mintlayer_mainnet: 'https://m-mainnet.com', - bitcoin_testnet: 'https://b-testnet.com', - bitcoin_mainnet: 'https://b-mainnet.com', -} - -Object.defineProperty(window, 'localStorage', { value: localStorageMock }) -LocalStorageService.setItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - initialCustomServers, -) - -describe('SettingsAPI', () => { - beforeEach(() => { - LocalStorageService.setItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - initialCustomServers, - ) - }) - test('renders the SettingsAPI component', () => { - render() - expect(screen.getByTestId('settings-api')).toBeInTheDocument() - expect(screen.getByTestId('title')).toHaveTextContent('API SERVERS') - expect(screen.getAllByTestId('description')).toHaveLength(2) - expect(screen.getAllByTestId('input')).toHaveLength(4) - expect(screen.getAllByRole('button')).toHaveLength(8) - }) - - test('loads current servers from local storage', () => { - const servers = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(servers).toEqual(initialCustomServers) - - render() - - const inputs = screen.getAllByTestId('input') - - expect(inputs[0]).toHaveAttribute( - 'placeholder', - initialCustomServers.mintlayer_testnet, - ) - expect(inputs[1]).toHaveAttribute( - 'placeholder', - initialCustomServers.mintlayer_mainnet, - ) - expect(inputs[2]).toHaveAttribute( - 'placeholder', - initialCustomServers.bitcoin_testnet, - ) - expect(inputs[3]).toHaveAttribute( - 'placeholder', - initialCustomServers.bitcoin_mainnet, - ) - }) - - test('handles submit successfully', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve({}), - }), - ) - - render() - - const customServersFromStorage = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(customServersFromStorage).toEqual(initialCustomServers) - - const input = screen.getByLabelText(/mintlayer testnet server/i) - fireEvent.change(input, { - target: { value: 'https://valid-mintlayer-testnet.com' }, - }) - - const submitButton = screen.getAllByRole('button', { name: /submit/i })[0] // Use the first submit button - fireEvent.click(submitButton) - - await waitFor(() => { - expect(screen.getByTestId('success-api-feedback')).toBeInTheDocument() - }) - - const customServersFromStorageAfterSubmit = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(customServersFromStorageAfterSubmit).not.toEqual( - initialCustomServers, - ) - - expect(customServersFromStorageAfterSubmit.mintlayer_testnet).toEqual( - 'https://valid-mintlayer-testnet.com', - ) - }) - - test('handles submit unsuccessfully', async () => { - global.fetch = jest.fn(() => - Promise.resolve({ - ok: false, - }), - ) - - const consoleErrorSpy = jest - .spyOn(console, 'error') - .mockImplementation(() => {}) - - render() - - const customServersFromStorage = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(customServersFromStorage).toEqual(initialCustomServers) - - const input = screen.getByLabelText(/mintlayer testnet server/i) - fireEvent.change(input, { - target: { value: 'https://invalid-mintlayer-testnet.com' }, - }) - - const submitButton = screen.getAllByRole('button', { name: /submit/i })[0] // Use the first submit button - fireEvent.click(submitButton) - - await waitFor(() => { - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Invalid mintlayer testnet server:', - expect.any(Error), - ) - }) - - const customServersFromStorageAfterSubmit = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - expect(customServersFromStorageAfterSubmit).toEqual(initialCustomServers) - }) - - test('handles reset', () => { - render() - - const customServersFromStorage = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(customServersFromStorage).toEqual(initialCustomServers) - - const resetButton = screen.getAllByRole('button', { name: /reset/i })[0] // Use the first reset button - fireEvent.click(resetButton) - - const customServersFromStorageAfterReset = LocalStorageService.getItem( - AppInfo.APP_LOCAL_STORAGE_CUSTOM_SERVERS, - ) - - expect(customServersFromStorageAfterReset).toEqual({ - mintlayer_mainnet: 'https://m-mainnet.com', - bitcoin_testnet: 'https://b-testnet.com', - bitcoin_mainnet: 'https://b-mainnet.com', - }) - }) -}) diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.css b/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.css deleted file mode 100644 index 78ab259f..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.css +++ /dev/null @@ -1,50 +0,0 @@ -.api-field-wrapper { - display: flex; - margin-bottom: 10px; -} - -.api-input-wrapper { - width: 75%; - margin-right: 5px; - - @media screen and (min-width: 801px) { - width: 77%; - } -} - -.api-button-wrapper { - display: flex; - justify-content: space-between; - align-items: flex-end; - width: 25%; - gap: 10px; - - @media screen and (min-width: 801px) { - width: 23%; - } -} - -.submit-api-button, -.reset-api-button { - display: flex; - justify-content: center; - align-items: center; - width: 85px; - padding: 10px 0; - height: 63px; - font-size: 1.2rem; - border-radius: var(--round-size); -} - -.reset-api-button { - background-color: rgb(var(--color-red)); - color: white; -} - -.api-input { - padding: 1rem 1rem; -} - -.success-api-icon path { - fill: #fff; -} diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.js b/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.js deleted file mode 100644 index ff07cb17..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.js +++ /dev/null @@ -1,154 +0,0 @@ -import { useState, useEffect } from 'react' - -import { Button } from '@BasicComponents' -import { TextField } from '@ComposedComponents' -import { ReactComponent as SuccessImg } from '@Assets/images/icon-success.svg' -import { ReactComponent as UnsuccessImg } from '@Assets/images/icon-cross.svg' - -import { StringHelpers } from '@Helpers' - -import './SettingsAPIItem.css' - -const SettingsApiItem = ({ - inputValue, - setInputValue, - walletData, - onSubmitClick, - onResetClick, -}) => { - const submitButtonExtraClasses = ['submit-api-button'] - const resetButtonExtraClasses = ['reset-api-button'] - const inputExtraclasses = ['api-input'] - - const [fieldValidity, setFieldValidity] = useState(false) - const [fieldPristinity, setFieldPristinity] = useState(true) - const [showSubmitSuccess, setShowSubmitSuccess] = useState(false) - const [showSubmitUnsuccess, setShowSubmitUnsuccess] = useState(false) - const [showResetFeedback, setShowResetFeedback] = useState(false) - - const checkFieldValidity = (fieldValidity) => { - const regex = /^https?:\/\/.{3,}\..+$/m - setFieldValidity(regex.test(fieldValidity)) - return regex.test(fieldValidity) - } - - const fieldChangeHandler = (value) => { - checkFieldValidity(value) - setInputValue(value) - } - - const onSubmit = async (data) => { - if (inputValue.length <= 0) { - return - } - setFieldPristinity(false) - const responce = await onSubmitClick(data) - if (responce) { - setShowSubmitSuccess(true) - } else { - setShowSubmitUnsuccess(true) - setFieldValidity(false) - setInputValue('Something went wrong. Try again with a valid server') - } - } - - const onReset = (data) => { - onResetClick(data) - setFieldPristinity(true) - setFieldValidity(false) - setInputValue('') - setShowSubmitSuccess(false) - setShowSubmitUnsuccess(false) - setShowResetFeedback(true) - } - - useEffect(() => { - if (showSubmitSuccess) { - setTimeout(() => { - setShowSubmitSuccess(false) - setInputValue('') - }, 2000) - } - if (showSubmitUnsuccess) { - setTimeout(() => { - setShowSubmitUnsuccess(false) - }, 2000) - } - if (showResetFeedback) { - setTimeout(() => { - setShowResetFeedback(false) - }, 2000) - } - - setFieldPristinity(true) - setFieldValidity(false) - }, [showSubmitSuccess, showSubmitUnsuccess, showResetFeedback, setInputValue]) - - const label = `${StringHelpers.capitalizeFirstLetter(walletData.wallet)} ${walletData.networkType} server` - - return ( -
    -
    - -
    -
    - - -
    -
    - ) -} - -export default SettingsApiItem diff --git a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.test.js b/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.test.js deleted file mode 100644 index 6e3f9503..00000000 --- a/src/components/containers/Settings/SettingsAPI/SettingsAPIItem.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import React from 'react' -import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import SettingsApiItem from './SettingsAPIItem' - -describe('SettingsApiItem', () => { - const mockOnSubmitClick = jest.fn() - const mockOnResetClick = jest.fn() - const mockSetInputValue = jest.fn() - - const walletData = { - wallet: 'mintlayer', - networkType: 'mainnet', - cuurrentServer: 'https://example.com', - } - - const renderComponent = (inputValue = '', fieldValidity) => { - render( - , - ) - } - - test('renders the component with initial state', () => { - renderComponent() - expect( - screen.getByLabelText(/Mintlayer mainnet server/i), - ).toBeInTheDocument() - expect( - screen.getByPlaceholderText(/https:\/\/example.com/i), - ).toBeInTheDocument() - expect(screen.getByText(/Submit/i)).toBeInTheDocument() - expect(screen.getByText(/Reset/i)).toBeInTheDocument() - }) - - test('handles input change and validation', () => { - renderComponent() - const input = screen.getByLabelText(/Mintlayer mainnet server/i) - fireEvent.change(input, { target: { value: 'https://validserver.com' } }) - expect(mockSetInputValue).toHaveBeenCalledWith('https://validserver.com') - }) - - test('handles submit invalid', async () => { - mockOnSubmitClick.mockResolvedValueOnce(false) - renderComponent('value') - const submitButton = screen.getByText(/Submit/i) - expect(submitButton).toBeDisabled() - fireEvent.click(submitButton) - - await waitFor(() => { - expect( - screen.queryByTestId('unsuccess-api-feedback'), - ).not.toBeInTheDocument() - }) - - expect(submitButton).toBeDisabled() - }) - - test('handles reset', () => { - renderComponent('https://validserver.com') - const resetButton = screen.getByText(/Reset/i) - expect(resetButton).not.toBeDisabled() - fireEvent.click(resetButton) - expect(mockOnResetClick).toHaveBeenCalled() - }) -}) diff --git a/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css new file mode 100644 index 00000000..c2468edd --- /dev/null +++ b/src/components/containers/Settings/SettingsAbout/SettingsAbout.module.css @@ -0,0 +1,38 @@ +.row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + cursor: default; +} + +.row + .row { + border-top: 1px solid rgba(var(--color-light-gray), 0.3); +} + +.rowClickable { + cursor: pointer; + transition: background 0.2s; +} + +.rowClickable:hover { + background: rgba(var(--color-black), 0.02); +} + +.label { + font-size: 14px; + font-weight: 600; + color: rgb(var(--color-black)); +} + +.value { + font-size: 13px; + color: rgb(var(--color-dark-gray)); +} + +.chevron { + width: 16px; + height: 16px; + color: rgba(var(--color-black), 0.25); + transform: rotate(-90deg); +} diff --git a/src/components/containers/Settings/SettingsAbout/SettingsAbout.tsx b/src/components/containers/Settings/SettingsAbout/SettingsAbout.tsx new file mode 100644 index 00000000..3bd7d715 --- /dev/null +++ b/src/components/containers/Settings/SettingsAbout/SettingsAbout.tsx @@ -0,0 +1,36 @@ +import { ReactComponent as ChevronIcon } from '@Assets/images/icon-chevron-down.svg' +import { APP_VERSION } from '@Version' +import { AppInfo } from '@Constants' + +import styles from './SettingsAbout.module.css' + +const SettingsAbout = () => { + return ( + <> +
    + Version + {APP_VERSION} +
    + + Terms & privacy + + + + Support + + + + ) +} + +export default SettingsAbout diff --git a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css index 35e1a81d..4932e426 100644 --- a/src/components/containers/Settings/SettingsBackup/SettingsBackup.css +++ b/src/components/containers/Settings/SettingsBackup/SettingsBackup.css @@ -1,26 +1,39 @@ .settings-backup { display: flex; align-items: center; - gap: 10px; + gap: 16px; } .backup-description { - max-width: 90%; - margin: 0 auto; + flex: 1; +} + +.backup-description h2 { + font-size: 14px; + letter-spacing: 0.05em; + color: rgb(var(--color-dark-gray)); + margin-bottom: 6px; +} + +.backup-description p { + font-size: 13px; + line-height: 1.5; + color: rgb(var(--color-black)); } .settings-backup-button { display: flex; justify-content: center; align-items: center; - width: 80px; - min-width: 80px; - padding: 1rem 0; - border-radius: var(--round-size); + padding: 1rem; + width: 56px; + min-width: 56px; + height: 56px; + border-radius: 16px; } .icon-json { - width: 30px; - height: 30px; + width: 26px; + height: 26px; fill: rgb(var(--color-white)); } diff --git a/src/components/containers/Settings/SettingsBackup/SettingsBackup.js b/src/components/containers/Settings/SettingsBackup/SettingsBackup.js index bac417f8..2ed75b56 100644 --- a/src/components/containers/Settings/SettingsBackup/SettingsBackup.js +++ b/src/components/containers/Settings/SettingsBackup/SettingsBackup.js @@ -25,11 +25,11 @@ const SettingsBackup = () => { return (
    -

    BACKUP WALLET

    +

    Backup wallet

    Backup your wallet to a JSON file. This file contains all the information needed to restore your wallet. Keep it safe and secure. diff --git a/src/components/containers/Settings/SettingsDelete/SettingsDelete.css b/src/components/containers/Settings/SettingsDelete/SettingsDelete.css index 067eecfe..e5f7ebcc 100644 --- a/src/components/containers/Settings/SettingsDelete/SettingsDelete.css +++ b/src/components/containers/Settings/SettingsDelete/SettingsDelete.css @@ -1,27 +1,40 @@ .settings-delete { display: flex; align-items: center; - gap: 10px; + gap: 16px; } .delete-description { - max-width: 90%; - margin: 0 auto; + flex: 1; +} + +.delete-description h2 { + font-size: 14px; + letter-spacing: 0.05em; + color: rgb(var(--color-red)); + margin-bottom: 6px; +} + +.delete-description p { + font-size: 13px; + line-height: 1.5; + color: rgb(var(--color-black)); } .settings-delete-button { display: flex; justify-content: center; align-items: center; - width: 80px; - min-width: 80px; - padding: 1rem 0; + padding: 1rem; + width: 56px; + min-width: 56px; + height: 56px; background: rgb(var(--color-red)); - border-radius: var(--round-size); + border-radius: 16px; } .icon-bin { - width: 30px; - height: 30px; + width: 26px; + height: 26px; fill: rgb(var(--color-white)); } diff --git a/src/components/containers/Settings/SettingsDelete/SettingsDelete.js b/src/components/containers/Settings/SettingsDelete/SettingsDelete.js index 793c60ff..a2215f28 100644 --- a/src/components/containers/Settings/SettingsDelete/SettingsDelete.js +++ b/src/components/containers/Settings/SettingsDelete/SettingsDelete.js @@ -31,7 +31,7 @@ const SettingsDelete = () => { >

    -

    DELETE WALLET

    +

    Delete wallet

    If you delete a wallet, you may lose access to all the funds associated with it. Please make sure that you have securely saved diff --git a/src/components/containers/Settings/SettingsSection/SettingsSection.module.css b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css new file mode 100644 index 00000000..ebf07137 --- /dev/null +++ b/src/components/containers/Settings/SettingsSection/SettingsSection.module.css @@ -0,0 +1,30 @@ +.section { + display: flex; + flex-direction: column; + min-height: max-content; +} + +.label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: rgb(var(--color-dark-gray)); + margin-bottom: 8px; + padding-left: 4px; +} + +.card { + background: rgb(var(--color-gray)); + border-radius: var(--round-size); + border: 1px solid rgba(var(--color-light-gray), 0.3); + overflow: hidden; +} + +.item { + padding: 18px 20px; +} + +.item + .item { + border-top: 1px solid rgba(var(--color-light-gray), 0.3); +} diff --git a/src/components/containers/Settings/SettingsSection/SettingsSection.tsx b/src/components/containers/Settings/SettingsSection/SettingsSection.tsx new file mode 100644 index 00000000..72ab94b2 --- /dev/null +++ b/src/components/containers/Settings/SettingsSection/SettingsSection.tsx @@ -0,0 +1,24 @@ +import { ReactNode } from 'react' +import styles from './SettingsSection.module.css' + +interface SettingsSectionProps { + title: string + children: ReactNode +} + +const SettingsSection = ({ title, children }: SettingsSectionProps) => { + return ( +

    +

    {title}

    +
    {children}
    +
    + ) +} + +const SettingsItem = ({ children }: { children: ReactNode }) => { + return
    {children}
    +} + +SettingsSection.Item = SettingsItem + +export default SettingsSection diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.css b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.css deleted file mode 100644 index 932257b8..00000000 --- a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.css +++ /dev/null @@ -1,8 +0,0 @@ -.settingsTestnet { - display: flex; - align-items: center; -} - -.testnetDescription { - max-width: 90%; -} diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.js b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.js deleted file mode 100644 index fd6e86a8..00000000 --- a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.js +++ /dev/null @@ -1,50 +0,0 @@ -import { useContext } from 'react' -import { Toggle } from '@BasicComponents' -import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' -import { VerticalGroup } from '@LayoutComponents' -import { AppInfo } from '@Constants' - -import './SettingsTestnet.css' -import { useNavigate } from 'react-router' - -const SettingsTestnet = () => { - const { networkType, toggleNetworkType } = useContext(SettingsContext) - const { logout } = useContext(AccountContext) - const navigate = useNavigate() - const { setAllDataFetching } = useContext(MintlayerContext) - const isTestnetEnabled = networkType === AppInfo.NETWORK_TYPES.TESTNET - const onToggle = () => { - setAllDataFetching(false) - toggleNetworkType() - - logout() - navigate('/') - } - return ( -
    -
    - -

    TESTNET MODE

    -

    - With Testnet mode, you can test transactions without the need for - actual coins. This is useful for testing purposes. -

    -

    - NOTE: When you switch network mode, you need to login again -

    -
    -
    - - -
    - ) -} - -export default SettingsTestnet diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css new file mode 100644 index 00000000..37f8405d --- /dev/null +++ b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.module.css @@ -0,0 +1,42 @@ +.container { + display: flex; + flex-direction: column; + gap: 12px; +} + +.title { + font-size: 13px; + font-weight: 600; + color: rgb(var(--color-black)); +} + +.switcher { + display: flex; + gap: 8px; + padding: 4px; + background: rgb(var(--color-gray)); + border-radius: var(--round-size-big); +} + +.option { + flex: 1; + padding: 9px 12px; + border: 1.5px solid transparent; + border-radius: var(--round-size-big); + background: rgb(var(--color-black), 0.05); + font-size: 14px; + font-weight: 600; + color: rgb(var(--color-dark-gray)); + cursor: pointer; + transition: all 0.25s ease; +} + +.option:not(.optionActive):hover { + background: rgb(var(--color-black), 0.1); +} + +.optionActive { + background: rgb(var(--mojito-green-soft)); + border-color: rgb(var(--mojito-green)); + color: rgb(var(--mojito-green)); +} diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.test.js b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.test.js index 1d770dcb..e3198ac0 100644 --- a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.test.js +++ b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.test.js @@ -1,6 +1,6 @@ import { render, screen, fireEvent } from '@testing-library/react' -import SettingsTestnet from './SettingsTestnet' +import SettingsTestnet from './SettingsTestnet.tsx' import { SettingsContext, MintlayerContext, AccountContext } from '@Contexts' import { BrowserRouter } from 'react-router' @@ -19,7 +19,6 @@ test('Render Inputs list item', async () => { - , , ) const component = screen.getByTestId('settings-testnet') @@ -40,14 +39,12 @@ test('toggles the network type', () => { - , - , , ) - const toggleButton = screen.getAllByTestId('toggle')[0] + const testnetButton = screen.getByText('Testnet') - fireEvent.click(toggleButton) + fireEvent.click(testnetButton) expect(toggleNetworkType).toHaveBeenCalled() }) diff --git a/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.tsx b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.tsx new file mode 100644 index 00000000..38b9e923 --- /dev/null +++ b/src/components/containers/Settings/SettingsTestnet/SettingsTestnet.tsx @@ -0,0 +1,60 @@ +import { useContext } from 'react' +import { useNavigate } from 'react-router' + +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' + +import styles from './SettingsTestnet.module.css' + +const SettingsTestnet = () => { + const { networkType, toggleNetworkType } = useContext(SettingsContext) + const { logout } = useContext(AccountContext) + const { setAllDataFetching } = useContext(MintlayerContext) + const navigate = useNavigate() + + const isMainnet = networkType === AppInfo.NETWORK_TYPES.MAINNET + + const switchNetwork = (target: string) => { + if (networkType === target) return + setAllDataFetching(false) + toggleNetworkType() + logout() + navigate('/') + } + + return ( +
    +

    + Active network +

    +
    + + +
    +
    + ) +} + +export default SettingsTestnet diff --git a/src/components/containers/Wallet/Delegation/Delegation.css b/src/components/containers/Wallet/Delegation/Delegation.css deleted file mode 100644 index bbd8f4d1..00000000 --- a/src/components/containers/Wallet/Delegation/Delegation.css +++ /dev/null @@ -1,146 +0,0 @@ -.transaction { - display: flex; - align-items: center; - padding: 12px 30px 12px 18px; - margin-bottom: 10px; - border-radius: 45px; - background: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); - word-break: break-all; - cursor: pointer; - justify-content: flex-end; - transition: all 0.3s ease-in-out; - - @media screen and (min-width: 801px) { - justify-content: flex-start; - } -} - -.transaction-logo-type { - display: flex; - align-items: center; - justify-content: center; - width: 72px; - min-width: 72px; - height: 72px; - background: rgb(var(--color-dim-green)); - border-radius: 50%; - font-size: 43px; -} - -.transaction-detail { - margin-left: 30px; - flex-grow: 1; -} - -.transaction-logo-out { - background: rgb(var(--color-black)); -} - -.arrow-icon { - width: 30px; - height: 36px; -} - -.arrow-icon-out { - transform: rotate(180deg); -} - -.transaction-date-amount { - display: flex; - align-items: center; - justify-content: space-between; -} - -.transaction-id { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 1.7rem; - font-weight: 600; - margin-bottom: 10px; - margin-top: 4px; - word-break: break-all; -} - -.transaction-date { - font-size: 1.2rem; - font-weight: 400; -} - -.transaction-date span { - font-weight: 600; -} - -.delegation-staking-icon { - width: 40px; - height: 40px; - color: #fff; -} - -.delegation-actions { - display: flex; - margin-top: 10px; -} - -.delegation-action-button { - margin-right: 10px; - padding: 0.8rem; -} - -.unconfirmed-delegation-message { - margin-top: 10px; - word-break: normal; -} - -@keyframes grow { - from { - width: 0; - } - to { - width: 95%; - } -} - -.transaction.decommissioned { - height: 55px; -} -.transaction.decommissioned .transaction-detail { - height: 40px; -} - -/* ROLLUP DELEGATION */ -.transaction.decommissioned.inactive-open { - height: auto; -} -.transaction.decommissioned.inactive-open .transaction-detail { - height: auto; -} - -.transaction.decommissioned.non-empty { - background: rgba(255, 223, 182, 0.5); -} - -.decommissioned-text { - background: rgb(var(--color-red)); - color: rgb(var(--color-white)); - padding: 5px; - border-radius: 20px; - margin-left: 20px; - font-size: 15px; -} - -.transaction:hover { - transform: scale(1.02); - border: 1px solid rgba(var(--color-light-green), 0.5); -} - -.transaction-logo-type.delegation-icon { - background-color: rgb(var(--color-dim-blue)); -} - -.delegation-staking-icon.decommissioned, -.transaction-logo-type.decommissioned { - background-color: rgb(var(--color-red)) !important; - color: rgb(var(--color-white)) !important; -} diff --git a/src/components/containers/Wallet/Delegation/Delegation.js b/src/components/containers/Wallet/Delegation/Delegation.js index a711ab1f..0db1f67b 100644 --- a/src/components/containers/Wallet/Delegation/Delegation.js +++ b/src/components/containers/Wallet/Delegation/Delegation.js @@ -6,14 +6,13 @@ import { Button } from '@BasicComponents' import DelegationDetails from './DelegationDetails' -import './Delegation.css' +import styles from './Delegation.module.css' import { format } from 'date-fns' import { useNavigate } from 'react-router' const Delegation = ({ delegation }) => { const navigate = useNavigate() - // staking only for Mintlayer const walletType = { name: 'Mintlayer', ticker: 'ML', @@ -21,7 +20,6 @@ const Delegation = ({ delegation }) => { } const [detailPopupOpen, setDetailPopupOpen] = useState(false) - const [inactiveOpen, setInactiveOpen] = useState(false) let delegationOject = delegation @@ -36,8 +34,6 @@ const Delegation = ({ delegation }) => { const value = delegationOject.balance ? delegationOject.balance.decimal : 0 - const buttonExtraStyles = ['delegation-action-button'] - const addFundsClickHandle = () => { navigate( '/wallet/' + @@ -58,154 +54,143 @@ const Delegation = ({ delegation }) => { ) } - delegationOject.addFundsClickHandle = addFundsClickHandle - delegationOject.withdrawClickHandle = withdrawClickHandle - const date = delegationOject.creation_time ? format(new Date(delegationOject.creation_time * 1000), 'dd/MM/yyyy HH:mm') : 'not confirmed' - const delegationClickHandle = (delegation) => { - delegationOject.decommissioned && !inactiveOpen - ? setInactiveOpen(true) - : setDetailPopupOpen(true) + const delegationClickHandle = () => { + setDetailPopupOpen(true) } + const isDecommissioned = delegationOject.decommissioned + const isUnconfirmed = + delegation.type === 'Unconfirmed' && delegation.mode === 'delegation' + const hasBalance = + delegationOject.balance && delegationOject.balance.length > 11 + + const cardClasses = [ + styles.card, + hasBalance && isDecommissioned ? styles.nonEmpty : '', + ] + .filter(Boolean) + .join(' ') + + const iconClass = [ + styles.icon, + isDecommissioned ? styles.iconDecommissioned : '', + ] + .filter(Boolean) + .join(' ') + + const buttonExtraStyles = [styles.actionButton] + return (
  • 11 ? 'non-empty' : 'empty' - }`} + className={cardClasses} data-testid="delegation" data-poolid={delegationOject.pool_id} onClick={delegationClickHandle} > - {delegation.type === 'Unconfirmed' && - delegation.mode === 'delegation' && ( - <> -
    -
    - -
    - - )} + {isUnconfirmed && ( + <> +
    +
    + +
    + + )} +
    - {delegation.decommissioned && !inactiveOpen ? ( - '!' - ) : ( - - )} +
    -
    -
    + +
    +

    + {delegation && delegationOject.pool_id + ? ML.formatAddress(delegationOject.pool_id) + : ''} + {isDecommissioned && ( + Inactive + )} +

    + {delegationOject.creation_time && (

    - {delegation && delegationOject.pool_id - ? ML.formatAddress(delegationOject.pool_id) - : ''} - - {delegationOject.decommissioned && ( - Inactive - )} + {date}

    -
    - {delegationOject.creation_time && ( -

    - Date: {date} -

    - )} - - {delegation.type === 'Unconfirmed' && - delegation.mode === 'delegation' && ( -

    - Preparing delegation for staking -

    - )} - -

    + Preparing delegation for staking +

    + )} +
    + +
    +

    + {delegation && value ? value : '—'} +

    +

    ML

    +
    + +
    + {delegation.type !== 'Unconfirmed' ? ( + <> +
    - {delegation.type !== 'Unconfirmed' && ( -
    - - -
    - )} - {delegation.type === 'Unconfirmed' && - delegation.mode === 'delegation' && ( -
    - - -
    - )} -
    + Add funds + + + + ) : ( + <> + + + + )}
    + {detailPopupOpen && ( - + )}
  • diff --git a/src/components/containers/Wallet/Delegation/Delegation.module.css b/src/components/containers/Wallet/Delegation/Delegation.module.css new file mode 100644 index 00000000..f7b49b84 --- /dev/null +++ b/src/components/containers/Wallet/Delegation/Delegation.module.css @@ -0,0 +1,141 @@ +.card { + display: flex; + align-items: center; + gap: 1rem; + min-height: max-content; + padding: 1rem 1.25rem; + border-radius: 35px; + background: rgb(var(--color-white)); + border: 1px solid rgb(var(--color-medium-gray)); + cursor: pointer; + transition: all 0.2s ease-in-out; + position: relative; + overflow: hidden; + list-style: none; +} + +.card:hover { + border-color: rgba(var(--mojito-green), 0.4); + box-shadow: 0 2px 8px rgba(var(--color-black), 0.06); +} + +.nonEmpty { + background: rgba(255, 223, 182, 0.3); + border-color: rgba(255, 200, 130, 0.4); +} + +.icon { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + min-width: 56px; + height: 56px; + border-radius: 50%; + background: rgb(var(--color-dim-blue)); + font-size: 1.5rem; + color: rgb(var(--color-white)); + flex-shrink: 0; +} + +.iconDecommissioned { + background: rgb(var(--color-dim-corail)); +} + +.stakeIcon { + width: 28px; + height: 28px; + color: rgb(var(--color-white)); +} + +.info { + flex: 1; + min-width: 0; +} + +.poolId { + font-size: 1rem; + font-weight: 600; + color: rgb(var(--color-black)); + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.inactiveBadge { + display: inline-block; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: rgb(var(--color-orange)); + background: rgba(var(--color-orange), 0.12); + padding: 0.2rem 0.6rem; + border-radius: 6px; + flex-shrink: 0; +} + +.date { + font-size: 0.8rem; + color: rgb(var(--color-dark-gray)); + margin: 0.2rem 0 0; +} + +.amountBlock { + text-align: right; + flex-shrink: 0; + min-width: 70px; +} + +.amount { + font-size: 1.25rem; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0; +} + +.currency { + font-size: 0.75rem; + color: rgb(var(--color-dark-gray)); + margin: 0; +} + +.actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.actionButton { + padding: 0.5rem 1rem; + font-size: 0.8rem; +} + +.progressBar { + position: absolute; + top: 0; + left: 0; + height: 3px; + width: 40px; + background-color: rgb(17, 150, 127); + animation: grow 60s cubic-bezier(0.4, 0, 1, 1) forwards; +} + +.loadingWrapper { + position: absolute; + left: 36px; + top: 50%; + margin-top: -38px; + margin-left: -2px; + transform: scale(1.2); +} + +@keyframes grow { + from { + width: 0; + } + to { + width: 95%; + } +} diff --git a/src/components/containers/Wallet/Delegation/Delegation.test.js b/src/components/containers/Wallet/Delegation/Delegation.test.js index 67bc07e8..b9868b2f 100644 --- a/src/components/containers/Wallet/Delegation/Delegation.test.js +++ b/src/components/containers/Wallet/Delegation/Delegation.test.js @@ -50,7 +50,7 @@ describe('Delegation', () => { ) expect(screen.getByTestId('delegation-date')).toHaveTextContent(date) expect(screen.getByTestId('delegation-amount')).toHaveTextContent( - `Amount: ${mockDelegation.balance.decimal}`, + mockDelegation.balance.decimal, ) }) diff --git a/src/components/containers/Wallet/Delegation/DelegationDetails.css b/src/components/containers/Wallet/Delegation/DelegationDetails.css deleted file mode 100644 index 05b29f33..00000000 --- a/src/components/containers/Wallet/Delegation/DelegationDetails.css +++ /dev/null @@ -1,64 +0,0 @@ -.delegation-details { - width: 100%; - max-width: 510px; - height: 500px; - - @media screen and (min-width: 801px) { - height: 605px; - } -} - -.delegation-details-items-wrapper { - height: 368px; - width: 100%; - padding: 10px; - overflow: auto; - margin-bottom: 20px; - - @media screen and (min-width: 801px) { - height: 473px; - } -} - -.delegation-details-item { - margin-bottom: 1.5rem; -} - -.delegation-details-item h2 { - margin-bottom: 8px; - font-size: 1.5rem; - font-weight: 400; -} - -.delegation-details-content { - font-size: 1.5rem; - font-weight: 600; - word-break: break-word; - cursor: auto; -} - -.delegation-details-buttonn { - margin-top: 1.3rem; -} - -.delegation-action-wrapper { - display: flex; - justify-content: space-between; - margin-bottom: 10px; -} - -/* .delegation-action-wrapper .delegation-details-button:not(:last-child) { - margin-right: 5px; -} */ - -.delegation-explorer-button-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.delegation-details-button:hover .delegation-explorer-button-icon { - animation: moveArrowUpRight 0.3s ease-in-out; -} diff --git a/src/components/containers/Wallet/Delegation/DelegationDetails.js b/src/components/containers/Wallet/Delegation/DelegationDetails.js index 2772696c..29708a79 100644 --- a/src/components/containers/Wallet/Delegation/DelegationDetails.js +++ b/src/components/containers/Wallet/Delegation/DelegationDetails.js @@ -1,22 +1,29 @@ import { useContext } from 'react' import { Button } from '@BasicComponents' +import { CopyButton } from '@ComposedComponents' import { SettingsContext } from '@Contexts' import { AppInfo } from '@Constants' +import { ML } from '@Helpers' import { format } from 'date-fns' +import { ReactComponent as DelegationIcon } from '@Assets/images/icon-delegation.svg' import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' -import './DelegationDetails.css' -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' +import styles from './DelegationDetails.module.css' const DelegationDetailsItem = ({ title, content }) => { return (
    -

    {title}

    + + {title} +
    {content} @@ -25,97 +32,117 @@ const DelegationDetailsItem = ({ title, content }) => { ) } -const DelegationDetails = ({ delegation }) => { +const DelegationDetails = ({ delegation, onAddFunds, onWithdraw }) => { const { networkType } = useContext(SettingsContext) const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - // eslint-disable-next-line no-unused-vars const date = delegation.creation_time ? format(new Date(delegation.creation_time * 1000), 'dd/MM/yyyy HH:mm') : 'not confirmed' const balance = delegation.balance.decimal - const buttonExtraStyles = ['delegation-details-button'] - const addressTitle = 'Spend address:' const delegationAddress = delegation ? delegation.spend_destination : '' const explorerLink = `https://${ isTestnet ? 'lovelace.' : '' }explorer.mintlayer.org/delegation/${delegation?.delegation_id}` - const addFundsClickHandle = () => { - delegation.addFundsClickHandle() - } - - const withdrawClickHandle = () => { - delegation.withdrawClickHandle() - } - return (
    -
    - {delegation.decommissioned && ( - - )}{' '} +
    +
    + +
    + Delegation +
    + {balance} + ML +
    +
    + {delegation.decommissioned ? 'Inactive' : 'Active'} +
    +
    + + {delegation.decommissioned && ( +
    + This pool is decommissioned and will not receive rewards. Please + withdraw your funds and delegate to an active pool. +
    + )} + +
    + {ML.formatAddress(delegation.pool_id, 16)} + + + } /> + {ML.formatAddress(delegationAddress, 16)} + + + } /> + {ML.formatAddress(delegation.delegation_id, 16)} + + + } />
    - - - {delegation.type !== 'Unconfirmed' && ( -
    - - -
    - )} - + + - -
    -
    + Withdraw + +
    + )} + + + +
    ) } diff --git a/src/components/containers/Wallet/Delegation/DelegationDetails.module.css b/src/components/containers/Wallet/Delegation/DelegationDetails.module.css new file mode 100644 index 00000000..3ec9c729 --- /dev/null +++ b/src/components/containers/Wallet/Delegation/DelegationDetails.module.css @@ -0,0 +1,159 @@ +.delegationDetails { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + overflow-y: auto; + flex: 1; +} + +.delegationDetails > * { + flex-shrink: 0; +} + +.banner { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-height: max-content; + padding: 28px 20px 24px; + background: rgb(var(--mojito-green-soft)); + border-radius: 16px; + border: 1.5px solid rgba(var(--mojito-green), 0.15); +} + +.bannerIcon { + width: 56px; + height: 56px; + min-height: 56px; + min-width: 56px; + border-radius: 12px; + background: rgb(var(--color-white)); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; +} + +.bannerIcon svg { + width: 24px; + height: 24px; + color: rgb(var(--mojito-green)); +} + +.bannerLabel { + font-size: 11px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(var(--color-black), 0.5); +} + +.bannerAmount { + font-size: 32px; + font-weight: 700; + color: rgb(var(--color-black)); + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 10px; +} + +.bannerAmountValue { + font-size: 32px; + font-weight: 800; +} + +.bannerTicker { + font-size: 14px; + font-weight: 600; + color: rgba(var(--color-black), 0.5); +} + +.bannerStatus { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 99px; + background: rgb(var(--color-white)); + font-size: 13px; + font-weight: 600; + color: rgb(var(--mojito-green)); + margin-top: 4px; +} + +.bannerStatusDecommissioned { + color: rgb(var(--color-red)); +} + +.detailsCard { + background: rgb(var(--color-white)); + border-radius: 16px; + border: 1.5px solid rgba(var(--color-black), 0.08); + overflow: hidden; +} + +.detailRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid rgba(var(--color-black), 0.06); +} + +.detailRow:last-child { + border-bottom: none; +} + +.detailLabel { + font-size: 13px; + font-weight: 400; + color: rgba(var(--color-black), 0.6); + flex-shrink: 0; +} + +.detailValue { + font-size: 14px; + font-weight: 600; + color: rgb(var(--color-black)); + text-align: right; + word-break: break-all; + max-width: 60%; + display: flex; + align-items: center; + gap: 8px; +} + +.warning { + background: rgba(var(--color-red), 0.08); + border: 1.5px solid rgba(var(--color-red), 0.2); + border-radius: 16px; + padding: 14px 18px; + font-size: 13px; + font-weight: 500; + color: rgb(var(--color-red)); + line-height: 1.5; +} + +.actionRow { + display: flex; + gap: 10px; +} + +.actionButton { + flex: 1; + padding: 10px 20px; +} + +.explorerButton { + width: 100%; + padding: 10px 20px; +} + +.explorerButton svg { + width: 14px; + height: 14px; +} diff --git a/src/components/containers/Wallet/Delegation/DelegationDetails.test.js b/src/components/containers/Wallet/Delegation/DelegationDetails.test.js index e30f7377..ea95e3f0 100644 --- a/src/components/containers/Wallet/Delegation/DelegationDetails.test.js +++ b/src/components/containers/Wallet/Delegation/DelegationDetails.test.js @@ -77,14 +77,18 @@ describe('DelegationDetails', () => { }) it('calls correct functions on button click', () => { - mockDelegation.addFundsClickHandle = jest.fn() - mockDelegation.withdrawClickHandle = jest.fn() + const mockAddFunds = jest.fn() + const mockWithdraw = jest.fn() render( - + @@ -93,9 +97,9 @@ describe('DelegationDetails', () => { ) fireEvent.click(screen.getByText('Add funds')) - expect(mockDelegation.addFundsClickHandle).toHaveBeenCalled() + expect(mockAddFunds).toHaveBeenCalled() fireEvent.click(screen.getByText('Withdraw')) - expect(mockDelegation.withdrawClickHandle).toHaveBeenCalled() + expect(mockWithdraw).toHaveBeenCalled() }) }) diff --git a/src/components/containers/Wallet/Delegation/DelegationList.css b/src/components/containers/Wallet/Delegation/DelegationList.css deleted file mode 100644 index 9c1b68d9..00000000 --- a/src/components/containers/Wallet/Delegation/DelegationList.css +++ /dev/null @@ -1,23 +0,0 @@ -.delegation-list { - height: 18rem; - overflow: auto; - padding: 7px; - - @media screen and (min-width: 801px) { - padding: 10px; - flex-grow: 1; - } -} - -.empty-list { - background: rgb(var(--color-gray)); - font-size: 1.5em; - list-style: none; - padding: 10px; - text-align: center; - - padding: 26px 20px; - border-radius: 20px; - border: 1px; - border: 1px solid rgba(var(--color-light-green), 0.2); -} diff --git a/src/components/containers/Wallet/Delegation/DelegationList.js b/src/components/containers/Wallet/Delegation/DelegationList.js index 8892f208..6750e8ab 100644 --- a/src/components/containers/Wallet/Delegation/DelegationList.js +++ b/src/components/containers/Wallet/Delegation/DelegationList.js @@ -1,16 +1,16 @@ import Delegation from './Delegation' -import { SkeletonLoader } from '@BasicComponents' -import './DelegationList.css' +import DelegationSkeleton from './DelegationSkeleton' +import styles from './DelegationList.module.css' const DelegationList = ({ delegationsList, delegationsLoading }) => { const renderSkeletonLoaders = () => - Array.from({ length: 6 }, (_, i) => ) + Array.from({ length: 4 }, (_, i) => ) const renderDelegations = () => { if (!delegationsList || !delegationsList.length) { return (
  • No Delegations in this wallet @@ -30,7 +30,7 @@ const DelegationList = ({ delegationsList, delegationsLoading }) => { return (
      {delegationsLoading ? renderSkeletonLoaders() : renderDelegations()} diff --git a/src/components/containers/Wallet/Delegation/DelegationList.module.css b/src/components/containers/Wallet/Delegation/DelegationList.module.css new file mode 100644 index 00000000..d7a867ae --- /dev/null +++ b/src/components/containers/Wallet/Delegation/DelegationList.module.css @@ -0,0 +1,20 @@ +.list { + display: flex; + flex-direction: column; + gap: 12px; + padding: 10px; + overflow: auto; + flex-grow: 1; + list-style: none; +} + +.empty { + background: rgb(var(--color-white)); + font-size: 1rem; + color: rgb(var(--color-dark-gray)); + list-style: none; + text-align: center; + padding: 2rem 1.5rem; + border-radius: 20px; + border: 1px solid rgb(var(--color-medium-gray)); +} diff --git a/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css b/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css new file mode 100644 index 00000000..4cca42d3 --- /dev/null +++ b/src/components/containers/Wallet/Delegation/DelegationSkeleton.module.css @@ -0,0 +1,88 @@ +.card { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.25rem; + border-radius: 35px; + background: rgb(var(--color-white)); + border: 1px solid rgb(var(--color-medium-gray)); + list-style: none; +} + +@keyframes shimmer { + 0% { + background-position: -200px 0; + } + 100% { + background-position: 200px 0; + } +} + +.shimmer { + background: linear-gradient( + 90deg, + rgb(var(--color-medium-gray)) 25%, + rgba(var(--color-medium-gray), 0.4) 50%, + rgb(var(--color-medium-gray)) 75% + ); + background-size: 400px 100%; + animation: shimmer 1.5s infinite linear; + border-radius: 6px; +} + +.icon { + width: 56px; + min-width: 56px; + height: 56px; + border-radius: 50%; +} + +.info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.poolId { + height: 14px; + width: 65%; +} + +.date { + height: 10px; + width: 40%; +} + +.amount { + text-align: right; + flex-shrink: 0; + min-width: 70px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.3rem; +} + +.amountValue { + height: 16px; + width: 50px; +} + +.amountCurrency { + height: 10px; + width: 20px; +} + +.actions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +.button { + height: 32px; + width: 72px; + border-radius: 20px; +} diff --git a/src/components/containers/Wallet/Delegation/DelegationSkeleton.tsx b/src/components/containers/Wallet/Delegation/DelegationSkeleton.tsx new file mode 100644 index 00000000..49ff6cf8 --- /dev/null +++ b/src/components/containers/Wallet/Delegation/DelegationSkeleton.tsx @@ -0,0 +1,21 @@ +import styles from './DelegationSkeleton.module.css' + +const DelegationSkeleton = () => ( +
    • +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
      +
    • +) + +export default DelegationSkeleton diff --git a/src/components/containers/Wallet/Nft/NftDetails.js b/src/components/containers/Wallet/Nft/NftDetails.js index bf16b76e..c7ca7d9b 100644 --- a/src/components/containers/Wallet/Nft/NftDetails.js +++ b/src/components/containers/Wallet/Nft/NftDetails.js @@ -61,10 +61,6 @@ const NftDetails = ({ nft, handleSend }) => { alt="NFT" />
  • - {/* */} new Decimal(rate).toDecimalPlaces(10).toString() const OrderDetailsItem = ({ title, content, copyContent }) => { return (
    - {title &&

    {title}

    } + {title && ( +

    + {title} +

    + )}
    - {content} - {copyContent && } + {content} + {copyContent && ( +
    + +
    + )}
    ) } const SwapInfoContent = ({ order, from }) => { - const tokenId = from - ? order.ask_currency.token_id - : order.give_currency.token_id - const tokenTicker = from - ? order.ask_currency.ticker - : order.give_currency.ticker + const currency = from ? order.ask_currency : order.give_currency + const balance = from ? order.ask_balance : order.give_balance + const tokenId = currency.token_id + const tokenTicker = currency.ticker + const displayTicker = currency.type === 'Coin' ? 'ML' : currency.ticker + const subtitle = + currency.type === 'Token' ? `(${currency.token_id})` : '(Mintlayer Coin)' + return (
    { size="big" />

    - {from - ? `${order.ask_balance.decimal} ${order.ask_currency.type === 'Coin' ? 'ML' : order.ask_currency.ticker}` - : `${order.give_balance.decimal} ${order.give_currency.type === 'Coin' ? 'ML' : order.give_currency.ticker}`} + {balance.decimal}{' '} + {displayTicker}

    - {from - ? `${order.ask_currency.type === 'Token' ? `(${order.ask_currency.token_id})` : '(Mintlayer Coin)'}` - : `${order.give_currency.type === 'Token' ? `(${order.give_currency.token_id})` : '(Mintlayer Coin)'}`} + {subtitle}

    + + {from ? 'FROM' : 'TO'} +
    ) } const OrderDetails = ({ order }) => { - const buttonExtraStyles = ['order-details-button'] - const inputExtraClasses = ['order-details-input'] - const { client, unusedAddresses } = useContext(MintlayerContext) + const { client, unusedAddresses, balance, tokenBalances } = + useContext(MintlayerContext) const [txErrorMessage, setTxErrorMessage] = useState(null) const [loading, setLoading] = useState(false) - const loadingExtraClasses = ['loading-big'] - const [amount, setAmount] = useState('') const [amountValidity, setAmountValidity] = useState(false) + const [inputValidity, setInputValidity] = useState('') - const amountChangeHandler = (value) => { - setAmount(value) - setAmountValidity( - value && - !isNaN(value) && - parseFloat(value) > 0 && - parseFloat(value) <= Number(order.ask_balance.decimal), - ) + const maxAmount = Number(order.ask_balance.decimal) + + const walletBalance = + order.ask_currency.type === 'Coin' + ? balance + : tokenBalances[order.ask_currency.token_id] + ? Number(tokenBalances[order.ask_currency.token_id].balance) + : 0 + console.log('Order ask balance:', order.ask_balance.decimal) + console.log('Wallet balance for token:', walletBalance) + console.log('Wallet balance:', balance) + console.log('Token balances:', tokenBalances) + + const handleValueChange = ({ value }) => { + setAmount(value || '') + } + + const handleAmountValidity = (valid) => { + setAmountValidity(valid) + setInputValidity(valid ? 'valid' : 'invalid') + if (valid) setTxErrorMessage(null) + } + + const validateAmount = (value) => { + if (value > walletBalance) return 'Insufficient wallet balance.' + if (value > maxAmount) return 'Amount exceeds available order balance.' + return null } const handleSwapClick = async () => { @@ -108,109 +142,103 @@ const OrderDetails = ({ order }) => { }) } } catch (error) { - if (typeof error === 'string') { - if (error?.message?.includes('Not enough token UTXOs')) { - setTxErrorMessage('Token blance is not enough to fill the order') - return - } - - if (error?.message?.includes('Failed to fetch order')) { - setTxErrorMessage('Order not found or invalid order ID') - return - } - - if (error?.message?.includes('Invalid addressable')) { - setTxErrorMessage('Invalid destination address') - return - } - - if (error.includes('Invalid addressable')) { - setTxErrorMessage('Invalid destination address') - return - } + const msg = typeof error === 'string' ? error : error?.message || '' + + if (msg.includes('Not enough token UTXOs')) { + setTxErrorMessage('Token balance is not enough to fill the order') + return + } + + if (msg.includes('Failed to fetch order')) { + setTxErrorMessage('Order not found or invalid order ID') + return + } + + if (msg.includes('Invalid addressable')) { + setTxErrorMessage('Invalid destination address') + return } console.error('Error filling order:', error) - setTxErrorMessage( - error?.message || 'An error occurred while filling the order', - ) + setTxErrorMessage(msg || 'An error occurred while filling the order') } finally { setLoading(false) } } + + const placeholderTicker = + order.ask_currency.type === 'Coin' ? 'ML' : order.ask_currency.ticker + return (
    {loading ? ( -
    - +
    +
    ) : ( <> -
    - Order details + + + +
    + -
    - - } - /> -
    - -
    - } - /> -
    -
    - Exchage rate: - - {` 1 ${order.ask_currency.ticker} ≈ ${Number(order.quote_rate).toFixed(10)} ${order.give_currency.ticker}`} - + + +
    + +
    + + Exchage rate:{' '} + {` 1 ${order.ask_currency.ticker} ≈ ${formatRate(order.quote_rate)} ${order.give_currency.ticker}`} + +

    + Available: {walletBalance} {placeholderTicker} +

    +
    + +
    + + + {txErrorMessage && ( +

    {txErrorMessage}

    + )}
    - - - {txErrorMessage ? ( - <> - - - ) : ( - <> - )} -
    - - -
    -
    -
    )}
    diff --git a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css new file mode 100644 index 00000000..0e77f02c --- /dev/null +++ b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.module.css @@ -0,0 +1,277 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + max-width: 560px; + gap: 16px; + padding: 8px 4px 4px; + flex-shrink: 0; +} + +.title { + font-size: 20px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0; + padding-left: 5px; +} + +.item { + display: flex; + flex-direction: column; + gap: 8px; + min-height: max-content; +} + +.itemTitle { + margin: 0; + padding-left: 5px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.8px; + color: rgba(var(--color-black), 0.45); + text-transform: uppercase; +} + +.itemContent { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 8px 8px 18px; + border-radius: 14px; + background: rgba(var(--color-black), 0.04); + min-height: 52px; +} + +.itemValue { + flex: 1; + font-family: monospace; + font-size: 15px; + font-weight: 600; + color: rgb(var(--color-black)); + word-break: break-all; +} + +.copyWrapper { + display: flex; + flex-shrink: 0; +} + +.copyWrapper button, +.copyWrapper button:hover, +.copyWrapper button:focus { + background-color: rgba(var(--color-main-green), 1); + width: 36px; + height: 36px; + border-radius: 10px; + padding: 0; + transition: opacity 0.2s ease; +} + +.copyWrapper button:hover, +.copyWrapper button:focus { + opacity: 0.85; +} + +.copyWrapper button svg { + width: 16px; + height: 16px; +} + +.copyWrapper button svg path, +.copyWrapper button:hover svg path, +.copyWrapper button:focus svg path { + fill: none; + stroke: rgb(var(--color-white)); +} + +.swapCardWrapper { + position: relative; + display: flex; + flex-direction: column; + min-height: max-content; + gap: 23px; + background: rgba(var(--color-black), 0.03); + border-radius: 20px; + padding: 16px; +} + +.swapCard { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + background: rgb(var(--color-white)); + border: 1px solid rgba(var(--color-black), 0.06); + border-radius: 16px; +} + +.swapCardText { + display: flex; + flex-direction: column; + flex: 1; + gap: 2px; + min-width: 0; +} + +.swapAmount { + margin: 0; + font-size: 18px; + display: flex; + align-items: baseline; + gap: 6px; +} + +.amountValue { + color: rgb(var(--color-black)); + font-weight: 700; + flex-shrink: 0; +} + +.amountTicker { + color: rgba(var(--color-black), 0.3); + font-weight: 600; +} + +.swapTokenId { + margin: 0; + font-size: 12px; + color: rgba(var(--color-black), 0.45); + font-family: monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + padding: 6px 12px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.6px; + flex-shrink: 0; +} + +.badgeFrom { + background: rgba(var(--color-black), 0.05); + color: rgba(var(--color-black), 0.4); +} + +.badgeTo { + background: rgba(var(--color-main-green), 0.15); + color: rgba(var(--color-main-green), 1); +} + +.arrowSeparator { + align-self: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(var(--color-main-green), 1); + display: flex; + align-items: center; + justify-content: center; + margin: -6px 0; + box-shadow: 0 0 0 4px rgba(var(--color-black), 0.03); +} + +.arrowIcon { + width: 16px; + height: 16px; +} + +.arrowIcon path { + fill: rgb(var(--color-white)); +} + +.exchangeRate { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + font-size: 13px; + color: rgba(var(--color-black), 0.55); +} + +.exchangeRate span { + font-size: 13px; +} + +.exchangeRate span:last-child { + font-weight: 700; + color: rgb(var(--color-black)); +} + +.actions { + position: relative; + display: flex; + gap: 10px; + align-items: stretch; + min-height: max-content; + padding-bottom: 25px; +} + +.actions > :first-child { + flex: 1 1 0; + min-width: 0; + width: 100%; +} + +.amountInput { + width: 100%; + padding: 14px 18px; + border: 1px solid rgba(var(--color-black), 0.08); + border-radius: 14px; + background: rgba(var(--color-black), 0.04); + font-size: 20px; +} +.amountInput::placeholder { + font-size: 20px; +} + +.walletBalance { + font-size: 12px; + color: rgba(var(--color-black), 0.5); + margin: 0; + padding: 0 4px; +} + +.swapButton { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 28px; + min-height: 48px; + border-radius: 14px; + font-weight: 700; + flex-shrink: 0; + width: auto; +} + +.swapButtonIcon { + width: 18px; + height: 18px; +} + +.swapButton:hover .swapButtonIcon { + animation: moveArrowRight 0.3s ease-in-out; +} + +.loadingWrapper { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 400px; +} + +.errorMessage { + position: absolute; + margin: 0; + font-size: 13px; + color: rgb(var(--color-red, 220, 53, 69)); + font-weight: 600; + left: 0; + bottom: 0; +} diff --git a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js index 987b9448..2bde1986 100644 --- a/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js +++ b/src/components/containers/Wallet/Orders/OrderDetails/OrderDetails.test.js @@ -2,7 +2,12 @@ import React from 'react' import { OrderDetailsItem, SwapInfoContent } from './OrderDetails' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import OrderDetails from './OrderDetails' -import { MintlayerContext, SettingsContext, AccountContext } from '@Contexts' +import { + MintlayerContext, + SettingsContext, + AccountContext, + TransactionProvider, +} from '@Contexts' import { ML } from '@Helpers' describe('OrderDetailsItem', () => { @@ -231,16 +236,21 @@ describe('SwapInfoContent', () => { }, } - render( + const { unmount } = render( , ) - expect(screen.getByText('0.000001 TKN3')).toBeInTheDocument() + expect(screen.getByTestId('token-amount')).toHaveTextContent( + '0.000001 TKN3', + ) + unmount() render() - expect(screen.getByText('999999.999999 ML')).toBeInTheDocument() + expect(screen.getByTestId('token-amount')).toHaveTextContent( + '999999.999999 ML', + ) }) }) @@ -292,6 +302,11 @@ const mockMintlayerContext = { unusedAddresses: { receive: 'testnet_addr1', }, + balance: 1000, + tokenBalances: { + token123: { balance: '500' }, + token456: { balance: '300' }, + }, } const mockSettingsContext = { @@ -309,7 +324,9 @@ const renderWithContext = (mockOrder) => { - + + + , @@ -329,14 +346,13 @@ describe('OrderDetails', () => { expect( screen.getByText(ML.formatAddress(mockTokenOrder.order_id, 36)), ).toBeInTheDocument() - expect(screen.getByText('Exchage rate:')).toBeInTheDocument() - expect(screen.getByText('1 TKN ≈ 2.0000000000 ML')).toBeInTheDocument() + expect(screen.getByText(/Exchage rate:.*1 TKN ≈ 2 ML/)).toBeInTheDocument() }) it('renders coin order correctly', () => { renderWithContext(mockCoinOrder) - expect(screen.getByText('1 ML ≈ 0.5000000000 TKN2')).toBeInTheDocument() + expect(screen.getByText(/1 ML ≈ 0.5 TKN2/)).toBeInTheDocument() expect(screen.getByPlaceholderText('ML amount')).toBeInTheDocument() }) diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.css b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.css deleted file mode 100644 index cc9b9f80..00000000 --- a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.css +++ /dev/null @@ -1,102 +0,0 @@ -.transaction { - display: flex; - align-items: center; - padding: 12px 30px 12px 18px; - margin-bottom: 10px; - border-radius: 45px; - background: rgb(var(--color-gray)); - border: 1px solid rgba(var(--color-light-green), 0.2); - word-break: break-all; - cursor: pointer; - justify-content: flex-end; - transition: all 0.3s ease-in-out; - - @media screen and (min-width: 801px) { - justify-content: flex-start; - } -} - -.transaction-logo-type { - display: flex; - align-items: center; - justify-content: center; - width: 72px; - min-width: 72px; - height: 72px; - background: rgb(var(--color-dim-green)); - border-radius: 50%; - font-size: 43px; -} - -.transaction-detail { - margin-left: 30px; - flex-grow: 1; -} - -.transaction-logo-out { - background: rgb(var(--color-black)); -} - -.arrow-icon { - width: 30px; - height: 36px; -} - -.arrow-icon-out { - transform: rotate(180deg); -} - -.transaction-date-amount { - display: flex; - align-items: center; - justify-content: space-between; -} - -.transaction-id { - display: flex; - align-items: center; - justify-content: space-between; - font-size: 1.7rem; - font-weight: 600; - margin-bottom: 10px; - margin-top: 4px; - word-break: break-all; -} - -.transaction-date { - font-size: 1.2rem; - font-weight: 400; -} - -.transaction-date span { - font-weight: 600; -} - -.order-swap-icon { - width: 40px; - height: 40px; - color: #fff; -} - -@keyframes grow { - from { - width: 0; - } - to { - width: 95%; - } -} - -.transaction:hover { - transform: scale(1.02); - border: 1px solid rgba(var(--color-light-green), 0.5); -} - -.transaction-logo-type.delegation-icon { - background-color: rgb(var(--color-dim-blue)); -} - -.balance-swap-icon { - width: 28px; - color: rgb(var(--color-light-gray)); -} diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.js b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.js index c0bf2172..8212bcc1 100644 --- a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.js +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.js @@ -1,12 +1,14 @@ -import React, { useState } from 'react' +import { useState } from 'react' +import Decimal from 'decimal.js' import { PopUp } from '@ComposedComponents' import { ML } from '@Helpers' -import { ReactComponent as SwapIcon } from '@Assets/images/icon-swap.svg' import OrderDetails from '../OrderDetails/OrderDetails' -import './OrderItem.css' +import styles from './OrderItem.module.css' + +const formatRate = (rate) => new Decimal(rate).toDecimalPlaces(10).toString() const OrderItem = ({ order }) => { const [detailPopupOpen, setDetailPopupOpen] = useState(false) @@ -15,50 +17,49 @@ const OrderItem = ({ order }) => { setDetailPopupOpen(true) } + const askTicker = order.ask_currency.ticker + const giveTicker = order.give_currency.ticker + const rateText = + order.quote_rate != null + ? `1 ${askTicker} = ${formatRate(order.quote_rate)} ${giveTicker}` + : null + return ( -
  • -
    + - -
    -
    -
    -

    + {ML.formatAddress(order.order_id)} -

    -
    -
    - {order.ask_balance.decimal} - {order.ask_currency.ticker} -
    - -
    - {order.give_balance.decimal} - {order.give_currency.ticker} -
    -
    -
    -
    + + {rateText && {rateText}} + + + {order.ask_balance.decimal} + {askTicker} + + + + {order.give_balance.decimal} + + {giveTicker} + + + + + {detailPopupOpen && ( )} -
  • + ) } diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css new file mode 100644 index 00000000..1e5bbab9 --- /dev/null +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.module.css @@ -0,0 +1,69 @@ +.row { + border-bottom: 1px solid rgba(var(--color-black), 0.05); + cursor: pointer; + transition: background 0.15s ease; +} + +.row:last-child { + border-bottom: none; +} + +.row:hover { + background: rgba(var(--color-black), 0.02); +} + +.orderIdCell { + padding: 16px 20px; + vertical-align: middle; + width: 45%; + border-left: 3px solid rgba(var(--color-main-green), 1); +} + +.orderId { + font-family: monospace; + font-weight: 700; + font-size: 14px; + display: block; + color: rgb(var(--color-black)); +} + +.exchangeRate { + font-size: 12px; + color: rgba(var(--color-black), 0.4); + margin-top: 2px; + display: block; +} + +.amountCell { + padding: 16px 20px; + vertical-align: middle; + width: 22%; +} + +.amount { + font-weight: 700; + font-size: 16px; + color: rgb(var(--color-black)); +} + +.amountGreen { + color: rgba(var(--color-main-green), 1); +} + +.ticker { + font-size: 12px; + color: rgba(var(--color-black), 0.4); + margin-left: 4px; +} + +.chevronCell { + padding: 16px 20px; + vertical-align: middle; + width: 11%; + text-align: right; +} + +.chevron { + font-size: 18px; + color: rgba(var(--color-black), 0.3); +} diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.test.js b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.test.js index 0e92c84d..7f3ddd2a 100644 --- a/src/components/containers/Wallet/Orders/OrderItem/OrderItem.test.js +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItem.test.js @@ -35,9 +35,19 @@ const mockTokenOrder = { }, } +const renderOrderItem = (order) => { + return render( + + + + +
    , + ) +} + describe('OrderItem', () => { it('renders order correctly', () => { - render() + renderOrderItem(mockOrder) expect(screen.getByTestId('order')).toBeInTheDocument() expect(screen.getByTestId('order-id')).toHaveTextContent( @@ -50,7 +60,7 @@ describe('OrderItem', () => { }) it('renders token order correctly', () => { - render() + renderOrderItem(mockTokenOrder) expect(screen.getByTestId('order')).toBeInTheDocument() expect(screen.getByText('50.75')).toBeInTheDocument() @@ -60,7 +70,7 @@ describe('OrderItem', () => { }) it('formats order ID correctly', () => { - render() + renderOrderItem(mockOrder) expect(screen.getByTestId('order-id')).toHaveTextContent( ML.formatAddress(mockOrder.order_id), @@ -68,17 +78,16 @@ describe('OrderItem', () => { }) it('applies correct CSS classes', () => { - render() + renderOrderItem(mockOrder) const orderItem = screen.getByTestId('order') - expect(orderItem).toHaveClass('transaction') + expect(orderItem).toHaveClass('row') }) - it('renders swap icons', () => { - render() + it('renders chevron indicator', () => { + renderOrderItem(mockOrder) - const swapIcons = screen.getAllByTestId('swap-icon') - expect(swapIcons).toHaveLength(2) + expect(screen.getByText('›')).toBeInTheDocument() }) it('handles long decimal values', () => { @@ -92,7 +101,7 @@ describe('OrderItem', () => { }, } - render() + renderOrderItem(orderWithLongDecimals) expect(screen.getByText('123.456789')).toBeInTheDocument() expect(screen.getByText('987.123456')).toBeInTheDocument() @@ -109,8 +118,25 @@ describe('OrderItem', () => { }, } - render() + renderOrderItem(orderWithoutTicker) expect(screen.getByTestId('order')).toBeInTheDocument() }) + + it('shows exchange rate when quote_rate is available', () => { + const orderWithRate = { + ...mockOrder, + quote_rate: 2.0, + } + + renderOrderItem(orderWithRate) + + expect(screen.getByText('1 ML = 2 TKN')).toBeInTheDocument() + }) + + it('does not show exchange rate when quote_rate is missing', () => { + renderOrderItem(mockOrder) + + expect(screen.queryByText(/1 ML =/)).not.toBeInTheDocument() + }) }) diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.js b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.js new file mode 100644 index 00000000..5bbb9588 --- /dev/null +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.js @@ -0,0 +1,26 @@ +import styles from './OrderItemSkeleton.module.css' + +const OrderItemSkeleton = () => { + return ( + + + + + + + + + + + + + + + + ) +} + +export default OrderItemSkeleton diff --git a/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css new file mode 100644 index 00000000..92082cc9 --- /dev/null +++ b/src/components/containers/Wallet/Orders/OrderItem/OrderItemSkeleton.module.css @@ -0,0 +1,64 @@ +.row { + border-bottom: 1px solid rgba(var(--color-black), 0.05); +} + +.row:last-child { + border-bottom: none; +} + +.orderIdCell { + padding: 16px 20px; + vertical-align: middle; + width: 45%; + border-left: 3px solid rgba(var(--color-main-green), 1); +} + +.amountCell { + padding: 16px 20px; + vertical-align: middle; + width: 22%; +} + +.chevronCell { + padding: 16px 20px; + vertical-align: middle; + width: 11%; + text-align: right; +} + +.bar { + display: block; + border-radius: 4px; + animation: order-skeleton-pulse 1s linear infinite alternate; +} + +.orderIdBar { + width: 70%; + height: 12px; + margin-bottom: 8px; +} + +.rateBar { + width: 45%; + height: 9px; +} + +.amountBar { + width: 60%; + height: 12px; +} + +.chevronBar { + width: 10px; + height: 14px; + margin-left: auto; +} + +@keyframes order-skeleton-pulse { + 0% { + background: rgba(var(--color-main-green), 0.1); + } + 100% { + background: rgba(var(--color-main-green), 0.2); + } +} diff --git a/src/components/containers/Wallet/Orders/OrderList/OrderList.css b/src/components/containers/Wallet/Orders/OrderList/OrderList.css deleted file mode 100644 index 4ae9c720..00000000 --- a/src/components/containers/Wallet/Orders/OrderList/OrderList.css +++ /dev/null @@ -1,30 +0,0 @@ -.order-list { - overflow: auto; - padding: 7px; - width: 100%; - - @media screen and (min-width: 801px) { - padding: 10px; - flex-grow: 1; - } -} - -.empty-list { - background: rgb(var(--color-gray)); - font-size: 1.5em; - list-style: none; - padding: 10px; - text-align: center; - - padding: 26px 20px; - border-radius: 20px; - border: 1px; - border: 1px solid rgba(var(--color-light-green), 0.2); -} - -.load-more-button-wrapper { - display: flex; - width: 100%; - justify-content: center; - margin-top: 16px; -} diff --git a/src/components/containers/Wallet/Orders/OrderList/OrderList.js b/src/components/containers/Wallet/Orders/OrderList/OrderList.js index e31e5a59..5e454c0d 100644 --- a/src/components/containers/Wallet/Orders/OrderList/OrderList.js +++ b/src/components/containers/Wallet/Orders/OrderList/OrderList.js @@ -1,31 +1,35 @@ -import { useEffect, useState } from 'react' +import { useState } from 'react' import { Button } from '@BasicComponents' import OrderItem from '../OrderItem/OrderItem' -import { SkeletonLoader } from '@BasicComponents' -import './OrderList.css' +import OrderItemSkeleton from '../OrderItem/OrderItemSkeleton' +import styles from './OrderList.module.css' const PAGE_SIZE = 10 +const SKELETON_ROWS = 6 const OrderList = ({ orderList, ordersLoading }) => { const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) - const [showedOrders, setShowedOrders] = useState([]) + const showedOrders = orderList ? orderList.slice(0, visibleCount) : [] - useEffect(() => { - orderList && setShowedOrders(orderList.slice(0, visibleCount)) - }, [visibleCount, orderList]) - - const renderSkeletonLoaders = () => - Array.from({ length: 6 }, (_, i) => ) + const renderSkeletonRows = () => + Array.from({ length: SKELETON_ROWS }, (_, i) => ( + + )) const renderOrders = () => { if (!orderList || !orderList.length) { return ( -
  • - No orders found -
  • + + No orders found + + ) } @@ -42,22 +46,36 @@ const OrderList = ({ orderList, ordersLoading }) => { } return ( - <> -
      - {ordersLoading ? renderSkeletonLoaders() : renderOrders()} - {orderList && showedOrders.length < orderList.length && ( +
      + + + + + + + + + + {ordersLoading ? renderSkeletonRows() : renderOrders()} +
      + ORDER ID + + YOU SEND + YOU GET
      + {!ordersLoading && + orderList && + showedOrders.length < orderList.length && (
      )} -
    - +
    ) } diff --git a/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css b/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css new file mode 100644 index 00000000..b3e87f4f --- /dev/null +++ b/src/components/containers/Wallet/Orders/OrderList/OrderList.module.css @@ -0,0 +1,97 @@ +.card { + background: rgb(var(--color-white)); + border-radius: 16px; + overflow: hidden; + width: 100%; + flex-grow: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.table { + width: 100%; + display: flex; + flex-direction: column; + flex-grow: 1; + min-height: 0; +} + +.table thead { + display: block; + width: 100%; + flex-shrink: 0; +} + +.table tbody { + display: block; + overflow-y: auto; + width: 100%; + min-height: 0; + flex-grow: 1; +} + +.table thead tr, +.table tbody tr { + display: table; + width: 100%; + table-layout: fixed; + height: fit-content; +} + +.colHeader { + text-align: left; + padding: 14px 20px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + color: rgba(var(--color-black), 0.4); + border-bottom: 1px solid rgba(var(--color-black), 0.06); + user-select: none; +} + +.colOrderId { + width: 45%; +} + +.colSend { + width: 22%; +} + +.colGet { + width: 22%; +} + +.colAction { + width: 11%; +} + +.loadMoreWrapper { + display: flex; + width: 100%; + justify-content: center; + padding: 16px; +} + +.noOrders { + text-align: center; + padding: 2rem; + color: rgba(var(--color-black), 0.4); +} + +.table tbody::-webkit-scrollbar { + width: 4px; +} + +.table tbody::-webkit-scrollbar-track { + background: transparent; +} + +.table tbody::-webkit-scrollbar-thumb { + background: rgba(var(--color-black), 0.1); + border-radius: 2px; +} + +.table tbody::-webkit-scrollbar-thumb:hover { + background: rgba(var(--color-black), 0.2); +} diff --git a/src/components/containers/Wallet/Orders/OrderList/OrderList.test.js b/src/components/containers/Wallet/Orders/OrderList/OrderList.test.js index bb7ac916..1feafcd8 100644 --- a/src/components/containers/Wallet/Orders/OrderList/OrderList.test.js +++ b/src/components/containers/Wallet/Orders/OrderList/OrderList.test.js @@ -279,7 +279,7 @@ describe('OrderList', () => { ) const orderList = screen.getByTestId('order-list') - expect(orderList).toHaveClass('order-list') + expect(orderList).toHaveClass('card') }) it('updates visible orders when orderList prop changes', () => { @@ -328,21 +328,33 @@ describe('OrderList', () => { ) const emptyItem = screen.getByTestId('order-empty') - expect(emptyItem).toHaveClass('empty-list') + expect(emptyItem).toHaveClass('emptyRow') }) -}) -it('displays correct order data', () => { - render( - , - ) - - // Check that order data is displayed (these would be rendered by OrderItem) - expect(screen.getByText('50.75')).toBeInTheDocument() - expect(screen.getByText('TKN2')).toBeInTheDocument() - expect(screen.getByText('25.125')).toBeInTheDocument() - expect(screen.getByText('ML')).toBeInTheDocument() + it('renders table headers', () => { + render( + , + ) + + expect(screen.getByText('ORDER ID')).toBeInTheDocument() + expect(screen.getByText('YOU SEND')).toBeInTheDocument() + expect(screen.getByText('YOU GET')).toBeInTheDocument() + }) + + it('displays correct order data', () => { + render( + , + ) + + expect(screen.getByText('50.75')).toBeInTheDocument() + expect(screen.getByText('TKN2')).toBeInTheDocument() + expect(screen.getByText('25.125')).toBeInTheDocument() + expect(screen.getByText('ML')).toBeInTheDocument() + }) }) diff --git a/src/components/containers/Wallet/ShowAddress.js b/src/components/containers/Wallet/ShowAddress.js index 8f613d41..b77e6c79 100644 --- a/src/components/containers/Wallet/ShowAddress.js +++ b/src/components/containers/Wallet/ShowAddress.js @@ -8,7 +8,7 @@ import './ShowAddress.css' const ShowAddress = ({ address }) => { const [toCopyLabel, afterCopyLabel] = ['Copy Address', 'Copied!'] - const copiedTimeoutInMs = 2 * 1_000 + const copiedTimeoutInMs = 2000 const [label, setLabel] = useState(toCopyLabel) const [disabled, setDisabled] = useState(false) diff --git a/src/components/containers/Wallet/Transaction.css b/src/components/containers/Wallet/Transaction.css index e353b2b6..4090a957 100644 --- a/src/components/containers/Wallet/Transaction.css +++ b/src/components/containers/Wallet/Transaction.css @@ -1,23 +1,26 @@ .transaction { display: flex; align-items: center; - padding: 12px 30px 12px 18px; - margin-bottom: 10px; - border-radius: 45px; + padding: 12px 29px 12px 8px; + margin-bottom: 0; + border-radius: 35px; background: rgb(var(--color-gray)); border: 1px solid rgba(var(--color-light-green), 0.2); word-break: break-all; cursor: pointer; position: relative; + max-height: 72px; + min-height: 72px; + box-sizing: border-box; } .transaction-logo-type { display: flex; align-items: center; justify-content: center; - width: 72px; - min-width: 72px; - height: 72px; + width: 60px; + min-width: 60px; + height: 60px; background: rgb(var(--color-dim-green)); border-radius: 50%; position: relative; @@ -25,14 +28,14 @@ } .transaction-logo-type-info { - font-size: 2.5rem; + font-size: 1.4rem; font-weight: 600; color: #fff; background: rgb(var(--color-dark-gray)); } .transaction-logo-type.transaction-logo-type-widthdrawal { - font-size: 2.5rem; + font-size: 1.4rem; font-weight: 600; color: #fff; background: rgb(var(--color-dark-gray)); @@ -41,14 +44,14 @@ .transaction-logo-type.transaction-logo-type-same { position: absolute; z-index: 10; - left: 30px; + left: 22px; background: rgb(var(--color-dark-gray)); } .transaction-logo-type.transaction-logo-type-same .loop-icon { transform: rotate(90deg); - width: 45px; - height: 45px; + width: 24px; + height: 24px; } .transaction-logo-type.transaction-logo-type-same .loop-icon path { @@ -56,7 +59,7 @@ } .transaction-detail { - margin-left: 30px; + margin-left: 16px; flex-grow: 1; } @@ -77,32 +80,32 @@ } .arrow-icon { - width: 30px; - height: 36px; + width: 18px; + height: 22px; } .arrow-icon-stake { - width: 30px; - height: 36px; - margin-left: -7px; + width: 18px; + height: 22px; + margin-left: -3px; } .stake-icon { - width: 35px; - height: 35px; + width: 20px; + height: 20px; } .unconfirmed-icon { - width: 34px; - height: 40px; + width: 20px; + height: 24px; color: #fff; } .delegation-icon { - width: 40px; - height: 40px; - margin-left: 5px; - margin-top: -5px; + width: 22px; + height: 22px; + margin-left: 2px; + margin-top: -2px; color: rgb(var(--color-black)); } @@ -121,21 +124,21 @@ } .transaction-id-info { - font-size: 1.4rem; + font-size: 0.95rem; font-weight: 600; - margin-bottom: 10px; + margin-bottom: 2px; word-break: break-word; } .transaction-id { - font-size: 1.7rem; + font-size: 1.1rem; font-weight: 600; - margin-bottom: 10px; + margin-bottom: 2px; word-break: break-all; } .transaction-date { - font-size: 1.2rem; + font-size: 0.85rem; font-weight: 400; } diff --git a/src/components/containers/Wallet/Transaction.js b/src/components/containers/Wallet/Transaction.js index 6ed7b256..4d996a4e 100644 --- a/src/components/containers/Wallet/Transaction.js +++ b/src/components/containers/Wallet/Transaction.js @@ -14,25 +14,25 @@ import { useNavigate } from 'react-router' import TransactionDetails from './TransactionDetails' -import './Transaction.css' +import styles from './Transaction.module.css' const Info = ({ transaction }) => { const navigate = useNavigate() return (
  • navigate('/settings')} >
    !
    -
    +

    {transaction.otherPart && transaction.otherPart} @@ -52,21 +52,21 @@ const Transaction = ({ transaction, getConfirmations }) => { ) : (

  • setDetailPopupOpen(true)} > {(transaction.type === 'Transfer' || !transaction.type) && transaction.date ? (
    @@ -75,20 +75,20 @@ const Transaction = ({ transaction, getConfirmations }) => { )} {transaction.type === 'CreateOrder' ? (
    - +
    ) : ( <> )} {transaction.type === 'FillOrder' ? (
    - +
    ) : ( <> @@ -96,33 +96,33 @@ const Transaction = ({ transaction, getConfirmations }) => { {transaction.sameWalletTransaction && !transaction.type === 'FillOrder' ? (
    - +
    ) : ( <> )} {transaction.type === 'Unconfirmed' || !transaction.date ? (
    - +
    ) : ( <> )} {transaction.type === 'Delegate Withdrawal' ? (
    - +
    @@ -131,37 +131,37 @@ const Transaction = ({ transaction, getConfirmations }) => { )} {transaction.type === 'CreateStakePool' ? (
    - +
    ) : ( <> )} {transaction.type === 'CreateDelegationId' ? (
    - +
    ) : ( <> )} {transaction.type === 'DelegateStaking' ? (
    - +
    ) : ( <> )} -
    +

    {transaction.direction === 'in' && @@ -175,9 +175,9 @@ const Transaction = ({ transaction, getConfirmations }) => { ` (+${transaction.otherPart.length - 1})`} {transaction.destAddress && ML.formatAddress(transaction.destAddress)}

    -
    +

    Date: {date} diff --git a/src/components/containers/Wallet/Transaction.module.css b/src/components/containers/Wallet/Transaction.module.css new file mode 100644 index 00000000..580e284e --- /dev/null +++ b/src/components/containers/Wallet/Transaction.module.css @@ -0,0 +1,153 @@ +.transaction { + display: flex; + align-items: center; + padding: 12px 29px 12px 8px; + margin-bottom: 0; + border-radius: 35px; + background: rgb(var(--color-gray)); + border: 1px solid rgba(var(--color-light-green), 0.2); + word-break: break-all; + cursor: pointer; + transition: all 0.3s ease-in-out; + position: relative; + max-height: 72px; + min-height: 72px; + box-sizing: border-box; +} + +.logoType { + display: flex; + align-items: center; + justify-content: center; + width: 60px; + min-width: 60px; + height: 60px; + background: rgb(var(--color-dim-green)); + border-radius: 50%; + position: relative; + z-index: 5; +} + +.logoTypeInfo { + font-size: 1.4rem; + font-weight: 600; + color: #fff; + background: rgb(var(--color-dark-gray)); +} + +.logoTypeWithdrawal { + font-size: 1.4rem; + font-weight: 600; + color: #fff; + background: rgb(var(--color-dark-gray)); +} + +.logoTypeSame { + position: absolute; + z-index: 10; + left: 22px; + background: rgb(var(--color-dark-gray)); +} + +.logoTypeSame .loopIcon { + transform: rotate(90deg); + width: 28px; + height: 28px; +} + +.logoTypeSame .loopIcon path { + stroke: rgb(var(--color-white)) !important; +} + +.detail { + margin-left: 16px; + flex-grow: 1; +} + +.logoOut { + background: rgb(var(--color-dim-corail)); +} + +.logoTypeStake { + background: rgb(var(--color-dim-blue)); +} + +.logoTypeDelegate { + background: rgb(var(--color-dim-purple)); +} + +.logoTypeUnconfirmed { + background: rgb(var(--color-dim-yellow)); +} + +.arrowIcon { + width: 22px; + height: 26px; +} + +.arrowIconStake { + width: 22px; + height: 26px; + margin-left: -3px; +} + +.stakeIcon { + width: 26px; + height: 26px; +} + +.unconfirmedIcon { + width: 26px; + height: 28px; + color: #fff; +} + +.delegationIcon { + width: 28px; + height: 28px; + margin-left: 2px; + margin-top: -2px; + color: rgb(var(--color-black)); +} + +.delegationIcon svg { + color: rgb(var(--color-black)); +} + +.arrowIconOut { + transform: rotate(180deg); +} + +.dateAmount { + display: flex; + align-items: center; + justify-content: space-between; +} + +.idInfo { + font-size: 0.95rem; + font-weight: 600; + margin-bottom: 2px; + word-break: break-word; +} + +.id { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 2px; + word-break: break-all; +} + +.date { + font-size: 0.85rem; + font-weight: 400; +} + +.date span { + font-weight: 600; +} + +.transaction:hover { + transform: scale(1.02); + border: 1px solid rgba(var(--color-light-green), 0.5); +} diff --git a/src/components/containers/Wallet/Transaction.test.js b/src/components/containers/Wallet/Transaction.test.js index ae012597..2da77b34 100644 --- a/src/components/containers/Wallet/Transaction.test.js +++ b/src/components/containers/Wallet/Transaction.test.js @@ -3,6 +3,7 @@ import { act } from 'react' import { format } from 'date-fns' import Transaction from './Transaction' +import styles from './Transaction.module.css' import { BTC } from '@Helpers' import { SettingsProvider, AccountProvider, MintlayerProvider } from '@Contexts' @@ -63,7 +64,7 @@ test('Render transaction component', async () => { 'Amount: ' + TRANSCTIONSAMPLE.value, ) - expect(transactionIcon).not.toHaveClass('transaction-logo-out') + expect(transactionIcon).not.toHaveClass(styles.logoOut) await act(async () => fireEvent.click(transaction)) }) @@ -96,5 +97,5 @@ test('Render transaction out component', async () => { 'Amount: ' + TRANSCTIONSAMPLE.value, ) - expect(transactionIcon).toHaveClass('transaction-logo-out') + expect(transactionIcon).toHaveClass(styles.logoOut) }) diff --git a/src/components/containers/Wallet/TransactionAmount.css b/src/components/containers/Wallet/TransactionAmount.css index 6f7c709c..d4ba9444 100644 --- a/src/components/containers/Wallet/TransactionAmount.css +++ b/src/components/containers/Wallet/TransactionAmount.css @@ -3,3 +3,8 @@ gap: 10px; align-items: center; } + +.balance-swap-icon { + width: 16px; + height: 16px; +} diff --git a/src/components/containers/Wallet/TransactionAmount.js b/src/components/containers/Wallet/TransactionAmount.js index 7b10317a..83a6a4cc 100644 --- a/src/components/containers/Wallet/TransactionAmount.js +++ b/src/components/containers/Wallet/TransactionAmount.js @@ -25,7 +25,7 @@ const TransactionAmount = ({ transaction, title, extraStyleClasses = [] }) => { {tokenMap[transaction.value?.from?.token_id] || 'ML'}

    diff --git a/src/components/containers/Wallet/TransactionButton.css b/src/components/containers/Wallet/TransactionButton.css index f24982b1..9f3a8a85 100644 --- a/src/components/containers/Wallet/TransactionButton.css +++ b/src/components/containers/Wallet/TransactionButton.css @@ -1,73 +1,152 @@ .transaction-item span { - font-size: 13px; + font-weight: 600; color: rgb(var(--color-lightest-blue)); } -.button-transaction, -.button-transaction-up, -.button-transaction-staking { +/* Base button reset */ +.button-transaction { + padding: 0; + border-radius: 16px; + transition: all 0.3s ease-in-out; +} + +/* Small square buttons (Swap, Staking, NFT, Sign, Addr.) */ +.button-transaction-small { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; width: 58px; height: 58px; min-width: 58px; min-height: 58px; - margin-bottom: 10px; - border-radius: 21px; - padding: 0; + background-color: rgb(var(--color-gray)); + border: 1px solid rgba(var(--color-light-green), 0.2); @media screen and (min-width: 801px) { width: 64px; height: 64px; min-width: 64px; min-height: 64px; - margin-bottom: 10px; - padding: 0; - border-radius: 25px; } } -.transaction-buttons-wrapper { - display: flex; +.button-transaction-small svg { + width: 24px; + height: 24px; } -.transaction-item { - display: flex; - width: fit-content; - flex-direction: column; - align-items: center; +.button-transaction-small .button-transaction-label { + font-size: 11px; + font-weight: 600; + color: rgb(var(--color-lightest-blue)); } -.transaction-item:hover .transaction-button-title { - color: rgb(var(--color-white)); +.button-transaction-small:hover, +.button-transaction-small:focus { + background-color: rgba(var(--color-light-green), 0.15); } -.transaction-item:not(:last-child) { - margin-right: 5px; +.button-transaction-small svg path { + stroke: rgb(var(--color-lightest-blue)); +} + +.button-transaction-small:hover svg path, +.button-transaction-small:focus svg path { + stroke: rgb(var(--color-black)); +} + +/* Wide buttons (Send, Receive) */ +.button-transaction-wide { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 58px; + min-height: 58px; + border-radius: 16px; + font-size: 16px; + font-weight: 700; @media screen and (min-width: 801px) { - margin-right: 15px; + height: 64px; + min-height: 64px; } } +/* Send button (green) */ +.button-transaction-up { + background-color: rgb(var(--color-green)); + color: rgb(var(--color-dark-teal)); +} + +.button-transaction-up:hover, +.button-transaction-up:focus { + background-color: rgb(var(--color-darker-green)); +} + .button-transaction-up svg { transform: rotate(180deg); } -.icon-arrow { - width: 37px; - height: 37px; +.button-transaction-up svg path { + stroke: rgb(var(--color-dark-teal)); + transition: stroke 0.3s ease-in-out; +} + +.button-transaction-up:hover svg path, +.button-transaction-up:focus svg path { + stroke: rgb(var(--color-green)); +} + +/* Receive button (outlined/light) */ +.button-transaction-receive { + color: rgb(var(--color-black)); + background-color: rgb(var(--color-gray)); + border: 1px solid rgba(var(--color-light-green), 0.2); +} + +.button-transaction-receive:hover, +.button-transaction-receive:focus { + background-color: rgba(var(--color-light-green), 0.15); + color: rgb(var(--color-black)); +} + +.button-transaction-receive svg path { + stroke: rgb(var(--color-lightest-blue)); +} + +.button-transaction-receive:hover svg path, +.button-transaction-receive:focus svg path { + stroke: rgb(var(--color-black)); } -.staking-icon { - width: 36px; - height: 36px; +/* Label inside wide buttons */ +.button-transaction-wide .button-transaction-label { + font-size: 16px; + font-weight: 700; + color: inherit; } -.sign-icon { - width: 36px; - height: 36px; +.transaction-buttons-wrapper { + display: flex; } -.swap-icon { - width: 36px; - height: 36px; +.transaction-item { + display: flex; + width: fit-content; + flex-direction: column; + align-items: center; +} + +.transaction-item-wide { + flex: 1; + max-width: 150px; +} + +.icon-arrow { + width: 18px; + height: 18px; } diff --git a/src/components/containers/Wallet/TransactionButton.js b/src/components/containers/Wallet/TransactionButton.js index 42d580ae..f54d814a 100644 --- a/src/components/containers/Wallet/TransactionButton.js +++ b/src/components/containers/Wallet/TransactionButton.js @@ -10,33 +10,58 @@ import { Button } from '@BasicComponents' import './TransactionButton.css' const TransactionButton = ({ title, mode, onClick, disabled }) => { - const buttonExtraClasses = ['button-transaction'] - const buttonUpExtraClasses = ['button-transaction', 'button-transaction-up'] - const buttonStakingExtraClasses = [ - 'button-transaction', - 'button-transaction-staking', - ] - const buttonSwapExtraClasses = [ - 'button-transaction', - 'button-transaction-swap', - ] + const isWide = mode === 'up' || !mode const getButtonStyles = () => { + if (mode === 'up') + return [ + 'button-transaction', + 'button-transaction-wide', + 'button-transaction-up', + ] + if (!mode) + return [ + 'button-transaction', + 'button-transaction-wide', + 'button-transaction-receive', + ] + if (mode === 'staking') + return [ + 'button-transaction', + 'button-transaction-small', + 'button-transaction-staking', + ] + if (mode === 'swap') + return [ + 'button-transaction', + 'button-transaction-small', + 'button-transaction-swap', + ] + return ['button-transaction', 'button-transaction-small'] + } + + const getIcon = () => { switch (mode) { - case 'up': - return buttonUpExtraClasses case 'staking': - return buttonStakingExtraClasses + return + case 'sign': + return + case 'nft': + return case 'swap': - return buttonSwapExtraClasses + return + case 'addresses': + return + case 'up': + return default: - return buttonExtraClasses + return } } return (
    - {title && {title}}
    ) } diff --git a/src/components/containers/Wallet/TransactionDetails.css b/src/components/containers/Wallet/TransactionDetails.css deleted file mode 100644 index 92977a25..00000000 --- a/src/components/containers/Wallet/TransactionDetails.css +++ /dev/null @@ -1,65 +0,0 @@ -.transaction-details { - width: 100%; - height: 100%; - max-width: 510px; - height: 466px; - - @media screen and (min-width: 801px) { - height: 630px; - } -} - -.transaction-details-items-wrapper { - height: 392px; - width: 100%; - padding: 10px; - overflow: auto; - - @media screen and (min-width: 801px) { - height: auto; - max-height: 560px; - } -} - -.transaction-details-item { - margin-bottom: 1.5rem; -} - -.transaction-details-item h2 { - margin-bottom: 8px; - font-size: 1.5rem; - font-weight: 400; -} - -.transactionDetItemContent { - font-size: 1.5rem; - font-weight: 600; - word-break: break-all; - cursor: auto; -} - -.transaction-details-button { - margin-top: 1.3rem; -} - -.transaction-explorer-button-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.transaction-details-button:hover .transaction-explorer-button-icon { - animation: moveArrowUpRight 0.3s ease-in-out; -} - -.transaction-amount.transaction-amount-big span { - font-size: 1.5rem !important; -} - -.transactionDetItemContent .transaction-amount { - font-size: 1.5rem; - font-weight: 600; - word-break: break-all; -} diff --git a/src/components/containers/Wallet/TransactionDetails.js b/src/components/containers/Wallet/TransactionDetails.js index 0c37a853..6cae6633 100644 --- a/src/components/containers/Wallet/TransactionDetails.js +++ b/src/components/containers/Wallet/TransactionDetails.js @@ -2,15 +2,28 @@ import { useEffect, useState, useContext } from 'react' import { format } from 'date-fns' import { Button } from '@BasicComponents' -import { Loading } from '@ComposedComponents' -import { SettingsContext } from '@Contexts' -import { ML, BTC } from '@Helpers' +import { Loading, CopyButton } from '@ComposedComponents' +import { SettingsContext, MintlayerContext } from '@Contexts' +import { ML, BTC, Format } from '@Helpers' +import { ReactComponent as ArrowIcon } from '@Assets/images/icon-arrow-down.svg' +import { ReactComponent as SwapIcon } from '@Assets/images/icon-swap.svg' import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' -import TransactionAmount from './TransactionAmount' +import { ReactComponent as IconSuccess } from '@Assets/images/icon-success.svg' -import './TransactionDetails.css' +import styles from './TransactionDetails.module.css' import { useParams } from 'react-router' -import { CenteredLayout } from '@LayoutComponents' + +const formatDate = (timestamp) => { + if (!timestamp) return 'not confirmed' + const txDate = new Date(timestamp * 1000) + const now = new Date() + const isToday = + txDate.getDate() === now.getDate() && + txDate.getMonth() === now.getMonth() && + txDate.getFullYear() === now.getFullYear() + if (isToday) return `Today, ${format(txDate, 'HH:mm')}` + return format(txDate, 'dd/MM/yyyy HH:mm') +} const getAddress = (tx) => tx.direction === 'out' @@ -20,12 +33,17 @@ const getAddress = (tx) => const TransactionDetailsItem = ({ title, content }) => { return (
    -

    {title}

    + + {title} +
    {content} @@ -36,31 +54,64 @@ const TransactionDetailsItem = ({ title, content }) => { const TransactionDetails = ({ transaction, getConfirmations }) => { const { networkType } = useContext(SettingsContext) + const { tokenMap, tokenBalances } = useContext(MintlayerContext) const { coinType } = useParams() + const isToken = !['Bitcoin', 'Mintlayer'].includes(coinType) const walletType = { name: coinType, - ticker: coinType === 'Bitcoin' ? 'BTC' : 'ML', + ticker: + coinType === 'Bitcoin' + ? 'BTC' + : isToken + ? tokenBalances[coinType]?.token_info?.token_ticker?.string || + tokenMap[coinType] || + coinType + : 'ML', chain: coinType === 'Bitcoin' ? 'bitcoin' : 'mintlayer', } const [confirmations, setConfirmations] = useState(null) - const date = transaction.date - ? format(new Date(transaction.date * 1000), 'dd/MM/yyyy HH:mm') - : 'not confirmed' - const buttonExtraStyles = ['transaction-details-button'] - const addressTitle = transaction?.direction === 'out' ? 'To:' : 'From:' + const date = formatDate(transaction.date) + const isReceive = transaction.direction === 'in' + const addressTitle = isReceive ? 'From' : 'To' const transactionAddress = getAddress(transaction) + const directionLabel = (() => { + switch (transaction.type) { + case 'CreateOrder': + case 'FillOrder': + return 'Swap' + case 'CreateStakePool': + return 'Create Stake Pool' + case 'CreateDelegationId': + return 'Create Delegation' + case 'DelegateStaking': + return 'Delegate Staking' + case 'Delegate Withdrawal': + return 'Delegation Withdrawal' + default: + return isReceive ? 'Receive' : 'Send' + } + })() + const isSwap = + transaction.type === 'FillOrder' || transaction.type === 'CreateOrder' + const amountSign = isReceive ? '+' : '-' + const formattedValue = transaction.value + ? Format.BTCValue(transaction.value) + : '0' + const externalBtcLink = BTC.getBtcTransactionLink( transaction?.txid, networkType, ) - const externalMlLink = ML.getMlTransactionLink(transaction?.txid, networkType) - const explorerLink = walletType.name === 'Bitcoin' ? externalBtcLink : externalMlLink + const explorerName = + walletType.name === 'Bitcoin' ? 'Block Explorer' : 'Mintlayer Explorer' + + const isConfirmed = confirmations !== null && confirmations !== 0 useEffect(() => { const getConfirmationAmount = async () => { @@ -73,76 +124,136 @@ const TransactionDetails = ({ transaction, getConfirmations }) => { return (
    -
    +
    +
    + {isSwap ? ( + + ) : ( + + )} +
    + {directionLabel} + {isSwap ? ( +
    +
    + + {transaction.value?.from?.amount || '0'} + + + {tokenMap[transaction.value?.from?.token_id] || 'ML'} + +
    + +
    + + {transaction.value?.to?.amount || '0'} + + + {tokenMap[transaction.value?.to?.token_id] || 'ML'} + +
    +
    + ) : ( +
    + + {amountSign} + {formattedValue} + + {walletType.ticker} +
    + )} +
    + {isConfirmed ? ( + <> + + Confirmed + + ) : confirmations === null ? ( + + ) : ( + 'Pending' + )} +
    +
    + +
    +
    + Date + {date} +
    + {transaction.type === 'FillOrder' && ( <> )} + {transaction.type === 'CreateOrder' && ( - <> - - + )} {transaction.type !== 'FillOrder' && ( - +
    + {addressTitle} +
    + {ML.formatAddress(transactionAddress, 16)} + +
    +
    )} - - - } - /> - - - } - /> + +
    + Confirmations + + {confirmations || confirmations === 0 ? confirmations : } + +
    - - + Transaction hash +
    + {transaction.txid} + +
    +
    + + + - - + + View on {explorerName} + +
    ) } diff --git a/src/components/containers/Wallet/TransactionDetails.module.css b/src/components/containers/Wallet/TransactionDetails.module.css new file mode 100644 index 00000000..a0b4b876 --- /dev/null +++ b/src/components/containers/Wallet/TransactionDetails.module.css @@ -0,0 +1,226 @@ +.transactionDetails { + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; + overflow-y: auto; + flex: 1; +} + +.transactionDetails > * { + flex-shrink: 0; +} + +.banner { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-height: max-content; + padding: 28px 20px 24px; + background: rgb(var(--mojito-green-soft)); + border-radius: 16px; + border: 1.5px solid rgba(var(--mojito-green), 0.15); +} + +.bannerOut { + background: rgba(var(--mojito-orange), 0.1); + border-color: rgba(var(--mojito-orange), 0.25); +} + +.bannerIconOrange svg { + color: rgb(var(--mojito-orange)); +} + +.bannerIcon { + width: 56px; + height: 56px; + min-height: 56px; + min-width: 56px; + border-radius: 12px; + background: rgb(var(--color-white)); + box-shadow: var(--shadow-sm); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 4px; +} + +.bannerIcon svg { + width: 22px; + height: 22px; + color: rgb(var(--mojito-green)); +} + +.bannerIconOut { + transform: rotate(180deg); +} + +.bannerDirection { + font-size: 11px; + font-weight: 700; + letter-spacing: 1.5px; + text-transform: uppercase; + color: rgba(var(--color-black), 0.5); +} + +.bannerAmount { + font-size: 32px; + font-weight: 700; + color: rgb(var(--mojito-green)); + display: flex; + align-items: baseline; + gap: 6px; + margin-top: 10px; +} + +.bannerAmountValue { + font-size: 32px; + font-weight: 800; +} + +.bannerAmountOut { + color: rgb(var(--mojito-orange)); +} + +.bannerSwap { + background: rgba(var(--color-purple), 0.1); + border-color: rgba(var(--color-purple), 0.2); +} + +.bannerIconSwap svg { + color: rgb(var(--color-purple)); +} + +.bannerSwapAmount { + display: flex; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.bannerSwapSide { + display: flex; + align-items: baseline; + gap: 4px; +} + +.bannerSwapSide .bannerAmountValue { + font-size: 22px; +} + +.bannerSwapArrow { + width: 18px; + height: 18px; + color: rgba(var(--color-black), 0.3); +} + +.bannerTicker { + font-size: 14px; + font-weight: 600; + color: rgba(var(--color-black), 0.5); +} + +.bannerStatus { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 12px; + border-radius: 99px; + background: rgb(var(--color-white)); + font-size: 13px; + font-weight: 600; + color: rgb(var(--mojito-green)); + margin-top: 4px; +} + +.bannerStatus svg { + width: 14px; + height: 14px; +} + +.bannerStatusPending { + color: rgb(var(--color-orange)); +} + +.detailsCard { + background: rgb(var(--color-white)); + border-radius: 16px; + border: 1.5px solid rgba(var(--color-black), 0.08); + overflow: hidden; +} + +.detailRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 18px; + border-bottom: 1px solid rgba(var(--color-black), 0.06); +} + +.detailRow:last-child { + border-bottom: none; +} + +.detailLabel { + font-size: 13px; + font-weight: 400; + color: rgba(var(--color-black), 0.6); +} + +.detailValue { + font-size: 14px; + font-weight: 600; + color: rgb(var(--color-black)); + text-align: right; + word-break: break-all; + max-width: 60%; + display: flex; + align-items: center; + gap: 8px; +} + +.detailValueGreen { + color: rgb(var(--mojito-green)); +} + +.hashSection { + display: flex; + flex-direction: column; + gap: 6px; +} + +.hashLabel { + font-size: 12px; + font-weight: 600; + color: rgb(var(--color-black)); + padding-left: 4px; +} + +.hashBox { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 14px; + background: #f5f7fa; + border-radius: 16px; + border: 1.5px solid rgba(var(--color-black), 0.02); +} + +.hashValue { + font-size: 13px; + font-weight: 400; + color: rgba(var(--color-black), 0.6); + word-break: break-all; + flex: 1; +} + +.detailsButton { + width: 100%; + padding: 10px 20px; +} + +.detailsButton svg { + width: 14px; + height: 14px; +} diff --git a/src/components/containers/Wallet/TransactionDetails.test.js b/src/components/containers/Wallet/TransactionDetails.test.js index 089aaaa2..1ae59ffb 100644 --- a/src/components/containers/Wallet/TransactionDetails.test.js +++ b/src/components/containers/Wallet/TransactionDetails.test.js @@ -1,9 +1,4 @@ -import { - render, - screen, - waitFor, - waitForElementToBeRemoved, -} from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router' import TransactionDetails from './TransactionDetails' @@ -97,24 +92,11 @@ test('Render transaction component', async () => { getConfirmations: mockConfirmations, }) const transactionDetails = screen.getByTestId('transaction-details') - const transactionDetailsItems = screen.getAllByTestId( - 'transaction-details-item', - ) - const transactionDetailsTitles = screen.getAllByTestId( - 'transaction-details-item-title', - ) - const transactionDetailsButton = screen.getByTestId('button') expect(transactionDetails).toBeInTheDocument() - expect(transactionDetailsButton).toBeInTheDocument() - expect(transactionDetailsItems).toHaveLength(5) - - expect(transactionDetailsTitles).toHaveLength(5) - expect(transactionDetailsTitles[0]).toHaveTextContent('From:') - - expect(transactionDetailsButton).toHaveTextContent('Open In Block Explorer') - - transactionDetailsButton.click() + expect(screen.getByText('Receive')).toBeInTheDocument() + expect(screen.getByText('From')).toBeInTheDocument() + expect(screen.getByText(/View on Block Explorer/)).toBeInTheDocument() await waitFor(() => { expect(mockConfirmations).toHaveBeenCalled() @@ -130,24 +112,12 @@ test('Render transaction out component', async () => { }) const transactionDetails = screen.getByTestId('transaction-details') - const transactionDetailsItems = screen.getAllByTestId( - 'transaction-details-item', - ) - const transactionDetailsTitles = screen.getAllByTestId( - 'transaction-details-item-title', - ) - const transactionDetailsContent = screen.getAllByTestId( - 'transaction-details-item-content', - ) expect(transactionDetails).toBeInTheDocument() - expect(transactionDetailsItems).toHaveLength(5) - - expect(transactionDetailsTitles).toHaveLength(5) - expect(transactionDetailsTitles[0]).toHaveTextContent('To:') + expect(screen.getByText('Send')).toBeInTheDocument() + expect(screen.getByText('To')).toBeInTheDocument() - await waitForElementToBeRemoved(() => screen.queryByTestId('loading')) - expect(Number(transactionDetailsContent[4].textContent)).toBeGreaterThan( - 1_000_000, - ) + await waitFor(() => { + expect(screen.getByText('1500000')).toBeInTheDocument() + }) }) diff --git a/src/components/containers/Wallet/TransactionsList.css b/src/components/containers/Wallet/TransactionsList.css index 0306aadf..ec852ffb 100644 --- a/src/components/containers/Wallet/TransactionsList.css +++ b/src/components/containers/Wallet/TransactionsList.css @@ -1,7 +1,11 @@ .transaction-list { + display: flex; + flex-direction: column; + gap: 0.4rem; height: 19rem; overflow: auto; padding: 7px; + animation: fadeInList 0.3s ease-in-out; @media screen and (min-width: 801px) { padding: 10px; @@ -9,6 +13,15 @@ } } +@keyframes fadeInList { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + .empty-list { background: rgb(var(--color-gray)); font-size: 1.5em; diff --git a/src/components/containers/Wallet/TransactionsList.js b/src/components/containers/Wallet/TransactionsList.js index baf74a61..36eb7a2f 100644 --- a/src/components/containers/Wallet/TransactionsList.js +++ b/src/components/containers/Wallet/TransactionsList.js @@ -11,7 +11,12 @@ const TransactionsList = ({ transactionsList, getConfirmations }) => { fetchingTransactions && transactionsList.length === 0 const renderSkeletonLoaders = () => - Array.from({ length: 6 }, (_, i) => ) + Array.from({ length: 6 }, (_, i) => ( + + )) const renderTransactions = () => { if (!transactionsList || !transactionsList.length) { diff --git a/src/components/containers/index.js b/src/components/containers/index.js index 155ea343..41c73bb8 100644 --- a/src/components/containers/index.js +++ b/src/components/containers/index.js @@ -1,9 +1,9 @@ -import CreateAccount from './CreateAccount/CreateAccount' +import CreateAccount from './CreateAccount/CreateAccount.tsx' import RestoreAccountMnemonic from './RestoreAccount/RestoreAccountMnemonic/RestoreAccountMnemonic' import RestoreAccountJson from './RestoreAccount/RestoreAccountJson/RestoreAccountJson' -import LoginContainer from './Login/Login' -import SetPassword from './Login/SetPassword' +import LoginContainer from './Login/Login.tsx' +import SetPassword from './Login/SetPassword.tsx' import ShowAddress from './Wallet/ShowAddress' import Transaction from './Wallet/Transaction' @@ -29,9 +29,10 @@ import CryptoList from './Dashboard/CryptoList' import DeleteAccount from './DeleteAccount/DeleteAccount' import SettingsDelete from './Settings/SettingsDelete/SettingsDelete' -import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet' -import SettingsAPI from './Settings/SettingsAPI/SettingsAPI' +import SettingsTestnet from './Settings/SettingsTestnet/SettingsTestnet.tsx' +import SettingsAbout from './Settings/SettingsAbout/SettingsAbout.tsx' import SettingsBackup from './Settings/SettingsBackup/SettingsBackup' +import SettingsSection from './Settings/SettingsSection/SettingsSection.tsx' import SignMessage from './Message/SignMessage/SignMessage' import VerifyMessage from './Message/VerifyMessage/VerifyMessage' @@ -69,10 +70,11 @@ const Dashboard = { } const Settings = { + SettingsAbout, SettingsTestnet, SettingsDelete, - SettingsAPI, SettingsBackup, + SettingsSection, } const RestoreAccount = { diff --git a/src/components/layouts/CenteredLayout/CenteredLayout.css b/src/components/layouts/CenteredLayout/CenteredLayout.css index 6eb217e8..b3e4fc80 100644 --- a/src/components/layouts/CenteredLayout/CenteredLayout.css +++ b/src/components/layouts/CenteredLayout/CenteredLayout.css @@ -3,4 +3,5 @@ justify-content: center; gap: 10px; width: 100%; + min-height: max-content; } diff --git a/src/components/layouts/VerticalGroup/VerticalGroup.css b/src/components/layouts/VerticalGroup/VerticalGroup.css index b79d0403..8a346c5d 100644 --- a/src/components/layouts/VerticalGroup/VerticalGroup.css +++ b/src/components/layouts/VerticalGroup/VerticalGroup.css @@ -27,6 +27,7 @@ .v-group.grow { flex-grow: 1; + min-height: 0; } .v-group.center { diff --git a/src/contexts/AccountProvider/AccountProvider.js b/src/contexts/AccountProvider/AccountProvider.js index 1655b656..1da2f85d 100644 --- a/src/contexts/AccountProvider/AccountProvider.js +++ b/src/contexts/AccountProvider/AccountProvider.js @@ -14,6 +14,7 @@ const AccountProvider = ({ value: propValue, children }) => { const [deletingAccount, setDeletingAccount] = useState(undefined) const [removeAccountPopupOpen, setRemoveAccountPopupOpen] = useState(false) const [sliderMenuOpen, setSliderMenuOpen] = useState(false) + const [customBackAction, setCustomBackAction] = useState(null) const isExtended = window.location.href.includes('popup.html') const accountRegistryName = 'unlockedAccount' @@ -107,10 +108,12 @@ const AccountProvider = ({ value: propValue, children }) => { setRemoveAccountPopupOpen, sliderMenuOpen, setSliderMenuOpen, + customBackAction, + setCustomBackAction, } useEffect(() => { - window.addEventListener('unload', setLoginTimeoutLimit) + window.addEventListener('pagehide', setLoginTimeoutLimit) }, []) return ( diff --git a/src/contexts/BitcoinProvider/BitcoinProvider.js b/src/contexts/BitcoinProvider/BitcoinProvider.js index b8670296..84bce192 100644 --- a/src/contexts/BitcoinProvider/BitcoinProvider.js +++ b/src/contexts/BitcoinProvider/BitcoinProvider.js @@ -149,13 +149,13 @@ const BitcoinProvider = ({ value: propValue, children }) => { (address) => address.info.chain_stats.tx_count === 0 && address.info.mempool_stats.tx_count === 0, - ) || receivingAddressesInfo[0].address, + )?.address || receivingAddressesInfo[0].address, changeAddress: changeAddressesInfo.find( (address) => address.info.chain_stats.tx_count === 0 && address.info.mempool_stats.tx_count === 0, - ) || changeAddressesInfo[0].address, + )?.address || changeAddressesInfo[0].address, } setUnusedAddresses(unusedAddress) diff --git a/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js b/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js index a40ff4d4..40c6e117 100644 --- a/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js +++ b/src/contexts/ExchangeRatesProvider/ExchangeRatesProvider.js @@ -12,6 +12,7 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { const [exchangeRate, setExchangeRate] = useState({}) const [yesterdayExchangeRate, setYesterdayExchangeRate] = useState({}) const [historyRates, setHistoryRates] = useState({}) + const [thirtyDaysHistoryRates, setThirtyDaysHistoryRates] = useState({}) const { accountID } = useContext(AccountContext) useEffect(() => { @@ -21,6 +22,7 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { const rates = {} const yesterdayRates = {} const historyRates = {} + const thirtyDaysRates = {} for (let i = 0; i < default_crypto.length; i++) { const response_rates = await ExchangeRates.getRate( default_crypto[i], @@ -42,11 +44,19 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { ) historyRates[`${default_crypto[i]}-${fiat}`] = JSON.parse(response_history)[`${default_crypto[i]}-${fiat}`] + + const response_thirty_days = await ExchangeRates.getThirtyDaysHist( + default_crypto[i], + fiat, + ) + thirtyDaysRates[`${default_crypto[i]}-${fiat}`] = + JSON.parse(response_thirty_days)[`${default_crypto[i]}-${fiat}`] } setExchangeRate(rates) setYesterdayExchangeRate(yesterdayRates) setHistoryRates(historyRates) + setThirtyDaysHistoryRates(thirtyDaysRates) } getData() @@ -58,6 +68,7 @@ const ExchangeRatesProvider = ({ value: propValue, children }) => { exchangeRate, yesterdayExchangeRate, historyRates, + thirtyDaysHistoryRates, } return ( diff --git a/src/contexts/MintlayerProvider/MintlayerProvider.js b/src/contexts/MintlayerProvider/MintlayerProvider.js index de758d37..11033e57 100644 --- a/src/contexts/MintlayerProvider/MintlayerProvider.js +++ b/src/contexts/MintlayerProvider/MintlayerProvider.js @@ -9,9 +9,6 @@ import { LocalStorageService } from '@Storage' const MintlayerContext = createContext() class InMemoryAccountProvider { - addresses = {} - navigate = null - constructor(addresses, navigate) { this.addresses = addresses this.navigate = navigate @@ -261,6 +258,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { const nftBalances = {} const transaction_ids = [] const non_zero_addresses = [] + const locked_addresses = [] addresses_data .filter(({ error }) => !error) @@ -287,6 +285,10 @@ const MintlayerProvider = ({ value: propValue, children }) => { non_zero_addresses.push(address_data.id) } + if (locked_coin_balance && locked_coin_balance.atoms !== '0') { + locked_addresses.push(address_data.id) + } + if (tokens) { tokens.forEach((token) => { const { token_id, amount } = token @@ -380,11 +382,6 @@ const MintlayerProvider = ({ value: propValue, children }) => { const unconfirmedTransactions = LocalStorageService.getItem(unconfirmedTransactionString) || [] - const fetchedUtxos = await ML.getBatchData( - non_zero_addresses, - '/address/:address/all-utxos', - ) - const fetchedSpendableUtxos = await ML.getBatchData( non_zero_addresses, '/address/:address/spendable-utxos', @@ -413,12 +410,17 @@ const MintlayerProvider = ({ value: propValue, children }) => { }, []) const availableUtxos = available.map((item) => item) - const lockedUtxos = fetchedUtxos.filter( + + const fetchedLockedUtxos = + locked_addresses.length > 0 + ? await ML.getBatchData(locked_addresses, '/address/:address/all-utxos') + : [] + const lockedUtxos = fetchedLockedUtxos.filter( (obj) => obj.utxo.type === 'LockThenTransfer', ) const availableNftInitialUtxos = fetchedSpendableUtxos.filter( - (item) => item.utxo.type === 'IssueNft', + (item) => item.utxo?.type === 'IssueNft', ) setNftInitialUtxos(availableNftInitialUtxos) @@ -442,10 +444,13 @@ const MintlayerProvider = ({ value: propValue, children }) => { ...currentMlAddresses.mlChangeAddresses, ] : [] - const delegations = await ML.getBatchData( + const allDelegations = await ML.getBatchData( addressList, '/address/:address/delegations', ) + const delegations = [ + ...new Map(allDelegations.map((d) => [d.delegation_id, d])).values(), + ] const delegationList = delegations.map( (delegation) => delegation.delegation_id, ) @@ -481,7 +486,7 @@ const MintlayerProvider = ({ value: propValue, children }) => { delegation_details[index].creation_block_height, creation_time: blocks_data.find( ({ height }) => - height === delegation_details[index].creation_block_height, + height === delegation_details[index]?.creation_block_height, ).header.timestamp.timestamp, } }) @@ -518,12 +523,21 @@ const MintlayerProvider = ({ value: propValue, children }) => { useEffect(() => { if (networkType !== currentNetworkType) { + setOrdersPairInfo([]) + setMlDelegationList([]) + setMlDelegationsBalance(0) fetchAllData(true) fetchDelegations(addresses) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [networkType, currentNetworkType, addresses]) + useEffect(() => { + setOrdersPairInfo([]) + setMlDelegationList([]) + setMlDelegationsBalance(0) + }, [accountID]) + useEffect(() => { Mintlayer.cancelAllRequests() setCurrentHeight(onlineHeight) diff --git a/src/contexts/SettingsProvider/SettingsProvider.js b/src/contexts/SettingsProvider/SettingsProvider.js index 6a68132a..2c38877c 100644 --- a/src/contexts/SettingsProvider/SettingsProvider.js +++ b/src/contexts/SettingsProvider/SettingsProvider.js @@ -10,11 +10,8 @@ const SettingsProvider = ({ value: propValue, children }) => { useEffect(() => { try { - const storedNetworkType = LocalStorageService.getItem('networkType') - if (storedNetworkType === null) { + if (!LocalStorageService.getItem('networkType')) { NetworkTypeEntity.set(AppInfo.NETWORK_TYPES.MAINNET) - } else { - setNetworkType(storedNetworkType) } } catch (error) { console.error('Error accessing localStorage:', error) diff --git a/src/css.d.ts b/src/css.d.ts new file mode 100644 index 00000000..cfe824ef --- /dev/null +++ b/src/css.d.ts @@ -0,0 +1,5 @@ +declare module '*.css' +declare module '*.module.css' { + const classes: { [key: string]: string } + export default classes +} diff --git a/src/hooks/UseStyleClasses/useStyleClasses.js b/src/hooks/UseStyleClasses/useStyleClasses.js index 1613dcee..79fd6288 100644 --- a/src/hooks/UseStyleClasses/useStyleClasses.js +++ b/src/hooks/UseStyleClasses/useStyleClasses.js @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback, useRef } from 'react' +import { useState, useCallback } from 'react' const ensureClassesAreArray = (classes) => Array.isArray(classes) ? classes : classes.split(' ') @@ -21,8 +21,9 @@ const removeItemsFromList = (oldList = '', newList = []) => { } const useStyleClasses = (classesList = []) => { - const effectCalled = useRef(false) - const [styleClasses, _setStyleClasses] = useState(formatClasses(classesList)) + const [styleClasses, _setStyleClasses] = useState( + formatClasses(ensureClassesAreArray(classesList)), + ) const setStyleClasses = useCallback((classes = []) => { _setStyleClasses(formatClasses(ensureClassesAreArray(classes))) @@ -42,19 +43,6 @@ const useStyleClasses = (classesList = []) => { [], ) - useEffect(() => { - /* - ! React version > 18 does mount, simulated unmount, and simulated mount - ! (https://reactjs.org/blog/2022/03/08/react-18-upgrade-guide.html#updates-to-strict-mode) - ! This if avoids the component the rest of the function to run more than once - ! (https://github.com/reactwg/react-18/discussions/18) - */ - if (effectCalled.current) return - effectCalled.current = true - - setStyleClasses(classesList) - }, [classesList, setStyleClasses]) - return { styleClasses, setStyleClasses, addStyleClass, removeStyleClass } } diff --git a/src/hooks/UseWalletInfo/useBtcWalletInfo.js b/src/hooks/UseWalletInfo/useBtcWalletInfo.js index a244f802..646e7704 100644 --- a/src/hooks/UseWalletInfo/useBtcWalletInfo.js +++ b/src/hooks/UseWalletInfo/useBtcWalletInfo.js @@ -1,7 +1,7 @@ import { useContext } from 'react' import { BitcoinContext } from '@Contexts' -const useBtcWalletInfo = (address) => { +const useBtcWalletInfo = () => { const { btcBalance, btcTransactions, diff --git a/src/hooks/useMediaQuery/useMediaQuery.js b/src/hooks/useMediaQuery/useMediaQuery.js index ee1a0b8f..15f8c55d 100644 --- a/src/hooks/useMediaQuery/useMediaQuery.js +++ b/src/hooks/useMediaQuery/useMediaQuery.js @@ -1,19 +1,20 @@ import { useState, useEffect } from 'react' const useMediaQuery = (query) => { - const [matches, setMatches] = useState(false) + const [matches, setMatches] = useState(() => window.matchMedia(query).matches) + const [prevQuery, setPrevQuery] = useState(query) + + if (query !== prevQuery) { + setPrevQuery(query) + setMatches(window.matchMedia(query).matches) + } useEffect(() => { const mediaQueryList = window.matchMedia(query) const documentChangeHandler = () => setMatches(mediaQueryList.matches) - // Set the initial state - setMatches(mediaQueryList.matches) - - // Listen for changes mediaQueryList.addEventListener('change', documentChangeHandler) - // Cleanup event listener on component unmount return () => { mediaQueryList.removeEventListener('change', documentChangeHandler) } diff --git a/src/hooks/useMediaQuery/useMediaQuery.test.js b/src/hooks/useMediaQuery/useMediaQuery.test.js new file mode 100644 index 00000000..8fad71a1 --- /dev/null +++ b/src/hooks/useMediaQuery/useMediaQuery.test.js @@ -0,0 +1,86 @@ +import { renderHook, act } from '@testing-library/react' +import useMediaQuery from './useMediaQuery' + +const createMockMediaQueryList = (matches) => { + const listeners = [] + return { + matches, + addEventListener: (event, handler) => listeners.push(handler), + removeEventListener: (event, handler) => { + const index = listeners.indexOf(handler) + if (index > -1) listeners.splice(index, 1) + }, + trigger(newMatches) { + this.matches = newMatches + listeners.forEach((handler) => handler()) + }, + } +} + +let mockMediaQueryList + +beforeEach(() => { + mockMediaQueryList = createMockMediaQueryList(false) + window.matchMedia = jest.fn().mockReturnValue(mockMediaQueryList) +}) + +test('useMediaQuery > returns initial match state (false)', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 801px)')) + expect(result.current).toBe(false) +}) + +test('useMediaQuery > returns initial match state (true)', () => { + mockMediaQueryList = createMockMediaQueryList(true) + window.matchMedia = jest.fn().mockReturnValue(mockMediaQueryList) + + const { result } = renderHook(() => useMediaQuery('(min-width: 801px)')) + expect(result.current).toBe(true) +}) + +test('useMediaQuery > calls matchMedia with the correct query', () => { + renderHook(() => useMediaQuery('(max-width: 767px)')) + expect(window.matchMedia).toHaveBeenCalledWith('(max-width: 767px)') +}) + +test('useMediaQuery > updates when media query changes', () => { + const { result } = renderHook(() => useMediaQuery('(min-width: 801px)')) + expect(result.current).toBe(false) + + act(() => { + mockMediaQueryList.trigger(true) + }) + + expect(result.current).toBe(true) +}) + +test('useMediaQuery > resubscribes when query changes', () => { + const firstList = createMockMediaQueryList(false) + const secondList = createMockMediaQueryList(true) + + window.matchMedia = jest.fn((query) => + query === '(min-width: 801px)' ? firstList : secondList, + ) + + const { result, rerender } = renderHook(({ query }) => useMediaQuery(query), { + initialProps: { query: '(min-width: 801px)' }, + }) + + expect(result.current).toBe(false) + + rerender({ query: '(max-width: 600px)' }) + + expect(result.current).toBe(true) +}) + +test('useMediaQuery > cleans up listener on unmount', () => { + const removeEventListener = jest.fn() + mockMediaQueryList.removeEventListener = removeEventListener + + const { unmount } = renderHook(() => useMediaQuery('(min-width: 801px)')) + unmount() + + expect(removeEventListener).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ) +}) diff --git a/src/index.js b/src/index.js index a7c479d6..5a63b24e 100644 --- a/src/index.js +++ b/src/index.js @@ -9,7 +9,13 @@ import { useNavigate, } from 'react-router' import { Mintlayer, ExchangeRates } from '@APIs' -import { ConnectionErrorPopup, Header, PopUp } from '@ComposedComponents' +import { + ConnectionErrorPopup, + Header, + PopUp, + Sidebar, +} from '@ComposedComponents' +import { BrandPanel } from '@BasicComponents' import { DeleteAccount } from '@ContainerComponents' import { Client } from '@mintlayer/sdk' @@ -38,6 +44,7 @@ import { SignExternalTransactionPage, OrderSwapPage, SignBitcoinTransactionPage, + ConfirmBtcTransactionPage, AddressPage, } from '@Pages' @@ -283,120 +290,132 @@ const App = () => { } return ( -
    -
    - {errorPopupOpen && ( - - )} - {removeAccountPopupOpen && ( - - - - )} - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - -
    + <> + + {!unlocked && } +
    +
    +
    + {errorPopupOpen && ( + + )} + {removeAccountPopupOpen && ( + + + + )} + + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + +
    +
    + ) } diff --git a/src/pages/AddressPage/AddressPage.css b/src/pages/AddressPage/AddressPage.css deleted file mode 100644 index 3ca9f2a3..00000000 --- a/src/pages/AddressPage/AddressPage.css +++ /dev/null @@ -1,38 +0,0 @@ -.address-page { - display: flex; - flex-direction: column; - gap: 16px; -} - -.address-page-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 10px; -} - -.address-page-header-receive { - display: flex; - align-items: center; - gap: 12px; -} - -.address-search-input { - width: 40%; - padding: 10px 16px; - border-radius: 36px; - border: 1px solid #e0e0e0; - background: transparent; - color: #222; - font-size: 16px; -} - -.qr-button-receive { - display: flex; - align-items: center; - justify-content: center; - width: 46px; - height: 46px; - padding: 6px; - border-radius: 11px; -} diff --git a/src/pages/AddressPage/AddressPage.js b/src/pages/AddressPage/AddressPage.js index 78c8df2e..5dca3119 100644 --- a/src/pages/AddressPage/AddressPage.js +++ b/src/pages/AddressPage/AddressPage.js @@ -1,53 +1,80 @@ import { useState, useContext } from 'react' import { useParams } from 'react-router' -import { AddressList } from '@ComposedComponents' -import { Button } from '@BasicComponents' -import { PopUp } from '@ComposedComponents' +import { AddressList, PopUp } from '@ComposedComponents' +import { Button, PageWrapper } from '@BasicComponents' import { Wallet } from '@ContainerComponents' import { ReactComponent as IconQr } from '@Assets/images/icons-qr.svg' +import { ReactComponent as IconSearch } from '@Assets/images/icon-search.svg' import { MintlayerContext, BitcoinContext } from '@Contexts' -import './AddressPage.css' +import styles from './AddressPage.module.css' const AddressPage = () => { - const { unusedAddresses: mintlayerUnusedAddresses } = - useContext(MintlayerContext) - const { unusedAddresses: bitcoinUnusedAddresses } = useContext(BitcoinContext) + const { + unusedAddresses: mintlayerUnusedAddresses, + addressData: mlAddressData, + } = useContext(MintlayerContext) + const { + unusedAddresses: bitcoinUnusedAddresses, + formatedAddresses: btcFormatedAddresses, + } = useContext(BitcoinContext) const [search, setSearch] = useState('') const [openShowAddress, setOpenShowAddress] = useState(false) const { coinType } = useParams() + const isBitcoin = coinType === 'Bitcoin' + const ticker = isBitcoin ? 'BTC' : 'ML' + + const addressCount = isBitcoin + ? btcFormatedAddresses?.length || 0 + : mlAddressData?.length || 0 + + const bitcoinAddress = bitcoinUnusedAddresses?.receivingAddress || '' + const requiredAddress = coinType === 'Mintlayer' - ? mintlayerUnusedAddresses.receive - : bitcoinUnusedAddresses.receivingAddress.address + ? mintlayerUnusedAddresses?.receive || '' + : bitcoinAddress + return ( -
    -
    -
    - - Receive + +
    +
    +
    + +
    +

    Receive address

    + + {ticker} {addressCount} addresses + +
    +
    +
    + + setSearch(e.target.value)} + className={styles.searchInput} + id="address-search-input" + /> +
    - setSearch(e.target.value)} - className="address-search-input" - /> -
    - - {openShowAddress && ( - - - - )} -
    + + + {openShowAddress && ( + + + + )} +
    + ) } diff --git a/src/pages/AddressPage/AddressPage.module.css b/src/pages/AddressPage/AddressPage.module.css new file mode 100644 index 00000000..4cf505e0 --- /dev/null +++ b/src/pages/AddressPage/AddressPage.module.css @@ -0,0 +1,106 @@ +.page { + display: flex; + flex-direction: column; + gap: 16px; + height: 100%; +} + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 0; + min-height: max-content; +} + +.headerLeft { + display: flex; + align-items: center; + gap: 14px; +} + +.qrButton.qrButton { + width: 48px; + height: 48px; + padding: 0; + background: rgba(var(--color-black), 0.04); + background-color: rgba(var(--color-black), 0.04); + border-radius: 12px; + color: rgb(var(--color-black)); + flex-shrink: 0; + transition: background 0.15s ease; +} + +.qrButton.qrButton:hover, +.qrButton.qrButton:focus { + background: rgba(var(--color-black), 0.08); + background-color: rgba(var(--color-black), 0.08); +} + +.qrButton.qrButton svg path { + stroke: rgb(var(--color-black)); +} + +.qrButton.qrButton:hover svg path, +.qrButton.qrButton:focus svg path { + stroke: rgb(var(--color-black)); +} + +.qrButton svg { + width: 24px; + height: 24px; + fill: rgba(var(--color-main-green), 1); +} + +.headerInfo { + display: flex; + flex-direction: column; + gap: 2px; +} + +.title { + font-size: 18px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0; +} + +.subtitle { + font-size: 13px; + color: rgba(var(--color-black), 0.5); +} + +.searchWrapper { + position: relative; + display: flex; + align-items: center; +} + +.searchIcon { + position: absolute; + left: 14px; + width: 16px; + height: 16px; + opacity: 0.35; + pointer-events: none; +} + +.searchInput { + padding: 10px 16px 10px 38px; + border-radius: 36px; + border: 1px solid rgba(var(--color-black), 0.12); + background: rgba(var(--color-white), 1); + color: rgb(var(--color-black)); + font-size: 14px; + width: 260px; + outline: none; + transition: border-color 0.2s ease; +} + +.searchInput:focus { + border-color: rgba(var(--color-main-green), 0.5); +} + +.searchInput::placeholder { + color: rgba(var(--color-black), 0.35); +} diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js new file mode 100644 index 00000000..671d5a76 --- /dev/null +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.js @@ -0,0 +1,276 @@ +import { useLocation, useNavigate } from 'react-router' +import { useState, useContext } from 'react' +import { Button, Error, PageWrapper } from '@BasicComponents' +import { PopUp, TextField, Loading } from '@ComposedComponents' +import { AccountContext, BitcoinContext, SettingsContext } from '@Contexts' +import { BTCTransaction, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { Account } from '@Entities' +import { BTC as BTCHelper } from '@Helpers' +import { Electrum } from '@APIs' +import { AppInfo } from '@Constants' +import { VerticalGroup, CenteredLayout } from '@LayoutComponents' + +import styles from './ConfirmBtcTransaction.module.css' + +const ConfirmBtcTransactionPage = () => { + const { state } = useLocation() + const navigate = useNavigate() + + const [isModalOpen, setIsModalOpen] = useState(false) + const [password, setPassword] = useState('') + const [sendingTransaction, setSendingTransaction] = useState(false) + const [transactionTxid, setTransactionTxid] = useState(null) + const [txErrorMessage, setTxErrorMessage] = useState(null) + const loadingExtraClasses = ['loading-big'] + + const extraButtonStyles = [styles.buttonSignTransaction] + + const { accountID, addresses } = useContext(AccountContext) + const { + btcUtxos, + unusedAddresses: unusedBtcAddresses, + fetchAllData, + } = useContext(BitcoinContext) + const { networkType } = useContext(SettingsContext) + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET + + const { + address, + amountInCrypto, + amountInFiat, + fee, + totalFeeFiat, + totalFeeCrypto, + walletType, + poolData, + } = state || {} + + const amountFiat = isTestnet ? '0,00' : amountInFiat + const feeFiat = isTestnet ? '0,00' : totalFeeFiat + const ticker = walletType?.name === 'Bitcoin' ? 'BTC' : 'ML' + + const isLowReward = + poolData && + (poolData[0].cost_per_block.decimal > AppInfo.APPROPRIATE_COST_PER_BLOCK || + parseFloat(poolData[0].margin_ratio_per_thousand) > + AppInfo.APPROPRIATE_MARGIN_RATIO_PER_THOUSAND) + + const getChangeAddress = () => { + const candidate = + unusedBtcAddresses?.changeAddress || + addresses?.btcAddresses?.btcChangeAddresses?.[0] + + if (typeof candidate === 'string') return candidate + if (typeof candidate?.address === 'string') return candidate.address + if (typeof candidate === 'object') { + const key = Object.keys(candidate)[0] + if (typeof key === 'string') return key + } + throw new Error('Missing BTC change address') + } + + const handleApprove = () => { + setIsModalOpen(true) + } + + const handleDecline = () => { + navigate(-1) + } + + const handleModalDecline = () => { + setPassword('') + setSendingTransaction(false) + setTxErrorMessage('') + setIsModalOpen(false) + } + + const handleModalSubmit = async () => { + if (!password) { + setTxErrorMessage('Password must be set.') + return + } + + setSendingTransaction(true) + try { + const { btcPrivateKeys } = await Account.unlockAccount( + accountID, + password, + { wallets: ['btc'] }, + ) + + const transactionAmountInSatoshi = BTCHelper.convertBtcToSatoshi( + state.transactionAmount, + ) + + const currentAccount = await Account.getAccount(accountID) + const btcWalletType = + currentAccount.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT + + // eslint-disable-next-line no-unused-vars + const [__, transactionHex] = await BTCTransaction.buildTransaction({ + to: address, + amount: transactionAmountInSatoshi, + utxos: btcUtxos || [], + feeRate: fee, + walletType: btcWalletType, + changeAddress: getChangeAddress(), + root: btcPrivateKeys, + }) + + const result = await Electrum.broadcastTransaction(transactionHex) + const txid = JSON.parse(result).txid + setTransactionTxid(txid) + setTxErrorMessage('') + setPassword('') + + if (fetchAllData) { + await fetchAllData(true) + } + } catch (e) { + if (e.address === '') { + setTxErrorMessage('Incorrect password') + setPassword('') + } else if (typeof e === 'string' && e.includes('Invalid amount')) { + setTxErrorMessage('Balance is not enough to cover the transaction') + setIsModalOpen(false) + } else { + setTxErrorMessage(e.message || 'Transaction failed') + setPassword('') + } + } finally { + setSendingTransaction(false) + } + } + + const goBackToWallet = async () => { + navigate('/wallet/Bitcoin') + } + + const passwordChangeHandler = (value) => { + setPassword(value) + } + + if (!state) { + navigate('/wallet/Bitcoin') + return null + } + + return ( + +
    +
    +

    Confirm Transaction

    +
    + +
    +
    +
    +
    +

    Recipient address

    +

    {address}

    +
    + +
    +

    Amount

    +

    {amountInCrypto} BTC

    +

    {amountFiat} USD

    +
    + +
    +

    Network fee

    +

    + {totalFeeCrypto} {ticker} +

    +

    + {feeFiat} USD + {walletType?.name !== 'Mintlayer' && ` · ${fee} sat/B`} +

    +
    +
    + + {isLowReward && ( +

    + Please note: The pool you are using has a high cost per block + and/or margin ratio. This may result in lower rewards. +

    + )} +
    +
    + +
    + + +
    + + {isModalOpen && ( + + {sendingTransaction && ( + +

    + Your transaction broadcasting to network. +

    + + + +
    + )} + + {!sendingTransaction && transactionTxid && ( + +

    Your transaction was sent.

    +

    Txid: {transactionTxid}

    + + + +
    + )} + + {!sendingTransaction && !transactionTxid && ( +
    +
    + + {txErrorMessage && } +
    +
    + + +
    +
    + )} +
    + )} +
    +
    + ) +} + +export default ConfirmBtcTransactionPage diff --git a/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css new file mode 100644 index 00000000..14b52790 --- /dev/null +++ b/src/pages/ConfirmBtcTransaction/ConfirmBtcTransaction.module.css @@ -0,0 +1,112 @@ +.signTransaction { + background-color: #ffffff; + margin: 0 auto; + font-family: 'Arial', sans-serif; + overflow: scroll; + width: 100%; + height: 100%; + position: relative; + padding: 20px; + border-radius: 10px; +} + +.signTransaction .header { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 1.5rem; + margin-bottom: 20px; +} + +.signTxTitle { + font-size: 1.5rem; + font-weight: bold; +} + +.signTransaction .signTxContent { + margin-bottom: 20px; + display: flex; + flex-direction: column; + height: 70%; + + @media screen and (min-width: 801px) { + height: 80%; + } +} + +.signTransaction .transactionPreviewWrapper { + height: 100%; + overflow: scroll; + width: 100%; +} + +.signTransaction .footer { + width: 100%; + display: flex; + justify-content: center; + gap: 12px; + bottom: 0; +} + +.modalTitle { + width: 100%; +} + +.modalContent { + width: 340px; + display: flex; + flex-direction: column; + align-items: center; + gap: 40px; + background-color: #ffffff; + border-radius: 8px; + text-align: center; +} + +.modalButtons { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + gap: 5px; +} + +.buttonSignTransaction { + width: 100%; +} + +.transactionDetails { + display: flex; + flex-direction: column; + gap: 16px; +} + +.signTxSection { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; + background: #f8fafc; + border-radius: 8px; +} + +.signTxSection h4 { + margin: 0; + color: #2d3748; + font-size: 0.95rem; +} + +.signTxSection p { + margin: 0; + font-size: 0.9rem; + color: #4a5568; + word-break: break-all; +} + +.poolWarning { + font-size: 12px; + font-weight: 600; + color: rgb(var(--color-red)); + line-break: normal; + margin-top: 16px; +} diff --git a/src/pages/ConnectionPage/ConnectionPage.js b/src/pages/ConnectionPage/ConnectionPage.js index e25e55bf..358c2aed 100644 --- a/src/pages/ConnectionPage/ConnectionPage.js +++ b/src/pages/ConnectionPage/ConnectionPage.js @@ -3,7 +3,7 @@ import './ConnectionPage.css' import { useLocation } from 'react-router' import { useContext, useState } from 'react' import { AccountContext } from '@Contexts' -import { Button, Toggle } from '@BasicComponents' +import { Button, Toggle, PageWrapper } from '@BasicComponents' import { ReactComponent as IconShield } from '@Assets/images/icon-shield.svg' const toHexString = (obj) => { @@ -160,59 +160,60 @@ export const ConnectionPage = () => { } return ( -
    -
    -

    - Connect Website to Your Mojito Wallet -

    -

    - The website {origin} is - requesting access to your wallet. -

    -
    - -
    -
    -
      - -
    • View your public addresses
    • -
    • Request transaction signing
    • -
    • Track connection status
    • -
    - - {requireBTC && ( - <> -
    -
    -
    Provide Bitcoin data (addresses AND public keys)
    - -
    + + +
    +

    + Connect Website to Your Mojito Wallet +

    +

    + The website {origin} is + requesting access to your wallet. +

    +
    -
    -
    i
    -

    - Note: This option is mandatory when - connecting to HTLC Atomic Swaps dApps. It provides both - Bitcoin addresses and public keys required for cross-chain - transactions. -

    +
    +
    +
      + +
    • View your public addresses
    • +
    • Request transaction signing
    • +
    • Track connection status
    • +
    + + {requireBTC && ( + <> +
    +
    +
    Provide Bitcoin data (addresses AND public keys)
    + +
    + +
    +
    i
    +

    + Note: This option is mandatory when + connecting to HTLC Atomic Swaps dApps. It provides both + Bitcoin addresses and public keys required for cross-chain + transactions. +

    +
    -
    - - )} -
    + + )} +
    - {/* // TODO: Make this work */} - {/*
    - + + ) } diff --git a/src/pages/CreateAccount/CreateAccount.css b/src/pages/CreateAccount/CreateAccount.module.css similarity index 77% rename from src/pages/CreateAccount/CreateAccount.css rename to src/pages/CreateAccount/CreateAccount.module.css index 302dde52..5963b48d 100644 --- a/src/pages/CreateAccount/CreateAccount.css +++ b/src/pages/CreateAccount/CreateAccount.module.css @@ -1,5 +1,10 @@ +.createAccountPage { + background: rgb(var(--color-white)); +} + .loadingText { margin-bottom: 60px; + font-size: 24px; font-weight: 400; text-align: center; } @@ -8,28 +13,22 @@ margin: 0 auto; } -.creating-loading-warapper { +.creatingLoadingWrapper { display: flex; justify-content: center; align-items: center; height: 50vh; } -.loading-big { +.loadingBig { width: 190px; height: 190px; } -.loading-big::after { +.loadingBig::after { width: 160px; height: 160px; border: 15px solid #fff; border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) transparent; } - -.loadingText { - margin-bottom: 60px; - font-size: 24px; - font-weight: 400; -} diff --git a/src/pages/CreateAccount/CreateAccount.js b/src/pages/CreateAccount/CreateAccount.tsx similarity index 53% rename from src/pages/CreateAccount/CreateAccount.js rename to src/pages/CreateAccount/CreateAccount.tsx index b94b3d6d..bb1861ae 100644 --- a/src/pages/CreateAccount/CreateAccount.js +++ b/src/pages/CreateAccount/CreateAccount.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useState } from 'react' +import { useContext, useState } from 'react' import { useNavigate } from 'react-router' import { Loading } from '@ComposedComponents' @@ -9,12 +9,13 @@ import { Account, loadAccountSubRoutines } from '@Entities' import { AccountContext } from '@Contexts' import { BTC, BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' -import './CreateAccount.css' +import { PageWrapper } from '@BasicComponents' +import styles from './CreateAccount.module.css' const CreateAccountPage = () => { const navigate = useNavigate() const [step, setStep] = useState(1) - const [words, setWords] = useState([]) + const [words, setWords] = useState([]) const { setWalletInfo } = useContext(AccountContext) const [creatingWallet, setCreatingWallet] = useState(false) @@ -24,9 +25,13 @@ const CreateAccountPage = () => { setWords(mnemonic.split(' ')) } - const createAccount = (accountName, accountPassword, selectedWallets) => { + const createAccount = ( + accountName: string, + accountPassword: string, + selectedWallets: string[], + ) => { setCreatingWallet(true) - let accountID = null + let accountID: string | null = null const mnemonic = words.join(' ') const btcAddressType = BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT const data = { @@ -37,7 +42,7 @@ const CreateAccountPage = () => { walletsToCreate: selectedWallets, } Account.saveAccount(data) - .then((id) => { + .then((id: string) => { accountID = id return Account.unlockAccount(id, accountPassword) }) @@ -47,30 +52,32 @@ const CreateAccountPage = () => { }) } - const loadingExtraClasses = ['loading-big'] - - return creatingWallet ? ( -
    - - -

    - {' '} - Just a sec, we are creating your wallet...{' '} -

    - -
    -
    -
    - ) : ( - + return ( + + {creatingWallet ? ( +
    + + +

    + {' '} + Just a sec, we are creating your wallet...{' '} +

    + +
    +
    +
    + ) : ( + + )} +
    ) } export default CreateAccountPage diff --git a/src/pages/CreateDelegation/CreateDelegation.js b/src/pages/CreateDelegation/CreateDelegation.js index 10aee883..6befe6a7 100644 --- a/src/pages/CreateDelegation/CreateDelegation.js +++ b/src/pages/CreateDelegation/CreateDelegation.js @@ -8,7 +8,7 @@ import { AccountContext, MintlayerContext } from '@Contexts' import { AppInfo } from '@Constants' import './CreateDelegation.css' -import { Error } from '@BasicComponents' +import { Error, PageWrapper } from '@BasicComponents' import { Loading } from '@ComposedComponents' const CreateDelegationPage = () => { @@ -59,13 +59,18 @@ const CreateDelegationPage = () => { const buildTransaction = async () => { if (transaction_conditions && transactionInformation?.to.length > 0) { setFeeLoading(true) - const unusedReceivingAddress = unusedAddresses.receive - const transaction = await client.buildDelegationCreate({ - pool_id: transactionInformation.to, - destination: unusedReceivingAddress, - }) - setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) - setFeeLoading(false) + try { + const unusedReceivingAddress = unusedAddresses.receive + const transaction = await client.buildDelegationCreate({ + pool_id: transactionInformation.to, + destination: unusedReceivingAddress, + }) + setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) + } catch (e) { + console.error('Failed to calculate delegation fee:', e) + } finally { + setFeeLoading(false) + } } } buildTransaction() @@ -97,7 +102,7 @@ const CreateDelegationPage = () => { } return ( - <> +
    {loading ? ( @@ -127,7 +132,7 @@ const CreateDelegationPage = () => { )}
    - +
    ) } diff --git a/src/pages/CreateRestore/CreateRestore.css b/src/pages/CreateRestore/CreateRestore.css deleted file mode 100644 index 3d51de3a..00000000 --- a/src/pages/CreateRestore/CreateRestore.css +++ /dev/null @@ -1,72 +0,0 @@ -.create-restore { - display: flex; - flex-direction: column; - height: 100%; -} - -.title-create { - font-size: 24px; - font-weight: 400; - margin: 4rem 0 4rem 0; -} - -.create-content-wrapper { - display: flex; - flex-direction: column; - height: 300px; - justify-content: space-between; - - @media screen and (min-width: 801px) { - height: auto; - flex-grow: 1; - } -} - -.create-button-wrapper { - display: flex; - justify-content: center; - align-items: center; - gap: 20px; -} - -.create-button-icon { - width: 13px; - height: 13px; - max-width: 13px; - max-height: 13px; - margin-left: 10px; -} - -.footnote-wrapper { - display: flex; - flex-direction: column; - align-items: center; - margin-top: 32px; - color: rgb(var(--color-lightest-blue)); -} - -.footnote-name { - font-size: 18px; -} - -.footnote-link { - font-size: 18px; - font-weight: bold; - color: rgb(var(--color-green)); - text-decoration: none; -} - -.footnote-version { - font-size: 15px; - margin-top: 5px; -} - -/* .restore-wallet-button { - background-color: rgb(var(--color-white)); - color: rgb(var(--color-black)); -} */ - -.restore-wallet-button:hover .create-button-icon, -.create-wallet-button:hover .create-button-icon { - animation: moveArrowUpRight 0.3s ease-out; -} diff --git a/src/pages/CreateRestore/CreateRestore.js b/src/pages/CreateRestore/CreateRestore.js index f78780b3..c826a078 100644 --- a/src/pages/CreateRestore/CreateRestore.js +++ b/src/pages/CreateRestore/CreateRestore.js @@ -1,13 +1,14 @@ import { useContext } from 'react' import { useNavigate } from 'react-router' -import { Button } from '@BasicComponents' +import { Button, PageWrapper } from '@BasicComponents' +import { ReactComponent as LogoIcon } from '@Assets/images/logo.svg' +import { ReactComponent as ShieldIcon } from '@Assets/images/icon-shield.svg' import { ReactComponent as IconArrowTopRight } from '@Assets/images/icon-arrow-right-top.svg' import { AccountContext } from '@Contexts' - -import './CreateRestore.css' import { LocalStorageService } from '@Storage' -import { APP_VERSION } from '@Version' + +import styles from './CreateRestore.module.css' const CreateRestorePage = () => { const { isExtended } = useContext(AccountContext) @@ -35,6 +36,7 @@ const CreateRestorePage = () => { } isExtended ? navigate('/set-account') : expandHandler('/set-account') } + const goToRestoreAccountPage = () => { if (isDevMode) { return navigate('/restore-account') @@ -45,54 +47,48 @@ const CreateRestorePage = () => { } return ( -
    -

    - Your Mintlayer, right in your browser. -

    -
    -
    + +
    + +

    Mojito

    +

    A fresh way to hold Mintlayer assets

    +

    + Self-custody wallet for Bitcoin and Mintlayer tokens. +
    + Live prices, fast swaps, no custodians. +

    +
    -
    - - ©Mintlayer, 2026 - - - mintlayer.org - - - v{APP_VERSION} - +
    + + + Non-custodial + + · + Audited + · + Open source
    -
    +
    ) } diff --git a/src/pages/CreateRestore/CreateRestore.module.css b/src/pages/CreateRestore/CreateRestore.module.css new file mode 100644 index 00000000..88376cfe --- /dev/null +++ b/src/pages/CreateRestore/CreateRestore.module.css @@ -0,0 +1,160 @@ +.pageWrapper { + background: var(--gradient-surface-green); +} + +.page { + display: flex; + flex-direction: column; + align-items: center; + height: 100%; + text-align: center; +} + +.logoIcon { + width: 80px; + height: 80px; + border-radius: 24px; + padding: 16px; + margin: 3% 0 10px 0; + background: linear-gradient(135deg, #e6f8f0, #fff); + box-shadow: rgba(30, 187, 129, 0.18) 0px 10px 30px; + border: 1px solid #e8ebf0; + + @media screen and (min-width: 801px) { + width: 110px; + height: 110px; + padding: 22px; + margin: 7% 0 15px 0; + border-radius: 32px; + } +} + +.title { + font-size: 18px; + font-weight: 700; + color: rgb(var(--color-black)); + margin: 0 0 30px 0; + + @media screen and (min-width: 801px) { + font-size: 24px; + margin: 0 0 40px 0; + } +} + +.logoText { + margin-bottom: 24px; + + @media screen and (min-width: 801px) { + margin-bottom: 32px; + } +} + +.heading { + font-size: 26px; + font-weight: 700; + line-height: 1.3; + color: rgb(var(--color-black)); + margin: 8px 0px 10px; + + @media screen and (min-width: 801px) { + font-size: 30px; + margin: 0 0 12px 0; + } +} + +.subtitle { + font-size: 14px; + font-weight: 400; + line-height: 1.5; + color: rgba(var(--color-black), 0.6); + margin: 0px 0px 28px; + + @media screen and (min-width: 801px) { + font-size: 16px; + margin: 0 0 40px 0; + max-width: 460px; + } +} + +.buttons { + display: flex; + flex-direction: column; + gap: 10px; + width: 100%; + max-width: 320px; + + @media screen and (min-width: 801px) { + max-width: 360px; + } +} + +.createButton { + width: 100%; + padding: 14px 24px; + height: 46px; + font-size: 15px; +} +.restoreButton { + width: 100%; + padding: 14px 24px; + height: 46px; + font-size: 15px; +} + +.buttonIcon { + width: 13px; + height: 13px; + max-width: 13px; + max-height: 13px; + margin-left: 10px; +} + +.restoreButton:hover .buttonIcon, +.createButton:hover .buttonIcon { + animation: moveArrowUpRight 0.3s ease-out; +} + +.badges { + display: flex; + align-items: center; + gap: 8px; + margin-top: 28px; + + @media screen and (min-width: 801px) { + margin-top: 40px; + gap: 12px; + } +} + +.badges span { + font-size: 12px; + color: rgba(var(--color-black), 0.35); + + @media screen and (min-width: 801px) { + font-size: 14px; + } +} + +.badgeDot { + font-size: 6px; + + @media screen and (min-width: 801px) { + font-size: 8px; + } +} + +.badgeWithIcon { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.badgeIcon { + width: 14px; + height: 14px; + + @media screen and (min-width: 801px) { + width: 16px; + height: 16px; + } +} diff --git a/src/pages/Dashboard/Dashboard.css b/src/pages/Dashboard/Dashboard.css index 92765a4c..6634648f 100644 --- a/src/pages/Dashboard/Dashboard.css +++ b/src/pages/Dashboard/Dashboard.css @@ -1,14 +1,13 @@ .stats { display: flex; - margin: 1.5rem 0 1.5rem; + margin-bottom: 1.5rem; min-height: max-content; - justify-content: space-between; + justify-content: center; + align-items: center; + gap: 55px; + min-height: 224px; @media screen and (min-width: 801px) { - margin: 1.5rem 0 2.75rem; + margin-bottom: 2.75rem; } } - -.stats > *:nth-child(2) { - width: 357px; -} diff --git a/src/pages/Dashboard/Dashboard.js b/src/pages/Dashboard/Dashboard.js index c8e78172..4cf086b0 100644 --- a/src/pages/Dashboard/Dashboard.js +++ b/src/pages/Dashboard/Dashboard.js @@ -13,6 +13,7 @@ import { import { Dashboard } from '@ContainerComponents' import { NumbersHelper, ObjectHelpers } from '@Helpers' +import { PageWrapper } from '@BasicComponents' import './Dashboard.css' import useOneDayAgoHist from 'src/hooks/UseOneDayAgoHist/useOneDayAgoHist' import { useNavigate } from 'react-router' @@ -204,7 +205,7 @@ const DashboardPage = () => { }, [accountID]) return ( - <> +
    { /> )} - + ) } diff --git a/src/pages/DelegationStake/DelegationStake.js b/src/pages/DelegationStake/DelegationStake.js index fd929f7b..6ffc06a5 100644 --- a/src/pages/DelegationStake/DelegationStake.js +++ b/src/pages/DelegationStake/DelegationStake.js @@ -8,7 +8,7 @@ import { AccountContext, MintlayerContext, TransactionContext } from '@Contexts' import { AppInfo } from '@Constants' import './DelegationStake.css' -import { Error } from '@BasicComponents' +import { Error, PageWrapper } from '@BasicComponents' import { Loading } from '@ComposedComponents' const DelegationStakePage = () => { @@ -65,12 +65,17 @@ const DelegationStakePage = () => { transactionInformation?.amount > 0 ) { setFeeLoading(true) - const transaction = await client.buildDelegationStake({ - amount: transactionInformation.amount, - delegation_id: transactionInformation.to, - }) - setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) - setFeeLoading(false) + try { + const transaction = await client.buildDelegationStake({ + amount: transactionInformation.amount, + delegation_id: transactionInformation.to, + }) + setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) + } catch (e) { + console.error('Failed to calculate staking fee:', e) + } finally { + setFeeLoading(false) + } } } buildTransaction() @@ -101,7 +106,7 @@ const DelegationStakePage = () => { } return ( - <> +
    {loading ? ( @@ -131,7 +136,7 @@ const DelegationStakePage = () => { )}
    - +
    ) } diff --git a/src/pages/DelegationWithdraw/DelegationWithdraw.js b/src/pages/DelegationWithdraw/DelegationWithdraw.js index 9e7f44ea..c1ed8251 100644 --- a/src/pages/DelegationWithdraw/DelegationWithdraw.js +++ b/src/pages/DelegationWithdraw/DelegationWithdraw.js @@ -8,7 +8,7 @@ import { AccountContext, MintlayerContext, TransactionContext } from '@Contexts' import { AppInfo } from '@Constants' import './DelegationWithdraw.css' -import { Error } from '@BasicComponents' +import { Error, PageWrapper } from '@BasicComponents' import { Loading } from '@ComposedComponents' const DelegationWithdrawPage = () => { @@ -104,7 +104,7 @@ const DelegationWithdrawPage = () => { } return ( - <> +
    {loading ? ( @@ -134,7 +134,7 @@ const DelegationWithdrawPage = () => { )}
    - +
    ) } diff --git a/src/pages/LockedBalance/LockedBalance.css b/src/pages/LockedBalance/LockedBalance.css deleted file mode 100644 index 2d2f72f0..00000000 --- a/src/pages/LockedBalance/LockedBalance.css +++ /dev/null @@ -1,16 +0,0 @@ -.animate-list-accounts { - overflow: hidden; - transform: translateX(-200%); - transition: transform 1s ease-in-out; -} - -.locked-balance-list-item { -} - -.locked-balance-list-item:nth-child(even) { - background-color: rgba(208, 192, 255, 0.2); -} - -.locked-balance-cell { - padding: 10px; -} diff --git a/src/pages/LockedBalance/LockedBalance.js b/src/pages/LockedBalance/LockedBalance.js index 2fbd2cfa..b69a010b 100644 --- a/src/pages/LockedBalance/LockedBalance.js +++ b/src/pages/LockedBalance/LockedBalance.js @@ -1,12 +1,11 @@ import { LockedBalanceList } from '@ComposedComponents' - -import './LockedBalance.css' +import { PageWrapper } from '@BasicComponents' const LockedBalancePage = () => { return ( - <> + - + ) } diff --git a/src/pages/Login/Login.css b/src/pages/Login/Login.css deleted file mode 100644 index 07c522a8..00000000 --- a/src/pages/Login/Login.css +++ /dev/null @@ -1,5 +0,0 @@ -.animate-list-accounts { - overflow: hidden; - transform: translateX(-200%); - transition: transform 1s ease-in-out; -} diff --git a/src/pages/Login/Login.js b/src/pages/Login/Login.js deleted file mode 100644 index c6e8e715..00000000 --- a/src/pages/Login/Login.js +++ /dev/null @@ -1,62 +0,0 @@ -import { useEffect, useState } from 'react' -import { useNavigate } from 'react-router' - -import { Login } from '@ContainerComponents' -import { useStyleClasses } from '@Hooks' - -import './Login.css' - -const LoginPage = ({ - accounts = [ - { id: 1, name: 'ABC' }, - { id: 2, name: 'RRR' }, - { id: 3, name: 'TTT' }, - ], - onSelect, - onCreate, - delay = 0, -}) => { - const navigate = useNavigate() - const [account, setAccount] = useState(undefined) - - const { styleClasses, addStyleClass, removeStyleClass } = useStyleClasses([]) - - useEffect(() => { - if (!account) return - - removeStyleClass('animate-list-accounts') - navigate('/set-account-password', { state: { account } }) - }, [account, removeStyleClass, navigate]) - - const goNext = (account) => { - addStyleClass('animate-list-accounts') - - delay > 0 - ? setTimeout(() => setAccount(account), delay) - : setAccount(account) - onSelect && onSelect() - } - - const goCreate = () => { - navigate('/', { state: { fromLogin: true } }) - onCreate && onCreate() - } - - return ( -
    - {!account && ( - - )} -
    - ) -} - -export default LoginPage diff --git a/src/pages/Login/Login.module.css b/src/pages/Login/Login.module.css new file mode 100644 index 00000000..711b8838 --- /dev/null +++ b/src/pages/Login/Login.module.css @@ -0,0 +1,19 @@ +.pageWrapper { + background: rgb(var(--color-white)); + flex-direction: row; + padding: 0; + + @media screen and (min-width: 801px) { + padding: 0; + } +} + +.page { + display: flex; + flex: 1; + padding: 1rem; + + @media screen and (min-width: 801px) { + padding: 2rem; + } +} diff --git a/src/pages/Login/Login.tsx b/src/pages/Login/Login.tsx new file mode 100644 index 00000000..9579cf69 --- /dev/null +++ b/src/pages/Login/Login.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import { Login } from '@ContainerComponents' +import { PageWrapper } from '@BasicComponents' + +import styles from './Login.module.css' + +interface Account { + id: string | number + name: string +} + +interface LoginPageProps { + accounts?: Account[] + onSelect?: () => void + onCreate?: () => void + delay?: number +} + +const LoginPage = ({ + accounts = [], + onSelect, + onCreate, + delay = 0, +}: LoginPageProps) => { + const navigate = useNavigate() + const [account, setAccount] = useState(undefined) + + useEffect(() => { + if (!account) return + navigate('/set-account-password', { state: { account } }) + }, [account, navigate]) + + const goNext = (account: Account) => { + delay > 0 + ? setTimeout(() => setAccount(account), delay) + : setAccount(account) + onSelect && onSelect() + } + + const goCreate = () => { + navigate('/create-restore', { state: { fromLogin: true } }) + onCreate && onCreate() + } + + return ( + +
    + {!account && ( + + )} +
    +
    + ) +} + +export default LoginPage diff --git a/src/pages/Login/SetAccountPassword.js b/src/pages/Login/SetAccountPassword.js deleted file mode 100644 index 5fd5722f..00000000 --- a/src/pages/Login/SetAccountPassword.js +++ /dev/null @@ -1,30 +0,0 @@ -import { useContext } from 'react' -import { useNavigate } from 'react-router' - -import { Login } from '@ContainerComponents' - -import { Account } from '@Entities' -import { AccountContext } from '@Contexts' - -const SetAccountPasswordPage = ({ nextAfterUnlock }) => { - const { setWalletInfo } = useContext(AccountContext) - const navigate = useNavigate() - - const login = (addresses, id, name) => { - setWalletInfo(addresses, id, name) - if (nextAfterUnlock) { - navigate(nextAfterUnlock.route, { state: nextAfterUnlock.state }) - } else { - navigate('/dashboard') - } - } - - return ( - - ) -} - -export default SetAccountPasswordPage diff --git a/src/pages/Login/SetAccountPassword.module.css b/src/pages/Login/SetAccountPassword.module.css new file mode 100644 index 00000000..5189afa0 --- /dev/null +++ b/src/pages/Login/SetAccountPassword.module.css @@ -0,0 +1,3 @@ +.pageWrapper { + background: rgb(var(--color-white)); +} diff --git a/src/pages/Login/SetAccountPassword.tsx b/src/pages/Login/SetAccountPassword.tsx new file mode 100644 index 00000000..b100cfc8 --- /dev/null +++ b/src/pages/Login/SetAccountPassword.tsx @@ -0,0 +1,45 @@ +import { useContext } from 'react' +import { useNavigate } from 'react-router' + +import { Login } from '@ContainerComponents' +import { Account } from '@Entities' +import { AccountContext } from '@Contexts' +import { PageWrapper } from '@BasicComponents' + +import styles from './SetAccountPassword.module.css' + +interface NextAfterUnlock { + route: string + state?: Record +} + +interface SetAccountPasswordPageProps { + nextAfterUnlock?: NextAfterUnlock | null +} + +const SetAccountPasswordPage = ({ + nextAfterUnlock, +}: SetAccountPasswordPageProps) => { + const { setWalletInfo } = useContext(AccountContext) + const navigate = useNavigate() + + const login = (addresses: unknown, id: string | number, name: string) => { + setWalletInfo(addresses, id, name) + if (nextAfterUnlock) { + navigate(nextAfterUnlock.route, { state: nextAfterUnlock.state }) + } else { + navigate('/dashboard') + } + } + + return ( + + + + ) +} + +export default SetAccountPasswordPage diff --git a/src/pages/MessagePage/MessagePage.js b/src/pages/MessagePage/MessagePage.js index 727f1c33..72106c41 100644 --- a/src/pages/MessagePage/MessagePage.js +++ b/src/pages/MessagePage/MessagePage.js @@ -1,6 +1,7 @@ import React, { useState } from 'react' import { Message } from '@ContainerComponents' +import { PageWrapper } from '@BasicComponents' import './MessagePage.css' @@ -8,7 +9,7 @@ const MessagePage = () => { const [activeTab, setActiveTab] = useState('sign') return ( -
    +
    -
    + ) } diff --git a/src/pages/Nft/Nft.js b/src/pages/Nft/Nft.js index 35e332ee..66d30d95 100644 --- a/src/pages/Nft/Nft.js +++ b/src/pages/Nft/Nft.js @@ -1,11 +1,14 @@ import { Wallet } from '@ContainerComponents' +import { PageWrapper } from '@BasicComponents' import styles from './Nft.module.css' const NftPage = () => { return ( -
    - -
    + +
    + +
    +
    ) } diff --git a/src/pages/NftSend/NftSend.js b/src/pages/NftSend/NftSend.js index e1ab7b77..812e815c 100644 --- a/src/pages/NftSend/NftSend.js +++ b/src/pages/NftSend/NftSend.js @@ -7,6 +7,7 @@ import { useExchangeRates, useMlWalletInfo } from '@Hooks' import { AccountContext, MintlayerContext } from '@Contexts' import { AppInfo } from '@Constants' +import { PageWrapper } from '@BasicComponents' import styles from './NftSend.module.css' const NftSendPage = () => { @@ -101,7 +102,7 @@ const NftSendPage = () => { } return ( - <> +
    { />
    - +
    ) } diff --git a/src/pages/OrderSwap/OrderSwap.css b/src/pages/OrderSwap/OrderSwap.css deleted file mode 100644 index 08d724bb..00000000 --- a/src/pages/OrderSwap/OrderSwap.css +++ /dev/null @@ -1,75 +0,0 @@ -.order-swap-title { - font-size: 1.5rem; - font-weight: bold; - margin-bottom: 1rem; - - @media screen and (min-width: 801px) { - margin-bottom: 2rem; - } -} - -.order-swap-form { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.swap-order-input { - padding: 1rem; -} - -.swap-order-loading { - display: flex; - justify-content: center; - align-items: center; - height: 100%; - width: 100%; -} - -.order-tabs { - display: flex; - margin: 10px 0 15px 0; - border-radius: 10px; - min-height: max-content; -} - -.swap-content { - display: flex; - width: 100%; - flex-direction: column; - align-items: center; - gap: 5px; - - @media screen and (min-width: 801px) { - gap: 15px; - } -} - -.swap-header { - display: flex; - justify-content: space-between; - align-items: center; - min-height: max-content; -} - -.swap-header-title { - font-size: 1.4rem; - font-weight: bold; -} - -.swap-title { - font-size: 1rem; - font-weight: bold; - padding-left: 5px; -} - -.swap-mode-toggle { - display: flex; - align-items: center; - gap: 10px; -} - -.swap-button { - align-self: center; - width: 40%; -} diff --git a/src/pages/OrderSwap/OrderSwap.js b/src/pages/OrderSwap/OrderSwap.js index a2d74b2e..90685b8b 100644 --- a/src/pages/OrderSwap/OrderSwap.js +++ b/src/pages/OrderSwap/OrderSwap.js @@ -1,12 +1,12 @@ import { useContext, useState } from 'react' import { useNavigate } from 'react-router' -import { Toggle } from '@BasicComponents' +import { Toggle, PageWrapper } from '@BasicComponents' import { VerticalGroup } from '@LayoutComponents' import { Wallet } from '@ContainerComponents' import { AccountContext, MintlayerContext } from '@Contexts' import { ManualSwap, SwapInterface } from '@ComposedComponents' -import './OrderSwap.css' +import styles from './OrderSwap.module.css' const OrderSwapPage = () => { const { accountID } = useContext(AccountContext) @@ -29,39 +29,40 @@ const OrderSwapPage = () => { } return ( - -
    -

    Swap Assets

    -
    - Advanced Mode - + + +
    +

    Swap Assets

    +
    + Advanced Mode + +
    -
    - {/* swap interface */} -
    - {mode === 'basic' ? ( - <> - - 0 ? sortedOrdersByRate : [] - } - ordersLoading={orderPairLoading} - /> - - ) : ( - - )} -
    - +
    + {mode === 'basic' ? ( + <> + + 0 ? sortedOrdersByRate : [] + } + ordersLoading={orderPairLoading} + /> + + ) : ( + + )} +
    + + ) } diff --git a/src/pages/OrderSwap/OrderSwap.module.css b/src/pages/OrderSwap/OrderSwap.module.css new file mode 100644 index 00000000..fcf85f16 --- /dev/null +++ b/src/pages/OrderSwap/OrderSwap.module.css @@ -0,0 +1,39 @@ +.header { + display: flex; + justify-content: space-between; + align-items: center; + min-height: max-content; +} + +.title { + font-size: 1.4rem; + font-weight: bold; +} + +.modeToggle { + display: flex; + align-items: center; + gap: 10px; +} + +.modeLabel { + font-weight: 500; + font-size: 14px; + color: rgba(var(--color-black), 0.6); +} + +.content { + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + gap: 5px; + flex-grow: 1; + min-height: 0; +} + +@media screen and (min-width: 801px) { + .content { + gap: 15px; + } +} diff --git a/src/pages/RestoreAccount/RestoreAccount.css b/src/pages/RestoreAccount/RestoreAccount.css deleted file mode 100644 index c166793f..00000000 --- a/src/pages/RestoreAccount/RestoreAccount.css +++ /dev/null @@ -1,45 +0,0 @@ -.restore-account-form { - margin-top: 36px; -} - -.title-restore { - font-size: 24px; - font-weight: 500; - margin: 2rem 0 2rem 0; -} - -.restore-button-wrapper { - display: grid; - gap: 0.5rem; - justify-content: center; -} - -.restore-button { - width: 300px; -} - -.creating-loading-warapper { - display: flex; - justify-content: center; - align-items: center; - height: 50vh; -} - -.loading-big { - width: 190px; - height: 190px; -} - -.loading-big::after { - width: 160px; - height: 160px; - border: 15px solid #fff; - border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) - transparent; -} - -.loadingText { - margin-bottom: 60px; - font-size: 24px; - font-weight: 400; -} diff --git a/src/pages/RestoreAccount/RestoreAccount.js b/src/pages/RestoreAccount/RestoreAccount.js deleted file mode 100644 index 108b5944..00000000 --- a/src/pages/RestoreAccount/RestoreAccount.js +++ /dev/null @@ -1,117 +0,0 @@ -import React, { useContext, useState } from 'react' -import { useNavigate } from 'react-router' - -import { AccountContext } from '@Contexts' -import { Account } from '@Entities' -import { BTC } from '@Cryptos' - -import { Loading } from '@ComposedComponents' -import { Header } from '@ComposedComponents' -import { Button } from '@BasicComponents' -import { CenteredLayout, VerticalGroup } from '@LayoutComponents' -import { RestoreAccount } from '@ContainerComponents' - -import './RestoreAccount.css' - -const RestoreAccountPage = () => { - const [step, setStep] = useState(1) - const [restoreMethod, setRestoreMethod] = useState('') - const [creatingWallet, setCreatingWallet] = useState(false) - const { setWalletInfo } = useContext(AccountContext) - const restoreButtonExtraClasses = ['restore-button'] - - const navigate = useNavigate() - - const createAccount = ( - accountName, - accountPassword, - mnemonic, - btcAddressType, - selectedWallets, - // eslint-disable-next-line max-params - ) => { - setCreatingWallet(true) - let accountID = null - const data = { - name: accountName, - password: accountPassword, - mnemonic, - walletType: btcAddressType, - walletsToCreate: selectedWallets, - } - Account.saveAccount(data) - .then((id) => { - accountID = id - return Account.unlockAccount(id, accountPassword) - }) - .then(({ addresses }) => { - setWalletInfo(addresses, accountID, accountName) - navigate('/dashboard') - }) - } - - const goToPrevStep = () => { - setRestoreMethod('') - navigate('/') - } - - const loadingExtraClasses = ['loading-big'] - - return creatingWallet ? ( -
    - - -

    - {' '} - Just a sec, we are restoring your wallet...{' '} -

    - -
    -
    -
    - ) : ( - <> - {!restoreMethod && ( - -
    -

    - Please select the method to restore your wallet -

    -
    - - -
    - - )} - {restoreMethod === 'mnemonic' && ( - - )} - {restoreMethod === 'json' && ( - - )} - - ) -} -export default RestoreAccountPage diff --git a/src/pages/RestoreAccount/RestoreAccount.module.css b/src/pages/RestoreAccount/RestoreAccount.module.css new file mode 100644 index 00000000..4705b58c --- /dev/null +++ b/src/pages/RestoreAccount/RestoreAccount.module.css @@ -0,0 +1,59 @@ +.restoreAccountPage { + background-color: rgb(var(--color-white)); +} + +.page { + display: flex; + flex-direction: column; + align-items: center; + padding: 20px; +} + +.title { + font-size: 24px; + font-weight: 700; + color: rgb(var(--color-black)); + margin-bottom: 8px; + text-align: center; +} + +.subtitle { + font-size: 14px; + color: rgb(var(--color-dark-gray)); + text-align: center; + margin-bottom: 28px; +} + +.cards { + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + max-width: 400px; +} + +.loadingWrapper { + display: flex; + justify-content: center; + align-items: center; + height: 50vh; +} + +.loadingBig { + width: 190px; + height: 190px; +} + +.loadingBig::after { + width: 160px; + height: 160px; + border: 15px solid #fff; + border-color: rgb(var(--color-main-green)) transparent rgb(var(--color-black)) + transparent; +} + +.loadingText { + margin-bottom: 60px; + font-size: 24px; + font-weight: 400; +} diff --git a/src/pages/RestoreAccount/RestoreAccount.tsx b/src/pages/RestoreAccount/RestoreAccount.tsx new file mode 100644 index 00000000..e5c09817 --- /dev/null +++ b/src/pages/RestoreAccount/RestoreAccount.tsx @@ -0,0 +1,117 @@ +import { useContext, useEffect, useState } from 'react' +import { useNavigate } from 'react-router' + +import { AccountContext } from '@Contexts' +import { Account } from '@Entities' +import { BTC } from '@Cryptos' + +import { Loading } from '@ComposedComponents' +import { PageWrapper, OptionCard } from '@BasicComponents' +import { CenteredLayout, VerticalGroup } from '@LayoutComponents' +import { RestoreAccount } from '@ContainerComponents' +import { ReactComponent as IconDocumentFilled } from '@Assets/images/icon-document-filled.svg' +import { ReactComponent as IconUpload } from '@Assets/images/icon-upload.svg' + +import styles from './RestoreAccount.module.css' + +const RestoreAccountPage = () => { + const [step, setStep] = useState(1) + const [restoreMethod, setRestoreMethod] = useState('') + const [creatingWallet, setCreatingWallet] = useState(false) + const { setWalletInfo, setCustomBackAction } = useContext(AccountContext) + + const navigate = useNavigate() + + const createAccount = ( + accountName: string, + accountPassword: string, + mnemonic: string, + btcAddressType: string, + selectedWallets: string[], + // eslint-disable-next-line max-params + ) => { + setCreatingWallet(true) + let accountID: string | null = null + const data = { + name: accountName, + password: accountPassword, + mnemonic, + walletType: btcAddressType, + walletsToCreate: selectedWallets, + } + Account.saveAccount(data) + .then((id: string) => { + accountID = id + return Account.unlockAccount(id, accountPassword) + }) + .then(({ addresses }) => { + setWalletInfo(addresses, accountID, accountName) + navigate('/dashboard') + }) + } + + const goToPrevStep = () => { + setRestoreMethod('') + navigate('/') + } + + /* eslint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + setCustomBackAction(() => goToPrevStep) + return () => setCustomBackAction(null) + }, []) + /* eslint-enable react-hooks/exhaustive-deps */ + + return ( + + {creatingWallet ? ( +
    + + +

    + Just a sec, we are restoring your wallet... +

    + +
    +
    +
    + ) : ( + <> + {!restoreMethod && ( +
    +

    Restore wallet

    +

    + Choose how you'd like to recover access +

    +
    + } + title="Seed Phrase" + description="Restore using your 12 or 24 word recovery phrase" + onClick={() => setRestoreMethod('mnemonic')} + /> + } + title="Backup file" + description="Restore from a JSON backup file exported from Mojito" + onClick={() => setRestoreMethod('json')} + /> +
    +
    + )} + {restoreMethod === 'mnemonic' && ( + + )} + {restoreMethod === 'json' && } + + )} +
    + ) +} +export default RestoreAccountPage diff --git a/src/pages/SendBtcTransaction/SendBtcTransaction.js b/src/pages/SendBtcTransaction/SendBtcTransaction.js index 6950a4f5..5e2aab80 100644 --- a/src/pages/SendBtcTransaction/SendBtcTransaction.js +++ b/src/pages/SendBtcTransaction/SendBtcTransaction.js @@ -2,20 +2,24 @@ import { useContext, useState } from 'react' import { useNavigate, useParams } from 'react-router' import { SendBtcTransaction } from '@ContainerComponents' +import { SendPageHeader } from '@ComposedComponents' import { VerticalGroup } from '@LayoutComponents' import { useExchangeRates, useBtcWalletInfo } from '@Hooks' -import { AccountContext, BitcoinContext } from '@Contexts' +import { AccountContext, BitcoinContext, SettingsContext } from '@Contexts' import { BTCTransaction } from '@Cryptos' import { Account } from '@Entities' import { BTC as BTCHelper, Format } from '@Helpers' -import { Electrum } from '@APIs' import { BTC_ADDRESS_TYPE_ENUM } from '@Cryptos' +import { AppInfo } from '@Constants' -import './SendBtcTransaction.css' +import { PageWrapper } from '@BasicComponents' +import styles from './SendBtcTransaction.module.css' const SendBtcTransactionPage = () => { const { addresses, accountID } = useContext(AccountContext) - const { fetchAllData } = useContext(BitcoinContext) + const { btcUtxos } = useContext(BitcoinContext) + const { networkType } = useContext(SettingsContext) + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET const { coinType } = useParams() const walletType = { @@ -25,9 +29,6 @@ const SendBtcTransactionPage = () => { tokenId: ['Mintlayer', 'Bitcoin'].includes(coinType) ? null : coinType, } - const { unusedAddresses: unusedBtcAddresses, btcUtxos } = - useContext(BitcoinContext) - const currentBtcAddress = addresses.btcAddresses.btcReceivingAddresses[0] const [totalFeeFiat, setTotalFeeFiat] = useState(0) const [totalFeeCrypto, setTotalFeeCrypto] = useState(0) @@ -42,7 +43,6 @@ const SendBtcTransactionPage = () => { tokenName, }) const [isFormValid, setFormValid] = useState(false) - const [transactionInformation, setTransactionInformation] = useState(null) const { exchangeRate } = useExchangeRates(tokenName, fiatName) @@ -75,60 +75,16 @@ const SendBtcTransactionPage = () => { const createTransaction = async (transactionInfo) => { await calculateBtcTotalFee(transactionInfo) - setTransactionInformation(transactionInfo) - } - - const getChangeAddress = () => { - const candidate = - unusedBtcAddresses?.changeAddress || - addresses?.btcAddresses?.btcChangeAddresses?.[0] - - if (typeof candidate === 'string') return candidate - if (typeof candidate?.address === 'string') return candidate.address - if (typeof candidate === 'object') { - const key = Object.keys(candidate)[0] - if (typeof key === 'string') return key - } - throw new Error('Missing BTC change address') - } - - const confirmBtcTransaction = async (password) => { - const { btcPrivateKeys } = await Account.unlockAccount( - accountID, - password, - { wallets: ['btc'] }, - ) - const transactionAmountInSatoshi = BTCHelper.convertBtcToSatoshi( - transactionInformation.amount, - ) - - const currentAccount = await Account.getAccount(accountID) - const btcWalletType = - currentAccount.walletType || BTC_ADDRESS_TYPE_ENUM.NATIVE_SEGWIT - - // eslint-disable-next-line no-unused-vars - const [__, transactionHex] = await BTCTransaction.buildTransaction({ - to: transactionInformation.to, - amount: transactionAmountInSatoshi, - utxos: btcUtxos || [], - feeRate: transactionInformation.fee, - walletType: btcWalletType, - changeAddress: getChangeAddress(), - root: btcPrivateKeys, - }) - - const result = await Electrum.broadcastTransaction(transactionHex) - return result - } - - const goBackToWallet = async () => { - navigate('/wallet/Bitcoin') - await fetchAllData(true) } return ( - <> -
    + +
    + { calculateTotalFee={calculateBtcTotalFee} setFormValidity={setFormValid} isFormValid={isFormValid} - confirmTransaction={confirmBtcTransaction} - goBackToWallet={goBackToWallet} walletType={walletType} />
    - +
    ) } diff --git a/src/pages/SendBtcTransaction/SendBtcTransaction.css b/src/pages/SendBtcTransaction/SendBtcTransaction.module.css similarity index 56% rename from src/pages/SendBtcTransaction/SendBtcTransaction.css rename to src/pages/SendBtcTransaction/SendBtcTransaction.module.css index 60039a26..0d7e222d 100644 --- a/src/pages/SendBtcTransaction/SendBtcTransaction.css +++ b/src/pages/SendBtcTransaction/SendBtcTransaction.module.css @@ -1,3 +1,4 @@ .page { margin: 0; + overflow: auto; } diff --git a/src/pages/SendMlTransaction/SendMlTransaction.js b/src/pages/SendMlTransaction/SendMlTransaction.js index 5ff2b640..5449a516 100644 --- a/src/pages/SendMlTransaction/SendMlTransaction.js +++ b/src/pages/SendMlTransaction/SendMlTransaction.js @@ -2,14 +2,19 @@ import { useContext, useState, useEffect, useMemo } from 'react' import { useNavigate, useParams } from 'react-router' import { SendMlTransaction } from '@ContainerComponents' +import { SendPageHeader } from '@ComposedComponents' import { VerticalGroup } from '@LayoutComponents' import { useExchangeRates, useMlWalletInfo } from '@Hooks' -import { AccountContext, MintlayerContext } from '@Contexts' +import { AccountContext, MintlayerContext, SettingsContext } from '@Contexts' +import { AppInfo } from '@Constants' -import './SendMlTransaction.css' +import { PageWrapper } from '@BasicComponents' +import styles from './SendMlTransaction.module.css' const SendMlTransactionPage = () => { const { addresses, accountID } = useContext(AccountContext) + const { networkType } = useContext(SettingsContext) + const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET const { coinType } = useParams() const walletType = useMemo( @@ -27,6 +32,7 @@ const SendMlTransactionPage = () => { const currentMlAddresses = addresses.mlAddresses const [totalFeeCrypto, setTotalFeeCrypto] = useState(0) const [feeLoading, setFeeLoading] = useState(false) + const [feeError, setFeeError] = useState('') const navigate = useNavigate() const { balance, tokenBalances } = datahook(currentMlAddresses, coinType) @@ -56,23 +62,45 @@ const SendMlTransactionPage = () => { const { exchangeRate } = useExchangeRates(tokenName, fiatName) useEffect(() => { - const buildTransaction = async () => { - if ( - isFormValid && - transactionInformation?.to.length > 0 && - transactionInformation?.amount > 0 - ) { - setFeeLoading(true) + if ( + !isFormValid || + !(transactionInformation?.to.length > 0) || + !(transactionInformation?.amount > 0) + ) { + setFeeLoading(false) + return + } + + let cancelled = false + setFeeLoading(true) + setFeeError('') + + const timer = setTimeout(async () => { + try { const transaction = await client.buildTransfer({ to: transactionInformation.to, amount: transactionInformation.amount, token_id: walletType?.tokenId, }) + if (cancelled) return setTotalFeeCrypto(transaction.JSONRepresentation.fee.decimal) - setFeeLoading(false) + } catch (error) { + if (cancelled) return + console.error('Fee calculation failed:', error) + setTotalFeeCrypto(0) + const message = error.message?.includes('Not enough coin UTXOs') + ? 'Insufficient balance' + : error.message || 'Fee calculation failed' + setFeeError(message) + } finally { + if (!cancelled) setFeeLoading(false) } + }, 400) + + return () => { + cancelled = true + clearTimeout(timer) } - buildTransaction() }, [transactionInformation, client, walletType, isFormValid]) if (!accountID) { @@ -103,12 +131,18 @@ const SendMlTransactionPage = () => { } return ( - <> -
    + +
    + { />
    - +
    ) } diff --git a/src/pages/SendMlTransaction/SendMlTransaction.css b/src/pages/SendMlTransaction/SendMlTransaction.module.css similarity index 56% rename from src/pages/SendMlTransaction/SendMlTransaction.css rename to src/pages/SendMlTransaction/SendMlTransaction.module.css index 60039a26..0d7e222d 100644 --- a/src/pages/SendMlTransaction/SendMlTransaction.css +++ b/src/pages/SendMlTransaction/SendMlTransaction.module.css @@ -1,3 +1,4 @@ .page { margin: 0; + overflow: auto; } diff --git a/src/pages/Settings/Settings.css b/src/pages/Settings/Settings.css deleted file mode 100644 index bee0f6be..00000000 --- a/src/pages/Settings/Settings.css +++ /dev/null @@ -1,27 +0,0 @@ -.settings-wrapper { - margin-top: 50px; - max-height: 435px; - overflow: auto; - - @media screen and (min-width: 801px) { - max-height: 100%; - } -} - -.settings-item { - position: relative; - padding: 20px 5px; - height: max-content; -} - -.divider { - position: absolute; - bottom: 0; - width: 100%; - height: 1px; - margin: 0 auto; - background: rgb(var(--color-medium-blue)); - background-position: center; - background-size: 90% 1px; - background-repeat: no-repeat; -} diff --git a/src/pages/Settings/Settings.js b/src/pages/Settings/Settings.js deleted file mode 100644 index 21f0df09..00000000 --- a/src/pages/Settings/Settings.js +++ /dev/null @@ -1,52 +0,0 @@ -import { Settings } from '@ContainerComponents' - -import './Settings.css' - -const SettingsPage = ({ unlocked }) => { - const SettingsList = [ - { - component: , - value: 'testnet', - visible: true, - }, - // Disable API settings since start using batch requests - // { - // component: , - // value: 'api', - // visible: true, - // }, - { - component: , - value: 'backup', - visible: unlocked, - }, - // Keep the delete wallet option at the bottom - { - component: , - value: 'delete', - visible: unlocked, - }, - ] - - return ( - <> -
      - {SettingsList.map((item) => ( -
      - {item.visible && ( -
    • - {item.component} -
      -
    • - )} -
      - ))} -
    - - ) -} - -export default SettingsPage diff --git a/src/pages/Settings/Settings.module.css b/src/pages/Settings/Settings.module.css new file mode 100644 index 00000000..4ea74956 --- /dev/null +++ b/src/pages/Settings/Settings.module.css @@ -0,0 +1,23 @@ +.wrapper { + margin-top: 30px; + max-height: 435px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 24px; + padding: 0 5px; + animation: fadeIn 0.4s ease-in-out; + + @media screen and (min-width: 801px) { + max-height: 100%; + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} diff --git a/src/pages/Settings/Settings.tsx b/src/pages/Settings/Settings.tsx new file mode 100644 index 00000000..af02261c --- /dev/null +++ b/src/pages/Settings/Settings.tsx @@ -0,0 +1,57 @@ +import { Settings } from '@ContainerComponents' +import { PageWrapper } from '@BasicComponents' +import styles from './Settings.module.css' + +interface SettingsPageProps { + unlocked?: boolean +} + +const SettingsPage = ({ unlocked }: SettingsPageProps) => { + const sections = [ + { + title: 'Network', + key: 'network', + visible: true, + items: [{ key: 'testnet', component: }], + }, + { + title: 'Wallet', + key: 'wallet', + visible: unlocked, + items: [ + { key: 'backup', component: }, + { key: 'delete', component: }, + ], + }, + { + title: 'About', + key: 'about', + visible: true, + content: , + }, + ] + + return ( + +
    + {sections.map((section) => + section.visible ? ( + + {section.content || + section.items?.map((item) => ( + + {item.component} + + ))} + + ) : null, + )} +
    +
    + ) +} + +export default SettingsPage diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css index bdaeaed1..469578f7 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.css @@ -7,6 +7,8 @@ width: 100%; height: 100%; position: relative; + padding: 20px; + border-radius: 10px; } .SignTransaction .header { @@ -83,12 +85,10 @@ } .SignTransaction .footer { - position: absolute; - width: 100%; display: flex; justify-content: center; gap: 12px; - bottom: 0; + bottom: 18px; } .modal { @@ -124,7 +124,7 @@ } .buttonSignTransaction { - width: 50%; + width: 40%; } /* HTLC Secret Management Styles */ diff --git a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js index 6a1fc39c..564f1cde 100644 --- a/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js +++ b/src/pages/SignBitcoinTransaction/SignBitcoinTransaction.js @@ -1,12 +1,12 @@ /* eslint-disable no-undef */ import { useLocation } from 'react-router' import { MOCKS } from './mocks' -import { Button } from '@BasicComponents' +import { Button, PageWrapper } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import { SignTransaction } from '@ContainerComponents' import './SignBitcoinTransaction.css' -import { useState, useContext, useEffect } from 'react' +import { useState, useContext, useMemo } from 'react' import { Network } from '../../services/Crypto/Mintlayer/@mintlayerlib-js' import * as bitcoin from 'bitcoinjs-lib' import { Account } from '@Entities' @@ -78,8 +78,6 @@ export const SignBitcoinTransactionPage = () => { const [secret, setSecret] = useState('') // Secret management state for HTLC transactions - const [generatedSecret, setGeneratedSecret] = useState(null) - const [generatedSecretHash, setGeneratedSecretHash] = useState(null) const [secretError, setSecretError] = useState('') const [mode, setMode] = useState('preview') @@ -87,10 +85,52 @@ export const SignBitcoinTransactionPage = () => { const [selectedMock, setSelectedMock] = useState('transfer') const extraButtonStyles = ['buttonSignTransaction'] - // State to hold the potentially modified transaction data - const [transactionState, setTransactionState] = useState(null) + const initialState = external_state || MOCKS[selectedMock] + + // Generate secret and prepare transaction state once for HTLC create transactions + const { generatedSecret, generatedSecretHash, htlcTransactionState } = + useMemo(() => { + const currentState = initialState + const transactionJSON = + currentState?.request?.data?.txData?.JSONRepresentation + const isCreate = transactionJSON?.secretHash + + if (!transactionJSON || !isCreate) { + return { + generatedSecret: null, + generatedSecretHash: null, + htlcTransactionState: null, + } + } + + try { + const secretObj = Secret.generateSecretObject() + const updatedState = JSON.parse(JSON.stringify(currentState)) + const updatedTransactionJSON = + updatedState.request.data.txData.JSONRepresentation + + if (updatedTransactionJSON.secretHash) { + updatedTransactionJSON.secretHash = JSON.stringify({ + secret_hash_hex: secretObj.secretHashHex, + }) + } + + return { + generatedSecret: secretObj.secretHex, + generatedSecretHash: secretObj.secretHashHex, + htlcTransactionState: updatedState, + } + } catch (error) { + console.error('Error generating secret:', error) + return { + generatedSecret: null, + generatedSecretHash: null, + htlcTransactionState: null, + } + } + }, [initialState]) - const state = transactionState || external_state || MOCKS[selectedMock] + const state = htlcTransactionState || initialState const revealed_secret = state?.request?.data?.txData?.JSONRepresentation.secret @@ -108,51 +148,6 @@ export const SignBitcoinTransactionPage = () => { state?.request?.data?.txData?.JSONRepresentation?.type === 'spendHtlc' // const isHTLCRefundTx = state?.request?.data?.txData?.JSONRepresentation?.type === 'refundHtlc' - useEffect(() => { - // SECRET FOR HTLC - // Check if this is a create HTLC transaction and if secret needs to be generated - const currentState = - transactionState || external_state || MOCKS[selectedMock] - const transactionJSON = - currentState?.request?.data?.txData?.JSONRepresentation - - if (!transactionJSON) { - return - } - - // If this is an HTLC create transaction and we haven't generated a secret yet - if (isHTLCCreateTx && !generatedSecret) { - try { - const secretObj = Secret.generateSecretObject() - setGeneratedSecret(secretObj.secretHex) - setGeneratedSecretHash(secretObj.secretHashHex) - - // Create a deep copy of the current state to avoid mutation - const updatedState = JSON.parse(JSON.stringify(currentState)) - const updatedTransactionJSON = - updatedState.request.data.txData.JSONRepresentation - - // Update the transaction with the generated secret hash - if (updatedTransactionJSON.secretHash) { - updatedTransactionJSON.secretHash = JSON.stringify({ - secret_hash_hex: secretObj.secretHashHex, - }) - } - - // Update the transaction state - setTransactionState(updatedState) - } catch (error) { - console.error('Error generating secret:', error) - } - } - }, [ - external_state, - selectedMock, - transactionState, - isHTLCCreateTx, - generatedSecret, - ]) - const handleApprove = async () => { setIsModalOpen(true) // Open the modal } @@ -225,7 +220,6 @@ export const SignBitcoinTransactionPage = () => { } } - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -235,7 +229,6 @@ export const SignBitcoinTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -297,7 +290,6 @@ export const SignBitcoinTransactionPage = () => { const result = { signedTxHex: tx, } - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -307,7 +299,6 @@ export const SignBitcoinTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -337,7 +328,6 @@ export const SignBitcoinTransactionPage = () => { signedTxHex: tx, } - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -347,7 +337,6 @@ export const SignBitcoinTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -396,7 +385,6 @@ export const SignBitcoinTransactionPage = () => { const requestId = state?.request?.requestId const method = 'signTransaction_reject' const result = 'null' - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -406,7 +394,6 @@ export const SignBitcoinTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -444,153 +431,155 @@ export const SignBitcoinTransactionPage = () => { } return ( -
    -
    -

    Sign Transaction

    - -
    - -
    - {!external_state && ( -
    - {Object.keys(MOCKS).map((key) => { - return ( -
    selectMock(key)} - title={key} - className={selectedMock === key ? 'active' : ''} - > - {key} -
    - ) - })} -
    - )} - - {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - - {/**/} -
    - )} - {mode === 'json' && } - - )} - - {/* HTLC Secret Information */} - {isHTLCCreateTx && generatedSecret && ( -
    -

    HTLC Secret Generated

    -
    -
    - -
    - {generatedSecret} - +
    + +
    + {!external_state && ( +
    + {Object.keys(MOCKS).map((key) => { + return ( +
    selectMock(key)} + title={key} + className={selectedMock === key ? 'active' : ''} > - 📋 - + {key} +
    + ) + })} +
    + )} + + {state?.request?.data?.txData?.JSONRepresentation && ( + <> + {mode === 'preview' && ( +
    + + {/**/}
    -
    -
    - -
    - {generatedSecretHash} - + )} + {mode === 'json' && } + + )} + + {/* HTLC Secret Information */} + {isHTLCCreateTx && generatedSecret && ( +
    +

    HTLC Secret Generated

    +
    +
    + +
    + {generatedSecret} + +
    +
    + +
    + {generatedSecretHash} + +
    +
    +
    +
    + ⚠️ Important: Save this secret securely! You + will need it to claim the HTLC later.
    -
    - ⚠️ Important: Save this secret securely! You will - need it to claim the HTLC later. + )} +
    + +
    + + +
    + + {isModalOpen && ( + +
    + + {isHTLCSpendTx && !revealed_secret && ( + <> +
    + + + {secretError && ( +
    {secretError}
    + )} +
    + + 💡 Enter the 32-byte secret in hexadecimal format + +
    +
    + + )} +
    + + +
    -
    + )}
    - -
    - - -
    - - {isModalOpen && ( - -
    - - {isHTLCSpendTx && !revealed_secret && ( - <> -
    - - - {secretError && ( -
    {secretError}
    - )} -
    - - 💡 Enter the 32-byte secret in hexadecimal format - -
    -
    - - )} -
    - - -
    -
    -
    - )} -
    + ) } diff --git a/src/pages/SignChallenge/SignChallenge.js b/src/pages/SignChallenge/SignChallenge.js index 7bca3df7..bb67b34e 100644 --- a/src/pages/SignChallenge/SignChallenge.js +++ b/src/pages/SignChallenge/SignChallenge.js @@ -2,7 +2,7 @@ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers } from '@Helpers' import { MOCKS } from './mocks' -import { Button } from '@BasicComponents' +import { Button, PageWrapper } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import './SignChallenge.css' @@ -89,7 +89,6 @@ export const SignChallengePage = () => { signature: signatureHex, } - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -99,7 +98,6 @@ export const SignChallengePage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -115,7 +113,6 @@ export const SignChallengePage = () => { const requestId = state?.request?.requestId const method = 'signChallenge_reject' const result = 'null' - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -125,7 +122,6 @@ export const SignChallengePage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -142,93 +138,95 @@ export const SignChallengePage = () => { } return ( -
    -
    -

    Sign Challenge

    -
    - -
    - {!external_state && ( -
    - {Object.keys(MOCKS).map((key) => { - return ( -
    selectMock(key)} - title={key} - className={selectedMock === key ? 'active' : ''} - > - {key} + +
    +
    +

    Sign Challenge

    +
    + +
    + {!external_state && ( +
    + {Object.keys(MOCKS).map((key) => { + return ( +
    selectMock(key)} + title={key} + className={selectedMock === key ? 'active' : ''} + > + {key} +
    + ) + })} +
    + )} + + {state?.request?.data && ( +
    +
    +
    Message to sign:
    +
    + {state?.request?.data?.message || 'No message provided'} +
    +
    +
    +
    Address to sign with:
    +
    + {state?.request?.data?.address || 'No address provided'}
    - ) - })} -
    - )} - - {state?.request?.data && ( -
    -
    -
    Message to sign:
    -
    - {state?.request?.data?.message || 'No message provided'}
    -
    -
    Address to sign with:
    -
    - {state?.request?.data?.address || 'No address provided'} + )} +
    + +
    + + +
    + + {isModalOpen && ( + +
    + +
    + +
    -
    + )}
    - -
    - - -
    - - {isModalOpen && ( - -
    - -
    - - -
    -
    -
    - )} -
    + ) } diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.css b/src/pages/SignExternalTransaction/SignExternalTransaction.css index 7e446f6d..5d3a3b88 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.css +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.css @@ -7,6 +7,8 @@ width: 100%; height: 100%; position: relative; + padding: 20px; + border-radius: 10px; } .SignTransaction .header { diff --git a/src/pages/SignExternalTransaction/SignExternalTransaction.js b/src/pages/SignExternalTransaction/SignExternalTransaction.js index bf25fd65..c0ff3c9e 100644 --- a/src/pages/SignExternalTransaction/SignExternalTransaction.js +++ b/src/pages/SignExternalTransaction/SignExternalTransaction.js @@ -2,7 +2,7 @@ import { useLocation } from 'react-router' import { SignTransaction as SignTxHelpers, Secret } from '@Helpers' import { MOCKS } from './mocks' -import { Button } from '@BasicComponents' +import { Button, PageWrapper } from '@BasicComponents' import { PopUp, TextField } from '@ComposedComponents' import { SignTransaction } from '@ContainerComponents' import { MintlayerContext } from '@Contexts' @@ -350,7 +350,6 @@ export const SignTransactionPage = () => { console.log('result', result) - // eslint-disable-next-line no-undef runtime.sendMessage( { action: 'popupResponse', @@ -360,7 +359,6 @@ export const SignTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -376,7 +374,7 @@ export const SignTransactionPage = () => { const requestId = state?.request?.requestId const method = 'signTransaction_reject' const result = 'null' - // eslint-disable-next-line no-undef + runtime.sendMessage( { action: 'popupResponse', @@ -386,7 +384,6 @@ export const SignTransactionPage = () => { result, }, () => { - // eslint-disable-next-line no-undef storage.local.remove('pendingRequest', () => { window.close() }) @@ -429,157 +426,159 @@ export const SignTransactionPage = () => { } return ( -
    -
    -

    Sign Transaction

    - -
    + +
    +
    +

    Sign Transaction

    + +
    + +
    + {!external_state && ( +
    + {Object.keys(MOCKS).map((key) => { + return ( +
    selectMock(key)} + title={key} + className={selectedMock === key ? 'active' : ''} + > + {key} +
    + ) + })} +
    + )} -
    - {!external_state && ( -
    - {Object.keys(MOCKS).map((key) => { - return ( -
    selectMock(key)} - title={key} - className={selectedMock === key ? 'active' : ''} - > - {key} + {state?.request?.data?.txData?.JSONRepresentation && ( + <> + {mode === 'preview' && ( +
    +
    - ) - })} -
    - )} - - {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - -
    - )} - {mode === 'json' && } - - )} - - {/* HTLC Secret Information */} - {isHTLCCreateTx && generatedSecret && ( -
    -

    HTLC Secret Generated

    -
    -
    - -
    - {generatedSecret} - + )} + {mode === 'json' && } + + )} + + {/* HTLC Secret Information */} + {isHTLCCreateTx && generatedSecret && ( +
    +

    HTLC Secret Generated

    +
    +
    + +
    + {generatedSecret} + +
    -
    -
    - -
    - {generatedSecretHash} - +
    + +
    + {generatedSecretHash} + +
    +
    +
    + {/* TODO: Add "Save Secret" button functionality here */} +

    + + 💡 Save this secret - you'll need it to claim the + HTLC later! + +

    -
    - {/* TODO: Add "Save Secret" button functionality here */} -

    - - 💡 Save this secret - you'll need it to claim the HTLC - later! - -

    +
    + )} +
    + +
    + + +
    + + {isModalOpen && ( + +
    + + {isHTLCClaim && ( + <> +
    + + + {secretError && ( +
    {secretError}
    + )} +
    + + 💡 Enter the 32-byte secret in hexadecimal format + +
    +
    + + )} +
    + +
    -
    + )}
    - -
    - - -
    - - {isModalOpen && ( - -
    - - {isHTLCClaim && ( - <> -
    - - - {secretError && ( -
    {secretError}
    - )} -
    - - 💡 Enter the 32-byte secret in hexadecimal format - -
    -
    - - )} -
    - - -
    -
    -
    - )} -
    + ) } diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.js b/src/pages/SignInternalTransaction/SignInternalTransaction.js index 153874d5..0b54fb65 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.js +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.js @@ -1,14 +1,13 @@ -/* eslint-disable no-undef */ import { useLocation, useNavigate } from 'react-router' import { SignTransaction as SignTxHelpers } from '@Helpers' import { MOCKS } from './mocks' -import { Button, Error } from '@BasicComponents' +import { Button, Error, PageWrapper } from '@BasicComponents' import { PopUp, TextField, Loading } from '@ComposedComponents' import { SignTransaction } from '@ContainerComponents' import { Mintlayer } from '@APIs' import { LocalStorageService } from '@Storage' -import './SignInternalTransaction.css' +import styles from './SignInternalTransaction.module.css' import { useState, useContext } from 'react' import { Network } from '../../services/Crypto/Mintlayer/@mintlayerlib-js' @@ -47,7 +46,7 @@ export const SignTransactionPage = () => { const [mode, setMode] = useState('preview') const [selectedMock, setSelectedMock] = useState('transfer') - const extraButtonStyles = ['buttonSignTransaction'] + const extraButtonStyles = [styles.buttonSignTransaction] const state = external_state || MOCKS[selectedMock] @@ -92,7 +91,7 @@ export const SignTransactionPage = () => { unlockedAccount = await Account.unlockAccount(accountID, password, { wallets: ['ml'], }) - } catch (unlockError) { + } catch { setTxErrorMessage('Incorrect password') setPassword('') return @@ -245,114 +244,112 @@ export const SignTransactionPage = () => { } return ( -
    -
    -

    Sign Transaction

    - -
    + +
    +
    +

    Sign Transaction

    + +
    + +
    + {!external_state && ( +
    + {Object.keys(MOCKS).map((key) => { + return ( +
    selectMock(key)} + title={key} + className={selectedMock === key ? 'active' : ''} + > + {key} +
    + ) + })} +
    + )} -
    - {!external_state && ( -
    - {Object.keys(MOCKS).map((key) => { - return ( -
    selectMock(key)} - title={key} - className={selectedMock === key ? 'active' : ''} - > - {key} + {state?.request?.data?.txData?.JSONRepresentation && ( + <> + {mode === 'preview' && ( +
    +
    - ) - })} -
    - )} + )} + {mode === 'json' && } + + )} +
    + +
    + + +
    + + {isModalOpen && ( + + {sendingTransaction && ( + +

    + Your transaction broadcasting to network. +

    + + + +
    + )} + + {!sendingTransaction && transactionId && ( + + )} - {state?.request?.data?.txData?.JSONRepresentation && ( - <> - {mode === 'preview' && ( -
    - + {!sendingTransaction && !transactionId && ( +
    +
    + + {txErrorMessage ? : <>} +
    +
    + + +
    )} - {mode === 'json' && } - + )}
    - -
    - - -
    - - {isModalOpen && ( - - {sendingTransaction && ( - -

    - Your transaction broadcasting to network. -

    - - - -
    - )} - - {!sendingTransaction && transactionId && ( - - )} - - {!sendingTransaction && !transactionId && ( -
    - - {txErrorMessage ? ( - <> - - - ) : ( - <> - )} -
    - - -
    -
    - )} -
    - )} -
    + ) } diff --git a/src/pages/SignInternalTransaction/SignInternalTransaction.css b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css similarity index 79% rename from src/pages/SignInternalTransaction/SignInternalTransaction.css rename to src/pages/SignInternalTransaction/SignInternalTransaction.module.css index 5f139d23..6f9f7851 100644 --- a/src/pages/SignInternalTransaction/SignInternalTransaction.css +++ b/src/pages/SignInternalTransaction/SignInternalTransaction.module.css @@ -1,5 +1,4 @@ -/* SignTransaction.css */ -.SignTransaction { +.signTransaction { background-color: #ffffff; margin: 0 auto; font-family: 'Arial', sans-serif; @@ -7,9 +6,11 @@ width: 100%; height: 100%; position: relative; + padding: 20px; + border-radius: 10px; } -.SignTransaction .header { +.signTransaction .header { display: flex; align-items: center; justify-content: space-between; @@ -22,7 +23,7 @@ font-weight: bold; } -.SignTransaction .SignTxContent { +.signTransaction .signTxContent { margin-bottom: 20px; display: flex; flex-direction: column; @@ -33,7 +34,7 @@ } } -.SignTransaction .mock_selector { +.signTransaction .mockSelector { display: flex; flex-direction: row; justify-content: space-around; @@ -46,11 +47,11 @@ right: 1px; } -.SignTransaction .mock_selector:hover { +.signTransaction .mockSelector:hover { opacity: 1; } -.SignTransaction .mock_selector div { +.signTransaction .mockSelector div { background-color: #f3f4f6; padding: 8px 16px; border-radius: 6px; @@ -60,17 +61,17 @@ transition: background-color 0.2s ease; } -.SignTransaction .mock_selector div:hover { +.signTransaction .mockSelector div:hover { background-color: #e5e7eb; } -.SignTransaction .transaction-preview-wrapper { +.signTransaction .transactionPreviewWrapper { height: 100%; overflow: scroll; width: 100%; } -.SignTransaction .transaction-raw-wrapper { +.signTransaction .transactionRawWrapper { background-color: #f9fafb; padding: 12px; border: 1px solid #e5e7eb; @@ -86,8 +87,7 @@ width: 100%; } -.SignTransaction .footer { - position: absolute; +.signTransaction .footer { width: 100%; display: flex; justify-content: center; @@ -107,19 +107,22 @@ align-items: center; } -.modal-content { - width: 90%; +.modalTitle { + width: 100%; +} + +.modalContent { + width: 340px; display: flex; flex-direction: column; align-items: center; gap: 40px; background-color: #ffffff; - padding: 20px; border-radius: 8px; text-align: center; } -.modal-buttons { +.modalButtons { display: flex; flex-direction: column; align-items: center; @@ -128,5 +131,5 @@ } .buttonSignTransaction { - width: 50%; + width: 100%; } diff --git a/src/pages/Staking/Staking.js b/src/pages/Staking/Staking.js index 0e6d58e7..955f6a0a 100644 --- a/src/pages/Staking/Staking.js +++ b/src/pages/Staking/Staking.js @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router' import { CurrentStaking } from '@ComposedComponents' import { AccountContext } from '@Contexts' +import { PageWrapper } from '@BasicComponents' import './Staking.css' @@ -17,9 +18,11 @@ const StakingPage = () => { } return ( -
    - -
    + +
    + +
    +
    ) } diff --git a/src/pages/Wallet/Wallet.css b/src/pages/Wallet/Wallet.css index b83c040a..4c588135 100644 --- a/src/pages/Wallet/Wallet.css +++ b/src/pages/Wallet/Wallet.css @@ -2,16 +2,26 @@ display: flex; flex-direction: column; height: 100%; + animation: fadeInWallet 0.3s ease-in-out; +} + +@keyframes fadeInWallet { + from { + opacity: 0; + } + to { + opacity: 1; + } } .transactions-buttons-wrapper { position: relative; display: flex; justify-content: center; - min-width: max-content; align-items: flex-end; max-width: 100%; min-height: max-content; + gap: 6px; } .balance-transactions-wrapper { diff --git a/src/pages/Wallet/Wallet.js b/src/pages/Wallet/Wallet.js index c312246b..f8c4484d 100644 --- a/src/pages/Wallet/Wallet.js +++ b/src/pages/Wallet/Wallet.js @@ -1,18 +1,14 @@ import React, { useNavigate, useParams } from 'react-router' import { useContext, useState } from 'react' -import { Balance, PopUp } from '@ComposedComponents' +import { Balance, PopUp, WalletHeader } from '@ComposedComponents' import { VerticalGroup } from '@LayoutComponents' import { Wallet } from '@ContainerComponents' -import { - useExchangeRates, - useBtcWalletInfo, - useMlWalletInfo, - useMediaQuery, -} from '@Hooks' +import { useExchangeRates, useBtcWalletInfo, useMlWalletInfo } from '@Hooks' import { AccountContext, MintlayerContext, BitcoinContext } from '@Contexts' import { BTC } from '@Helpers' +import { PageWrapper } from '@BasicComponents' import './Wallet.css' import { StakingWarning } from '@ComposedComponents' @@ -23,39 +19,12 @@ const ActionButtons = ({ data }) => { const requredAddress = data.walletType.name === 'Mintlayer' ? mintlayerUnusedAddresses.receive - : bitcoinUnusedAddresses?.receivingAddress?.address || '' + : bitcoinUnusedAddresses?.receivingAddress || '' return (
    {data.walletType.name === 'Mintlayer' && ( - <> - - - - - - + )} - {data.walletType.chain === 'mintlayer' && ( { onClick={data.setOpenMlTransactionForm} /> )} - {data.walletType.name === 'Bitcoin' && ( { title={'Receive'} onClick={() => data.setOpenShowAddress(true)} /> + {data.walletType.name === 'Mintlayer' && ( + <> + + + + + + )} + {data.openShowAddress && ( @@ -94,8 +91,6 @@ const WalletPage = () => { chain: coinType === 'Bitcoin' ? 'bitcoin' : 'mintlayer', } - const isExtendedView = useMediaQuery('(min-width: 801px)') - const datahook = walletType.chain === 'bitcoin' ? useBtcWalletInfo : useMlWalletInfo @@ -166,29 +161,27 @@ const WalletPage = () => { } return ( -
    - +
    -
    + + -
    - - - -
    + + +
    +
    + ) } diff --git a/src/pages/index.js b/src/pages/index.js index d6558789..3f1e3ef2 100644 --- a/src/pages/index.js +++ b/src/pages/index.js @@ -1,14 +1,14 @@ -import CreateAccountPage from './CreateAccount/CreateAccount' +import CreateAccountPage from './CreateAccount/CreateAccount.tsx' import CreateRestorePage from './CreateRestore/CreateRestore' import HomePage from './Home/Home' -import LoginPage from './Login/Login' -import SetAccountPasswordPage from './Login/SetAccountPassword' -import RestoreAccountPage from './RestoreAccount/RestoreAccount' +import LoginPage from './Login/Login.tsx' +import SetAccountPasswordPage from './Login/SetAccountPassword.tsx' +import RestoreAccountPage from './RestoreAccount/RestoreAccount.tsx' import WalletPage from './Wallet/Wallet' import SendBtcTransactionPage from './SendBtcTransaction/SendBtcTransaction' import SendMlTransactionPage from './SendMlTransaction/SendMlTransaction' import DashboardPage from './Dashboard/Dashboard' -import SettingsPage from './Settings/Settings' +import SettingsPage from './Settings/Settings.tsx' import StakingPage from './Staking/Staking' import ConnectionPage from './ConnectionPage/ConnectionPage' import CreateDelegationPage from './CreateDelegation/CreateDelegation' @@ -23,6 +23,7 @@ import SignExternalTransactionPage from './SignExternalTransaction/SignExternalT import SignInternalTransaction from './SignInternalTransaction/SignInternalTransaction' import OrderSwapPage from './OrderSwap/OrderSwap' import SignBitcoinTransactionPage from './SignBitcoinTransaction/SignBitcoinTransaction' +import ConfirmBtcTransactionPage from './ConfirmBtcTransaction/ConfirmBtcTransaction' import AddressPage from './AddressPage/AddressPage' export { @@ -51,5 +52,6 @@ export { SignInternalTransaction, OrderSwapPage, SignBitcoinTransactionPage, + ConfirmBtcTransactionPage, AddressPage, } diff --git a/src/services/API/Electrum/Electrum.js b/src/services/API/Electrum/Electrum.js index af688458..853fd980 100644 --- a/src/services/API/Electrum/Electrum.js +++ b/src/services/API/Electrum/Electrum.js @@ -128,7 +128,7 @@ const checkApiAvailability = async () => { try { await getLastBlockHeight() return true - } catch (error) { + } catch { return false } } diff --git a/src/services/API/ExchangeRates/ExchangeRates.js b/src/services/API/ExchangeRates/ExchangeRates.js index ad4e9f60..1288389b 100644 --- a/src/services/API/ExchangeRates/ExchangeRates.js +++ b/src/services/API/ExchangeRates/ExchangeRates.js @@ -1,9 +1,12 @@ -const EXCHANGE_RATES_SERVER_URL = 'https://rates-api.mintlayer.org' +import { EnvVars } from '@Constants' + +const EXCHANGE_RATES_SERVER_URL = EnvVars.EXCHANGE_RATES_SERVER const EXCHANGE_RATES_SERVER_ENDPOINTS = { GET_RATE: '/getCurrentRate/:crypto/:fiat', GET_OLD_RATE: '/getOneDayAgoRate/:crypto/:fiat', GET_HIST: '/getOneDayAgoHist/:crypto/:fiat', + GET_THIRTY_DAYS_HIST: '/getThirtyDaysHist/:crypto/:fiat', } const requestExchangeRates = async (endpoint, request = fetch) => { @@ -42,4 +45,12 @@ const getOneDayAgoHist = (crypto, fiat) => ), ) -export { getRate, getOneDayAgoRate, getOneDayAgoHist } +const getThirtyDaysHist = (crypto, fiat) => + requestExchangeRates( + EXCHANGE_RATES_SERVER_ENDPOINTS.GET_THIRTY_DAYS_HIST.replace( + ':crypto', + crypto, + ).replace(':fiat', fiat), + ) + +export { getRate, getOneDayAgoRate, getOneDayAgoHist, getThirtyDaysHist } diff --git a/src/services/Crypto/BTC/BTC.worker.js b/src/services/Crypto/BTC/BTC.worker.js index b36aa403..c7e7e662 100644 --- a/src/services/Crypto/BTC/BTC.worker.js +++ b/src/services/Crypto/BTC/BTC.worker.js @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-globals */ import { generateMnemonic, getSeedFromMnemonic } from './BTC' const WalletWorkerEnum = { diff --git a/src/services/Crypto/Cipher/Cipher.worker.js b/src/services/Crypto/Cipher/Cipher.worker.js index 9f7bf63f..d5f2b6bf 100644 --- a/src/services/Crypto/Cipher/Cipher.worker.js +++ b/src/services/Crypto/Cipher/Cipher.worker.js @@ -1,4 +1,3 @@ -/* eslint-disable no-restricted-globals */ import { generatePBKDF2Key, encryptAES, decryptAES } from './Cipher' const CipherWorkerEnum = { diff --git a/src/services/Crypto/Mintlayer/Mintlayer.js b/src/services/Crypto/Mintlayer/Mintlayer.js index d97dcecb..39119420 100644 --- a/src/services/Crypto/Mintlayer/Mintlayer.js +++ b/src/services/Crypto/Mintlayer/Mintlayer.js @@ -103,7 +103,7 @@ export const getWalletPrivKeysList = (mlPrivateKey, network, offset = 21) => { } } -const checkIfAddressesUsed = async (addresses, network) => { +const checkIfAddressesUsed = async (addresses) => { const data = await batchRequestMintlayer({ ids: addresses, type: '/address/:address', @@ -272,7 +272,6 @@ export const getEncodedWitness = ( inputs, index, networkType, - // eslint-disable-next-line max-params ) => { const networkIndex = NETWORKS[networkType] return encode_witness( diff --git a/src/services/Database/IndexedDB/IndexedDB.js b/src/services/Database/IndexedDB/IndexedDB.js index 55104ab1..df7311a3 100644 --- a/src/services/Database/IndexedDB/IndexedDB.js +++ b/src/services/Database/IndexedDB/IndexedDB.js @@ -2,7 +2,7 @@ import { accountsMigration_01_add_mlwallet_private_keys, accountsMigration_02_add_htls_secrets_field, } from '../migrations/migrations' -// eslint-disable-next-line no-restricted-globals + const glob = typeof window !== 'undefined' ? window : self /* istanbul ignore next */ const IDB = @@ -43,7 +43,7 @@ const openDatabase = (DB = IDB) => { } const createTransaction = async (openedDb, onError) => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { const transaction = openedDb.transaction([ACCOUNTSSTORENAME], 'readwrite') transaction.onerror = (event) => @@ -87,7 +87,7 @@ const clearDatabase = async (onError, DB = IDB) => { const store = transaction.objectStore(ACCOUNTSSTORENAME) const request = store.clear() - request.onsuccess = function (event) { + request.onsuccess = function () { console.log('All records have been removed from the store.') } request.onerror = function (event) { @@ -140,7 +140,7 @@ const deleteAccount = async (accountId, onError, DB = IDB) => { const store = transaction.objectStore(ACCOUNTSSTORENAME) const request = store.delete(accountId) - request.onsuccess = function (event) { + request.onsuccess = function () { console.log('Account has been removed from the store.') } request.onerror = function (event) { diff --git a/src/types/modules.d.ts b/src/types/modules.d.ts new file mode 100644 index 00000000..dcdb1e5f --- /dev/null +++ b/src/types/modules.d.ts @@ -0,0 +1,7 @@ +declare module '*.png' { + const src: string + export default src +} + +declare const browser: any +declare const chrome: any diff --git a/src/types/svg.d.ts b/src/types/svg.d.ts new file mode 100644 index 00000000..5584ba01 --- /dev/null +++ b/src/types/svg.d.ts @@ -0,0 +1,6 @@ +declare module '*.svg' { + import React from 'react' + export const ReactComponent: React.FC> + const src: string + export default src +} diff --git a/src/utils/Constants/AppInfo/AppInfo.js b/src/utils/Constants/AppInfo/AppInfo.js index 2d4678d8..4f67822d 100644 --- a/src/utils/Constants/AppInfo/AppInfo.js +++ b/src/utils/Constants/AppInfo/AppInfo.js @@ -26,6 +26,9 @@ const ML_EXPLORER_MAINNET = 'https://explorer.mintlayer.org/' const ML_EXPLORER_TESTNET = 'https://lovelace.explorer.mintlayer.org/' const BTC_EXPLORER_MAINNET = 'https://blockstream.info/' const BTC_EXPLORER_TESTNET = 'https://explorer.gomaestro.org/bitcoin/testnet/' +const PRIVACY_POLICY_URL = + 'https://www.mintlayer.org/tc/mojito-browser-extension-privacy-policy/' +const CONTACT_US_URL = 'mailto:support@mintlayer.org' const BTC_DEFAULT_ADDRESSES_BATCH = 3 const BTC_MAX_TRANSACTION_FEE = 100000 // 0.001 BTC const BTC_MAX_FEERATE = 200 @@ -127,7 +130,7 @@ const WALLETS_NAVIGATION = [ const WALLET_NAME_ERROR = 'The wallet name should have at least 4 characters.' const WALLET_PASSWORD_ERROR = [ 'Your password should have at least 8 characters.', - 'Also it should have a lowercase letter, an uppercase letter, a digit, and a special char like: /\\*()&^%$#@-_=+\'"?!:;<>~`', + 'Also it should have a lowercase letter, an uppercase letter, a digit, and a special character.', ] const MAX_ML_FEE = 500000000000 // 5 ML in atoms @@ -166,4 +169,6 @@ export { COLOR_LIST, WALLET_NAME_ERROR, WALLET_PASSWORD_ERROR, + PRIVACY_POLICY_URL, + CONTACT_US_URL, } diff --git a/src/utils/Constants/EnvironmentVars/EnvironmentVars.js b/src/utils/Constants/EnvironmentVars/EnvironmentVars.js index 99d30350..87a70885 100644 --- a/src/utils/Constants/EnvironmentVars/EnvironmentVars.js +++ b/src/utils/Constants/EnvironmentVars/EnvironmentVars.js @@ -7,6 +7,7 @@ const MAINNET_MINTLAYER_SERVERS = process.env.MAINNET_MINTLAYER_SERVERS.split(',') const TESTNET_MINTLAYER_SERVERS = process.env.TESTNET_MINTLAYER_SERVERS.split(',') +const EXCHANGE_RATES_SERVER = process.env.EXCHANGE_RATES_SERVER export { BTC_NETWORK, @@ -16,4 +17,5 @@ export { TESTNET_ELECTRUM_SERVERS, MAINNET_MINTLAYER_SERVERS, TESTNET_MINTLAYER_SERVERS, + EXCHANGE_RATES_SERVER, } diff --git a/src/utils/Helpers/BTC/BTC.js b/src/utils/Helpers/BTC/BTC.js index 1e7d0fc8..fd2617d0 100644 --- a/src/utils/Helpers/BTC/BTC.js +++ b/src/utils/Helpers/BTC/BTC.js @@ -5,8 +5,8 @@ import { LocalStorageService } from '@Storage' import Decimal from 'decimal.js' const AVERAGE_MIN_PER_BLOCK = 15 -const SATOSHI_BTC_CONVERSION_FACTOR = 100_000_000 -const MAX_BTC = 21_000_000 +const SATOSHI_BTC_CONVERSION_FACTOR = 100000000 +const MAX_BTC = 21000000 const MAX_BTC_IN_SATOSHIS = MAX_BTC * SATOSHI_BTC_CONVERSION_FACTOR const blockLevels = { @@ -211,9 +211,11 @@ const calculateBalances = (cryptos, yesterdayExchangeRates) => { const getStats = (proportionDiffs, balanceDiffs, networkType) => { const isTestnet = networkType === AppInfo.NETWORK_TYPES.TESTNET - const percentValue = isTestnet - ? 0 - : new Decimal(proportionDiffs.total || 0).minus(1).times(100).toFixed(2) + const hasBalance = proportionDiffs.total !== 0 + const percentValue = + isTestnet || !hasBalance + ? 0 + : new Decimal(proportionDiffs.total || 0).minus(1).times(100).toFixed(2) const fiatValue = isTestnet ? 0 : new Decimal(balanceDiffs.total || 0).toFixed(2) diff --git a/src/utils/Helpers/ML/MLTransaction.js b/src/utils/Helpers/ML/MLTransaction.js index 8149a2d0..c60cb4b8 100644 --- a/src/utils/Helpers/ML/MLTransaction.js +++ b/src/utils/Helpers/ML/MLTransaction.js @@ -155,7 +155,6 @@ const getEncodedWitnesses = ( transaction, opt_utxos, network, - // eslint-disable-next-line max-params ) => { const data = utxos.flat() const encodedWitnesses = data.map((utxo, index) => { @@ -741,10 +740,8 @@ const createNft = async ({ changeAddress, network, transactionMode, - adjustedFee, chainTip, }) => { - // const fee = adjustedFee const fee = 600400000000 const amountCoinFee = BigInt(fee) const amountToUseFinaleCoin = amountCoinFee diff --git a/src/utils/Helpers/ML/SignTransaction.js b/src/utils/Helpers/ML/SignTransaction.js index 70279861..27ef58a8 100644 --- a/src/utils/Helpers/ML/SignTransaction.js +++ b/src/utils/Helpers/ML/SignTransaction.js @@ -387,7 +387,7 @@ export function getTransactionHEX( transactionJSONrepresentation, addressesPrivateKeys, secret = null, - htlc = {}, + // htlc = {}, }, _network, blockHeight, diff --git a/src/utils/Helpers/Number/Number.js b/src/utils/Helpers/Number/Number.js index 2317456f..5452bdeb 100644 --- a/src/utils/Helpers/Number/Number.js +++ b/src/utils/Helpers/Number/Number.js @@ -1,6 +1,5 @@ import { AppInfo } from '@Constants' import { getNumber } from './Format' - const INTEGER_LENGHT_THRESHOLD = 2 const SAFE_INTEGER_LENGTH = Number.MAX_SAFE_INTEGER.toString().length - INTEGER_LENGHT_THRESHOLD @@ -16,8 +15,13 @@ const floatStringToNumber = (value = '') => { return parseFloat(parsedValue) } -const getDecimalNumber = (value) => - (Math.trunc(getNumber(value) * 100) / 100).toFixed(2) +const getDecimalNumber = (value) => { + const num = getNumber(value) + if (num >= 0.01) return num.toFixed(2) + if (num === 0) return '0.00' + const significantDigits = 2 + return num.toPrecision(significantDigits) +} const isInteger = (number) => number === ~~number diff --git a/tests/01-create-account.spec.js b/tests/01-create-account.spec.js index 94ad1957..7a37a7e5 100644 --- a/tests/01-create-account.spec.js +++ b/tests/01-create-account.spec.js @@ -6,12 +6,14 @@ test('Create account', async ({ page }) => { test.setTimeout(190000) await page.goto('http://127.0.0.1:8000') - await expect(page.locator('h1')).toHaveText('Mojito') - await expect(page.locator('h2')).toHaveText( - 'Your Mintlayer, right in your browser.', + const createRestore = page.getByTestId('create-restore') + await expect(createRestore).toBeVisible() + await expect(createRestore.locator('h1')).toHaveText('Mojito') + await expect(createRestore.locator('h2')).toHaveText( + 'A fresh way to hold Mintlayer assets', ) - await page.getByRole('button', { name: 'Create' }).click() + await page.getByRole('button', { name: 'Create a new wallet' }).click() await expect(page.locator('label')).toHaveText( 'Create a name for your wallet', @@ -64,10 +66,8 @@ test('Create account', async ({ page }) => { await page.getByRole('button', { name: 'Create Wallet' }).click() - await page.waitForSelector(`:text("${WALLET_NAME}")`) - await expect(page.locator(`:text("${WALLET_NAME}")`)).toBeVisible() + await expect(page.getByText(WALLET_NAME).first()).toBeVisible() - await page.waitForSelector(':text("Mintlayer (ML)")') - await expect(page.locator(':text("Bitcoin (BTC)")')).toBeVisible() - await expect(page.locator(':text("Mintlayer (ML)")')).toBeVisible() + await expect(page.getByText('Bitcoin (BTC)')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Mintlayer (ML)')).toBeVisible() }) diff --git a/tests/02-restore-account.spec.js b/tests/02-restore-account.spec.js index 186c99d4..8da88926 100644 --- a/tests/02-restore-account.spec.js +++ b/tests/02-restore-account.spec.js @@ -8,12 +8,12 @@ const restoreAccountTest = async ({ page }) => { await expect(page.locator('h1')).toHaveText('Mojito') await expect(page.locator('h2')).toHaveText( - 'Your Mintlayer, right in your browser.', + 'A fresh way to hold Mintlayer assets', ) - await page.getByRole('button', { name: 'Restore' }).click() + await page.getByText('Import existing wallet').click() - await page.getByRole('button', { name: 'Seed Phrase' }).click() + await page.getByText('Seed Phrase').click() await expect(page.locator('label')).toHaveText( 'Create a name for your wallet', @@ -50,12 +50,10 @@ const restoreAccountTest = async ({ page }) => { await page.getByRole('button', { name: 'Continue' }).click() - await page.waitForSelector(`:text("${WALLET_NAME}")`) - await expect(page.locator(`:text("${WALLET_NAME}")`)).toBeVisible() + await expect(page.getByText(WALLET_NAME).first()).toBeVisible() - await page.waitForSelector(':text("Mintlayer (ML)")') - await expect(page.locator(':text("Bitcoin (BTC)")')).toBeVisible() - await expect(page.locator(':text("Mintlayer (ML)")')).toBeVisible() + await expect(page.getByText('Bitcoin (BTC)')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Mintlayer (ML)')).toBeVisible() } test('Restore account', restoreAccountTest) diff --git a/tests/03-log-in-out.spec.js b/tests/03-log-in-out.spec.js index 5edb2e3f..4fa6b878 100644 --- a/tests/03-log-in-out.spec.js +++ b/tests/03-log-in-out.spec.js @@ -12,21 +12,17 @@ beforeEach(async ({ page: newPage }) => { test('Log in and Log out', async () => { test.setTimeout(190000) - await page.click('button.header-menu-button') - const logoutElement = page.getByText('Logout', { selector: 'li' }) - await logoutElement.click() - await expect(page.locator(':text("Available wallet")')).toBeVisible() + await page.getByTestId('navigation-logout').click() + await expect(page.getByText('Choose an account')).toBeVisible() - const account = page.getByText('SenderWallet', { selector: 'div' }) - await account.click() + await page.getByText(senderData.WALLET_NAME).click() - await expect(page.locator(`:text("Password for")`)).toBeVisible() - await expect(page.locator(`:text("${senderData.WALLET_NAME}")`)).toBeVisible() + await expect(page.getByText('Welcome back')).toBeVisible() + await expect(page.getByText(senderData.WALLET_NAME).first()).toBeVisible() await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) - await page.getByRole('button', { name: 'Log In' }).click() + await page.getByTestId('login-password-submit').click() - await page.waitForSelector(':text("Mintlayer (ML)")') - await expect(page.locator(':text("Bitcoin (BTC)")')).toBeVisible() - await expect(page.locator(':text("Mintlayer (ML)")')).toBeVisible() + await expect(page.getByText('Bitcoin (BTC)')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Mintlayer (ML)')).toBeVisible() }) diff --git a/tests/05-create-btc-transaction.spec.js b/tests/05-create-btc-transaction.spec.js index cb46634b..c195608c 100644 --- a/tests/05-create-btc-transaction.spec.js +++ b/tests/05-create-btc-transaction.spec.js @@ -14,53 +14,43 @@ beforeEach(async ({ page: newPage }) => { test('Create BTC transaction', async () => { test.setTimeout(300000) await page.waitForTimeout(10000) - await page.click('button.btn.update-button') + await page.click('button.update-button') await page.waitForTimeout(10000) - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Bitcoin (Testnet)")', - ) + await page.getByText('Bitcoin (Testnet)').click() await page.click('button.button-transaction-up') - await expect(page.locator(':text("Send to:")')).toBeVisible() + await expect(page.getByText('Recipient address')).toBeVisible() - await page.fill( - 'input[placeholder="tb1... or 1... or 3..."]', - receiverData.BTC_RECEIVING_ADDRESS, - ) + await page.locator('input#address').fill(receiverData.BTC_RECEIVING_ADDRESS) await page.fill('input[placeholder="0"]', '0.00000001') - await page.getByRole('button', { name: 'high' }).click() - await page.getByRole('button', { name: 'Send' }).click() - + await page.getByRole('button', { name: 'Fast' }).click() await page.waitForTimeout(2000) + await page.getByRole('button', { name: 'Send' }).click({ timeout: 30000 }) - await expect(page.getByTestId('popup').getByText('Send to:')).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText(`${receiverData.BTC_RECEIVING_ADDRESS}`), - ).toBeVisible() - - await expect( - page.getByTestId('popup').getByText('1e-8BTC(0,00USD)'), - ).toBeVisible() - await expect(page.getByTestId('popup').getByText('Total fee:')).toBeVisible() + await expect(page.getByText('Confirm Transaction')).toBeVisible() + await expect(page.getByText(receiverData.BTC_RECEIVING_ADDRESS)).toBeVisible() + await expect(page.getByText('Network fee')).toBeVisible() await page.getByRole('button', { name: 'Confirm' }).click() - await expect( - page.getByTestId('popup').getByText('Enter your password'), - ).toBeVisible() - await page.fill('input[placeholder="Password"]', receiverData.WALLET_PASSWORD) + await expect(page.getByText('Enter your password')).toBeVisible() + await page.fill( + 'input[placeholder="Enter your password"]', + receiverData.WALLET_PASSWORD, + ) - await page.getByRole('button', { name: 'Send Transaction' }).click() await page.route('*/**/tx', async (route) => { - const json = - 'a6a3d270fa33eb7fca6f6d2f56c0c3c431f9cad51b2a7881208b5e8f4ec12dcf' - await route.fulfill({ json }) + await route.fulfill({ + body: JSON.stringify({ + txid: 'a6a3d270fa33eb7fca6f6d2f56c0c3c431f9cad51b2a7881208b5e8f4ec12dcf', + }), + contentType: 'application/json', + }) }) - await page.waitForSelector(':text("Your transaction was sent.")') + await page.getByRole('button', { name: 'Submit' }).click() - const resultTitleText = await page.textContent('h3.result-title') - const txid = resultTitleText.split(': ')[1] + await expect(page.getByText('Your transaction was sent.')).toBeVisible({ + timeout: 30000, + }) }) diff --git a/tests/06-create-ml-transaction.spec.js b/tests/06-create-ml-transaction.spec.js index 59b09913..03870d06 100644 --- a/tests/06-create-ml-transaction.spec.js +++ b/tests/06-create-ml-transaction.spec.js @@ -2,7 +2,6 @@ import { expect, test, beforeEach } from '@playwright/test' import { useRestoreWallet } from './helpers//hooks/useRestore' import { useSetTestnet } from './helpers/hooks/useSetTestnet' import { receiverData } from './data/index.js' -import { formatAddress } from './helpers/helpers.js' let page @@ -13,16 +12,12 @@ beforeEach(async ({ page: newPage }) => { await useSetTestnet(page) }) -const formatedReceiverAddress = formatAddress(receiverData.ML_RECEIVING_ADDRESS) - test('Create ML transaction', async () => { await page.waitForTimeout(10000) - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) + await page.getByText('Mintlayer (Testnet)').click() await page.click('button.button-transaction-up') - await expect(page.locator(':text("Send to:")')).toBeVisible() + await expect(page.getByText('Recipient address')).toBeVisible() await page.fill( 'input[placeholder="tmt1..."]', @@ -38,6 +33,23 @@ test('Create ML transaction', async () => { page.getByRole('button', { name: 'Switch to json' }), ).toBeVisible() await expect(page.getByRole('button', { name: 'Decline' })).toBeVisible() + + await page.route('**/transaction', async (route) => { + if (route.request().method() === 'POST') { + const json = { + tx_id: + '2d2f9f3173eeeda73fd8705d41488ba2337e83fcd808bc732458a3752846ebb5', + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(json), + }) + } else { + route.continue() + } + }) + await page.getByRole('button', { name: 'Approve and return to page' }).click() await expect(page.getByText('Re-enter your Password')).toBeVisible() @@ -48,27 +60,7 @@ test('Create ML transaction', async () => { await page.getByRole('button', { name: 'Submit' }).click() - await page.route( - 'https://api-server-lovelace.mintlayer.org/api/v2/transaction', - async (route) => { - if (route.request().method() === 'POST') { - const json = { - tx_id: - '2d2f9f3173eeeda73fd8705d41488ba2337e83fcd808bc732458a3752846ebb5', - } - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(json), - }) - } else { - route.continue() - } - }, - ) - - await page.waitForSelector(':text("Your transaction was sent.")') - - const resultTitleText = await page.textContent('h3.result-title') - const txid = resultTitleText.split(': ')[1] + await expect(page.getByText('Your transaction was sent.')).toBeVisible({ + timeout: 30000, + }) }) diff --git a/tests/06-swap-ml-tokens.spec.js b/tests/06-swap-ml-tokens.spec.js deleted file mode 100644 index 4d0a5311..00000000 --- a/tests/06-swap-ml-tokens.spec.js +++ /dev/null @@ -1,358 +0,0 @@ -import { expect, test, beforeEach } from '@playwright/test' -import { useRestoreWallet } from './helpers//hooks/useRestore' -import { useSetTestnet } from './helpers/hooks/useSetTestnet' -import { senderData } from './data/index.js' - -let page - -beforeEach(async ({ page: newPage }) => { - test.setTimeout(300000) - page = newPage - await useRestoreWallet(page, 'sender') - await useSetTestnet(page) -}) - -const SEARCH_REQUEST_URL = - 'https://api-server-lovelace.mintlayer.org/api/v2/order/pair/tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m_TML' -const SEARCH_REQUEST_RESPONSE = [ - { - ask_balance: { - atoms: '1900000000000', - decimal: '19', - }, - ask_currency: { - type: 'Coin', - }, - conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', - give_balance: { - atoms: '3800000000000', - decimal: '38', - }, - give_currency: { - token_id: - 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', - type: 'Token', - }, - initially_asked: { - atoms: '5000000000000', - decimal: '50', - }, - initially_given: { - atoms: '10000000000000', - decimal: '100', - }, - nonce: 5, - order_id: - 'tordr1q3v0xjc2x0qexcwp953qyju223h3ej4hnmza7vxnzm6zz27djlmq69m7mf', - }, - { - ask_balance: { - atoms: '8878788000000', - decimal: '88.78788', - }, - ask_currency: { - token_id: - 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', - type: 'Token', - }, - conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', - give_balance: { - atoms: '17757576000000', - decimal: '177.57576', - }, - give_currency: { - type: 'Coin', - }, - initially_asked: { - atoms: '10000000000000', - decimal: '100', - }, - initially_given: { - atoms: '20000000000000', - decimal: '200', - }, - nonce: 4, - order_id: - 'tordr1ckcck85mwhc2yz3qahdse7tpyywt49gv9flekyaghkhd59t7gftq57gs7m', - }, - { - ask_balance: { - atoms: '9900000000000', - decimal: '99', - }, - ask_currency: { - type: 'Coin', - }, - conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', - give_balance: { - atoms: '9900000000000', - decimal: '99', - }, - give_currency: { - token_id: - 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', - type: 'Token', - }, - initially_asked: { - atoms: '10000000000000', - decimal: '100', - }, - initially_given: { - atoms: '10000000000000', - decimal: '100', - }, - nonce: 1, - order_id: - 'tordr1jujter3n8fd6wpfenvxgn33kq38nklrf5dpg8xyle4hegulwaeesnc7hla', - }, -] - -const POST_TRANSACTION_RESPONSE = { - success: true, - tx_id: '8317215e06e4f36e63901789ede0825467745ee01010a1f8caeb938f9a478432', - status: 'accepted', - timestamp: 1753314844, -} - -test('Swap ML tokens', async () => { - await page.route(SEARCH_REQUEST_URL, async (route) => { - if (route.request().method() === 'GET') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(SEARCH_REQUEST_RESPONSE), - }) - } else { - route.continue() - } - }) - - await page.route( - 'https://api-server-lovelace.mintlayer.org/api/v2/transaction', - async (route) => { - if (route.request().method() === 'POST') { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify(POST_TRANSACTION_RESPONSE), - }) - } else { - route.continue() - } - }, - ) - - await page.waitForTimeout(3000) - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) - - await page.click('button.button-transaction-swap') - await expect(page.locator(':text("Swap From")')).toBeVisible() - await expect(page.locator(':text("Swap Assets")')).toBeVisible() - await expect(page.locator('.swap-token-select')).toHaveCount(2) - await expect(page.locator('input.swap-amount-input')).toBeVisible() - await expect(page.locator('input.swap-amount-input')).toHaveValue('') - await expect(page.locator('input.swap-amount-input')).toHaveAttribute( - 'placeholder', - '0', - ) - - await expect(page.locator('.swap-arrow-button')).toBeVisible() - - await expect(page.locator(':text("Swap To")')).toBeVisible() - await expect(page.locator('.find-order-button')).toBeVisible() - await expect(page.locator('.find-order-button')).toHaveText('Find orders') - await expect(page.locator('.find-order-button')).toBeDisabled() - await expect(page.locator('.empty-list')).toBeVisible() - await expect(page.locator('.empty-list')).toHaveText('No orders found') - - await page.locator('.swap-token-select').first().click() - await expect(page.locator('.token-popup-swap')).toBeVisible() - await expect(page.locator('.token-popup-swap h2')).toHaveText('Swap from') - await expect(page.locator('input.swap-token-search-input')).toBeVisible() - await expect(page.locator('input.swap-token-search-input')).toHaveValue('') - await expect(page.locator('input.swap-token-search-input')).toHaveAttribute( - 'placeholder', - 'Search by symbol or token id', - ) - // suppouse to have 3 items - await expect(page.locator('.token-popup-swap li')).toHaveCount(3) - await expect(page.locator('.token-popup-swap li')).toHaveText([ - 'ML Coins', - 'LLAZY (tmltk1006rkw...5npxyqpfwpy3)', - 'SSwissDogs (tmltk1nzscrd...c9x9wsgwyr3m)', - ]) - - await expect( - page.locator('.token-popup-swap li .swap-token-logo'), - ).toHaveCount(3) - await page - .locator( - '.token-popup-swap li:has-text("SwissDogs (tmltk1nzscrd...c9x9wsgwyr3m)")', - ) - .click() - await expect(page.locator('.swap-token-select').first()).toHaveText( - 'SSwissDogs (tmltk1nz...wsgwyr3m)', - ) - await expect(page.locator('.from-token-balance')).toHaveText('Balance: 13') - - await page.locator('.swap-token-select').last().click() - await expect(page.locator('.token-popup-swap')).toBeVisible() - await expect(page.locator('.token-popup-swap h2')).toHaveText('Swap to') - await expect(page.locator('input.swap-token-search-input')).toBeVisible() - await expect(page.locator('input.swap-token-search-input')).toHaveValue('') - await expect(page.locator('input.swap-token-search-input')).toHaveAttribute( - 'placeholder', - 'Search by symbol or token id', - ) - const itemCount = await page.locator('.token-popup-swap li').count() - expect(itemCount).toBeGreaterThan(2) - - await expect( - page.locator('.token-popup-swap li:has-text("ML Coins")'), - ).toBeVisible() - const itemLogoCount = await page - .locator('.token-popup-swap li .swap-token-logo') - .count() - expect(itemLogoCount).toBeGreaterThan(2) - await page.locator('.token-popup-swap li:has-text("ML Coins")').click() - await expect(page.locator('.swap-token-select').last()).toHaveText( - 'ML (Mintlayer)', - ) - - await page.locator('input.swap-amount-input').fill('1') - await expect(page.locator('input.swap-amount-input')).toHaveValue('1') - await expect(page.locator('.find-order-button')).not.toBeDisabled() - await page.click('.find-order-button') - await expect(page.locator('.empty-list')).toBeHidden() - await expect(page.locator('.order-list')).toBeVisible() - - expect(page.locator('.order-list li.transaction')).toHaveCount(1) - await expect(page.getByText('tordr1ckcck8...t7gftq57gs7m')).toBeVisible() - await expect( - page.locator('.order-list li.transaction').getByText('88.78788'), - ).toBeVisible() - await expect( - page.locator('.order-list li.transaction').getByText('SwissDogs'), - ).toBeVisible() - await expect( - page.locator('.order-list li.transaction').getByText('177.57576'), - ).toBeVisible() - await expect( - page.locator('.order-list li.transaction').getByText('88.78788'), - ).toBeVisible() - await expect( - page.locator('.order-list li.transaction').getByText('TML'), - ).toBeVisible() - - await page.click('.order-list li.transaction') - await expect(page.locator('.order-details')).toBeVisible() - await expect( - page.locator('[data-testid="order-details-item-title"]'), - ).toHaveText('Order id:') - await expect( - page.locator('[data-testid="order-details-item-content"]').first(), - ).toHaveText('tordr1ckcck85mwhc2...hkhd59t7gftq57gs7m') - await expect(page.locator('.order-details .copy-btn')).toBeVisible() - await expect( - page.locator('.token-info-content-amount').getByText('88.78788 SwissDogs'), - ).toBeVisible() - await expect( - page - .locator('.order-details') - .getByText( - '(tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m)', - ), - ).toBeVisible() - await expect( - page.locator('.token-info-content-amount').getByText('177.57576 ML'), - ).toBeVisible() - await expect( - page.locator('.order-details').getByText('(Mintlayer Coin)'), - ).toBeVisible() - - await expect(page.locator('.order-details-exchange-rate')).toBeVisible() - await expect( - page.locator('span:has-text("1 SwissDogs ≈ 2.0000000000 TML")'), - ).toBeVisible() - await expect(page.locator('.order-details-input')).toBeVisible() - await expect(page.locator('.order-details-input')).toHaveAttribute( - 'placeholder', - 'SwissDogs amount', - ) - await expect(page.locator('.order-details-input')).toHaveValue('') - await expect(page.locator('.order-details-button')).toBeVisible() - await expect(page.locator('.order-details-button')).toHaveText('Swap') - - await page.locator('.order-details-input').fill('1') - await expect(page.locator('.order-details-input')).toHaveValue('1') - - await page.click('.order-details-button') - await page.waitForTimeout(5000) - await expect(page.locator('.order-details')).toBeHidden() - - await expect(page.locator('.SignTransaction')).toBeVisible() - await expect(page.locator('.signTxTitle')).toHaveText('Sign Transaction') - await expect(page.locator('.preview-section-header h3')).toHaveText( - 'Transaction Preview', - ) - await expect( - page.locator('.SignTransaction').getByText('Estimated changes:'), - ).toBeVisible() - await expect( - page.locator('.SignTransaction').getByText('Fill order'), - ).toBeVisible() - await expect( - page.locator('.SignTransaction').getByText('Order id:'), - ).toBeVisible() - await expect( - page - .locator('.SignTransaction') - .getByText( - 'tordr1ckcck85mwhc2yz3qahdse7tpyywt49gv9flekyaghkhd59t7gftq57gs7m', - ), - ).toBeVisible() - await expect( - page.locator('.SignTransaction').getByText('Network fee:'), - ).toBeVisible() - - await expect(page.locator('.SignTransaction .footer')).toBeVisible() - await expect( - page.locator('.SignTransaction .footer').getByText('Decline'), - ).toBeVisible() - await expect( - page - .locator('.SignTransaction .footer') - .getByText('Approve and return to page'), - ).toBeVisible() - await page.click( - '.SignTransaction .footer button:has-text("Approve and return to page")', - ) - await expect(page.locator('.modal-content')).toBeVisible() - await expect( - page.locator('.modal-content').getByText('Re-enter your Password'), - ).toBeVisible() - await expect( - page.locator('.modal-content [data-testid="input"][type="password"]'), - ).toBeVisible() - await expect( - page.locator('.modal-content [data-testid="input"][type="password"]'), - ).toHaveValue('') - await page - .locator('.modal-content [data-testid="input"][type="password"]') - .fill(senderData.WALLET_PASSWORD) - await expect( - page.locator('.modal-content [data-testid="input"][type="password"]'), - ).toHaveValue(senderData.WALLET_PASSWORD) - await page.click('.modal-buttons button:has-text("Submit")') - await page.waitForTimeout(2000) - await expect( - page.locator('h2').getByText('Your transaction was sent'), - ).toBeVisible() - await expect( - page.getByText( - '8317215e06e4f36e63901789ede0825467745ee01010a1f8caeb938f9a478432', - ), - ).toBeVisible() -}) diff --git a/tests/07-create-ml-delegation.spec.js b/tests/07-create-ml-delegation.spec.js deleted file mode 100644 index 02ca7546..00000000 --- a/tests/07-create-ml-delegation.spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { expect, test, beforeEach } from '@playwright/test' -import { useRestoreWallet } from './helpers//hooks/useRestore' -import { useSetTestnet } from './helpers/hooks/useSetTestnet' -import { receiverData, senderData } from './data/index.js' -import { formatAddress } from './helpers/helpers.js' - -let page - -beforeEach(async ({ page: newPage }) => { - test.setTimeout(190000) - page = newPage - await useRestoreWallet(page, 'sender') - await useSetTestnet(page) -}) - -const formatedReceiverAddress = formatAddress(receiverData.ML_RECEIVING_ADDRESS) -const formatedPoolId = formatAddress(senderData.POOL_ID) - -test('Create ML delegation', async () => { - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) - - await page.click('button.button-transaction-staking') - - await page.waitForSelector(`:text("${formatedPoolId}")`) - await expect(page.locator(`:text("${formatedPoolId}")`).nth(0)).toBeVisible() - - await page.getByRole('button', { name: 'Create new delegation' }).click() - - await page.waitForTimeout(1000) - await expect(page.locator(':text("Pool id:")')).toBeVisible() - - await page.fill('input[placeholder="tpool1..."]', senderData.POOL_ID) - await page.getByRole('button', { name: 'Create' }).click() - - await page.waitForTimeout(1000) - - await expect(page.getByTestId('popup').getByText('Send to:')).toBeVisible() - await expect( - page.getByTestId('popup').getByText(`${senderData.POOL_ID}`), - ).toBeVisible() - - await expect( - page.getByTestId('popup').getByText('0.00ML(0,00USD)'), - ).toBeVisible() - await expect(page.getByTestId('popup').getByText('Total fee:')).toBeVisible() - - await page.getByRole('button', { name: 'Confirm' }).click() - - await expect( - page.getByTestId('popup').getByText('Enter your password'), - ).toBeVisible() - await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) - - await page.getByRole('button', { name: 'Send Transaction' }).click() - - await page.route( - 'https://api-server-lovelace.mintlayer.org/api/v2/transaction', - (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - tx_id: - 'ba6a6be12a1226f0038365ff2554dfb9f5aa2cb468a523ee4142fd1f1f6d3254', - }), - }), - ) - - await page.waitForTimeout(2000) - - await page.waitForSelector(':text("Your transaction was sent.")') -}) diff --git a/tests/07-swap-ml-tokens.spec.js b/tests/07-swap-ml-tokens.spec.js new file mode 100644 index 00000000..be5a1359 --- /dev/null +++ b/tests/07-swap-ml-tokens.spec.js @@ -0,0 +1,296 @@ +import { expect, test, beforeEach } from '@playwright/test' +import { useRestoreWallet } from './helpers//hooks/useRestore' +import { useSetTestnet } from './helpers/hooks/useSetTestnet' +import { senderData } from './data/index.js' + +let page + +beforeEach(async ({ page: newPage }) => { + test.setTimeout(300000) + page = newPage + await useRestoreWallet(page, 'sender') + await useSetTestnet(page) +}) + +const SEARCH_REQUEST_URL = + '**/order/pair/tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m_TML' +const SEARCH_REQUEST_RESPONSE = [ + { + ask_balance: { + atoms: '1900000000000', + decimal: '19', + }, + ask_currency: { + type: 'Coin', + }, + conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', + give_balance: { + atoms: '3800000000000', + decimal: '38', + }, + give_currency: { + token_id: + 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', + type: 'Token', + }, + initially_asked: { + atoms: '5000000000000', + decimal: '50', + }, + initially_given: { + atoms: '10000000000000', + decimal: '100', + }, + nonce: 5, + order_id: + 'tordr1q3v0xjc2x0qexcwp953qyju223h3ej4hnmza7vxnzm6zz27djlmq69m7mf', + }, + { + ask_balance: { + atoms: '8878788000000', + decimal: '88.78788', + }, + ask_currency: { + token_id: + 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', + type: 'Token', + }, + conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', + give_balance: { + atoms: '17757576000000', + decimal: '177.57576', + }, + give_currency: { + type: 'Coin', + }, + initially_asked: { + atoms: '10000000000000', + decimal: '100', + }, + initially_given: { + atoms: '20000000000000', + decimal: '200', + }, + nonce: 4, + order_id: + 'tordr1ckcck85mwhc2yz3qahdse7tpyywt49gv9flekyaghkhd59t7gftq57gs7m', + }, + { + ask_balance: { + atoms: '9900000000000', + decimal: '99', + }, + ask_currency: { + type: 'Coin', + }, + conclude_destination: 'tmt1q8apcsvnm648wnvhhz36cehu6lmrqkcwr5qqmju9', + give_balance: { + atoms: '9900000000000', + decimal: '99', + }, + give_currency: { + token_id: + 'tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m', + type: 'Token', + }, + initially_asked: { + atoms: '10000000000000', + decimal: '100', + }, + initially_given: { + atoms: '10000000000000', + decimal: '100', + }, + nonce: 1, + order_id: + 'tordr1jujter3n8fd6wpfenvxgn33kq38nklrf5dpg8xyle4hegulwaeesnc7hla', + }, +] + +const POST_TRANSACTION_RESPONSE = { + success: true, + tx_id: '8317215e06e4f36e63901789ede0825467745ee01010a1f8caeb938f9a478432', + status: 'accepted', + timestamp: 1753314844, +} + +test('Swap ML tokens', async () => { + await page.route(SEARCH_REQUEST_URL, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(SEARCH_REQUEST_RESPONSE), + }) + } else { + route.continue() + } + }) + + await page.route('**/transaction', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(POST_TRANSACTION_RESPONSE), + }) + } else { + route.continue() + } + }) + + await page.waitForTimeout(3000) + await page.getByText('Mintlayer (Testnet)').click() + + await page.click('button.button-transaction-swap') + await expect(page.getByText('Swap From')).toBeVisible() + await expect(page.getByText('Swap Assets')).toBeVisible() + await expect(page.getByTestId('select-token-swap')).toHaveCount(2) + await expect(page.locator('input#swap-amount-input')).toBeVisible() + await expect(page.locator('input#swap-amount-input')).toHaveValue('') + await expect(page.locator('input#swap-amount-input')).toHaveAttribute( + 'placeholder', + '0', + ) + + await expect(page.getByText('Swap To')).toBeVisible() + await expect(page.getByRole('button', { name: 'Find orders' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Find orders' })).toBeDisabled() + await expect(page.getByText('No orders found')).toBeVisible() + + await page.getByTestId('select-token-swap').first().click() + await expect(page.getByTestId('swap-popup-content')).toBeVisible() + await expect(page.getByTestId('swap-popup-title')).toHaveText('Swap from') + await expect( + page.locator('input[placeholder="Search by symbol or token id"]'), + ).toBeVisible() + await expect( + page.locator('input[placeholder="Search by symbol or token id"]'), + ).toHaveValue('') + + const fromTokenItems = page.getByTestId('swap-token-list').locator('li') + await expect(fromTokenItems).toHaveCount(3) + await expect(fromTokenItems).toHaveText([ + 'ML Coins', + 'LLAZY (tmltk1006rkw...5npxyqpfwpy3)', + 'SSwissDogs (tmltk1nzscrd...c9x9wsgwyr3m)', + ]) + + await page.getByText('SwissDogs (tmltk1nzscrd...c9x9wsgwyr3m)').click() + await expect(page.getByTestId('select-token-swap').first()).toContainText( + 'SwissDogs', + ) + await expect(page.getByText('Balance: 13')).toBeVisible() + + await page.getByTestId('select-token-swap').last().click() + await expect(page.getByTestId('swap-popup-content')).toBeVisible() + await expect(page.getByTestId('swap-popup-title')).toHaveText('Swap to') + await expect( + page.locator('input[placeholder="Search by symbol or token id"]'), + ).toBeVisible() + await expect( + page.locator('input[placeholder="Search by symbol or token id"]'), + ).toHaveValue('') + const toTokenItems = page.getByTestId('swap-token-list').locator('li') + const toItemCount = await toTokenItems.count() + expect(toItemCount).toBeGreaterThan(2) + + await expect(page.getByText('ML Coins')).toBeVisible() + await page.getByText('ML Coins').click() + await expect(page.getByTestId('select-token-swap').last()).toContainText( + 'ML (Mintlayer)', + ) + + await page.locator('input#swap-amount-input').fill('1') + await expect(page.locator('input#swap-amount-input')).toHaveValue('1') + await expect( + page.getByRole('button', { name: 'Find orders' }), + ).not.toBeDisabled() + await page.getByRole('button', { name: 'Find orders' }).click() + await expect(page.getByText('No orders found')).toBeHidden() + await expect(page.getByTestId('order-list')).toBeVisible() + + const orders = page.getByTestId('order') + await expect(orders).toHaveCount(1) + await expect(page.getByText('tordr1ckcck8...t7gftq57gs7m')).toBeVisible() + await expect(page.getByText('88.78788')).toBeVisible() + await expect(page.getByText('SwissDogs').first()).toBeVisible() + await expect(page.getByText('177.57576')).toBeVisible() + await expect(page.getByText('TML').first()).toBeVisible() + + await orders.first().click() + await expect(page.getByTestId('order-details')).toBeVisible() + await expect(page.getByTestId('order-details-item-title')).toHaveText( + 'Order id:', + ) + await expect( + page.getByTestId('order-details-item-content').first(), + ).toContainText('tordr1ckcck85mwhc2') + await expect(page.getByTestId('copy-btn')).toBeVisible() + await expect(page.getByText('88.78788').first()).toBeVisible() + await expect( + page + .getByTestId('order-details') + .getByText( + '(tmltk1nzscrdpvy5ng3ywesda9gevvu4s3asryx4ts9t7d4mkxr4c9x9wsgwyr3m)', + ), + ).toBeVisible() + await expect(page.getByText('177.57576').first()).toBeVisible() + await expect( + page.getByTestId('order-details').getByText('(Mintlayer Coin)'), + ).toBeVisible() + + await expect(page.getByText('Exchage rate:')).toBeVisible() + await expect(page.getByText(/1 SwissDogs ≈.*TML/)).toBeVisible() + await expect( + page.locator('input[placeholder="SwissDogs amount"]'), + ).toBeVisible() + await expect( + page.locator('input[placeholder="SwissDogs amount"]'), + ).toHaveValue('') + await expect(page.getByRole('button', { name: 'Swap' })).toBeVisible() + + await page.locator('input[placeholder="SwissDogs amount"]').fill('1') + + await page.getByRole('button', { name: 'Swap' }).click() + await page.waitForTimeout(5000) + await expect(page.getByTestId('order-details')).toBeHidden() + + await expect(page.getByText('Sign Transaction')).toBeVisible() + await expect(page.getByText('Transaction Preview')).toBeVisible() + await expect(page.getByText('Estimated changes:')).toBeVisible() + await expect(page.getByText('Fill order')).toBeVisible() + await expect(page.getByText('Order id:')).toBeVisible() + await expect( + page.getByText( + 'tordr1ckcck85mwhc2yz3qahdse7tpyywt49gv9flekyaghkhd59t7gftq57gs7m', + ), + ).toBeVisible() + await expect(page.getByText('Network fee:')).toBeVisible() + + await expect(page.getByRole('button', { name: 'Decline' })).toBeVisible() + await expect( + page.getByRole('button', { name: 'Approve and return to page' }), + ).toBeVisible() + await page.getByRole('button', { name: 'Approve and return to page' }).click() + + await expect(page.getByText('Re-enter your Password')).toBeVisible() + await expect( + page.locator('input[placeholder="Enter your password"]'), + ).toBeVisible() + await expect( + page.locator('input[placeholder="Enter your password"]'), + ).toHaveValue('') + await page + .locator('input[placeholder="Enter your password"]') + .fill(senderData.WALLET_PASSWORD) + + await page.getByRole('button', { name: 'Submit' }).click() + await page.waitForTimeout(2000) + await expect(page.getByText('Your transaction was sent')).toBeVisible() + await expect( + page.getByText( + '8317215e06e4f36e63901789ede0825467745ee01010a1f8caeb938f9a478432', + ), + ).toBeVisible() +}) diff --git a/tests/08-create-ml-delegation.spec.js b/tests/08-create-ml-delegation.spec.js new file mode 100644 index 00000000..006c8f52 --- /dev/null +++ b/tests/08-create-ml-delegation.spec.js @@ -0,0 +1,74 @@ +import { expect, test, beforeEach } from '@playwright/test' +import { useRestoreWallet } from './helpers//hooks/useRestore' +import { useSetTestnet } from './helpers/hooks/useSetTestnet' +import { senderData } from './data/index.js' +import { formatAddress } from './helpers/helpers.js' + +let page + +beforeEach(async ({ page: newPage }) => { + test.setTimeout(300000) + page = newPage + await useRestoreWallet(page, 'sender') + await useSetTestnet(page) +}) + +const formatedPoolId = formatAddress(senderData.POOL_ID) + +test('Create ML delegation', async () => { + // Mock transaction broadcast + await page.route('**/transaction', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tx_id: + 'ba6a6be12a1226f0038365ff2554dfb9f5aa2cb468a523ee4142fd1f1f6d3254', + }), + }) + } else { + await route.continue() + } + }) + + await page.getByText('Mintlayer (Testnet)').click() + await page.click('button.button-transaction-staking') + + await expect(page.getByText(formatedPoolId).first()).toBeVisible({ + timeout: 30000, + }) + + await page.getByRole('button', { name: 'Create new delegation' }).click() + + await expect(page.getByText('Pool id')).toBeVisible() + + await page.fill('input[placeholder="tpool1..."]', senderData.POOL_ID) + + // Wait for fee calculation and Create button to become enabled + await expect(page.getByRole('button', { name: 'Create' })).toBeEnabled({ + timeout: 60000, + }) + await page.getByRole('button', { name: 'Create' }).click() + + // Sign transaction page + await expect(page.getByText('Sign Transaction')).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText('Pool Id:')).toBeVisible() + await expect(page.getByText(senderData.POOL_ID)).toBeVisible() + + await page.getByRole('button', { name: 'Approve and return to page' }).click() + + // Password modal + await expect(page.getByText('Re-enter your Password')).toBeVisible() + await page.fill( + 'input[placeholder="Enter your password"]', + senderData.WALLET_PASSWORD, + ) + await page.getByRole('button', { name: 'Submit' }).click() + + await expect(page.getByText('Your transaction was sent.')).toBeVisible({ + timeout: 30000, + }) +}) diff --git a/tests/08-create-ml-staking.spec.js b/tests/09-create-ml-staking.spec.js similarity index 58% rename from tests/08-create-ml-staking.spec.js rename to tests/09-create-ml-staking.spec.js index e864372a..02f929be 100644 --- a/tests/08-create-ml-staking.spec.js +++ b/tests/09-create-ml-staking.spec.js @@ -1,66 +1,69 @@ import { expect, test, beforeEach } from '@playwright/test' import { useRestoreWallet } from './helpers//hooks/useRestore' import { useSetTestnet } from './helpers/hooks/useSetTestnet' -import { receiverData, senderData } from './data/index.js' -import { formatAddress } from './helpers/helpers.js' -import { time } from 'console' +import { senderData } from './data/index.js' let page beforeEach(async ({ page: newPage }) => { - test.setTimeout(190000) + test.setTimeout(300000) page = newPage await useRestoreWallet(page, 'sender') await useSetTestnet(page) }) test('Create ML staking', async () => { - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) + // Mock transaction broadcast + await page.route('**/transaction', async (route) => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + tx_id: + 'ba6a6be12a1226f0038365ff2554dfb9f5aa2cb468a523ee4142fd1f1f6d3254', + }), + }) + } else { + await route.continue() + } + }) + await page.getByText('Mintlayer (Testnet)').click() await page.click('button.button-transaction-staking') - await page.waitForTimeout(5000) await page.getByRole('button', { name: 'Add funds' }).nth(0).click() - await expect(page.locator(':text("Deleg id:")')).toBeVisible() + await expect(page.getByText('Deleg id')).toBeVisible() const inputValue = await page - .locator('input.input.address-field') + .locator('input[placeholder="tdelg1..."]') .inputValue() expect(inputValue).not.toBe('') await page.fill('input[placeholder="0"]', '1.1') + // Wait for fee calculation and Send button to become enabled + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled({ + timeout: 60000, + }) await page.getByRole('button', { name: 'Send' }).click() - await expect(page.getByText('Sign Transaction')).toBeVisible() - - await expect( - page.getByRole('button', { name: 'Switch to json' }), - ).toBeVisible() + // Sign transaction page + await expect(page.getByText('Sign Transaction')).toBeVisible({ + timeout: 30000, + }) await expect(page.getByRole('button', { name: 'Decline' })).toBeVisible() await page.getByRole('button', { name: 'Approve and return to page' }).click() + // Password modal await expect(page.getByText('Re-enter your Password')).toBeVisible() await page.fill( 'input[placeholder="Enter your password"]', - receiverData.WALLET_PASSWORD, + senderData.WALLET_PASSWORD, ) - await page.getByRole('button', { name: 'Submit' }).click() - await page.route( - 'https://api-server-lovelace.mintlayer.org/api/v2/transaction', - (route) => - route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - tx_id: - 'ba6a6be12a1226f0038365ff2554dfb9f5aa2cb468a523ee4142fd1f1f6d3254', - }), - }), - ) - await page.waitForSelector(':text("Your transaction was sent.")') + await expect(page.getByText('Your transaction was sent.')).toBeVisible({ + timeout: 30000, + }) }) diff --git a/tests/09-transaction-details.spec.js b/tests/09-transaction-details.spec.js deleted file mode 100644 index cdc178ab..00000000 --- a/tests/09-transaction-details.spec.js +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test, beforeEach } from '@playwright/test' -import { useRestoreWallet } from './helpers//hooks/useRestore' -import { useSetTestnet } from './helpers/hooks/useSetTestnet' -import { senderData } from './data/index.js' - -let page - -beforeEach(async ({ page: newPage }) => { - page = newPage - await useRestoreWallet(page, 'sender') - await useSetTestnet(page) -}) - -test('Transaction details', async () => { - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) - - await page.waitForSelector('li.transaction') - await page.click('li.transaction:first-of-type') - - await expect(page.getByTestId('popup').getByText('Date:')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Amount:')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Tx:')).toBeVisible() - await expect( - page.getByTestId('popup').getByText('Confirmations:'), - ).toBeVisible() - - const elements = await page.$$eval( - '.transactionDetItemContent > :first-child', - (elements) => elements.map((el) => el.innerText), - ) - elements.forEach((element) => { - expect(element).not.toBe('') - }) - - await expect( - page.getByTestId('popup').getByText('Open In Block Explorer'), - ).toBeVisible() -}) diff --git a/tests/10-delegation-details.spec.js b/tests/10-delegation-details.spec.js deleted file mode 100644 index 6c6ae86d..00000000 --- a/tests/10-delegation-details.spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { expect, test, beforeEach } from '@playwright/test' -import { useRestoreWallet } from './helpers//hooks/useRestore' -import { useSetTestnet } from './helpers/hooks/useSetTestnet' - -let page - -beforeEach(async ({ page: newPage }) => { - test.setTimeout(190000) - page = newPage - await useRestoreWallet(page, 'sender') - await useSetTestnet(page) -}) - -test('Delegation details', async () => { - await page.click( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet)")', - ) - await page.click('button.button-transaction-staking') - - await page.waitForSelector('li.transaction') - await page.click('li.transaction:first-of-type') - - await expect(page.getByTestId('popup').getByText('Date:')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Pool id:')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Amount:')).toBeVisible() - await expect( - page.getByTestId('popup').getByText('Spend address:'), - ).toBeVisible() - - const elements = await page.$$eval( - '.transactionDetItemContent > :first-child', - (elements) => elements.map((el) => el.innerText), - ) - elements.forEach((element) => { - expect(element).not.toBe('') - }) - - await expect(page.getByTestId('popup').getByText('Add Funds')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Withdraw')).toBeVisible() - await expect( - page.getByTestId('popup').getByText('Open In Block Explorer'), - ).toBeVisible() -}) diff --git a/tests/10-transaction-details.spec.js b/tests/10-transaction-details.spec.js new file mode 100644 index 00000000..8e469288 --- /dev/null +++ b/tests/10-transaction-details.spec.js @@ -0,0 +1,33 @@ +import { expect, test, beforeEach } from '@playwright/test' +import { useRestoreWallet } from './helpers//hooks/useRestore' +import { useSetTestnet } from './helpers/hooks/useSetTestnet' + +let page + +beforeEach(async ({ page: newPage }) => { + test.setTimeout(300000) + page = newPage + await useRestoreWallet(page, 'sender') + await useSetTestnet(page) +}) + +test('Transaction details', async () => { + await page.getByText('Mintlayer (Testnet)').click() + + await page.getByTestId('transaction').first().waitFor({ timeout: 60000 }) + await page.getByTestId('transaction').first().click() + + const popup = page.getByTestId('popup') + await expect(popup.getByText('Date')).toBeVisible() + await expect(popup.getByText('Confirmations')).toBeVisible() + await expect(popup.getByText('Transaction hash')).toBeVisible() + + const detailValues = popup.getByTestId('transaction-details-item-content') + const count = await detailValues.count() + for (let i = 0; i < count; i++) { + const text = await detailValues.nth(i).innerText() + expect(text).not.toBe('') + } + + await expect(popup.getByText('View on Mintlayer Explorer')).toBeVisible() +}) diff --git a/tests/11-delegation-details.spec.js b/tests/11-delegation-details.spec.js new file mode 100644 index 00000000..e383762c --- /dev/null +++ b/tests/11-delegation-details.spec.js @@ -0,0 +1,37 @@ +import { expect, test, beforeEach } from '@playwright/test' +import { useRestoreWallet } from './helpers//hooks/useRestore' +import { useSetTestnet } from './helpers/hooks/useSetTestnet' + +let page + +beforeEach(async ({ page: newPage }) => { + test.setTimeout(300000) + page = newPage + await useRestoreWallet(page, 'sender') + await useSetTestnet(page) +}) + +test('Delegation details', async () => { + await page.getByText('Mintlayer (Testnet)').click() + await page.click('button.button-transaction-staking') + + await page.getByTestId('delegation').first().waitFor({ timeout: 60000 }) + await page.getByTestId('delegation').first().click() + + const popup = page.getByTestId('popup') + await expect(popup.getByText('Date')).toBeVisible() + await expect(popup.getByText('Pool id')).toBeVisible() + await expect(popup.getByText('Amount')).toBeVisible() + await expect(popup.getByText('Spend address')).toBeVisible() + + const detailValues = popup.getByTestId('delegation-details-item-content') + const count = await detailValues.count() + for (let i = 0; i < count; i++) { + const text = await detailValues.nth(i).innerText() + expect(text).not.toBe('') + } + + await expect(popup.getByText('Add funds')).toBeVisible() + await expect(popup.getByText('Withdraw')).toBeVisible() + await expect(popup.getByText('Open in Block Explorer')).toBeVisible() +}) diff --git a/tests/11-delete-account.spec.js b/tests/11-delete-account.spec.js deleted file mode 100644 index 0325ace2..00000000 --- a/tests/11-delete-account.spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import { test, beforeEach, expect } from '@playwright/test' -import { useRestoreWallet } from './helpers//hooks/useRestore' -import { senderData } from './data/index.js' -let page - -const deleteDescription = - 'If you delete a wallet, you may lose access to all the funds associated with it. Please make sure that you have securely saved your seed phrase before proceeding.' - -beforeEach(async ({ page: newPage }) => { - test.setTimeout(190000) - page = newPage - await useRestoreWallet(page, 'sender') -}) - -test('Delete account - cancel', async () => { - await page.click('button.header-menu-button') - const settings = page.getByText('Settings', { selector: 'li' }) - await settings.click() - - await expect(page.locator(':text("DELETE WALLET")')).toBeVisible() - await expect(page.locator(`:text("${deleteDescription}")`)).toBeVisible() - - const isDeleteButtonVisible = await page - .locator('button.settings-delete-button') - .isVisible() - expect(isDeleteButtonVisible).toBe(true) - - await page.click('button.settings-delete-button') - - await expect( - page - .getByTestId('popup') - .getByText('Are you sure you want to permanently delete your wallet?'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'All local data associated with this wallet will be permanently lost.', - ), - ).toBeVisible() - await expect( - page.getByTestId('popup').getByText('This action cannot be undone.'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'Please make sure that you have securely saved your seed phrase before proceeding.', - ), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText('Please confirm that you wish to proceed.'), - ).toBeVisible() - - await expect(page.getByTestId('popup').getByText('Cancel')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Continue')).toBeVisible() - await page.getByRole('button', { name: 'Cancel' }).click() - await expect(page.locator(':text("DELETE WALLET")')).toBeVisible() -}) - -test('Delete account from settings', async () => { - await page.click('button.header-menu-button') - const settings = page.getByText('Settings', { selector: 'li' }) - await settings.click() - - await expect(page.locator(':text("DELETE WALLET")')).toBeVisible() - await expect(page.locator(`:text("${deleteDescription}")`)).toBeVisible() - - const isDeleteButtonVisible = await page - .locator('button.settings-delete-button') - .isVisible() - expect(isDeleteButtonVisible).toBe(true) - - await page.click('button.settings-delete-button') - - await expect( - page - .getByTestId('popup') - .getByText('Are you sure you want to permanently delete your wallet?'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'All local data associated with this wallet will be permanently lost.', - ), - ).toBeVisible() - await expect( - page.getByTestId('popup').getByText('This action cannot be undone.'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'Please make sure that you have securely saved your seed phrase before proceeding.', - ), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText('Please confirm that you wish to proceed.'), - ).toBeVisible() - - await expect(page.getByTestId('popup').getByText('Cancel')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Continue')).toBeVisible() - await page.getByRole('button', { name: 'Continue' }).click() - - await expect( - page.getByTestId('popup').getByText('Password for'), - ).toBeVisible() - await expect( - page.getByTestId('popup').getByText(senderData.WALLET_NAME), - ).toBeVisible() - - await expect(page.locator('input[type="password"]')).toHaveAttribute( - 'placeholder', - 'Password', - ) - - await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) - await page.getByRole('button', { name: 'Delete Wallet' }).click() - await page.waitForSelector(':text("Your Mintlayer, right in your browser.")') -}) - -test('Delete account from login', async () => { - await page.click('button.header-menu-button') - const logoutElement = page.getByText('Logout', { selector: 'li' }) - await logoutElement.click() - - await expect(page.locator(':text("Available wallet")')).toBeVisible() - await expect(page.locator(`:text("${senderData.WALLET_NAME}")`)).toBeVisible() - await page.locator('button[name="account"]').hover() - - await page.click('button.delete-button') - - await expect( - page - .getByTestId('popup') - .getByText('Are you sure you want to permanently delete your wallet?'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'All local data associated with this wallet will be permanently lost.', - ), - ).toBeVisible() - await expect( - page.getByTestId('popup').getByText('This action cannot be undone.'), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText( - 'Please make sure that you have securely saved your seed phrase before proceeding.', - ), - ).toBeVisible() - await expect( - page - .getByTestId('popup') - .getByText('Please confirm that you wish to proceed.'), - ).toBeVisible() - - await expect(page.getByTestId('popup').getByText('Cancel')).toBeVisible() - await expect(page.getByTestId('popup').getByText('Continue')).toBeVisible() - await page.getByRole('button', { name: 'Continue' }).click() - - await expect( - page.getByTestId('popup').getByText('Password for'), - ).toBeVisible() - await expect( - page.getByTestId('popup').getByText(senderData.WALLET_NAME), - ).toBeVisible() - - await expect(page.locator('input[type="password"]')).toHaveAttribute( - 'placeholder', - 'Password', - ) - - await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) - await page.getByRole('button', { name: 'Delete Wallet' }).click() - await page.waitForSelector(':text("Your Mintlayer, right in your browser.")') -}) diff --git a/tests/12-address-page.spec.js b/tests/12-address-page.spec.js deleted file mode 100644 index ecb75ed4..00000000 --- a/tests/12-address-page.spec.js +++ /dev/null @@ -1,110 +0,0 @@ -import { test, expect, Page } from '@playwright/test' -import { useRestoreWallet } from './helpers/hooks/useRestore' -import { useSetTestnet } from './helpers/hooks/useSetTestnet' - -/* Helpers */ - -async function openAndWaitPopup(page, linkLocator) { - const [popup] = await Promise.all([ - page.waitForEvent('popup'), - linkLocator.click(), - ]) - return popup -} - -async function openAddressesSection(page) { - await page - .locator('div', { hasText: /^Addresses$/ }) - .getByTestId('button') - .click() - await expect(page.getByTestId('address-table')).toBeVisible() - await assertAddressTableHeaders(page) -} - -async function assertAddressTableHeaders(page) { - await expect(page.getByTestId('address-table')).toBeVisible() - const table = page.getByTestId('address-table') - await expect(table.locator('th.address-title')).toHaveCount(3, { - timeout: 5000, - }) - await expect( - table.locator('th.address-title').filter({ hasText: /^Address$/i }), - ).toBeVisible() - await expect( - table.locator('th.address-title').filter({ hasText: /^Status$/i }), - ).toBeVisible() - await expect( - table.locator('th.address-title').filter({ hasText: /^Balances$/i }), - ).toBeVisible() -} - -async function assertAddressRow(page, index, statusPattern, symbol) { - const row = page.getByTestId(`address-row-${index}`) - await expect(row).toBeVisible() - await expect(row.getByText(statusPattern)).toBeVisible() - await expect(row.getByText(symbol)).toBeVisible() -} - -async function toggleTokens(page) { - const btn = page.getByRole('button', { name: /tokens/i }) - await btn.click() -} - -test.beforeEach(async ({ page }) => { - test.setTimeout(300_000) - await useRestoreWallet(page, 'sender') - await useSetTestnet(page) -}) - -test.describe('Addresses page', () => { - test('BTC address interactions', async ({ page }) => { - await page - .getByText(/Bitcoin.*Testnet/i) - .first() - .click() - await openAddressesSection(page) - - await assertAddressRow(page, 0, /Used|Unused/i, 'BTC') - const search = page.getByRole('textbox', { name: /Search by address/i }) - await search.fill('tmlt') - await expect(search).toHaveValue(/tmlt/i) - - await page.locator('.btn.qr-button-receive').click() - expect(page.locator('.qrcode')).toBeVisible() - expect(page.locator('text=Address:')).toBeVisible() - expect(page.locator('strong').filter({ hasText: /^tb1q/i })).toBeVisible() - }) - - test('Mintlayer address + token expansion', async ({ page }) => { - await page - .getByText(/Mintlayer.*Testnet/i) - .first() - .click() - await openAddressesSection(page) - await assertAddressRow(page, 0, /Used|Unused/i, 'ML') - - await toggleTokens(page) - await toggleTokens(page) - await toggleTokens(page) - const tokenRows = page.locator('.token-item') - await expect(tokenRows.first()).toBeVisible() - }) - - test('Multiple BTC address popups (sample)', async ({ page }) => { - await page - .getByText(/Bitcoin.*Testnet/i) - .first() - .click() - await openAddressesSection(page) - - const targetPatterns = [/tb1qqjwg6/i, /tb1qyxzlg/i] - - for (const pattern of targetPatterns) { - const popup = await openAndWaitPopup( - page, - page.getByRole('link').filter({ hasText: pattern }).first(), - ) - expect(popup).toBeDefined() - } - }) -}) diff --git a/tests/12-delete-account.spec.js b/tests/12-delete-account.spec.js new file mode 100644 index 00000000..c69007f0 --- /dev/null +++ b/tests/12-delete-account.spec.js @@ -0,0 +1,132 @@ +import { test, beforeEach, expect } from '@playwright/test' +import { useRestoreWallet } from './helpers//hooks/useRestore' +import { senderData } from './data/index.js' +let page + +const deleteDescription = + 'If you delete a wallet, you may lose access to all the funds associated with it. Please make sure that you have securely saved your seed phrase before proceeding.' + +beforeEach(async ({ page: newPage }) => { + test.setTimeout(300000) + page = newPage + await useRestoreWallet(page, 'sender') +}) + +test('Delete account - cancel', async () => { + await page.getByText('Settings').click() + + await expect(page.getByText('Delete wallet')).toBeVisible() + await expect(page.getByText(deleteDescription)).toBeVisible() + + await expect(page.locator('button.settings-delete-button')).toBeVisible() + await page.click('button.settings-delete-button') + + const popup = page.getByTestId('popup') + await expect(popup.getByText('Delete wallet permanently?')).toBeVisible() + await expect( + popup.getByText( + 'All local data associated with this wallet will be permanently lost.', + ), + ).toBeVisible() + await expect(popup.getByText('This action cannot be undone')).toBeVisible() + await expect( + popup.getByText( + 'Make sure you have securely saved your seed phrase before proceeding.', + ), + ).toBeVisible() + + await expect(popup.getByRole('button', { name: 'Cancel' })).toBeVisible() + await popup.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByText('Delete wallet')).toBeVisible() +}) + +test('Delete account from settings', async () => { + await page.getByText('Settings').click() + + await expect(page.getByText('Delete wallet')).toBeVisible() + await expect(page.getByText(deleteDescription)).toBeVisible() + + await expect(page.locator('button.settings-delete-button')).toBeVisible() + await page.click('button.settings-delete-button') + + const popup = page.getByTestId('popup') + await expect(popup.getByText('Delete wallet permanently?')).toBeVisible() + await expect( + popup.getByText( + 'All local data associated with this wallet will be permanently lost.', + ), + ).toBeVisible() + await expect(popup.getByText('This action cannot be undone')).toBeVisible() + + await popup + .locator('label') + .filter({ hasText: 'I have saved my seed phrase' }) + .click() + await popup + .locator('label') + .filter({ hasText: 'I understand this action is irreversible' }) + .click() + + await popup.getByRole('button', { name: 'Delete wallet' }).click() + + await expect(popup.getByText(senderData.WALLET_NAME)).toBeVisible() + await expect( + popup.getByText('Enter your password to delete this wallet'), + ).toBeVisible() + + await expect(page.locator('input[type="password"]')).toHaveAttribute( + 'placeholder', + 'Password', + ) + + await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) + await page.getByRole('button', { name: 'Delete Wallet' }).click() + await expect(page.getByTestId('create-restore')).toBeVisible({ + timeout: 30000, + }) +}) + +test('Delete account from login', async () => { + await page.getByText('Logout').click() + + await expect(page.getByText('Choose an account')).toBeVisible() + await expect(page.getByText(senderData.WALLET_NAME)).toBeVisible() + + await page.getByTestId('delete-wallet-button').first().click() + + const popup = page.getByTestId('popup') + await expect(popup.getByText('Delete wallet permanently?')).toBeVisible() + await expect( + popup.getByText( + 'All local data associated with this wallet will be permanently lost.', + ), + ).toBeVisible() + await expect(popup.getByText('This action cannot be undone')).toBeVisible() + + await popup + .locator('label') + .filter({ hasText: 'I have saved my seed phrase' }) + .click() + await popup + .locator('label') + .filter({ hasText: 'I understand this action is irreversible' }) + .click() + + await popup.getByRole('button', { name: 'Delete wallet' }).click() + + await expect(popup.getByText(senderData.WALLET_NAME)).toBeVisible() + await expect( + popup.getByText('Enter your password to delete this wallet'), + ).toBeVisible() + + await expect(page.locator('input[type="password"]')).toHaveAttribute( + 'placeholder', + 'Password', + ) + + await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) + await page.getByRole('button', { name: 'Delete Wallet' }).click() + await expect(page.getByTestId('create-restore')).toBeVisible({ + timeout: 30000, + }) +}) diff --git a/tests/13-address-page.spec.js b/tests/13-address-page.spec.js new file mode 100644 index 00000000..f861219e --- /dev/null +++ b/tests/13-address-page.spec.js @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test' +import { useRestoreWallet } from './helpers/hooks/useRestore' +import { useSetTestnet } from './helpers/hooks/useSetTestnet' + +async function navigateToAddressPage(page, coinPattern) { + await page.getByText(coinPattern).first().click() + await page.getByText('Addr.').click() + await expect(page.getByTestId('address-table')).toBeVisible({ + timeout: 60000, + }) + await page.getByTestId('address-row-0').waitFor({ timeout: 60000 }) +} + +test.beforeEach(async ({ page }) => { + test.setTimeout(300_000) + await useRestoreWallet(page, 'sender') + await useSetTestnet(page) +}) + +test.describe('Addresses page', () => { + test('BTC address interactions', async ({ page }) => { + await navigateToAddressPage(page, /Bitcoin.*Testnet/i) + + const table = page.getByTestId('address-table') + await expect(table.getByText('ADDRESS')).toBeVisible() + await expect(table.getByText('STATUS')).toBeVisible() + await expect(table.getByText('BALANCE')).toBeVisible() + + const firstRow = page.getByTestId('address-row-0') + await expect(firstRow.getByText(/Used|Unused/)).toBeVisible() + await expect(firstRow.getByText('BTC')).toBeVisible() + + const link = firstRow.locator('a').first() + await expect(link).toHaveAttribute('target', '_blank') + const href = await link.getAttribute('href') + expect(href).toBeTruthy() + + const search = page.locator('#address-search-input') + await search.fill('tb1') + await expect(search).toHaveValue('tb1') + }) + + test('Mintlayer address + token expansion', async ({ page }) => { + await navigateToAddressPage(page, /Mintlayer.*Testnet/i) + + const firstRow = page.getByTestId('address-row-0') + await expect(firstRow.getByText(/Used|Unused/)).toBeVisible() + await expect(firstRow.getByText('ML')).toBeVisible() + + const tokensButton = page.getByText(/\d+ tokens?/) + if ((await tokensButton.count()) > 0) { + await tokensButton.first().click() + await tokensButton.first().click() + } + }) + + test('QR code popup', async ({ page }) => { + await navigateToAddressPage(page, /Bitcoin.*Testnet/i) + + const firstRow = page.getByTestId('address-row-0') + await firstRow.locator('button').last().click() + + const popup = page.getByTestId('popup') + await expect(popup.locator('.qrcode')).toBeVisible() + await expect(popup.getByText('Address:')).toBeVisible() + await expect(popup.getByText('Copy Address')).toBeVisible() + }) +}) diff --git a/tests/helpers/hooks/useLogin.js b/tests/helpers/hooks/useLogin.js index f6024aae..7abea2a4 100644 --- a/tests/helpers/hooks/useLogin.js +++ b/tests/helpers/hooks/useLogin.js @@ -1,17 +1,17 @@ -import { expect, test } from '@playwright/test' +import { expect } from '@playwright/test' import { senderData } from '../../data/index.js' export const useLogin = async (page) => { - await expect(page.locator(':text("Available wallet")')).toBeVisible() + await expect(page.getByText('Choose an account')).toBeVisible() - const account = page.getByText('SenderWallet', { selector: 'div' }) - await account.click() + await page.getByText(senderData.WALLET_NAME).click() - await expect(page.locator(`:text("Password for")`)).toBeVisible() - await expect(page.locator(`:text("${senderData.WALLET_NAME}")`)).toBeVisible() + await expect(page.getByText('Welcome back')).toBeVisible() await page.fill('input[placeholder="Password"]', senderData.WALLET_PASSWORD) - await page.getByRole('button', { name: 'Log In' }).click() + await page.getByTestId('login-password-submit').click() - await page.waitForSelector(':text("Mintlayer")') + await expect(page.getByText(/Mintlayer/).first()).toBeVisible({ + timeout: 30000, + }) } diff --git a/tests/helpers/hooks/useRestore.js b/tests/helpers/hooks/useRestore.js index 9cbc58c0..e9e673db 100644 --- a/tests/helpers/hooks/useRestore.js +++ b/tests/helpers/hooks/useRestore.js @@ -5,8 +5,8 @@ export const useRestoreWallet = async (page, walletType) => { const wallet = walletType === 'sender' ? senderData : receiverData const walletName = wallet.WALLET_NAME await page.goto('http://127.0.0.1:8000') - await page.getByRole('button', { name: 'Restore' }).click() - await page.getByRole('button', { name: 'Seed Phrase' }).click() + await page.getByText('Import existing wallet').click() + await page.getByText('Seed Phrase').click() await page.fill('input[placeholder="Wallet Name"]', wallet.WALLET_NAME) await page.getByRole('button', { name: 'Continue' }).click() await page.fill('input[placeholder="Password"]', wallet.WALLET_PASSWORD) @@ -21,10 +21,7 @@ export const useRestoreWallet = async (page, walletType) => { await textarea[0].fill(mnemonicString) await page.getByRole('button', { name: 'Continue' }).click() - await page.waitForSelector(`:text("${walletName}")`) - await expect(page.locator(`:text("${walletName}")`)).toBeVisible() - await page.waitForSelector(':text("Mintlayer (ML)")') - await page.waitForSelector(':text("Bitcoin (BTC)")') - await expect(page.locator(':text("Bitcoin (BTC)")')).toBeVisible() - await expect(page.locator(':text("Mintlayer (ML)")')).toBeVisible() + await expect(page.getByText(walletName).first()).toBeVisible() + await expect(page.getByText('Bitcoin (BTC)')).toBeVisible({ timeout: 30000 }) + await expect(page.getByText('Mintlayer (ML)')).toBeVisible() } diff --git a/tests/helpers/hooks/useSetTestnet.js b/tests/helpers/hooks/useSetTestnet.js index 77f4ea9a..963ed609 100644 --- a/tests/helpers/hooks/useSetTestnet.js +++ b/tests/helpers/hooks/useSetTestnet.js @@ -2,17 +2,15 @@ const { expect } = require('@playwright/test') import { useLogin } from './useLogin.js' export const useSetTestnet = async (page) => { - await page.click('button.header-menu-button') - const settings = page.getByText('Settings', { selector: 'li' }) - await settings.click() + await page.getByText('Settings').click() - await page.click('strong:text("testnet switcher")') + await page.getByRole('button', { name: 'Testnet' }).click() await useLogin(page) - await page.waitForSelector( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Mintlayer (Testnet")', - ) - await page.waitForSelector( - 'li.crypto-item[data-testid="crypto-item"] h5:text("Bitcoin (Testnet")', - ) + await expect(page.getByText('Mintlayer (Testnet)')).toBeVisible({ + timeout: 30000, + }) + await expect(page.getByText('Bitcoin (Testnet)')).toBeVisible({ + timeout: 30000, + }) } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..ec539e51 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowJs": true, + "checkJs": false, + "noEmit": true, + "isolatedModules": true, + "resolveJsonModule": true, + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@BasicComponents": ["./src/components/basic/index.js"], + "@ComposedComponents": ["./src/components/composed/index.js"], + "@LayoutComponents": ["./src/components/layouts/index.js"], + "@ContainerComponents": ["./src/components/containers/index.js"], + "@Assets/*": ["./src/assets/*"], + "@Contexts": ["./src/contexts/index.js"], + "@Hooks": ["./src/hooks/index.js"], + "@Pages": ["./src/pages/index.js"], + "@APIs": ["./src/services/API/index.js"], + "@Cryptos": ["./src/services/Crypto/index.js"], + "@Databases": ["./src/services/Database/index.js"], + "@Entities": ["./src/services/Entity/index.js"], + "@Helpers": ["./src/utils/Helpers/index.js"], + "@Constants": ["./src/utils/Constants/index.js"], + "@TestData": ["./src/utils/TestData/index.js"], + "@Storage": ["./src/services/Storage/index.js"], + "@Version": ["./src/version/version.js"] + } + }, + "include": ["src"], + "exclude": ["node_modules", "build"] +} diff --git a/webpack.config.js b/webpack.config.js index 3cd61a57..e67f5250 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,8 +5,6 @@ const MiniCssExtractPlugin = require('mini-css-extract-plugin') const CopyWebpackPlugin = require('copy-webpack-plugin') const Dotenv = require('dotenv-webpack') -const isDevelopment = process.env.NODE_ENV !== 'production' - // Path aliases from jsconfig.json const aliases = { '@BasicComponents': path.resolve(__dirname, 'src/components/basic/index.js'), @@ -38,269 +36,277 @@ const aliases = { src: path.resolve(__dirname, 'src'), } -module.exports = { - mode: isDevelopment ? 'development' : 'production', - - entry: { - main: './src/index.js', - }, - - output: { - path: path.resolve(__dirname, 'build'), - filename: isDevelopment - ? 'static/js/[name].js' - : 'static/js/[name].[contenthash:8].js', - chunkFilename: isDevelopment - ? 'static/js/[name].chunk.js' - : 'static/js/[name].[contenthash:8].chunk.js', - assetModuleFilename: 'static/media/[name].[hash:8][ext]', - publicPath: isDevelopment ? '/' : '', - clean: true, - }, +module.exports = (env, argv) => { + const isDevelopment = argv.mode !== 'production' - devtool: isDevelopment ? 'cheap-module-source-map' : 'source-map', + return { + mode: isDevelopment ? 'development' : 'production', - devServer: { - static: { - directory: path.join(__dirname, 'public'), - }, - hot: true, - port: process.env.PORT || 3000, - client: { - webSocketURL: 'auto://0.0.0.0:0/ws', - }, - open: true, - historyApiFallback: { - disableDotRule: true, - rewrites: [ - { - from: /\.wasm$/, - to: (context) => context.parsedUrl.pathname, - }, - ], - }, - setupMiddlewares: (middlewares, devServer) => { - if (devServer && devServer.app) { - devServer.app.use((req, res, next) => { - if (req.url && req.url.endsWith('.wasm')) { - res.setHeader('Content-Type', 'application/wasm') - } - next() - }) - } - return middlewares + entry: { + main: './src/index.js', }, - }, - resolve: { - extensions: ['.js', '.jsx', '.json', '.mjs', '.wasm'], - alias: aliases, - fallback: { - stream: require.resolve('stream-browserify'), - vm: require.resolve('vm-browserify'), - process: require.resolve('process/browser.js'), - buffer: require.resolve('buffer'), - crypto: require.resolve('crypto-browserify'), + output: { + path: path.resolve(__dirname, 'build'), + filename: isDevelopment + ? 'static/js/[name].js' + : 'static/js/[name].[contenthash:8].js', + chunkFilename: isDevelopment + ? 'static/js/[name].chunk.js' + : 'static/js/[name].[contenthash:8].chunk.js', + assetModuleFilename: 'static/media/[name].[hash:8][ext]', + publicPath: '/', + clean: true, }, - }, - module: { - rules: [ - // JavaScript/JSX - { - test: /\.(js|jsx|mjs)$/, - exclude: /node_modules/, - use: { - loader: 'babel-loader', - options: { - presets: [ - [ - '@babel/preset-env', - { targets: { browsers: ['last 2 versions'] } }, - ], - ['@babel/preset-react', { runtime: 'automatic' }], - ], - cacheDirectory: true, - }, - }, + devtool: isDevelopment ? 'cheap-module-source-map' : 'source-map', + + devServer: { + static: { + directory: path.join(__dirname, 'public'), }, - // Fix for ESM modules requiring fully specified extensions - { - test: /\.m?js/, - resolve: { - fullySpecified: false, - }, + hot: true, + port: process.env.PORT || 3000, + client: { + webSocketURL: 'auto://0.0.0.0:0/ws', }, - // CSS - { - test: /\.module\.css$/, - use: [ - isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader, + open: true, + historyApiFallback: { + disableDotRule: true, + rewrites: [ { - loader: 'css-loader', - options: { - esModule: true, - modules: { - namedExport: false, - exportLocalsConvention: 'asIs', - }, - }, + from: /\.wasm$/, + to: (context) => context.parsedUrl.pathname, }, ], }, - { - test: /\.css$/, - exclude: /\.module\.css$/, - use: [ - isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader, - 'css-loader', - ], + setupMiddlewares: (middlewares, devServer) => { + if (devServer && devServer.app) { + devServer.app.use((req, res, next) => { + if (req.url && req.url.endsWith('.wasm')) { + res.setHeader('Content-Type', 'application/wasm') + } + next() + }) + } + return middlewares }, - // SVG as React component - { - test: /\.svg$/, - use: [ - { - loader: '@svgr/webpack', + }, + + resolve: { + extensions: ['.ts', '.tsx', '.js', '.jsx', '.json', '.mjs', '.wasm'], + alias: aliases, + fallback: { + stream: require.resolve('stream-browserify'), + vm: require.resolve('vm-browserify'), + process: require.resolve('process/browser.js'), + buffer: require.resolve('buffer'), + crypto: require.resolve('crypto-browserify'), + }, + }, + + module: { + rules: [ + // JavaScript/JSX + { + test: /\.(js|jsx|mjs|ts|tsx)$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', options: { - svgo: true, - svgoConfig: { - plugins: [ - { - name: 'preset-default', - params: { - overrides: { - removeViewBox: false, + presets: [ + [ + '@babel/preset-env', + { targets: { browsers: ['last 2 versions'] } }, + ], + ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-typescript', + ], + cacheDirectory: true, + }, + }, + }, + // Fix for ESM modules requiring fully specified extensions + { + test: /\.m?js/, + resolve: { + fullySpecified: false, + }, + }, + // CSS + { + test: /\.module\.css$/, + use: [ + isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader, + { + loader: 'css-loader', + options: { + esModule: true, + modules: { + namedExport: false, + exportLocalsConvention: 'asIs', + localIdentName: isDevelopment + ? '[name]__[local]--[hash:base64:5]' + : '[hash:base64:8]', + }, + }, + }, + ], + }, + { + test: /\.css$/, + exclude: /\.module\.css$/, + use: [ + isDevelopment ? 'style-loader' : MiniCssExtractPlugin.loader, + 'css-loader', + ], + }, + // SVG as React component + { + test: /\.svg$/, + use: [ + { + loader: '@svgr/webpack', + options: { + svgo: true, + svgoConfig: { + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + removeViewBox: false, + }, }, }, - }, - { - name: 'removeDimensions', - active: true, - }, - ], + { + name: 'removeDimensions', + active: true, + }, + ], + }, }, }, - }, - 'url-loader', - ], - }, - // Images - { - test: /\.(png|jpg|jpeg|gif|ico|webp|bmp)$/, - type: 'asset/resource', - }, - // Fonts - { - test: /\.(woff|woff2|eot|ttf|otf)$/, - type: 'asset/resource', - generator: { - filename: 'static/media/[name].[hash:8][ext]', + 'url-loader', + ], }, - }, - // WebAssembly - { - test: /\.wasm$/, - type: 'asset/resource', - generator: { - filename: '[name].[contenthash:8][ext]', + // Images + { + test: /\.(png|jpg|jpeg|gif|ico|webp|bmp)$/, + type: 'asset/resource', }, - }, - ], - }, - - plugins: [ - // HTML template - new HtmlWebpackPlugin({ - template: './public/index.html', - filename: 'index.html', - inject: true, - minify: isDevelopment - ? false - : { - removeComments: true, - collapseWhitespace: true, - removeRedundantAttributes: true, - useShortDoctype: true, - removeEmptyAttributes: true, - removeStyleLinkTypeAttributes: true, - keepClosingSlash: true, - minifyJS: true, - minifyCSS: true, - minifyURLs: true, + // Fonts + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + type: 'asset/resource', + generator: { + filename: 'static/media/[name].[hash:8][ext]', }, - }), - - // CSS extraction for production - new MiniCssExtractPlugin({ - filename: 'static/css/[name].[contenthash:8].css', - chunkFilename: 'static/css/[name].[contenthash:8].chunk.css', - }), - - // Copy static files - new CopyWebpackPlugin({ - patterns: [ + }, + // WebAssembly { - from: 'public', - to: '', - globOptions: { - ignore: ['**/index.html'], + test: /\.wasm$/, + type: 'asset/resource', + generator: { + filename: '[name].[contenthash:8][ext]', }, }, ], - }), + }, - // Environment variables - new Dotenv({ - path: `./.env${isDevelopment ? '' : '.production'}`, - systemvars: true, - silent: true, - ignoreStub: true, - }), + plugins: [ + // HTML template + new HtmlWebpackPlugin({ + template: './public/index.html', + filename: 'index.html', + inject: true, + minify: isDevelopment + ? false + : { + removeComments: true, + collapseWhitespace: true, + removeRedundantAttributes: true, + useShortDoctype: true, + removeEmptyAttributes: true, + removeStyleLinkTypeAttributes: true, + keepClosingSlash: true, + minifyJS: true, + minifyCSS: true, + minifyURLs: true, + }, + }), - // Map node: scheme imports (e.g. node:crypto) to browser polyfills - new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => { - resource.request = resource.request.replace(/^node:/, '') - }), + // CSS extraction for production + new MiniCssExtractPlugin({ + filename: 'static/css/[name].[contenthash:8].css', + chunkFilename: 'static/css/[name].[contenthash:8].chunk.css', + }), - // Provide polyfills - new webpack.ProvidePlugin({ - process: 'process/browser.js', - Buffer: ['buffer', 'Buffer'], - }), - ], + // Copy static files + new CopyWebpackPlugin({ + patterns: [ + { + from: 'public', + to: '', + globOptions: { + ignore: ['**/index.html'], + }, + }, + ], + }), - // WebAssembly experiments - experiments: { - asyncWebAssembly: true, - syncWebAssembly: true, - }, + // Environment variables + new Dotenv({ + path: `./.env${isDevelopment ? '' : '.production'}`, + systemvars: true, + silent: true, + ignoreStub: true, + }), - optimization: { - splitChunks: { - chunks: 'all', - cacheGroups: { - vendor: { - test: /[\\/]node_modules[\\/]/, - name: 'vendors', - chunks: 'all', + // Map node: scheme imports (e.g. node:crypto) to browser polyfills + new webpack.NormalModuleReplacementPlugin(/^node:/, (resource) => { + resource.request = resource.request.replace(/^node:/, '') + }), + + // Provide polyfills + new webpack.ProvidePlugin({ + process: 'process/browser.js', + Buffer: ['buffer', 'Buffer'], + }), + ], + + // WebAssembly experiments + experiments: { + asyncWebAssembly: true, + syncWebAssembly: true, + }, + + optimization: { + splitChunks: { + chunks: 'all', + cacheGroups: { + vendor: { + test: /[\\/]node_modules[\\/]/, + name: 'vendors', + chunks: 'all', + }, }, }, }, - }, - // Suppress performance hints in development - performance: { - hints: isDevelopment ? false : 'warning', - maxAssetSize: 512000, - maxEntrypointSize: 512000, - }, + // Suppress performance hints in development + performance: { + hints: isDevelopment ? false : 'warning', + maxAssetSize: 512000, + maxEntrypointSize: 512000, + }, - stats: { - colors: true, - modules: false, - children: false, - chunks: false, - chunkModules: false, - }, + stats: { + colors: true, + modules: false, + children: false, + chunks: false, + chunkModules: false, + }, + } }