From 64e68d35c341edd6bf760ff750e023945eefe35c Mon Sep 17 00:00:00 2001 From: Stepan Lavrentev <40560660+stepanLav@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:33:36 +0300 Subject: [PATCH] Feat/fiat values (#1104) * Feat: Price provider model (#1084) * Feat: Currency settings (#1076) * Feat: fiat balance assets and staking (#1087) * Feat: amount input currency (#1083) * feat: currency select modal * chore: added icon for currency * feat: added currency mode to amount input * feat: price provider model * feat: load assets prices * chore: code style, export events * feat: integrated currency modal to currenc settings * chore: updated crouped select semantics * chore: fixed General Action test * chore: fixed grouped select active state * feat: tests for models, review fixes * chore: fixed pr comments * chore: added currency form model * fix: fixed modal reload after submit * feat: [wip] amount input currency integration * chore: removed button disabled condition * feat: amount input with currency integration * fix: tests * feat: form model, test * chore: removed GroupedSelect * fix: ui fix: correct input for priceless asset * fix: revert commented code --------- Co-authored-by: Egor B Co-authored-by: Yaroslav Grachev Co-authored-by: asmadek * Feat: wallet fiat balance balance (#1099) * Feat: Operations parameters fiat amount (#1098) * Feat: Price provider model (#1084) * Feat: Currency settings (#1076) * Feat: fiat balance assets and staking (#1087) * Feat: amount input currency (#1083) * feat: currency select modal * chore: added icon for currency * feat: added currency mode to amount input * feat: price provider model * feat: load assets prices * chore: code style, export events * feat: integrated currency modal to currenc settings * chore: updated crouped select semantics * chore: fixed General Action test * chore: fixed grouped select active state * feat: tests for models, review fixes * chore: fixed pr comments * chore: added currency form model * fix: fixed modal reload after submit * feat: [wip] amount input currency integration * chore: removed button disabled condition * feat: amount input with currency integration * fix: tests * feat: form model, test * chore: removed GroupedSelect * fix: ui fix: correct input for priceless asset * fix: revert commented code --------- Co-authored-by: Egor B Co-authored-by: Yaroslav Grachev Co-authored-by: asmadek * feat: added fiat amount for transfer and multisig operations * feat: added fiat values to staking operations --------- Co-authored-by: Yaroslav Grachev Co-authored-by: Aleksandr Makhnev Co-authored-by: Egor B * Feat: Fiat update rules (#1096) * feat: fiat update rules * chore: added test * Update src/renderer/entities/price/model/__tests__/price-provider-model.test.ts Co-authored-by: Yaroslav Grachev * chore: updated price proveder test * chore: improved priceProvider test --------- Co-authored-by: Egor B Co-authored-by: Yaroslav Grachev * Feat/adding dev build (#1103) * Feat: Transaction service refactoring (#1053) * feat: added new transaction service, extrinsic builder and changed transfer florw * feat: removed extra components, changed get fee func * fix: wrap tx for submit step only if account is multisig * feat: implemented new service usage for staking transactions * fix: fix build tx error * chore: fixed tests, removed extrinsic service from public api * fix: signatory error fix * chore: renamed wrappers * chore: renamed operation footer prop and make it optional * Update src/renderer/entities/transaction/lib/extrinsicService.ts Co-authored-by: Yaroslav Grachev * Update src/renderer/entities/multisig/lib/multisigTx/common/utils.ts Co-authored-by: Yaroslav Grachev * chore: fixed pr comments * chore: fixed linter error * chore: removed extra props from transfer form * chore: fix lockfile * chore: fixed confirmation test * fix: fixed xcm transfer fee error * fix: fixed amount error * chore: fixed invalid character error --------- Co-authored-by: Egor B Co-authored-by: Yaroslav Grachev * feat: add stage build * fix: remove config separation * fix: change triggers * fix: github triggers * fix: change approach to url management * fix: change trigger and github command * fix: add separate webpack for internal build * fix: eslinter * fix: typo in yamls * Update src/main/factories/create.ts Co-authored-by: Aleksandr Makhnev --------- Co-authored-by: egor0798 Co-authored-by: Egor B Co-authored-by: Yaroslav Grachev Co-authored-by: Aleksandr Makhnev * Fix: Currency form value reset on close (#1107) * fix: added form reset on close * feat: added disabled condition for save button * chore: fixed pr comments --------- Co-authored-by: Egor B * fix: changed default fiat flag value to true (#1106) Co-authored-by: Egor B * fix: wallet balance assets undefined fix (#1108) Co-authored-by: Egor B * Fix: zero balance errors fix (#1109) * fix: network zero balances possible fix * fix: wallet 0 balance fix, asset display 0 fix * fix: wallet zero balance fix * fix: network zero balance fix --------- Co-authored-by: Egor B * fix: removed second shimmer from assets if fiat flag is false (#1110) Co-authored-by: Egor B --------- Co-authored-by: Yaroslav Grachev Co-authored-by: egor0798 Co-authored-by: Aleksandr Makhnev Co-authored-by: Egor B --- src/renderer/app/providers/routes/paths.ts | 1 + .../app/providers/routes/routesConfig.tsx | 1 + src/renderer/app/styles/theme/default.css | 4 +- src/renderer/assets/currency/currencies.json | 424 ++++++++++++++++++ .../assets/images/arrows/swap-arrow.svg | 4 + .../assets/images/functionals/currency.svg | 3 + .../Wallets/ActiveAccountCard.tsx | 10 + .../Wallets/WalletFiatBalance.tsx | 79 ++++ .../PrimaryLayout/Wallets/WalletGroup.tsx | 23 +- .../ui/AddressWithName/AddressWithName.tsx | 22 +- .../AddressWithTwoLines.tsx | 50 +++ src/renderer/entities/account/ui/index.ts | 1 + .../entities/asset/ui/AssetCard/AssetCard.tsx | 29 +- .../asset/ui/AssetDetails/AssetDetails.tsx | 2 + .../entities/asset/ui/AssetIcon/AssetIcon.tsx | 1 + .../lib/__tests__/chainsService.test.ts | 100 +++++ .../entities/network/lib/chainsService.ts | 56 ++- src/renderer/entities/price/index.ts | 3 + src/renderer/entities/price/lib/constants.ts | 7 + src/renderer/entities/price/lib/types.ts | 3 + .../entities/price/lib/useCurrencyRate.ts | 13 + .../model/__tests__/currency-model.test.ts | 61 +++ .../__tests__/price-provider-model.test.ts | 117 +++++ .../entities/price/model/currency-model.ts | 82 ++++ .../price/model/price-provider-model.ts | 107 +++++ .../entities/price/ui/AssetFiatBalance.tsx | 48 ++ .../entities/price/ui/FiatBalance.tsx | 24 + src/renderer/entities/price/ui/Price.tsx | 13 + src/renderer/entities/price/ui/TokenPrice.tsx | 46 ++ .../transaction/ui/Deposit/Deposit.tsx | 9 +- .../entities/transaction/ui/Fee/Fee.tsx | 18 +- .../entities/transaction/ui/XcmFee/XcmFee.tsx | 18 +- .../features/currency/CurrencyForm/index.ts | 1 + .../model/__tests__/currency-form.test.ts | 52 +++ .../CurrencyForm/model/currency-form.ts | 92 ++++ .../currency/CurrencyForm/ui/CurrencyForm.tsx | 90 ++++ src/renderer/features/currency/index.ts | 1 + .../pages/Assets/AssetsList/AssetsList.tsx | 19 +- .../pages/Assets/AssetsList/common/utils.ts | 18 +- .../NetworkAssets/NetworkAssets.tsx | 38 +- .../NetworkFiatBalance/NetworkFiatBalance.tsx | 57 +++ src/renderer/pages/Operations/Operations.tsx | 5 + .../components/ActionSteps/Confirmation.tsx | 4 +- .../pages/Operations/components/Log.tsx | 23 +- .../pages/Operations/components/Operation.tsx | 15 +- .../components/TransactionAmount.tsx | 33 +- .../components/modals/ApproveTx.tsx | 5 + .../Operations/components/modals/RejectTx.tsx | 5 + .../pages/Settings/Currency/Currency.tsx | 10 + .../GeneralActions/GeneralActions.test.tsx | 16 +- .../GeneralActions/GeneralActions.tsx | 21 +- src/renderer/pages/Settings/index.ts | 1 + .../pages/Staking/Operations/Bond/Bond.tsx | 5 + .../ChangeValidators/ChangeValidators.tsx | 5 + .../Operations/Destination/Destination.tsx | 5 + .../Staking/Operations/Redeem/Redeem.tsx | 5 + .../Staking/Operations/Restake/Restake.tsx | 5 + .../Operations/StakeMore/StakeMore.tsx | 5 + .../Staking/Operations/Unstake/Unstake.tsx | 5 + .../components/Confirmation/Confirmation.tsx | 10 +- .../components/Validators/Validators.tsx | 33 +- .../pages/Staking/Overview/Overview.tsx | 5 + .../AboutStaking/AboutStaking.test.tsx | 2 +- .../components/AboutStaking/AboutStaking.tsx | 37 +- .../components/NetworkInfo/NetworkInfo.tsx | 28 +- .../NominatorsList/NominatorsList.tsx | 49 +- .../ValidatorsModal/ValidatorsModal.tsx | 21 +- .../shared/api/local-storage/index.ts | 1 + .../service/localStorageService.ts | 24 + .../__tests__/coingeckoService.test.ts} | 18 +- .../__tests__/fiatService.test.ts | 80 ++++ .../__tests__}/utils.test.ts | 4 +- .../api/price-provider/common/constants.ts | 5 + .../{price => price-provider}/common/types.ts | 10 + .../{price => price-provider}/common/utils.ts | 17 +- .../shared/api/price-provider/index.ts | 3 + .../service/coingeckoService.ts | 47 ++ .../api/price-provider/service/fiatService.ts | 52 +++ .../api/price/coingecko/CoingeckoAdapter.ts | 51 --- .../shared/api/price/coingecko/consts.ts | 1 - src/renderer/shared/api/price/index.ts | 3 - src/renderer/shared/core/index.ts | 2 +- .../shared/core/model/kernel-model.ts | 6 +- src/renderer/shared/lib/utils/balance.ts | 13 + .../shared/ui/Dropdowns/common/types.ts | 6 + src/renderer/shared/ui/Icon/data/arrow.tsx | 2 + .../shared/ui/Icon/data/functionals.tsx | 2 + .../ui/Inputs/AmountInput/AmountInput.tsx | 76 +++- .../shared/ui/Modals/BaseModal/BaseModal.tsx | 4 +- src/renderer/shared/ui/index.ts | 2 +- src/renderer/widgets/CurrencyModal/index.ts | 1 + .../CurrencyModal/ui/CurrencyModal.tsx | 33 ++ .../SendAssetModal/ui/SendAssetModal.tsx | 5 + .../components/ActionSteps/Confirmation.tsx | 4 +- src/renderer/widgets/index.ts | 1 + src/shared/locale/en.json | 20 +- src/shared/locale/ru.json | 18 + 97 files changed, 2361 insertions(+), 255 deletions(-) create mode 100644 src/renderer/assets/currency/currencies.json create mode 100644 src/renderer/assets/images/arrows/swap-arrow.svg create mode 100644 src/renderer/assets/images/functionals/currency.svg create mode 100644 src/renderer/components/layout/PrimaryLayout/Wallets/WalletFiatBalance.tsx create mode 100644 src/renderer/entities/account/ui/AddressWithTwoLines/AddressWithTwoLines.tsx create mode 100644 src/renderer/entities/price/index.ts create mode 100644 src/renderer/entities/price/lib/constants.ts create mode 100644 src/renderer/entities/price/lib/types.ts create mode 100644 src/renderer/entities/price/lib/useCurrencyRate.ts create mode 100644 src/renderer/entities/price/model/__tests__/currency-model.test.ts create mode 100644 src/renderer/entities/price/model/__tests__/price-provider-model.test.ts create mode 100644 src/renderer/entities/price/model/currency-model.ts create mode 100644 src/renderer/entities/price/model/price-provider-model.ts create mode 100644 src/renderer/entities/price/ui/AssetFiatBalance.tsx create mode 100644 src/renderer/entities/price/ui/FiatBalance.tsx create mode 100644 src/renderer/entities/price/ui/Price.tsx create mode 100644 src/renderer/entities/price/ui/TokenPrice.tsx create mode 100644 src/renderer/features/currency/CurrencyForm/index.ts create mode 100644 src/renderer/features/currency/CurrencyForm/model/__tests__/currency-form.test.ts create mode 100644 src/renderer/features/currency/CurrencyForm/model/currency-form.ts create mode 100644 src/renderer/features/currency/CurrencyForm/ui/CurrencyForm.tsx create mode 100644 src/renderer/features/currency/index.ts create mode 100644 src/renderer/pages/Assets/AssetsList/components/NetworkFiatBalance/NetworkFiatBalance.tsx create mode 100644 src/renderer/pages/Settings/Currency/Currency.tsx create mode 100644 src/renderer/shared/api/local-storage/index.ts create mode 100644 src/renderer/shared/api/local-storage/service/localStorageService.ts rename src/renderer/shared/api/{price/coingecko/CoingeckoAdapter.test.ts => price-provider/__tests__/coingeckoService.test.ts} (66%) create mode 100644 src/renderer/shared/api/price-provider/__tests__/fiatService.test.ts rename src/renderer/shared/api/{price/common => price-provider/__tests__}/utils.test.ts (93%) create mode 100644 src/renderer/shared/api/price-provider/common/constants.ts rename src/renderer/shared/api/{price => price-provider}/common/types.ts (78%) rename src/renderer/shared/api/{price => price-provider}/common/utils.ts (65%) create mode 100644 src/renderer/shared/api/price-provider/index.ts create mode 100644 src/renderer/shared/api/price-provider/service/coingeckoService.ts create mode 100644 src/renderer/shared/api/price-provider/service/fiatService.ts delete mode 100644 src/renderer/shared/api/price/coingecko/CoingeckoAdapter.ts delete mode 100644 src/renderer/shared/api/price/coingecko/consts.ts delete mode 100644 src/renderer/shared/api/price/index.ts create mode 100644 src/renderer/widgets/CurrencyModal/index.ts create mode 100644 src/renderer/widgets/CurrencyModal/ui/CurrencyModal.tsx diff --git a/src/renderer/app/providers/routes/paths.ts b/src/renderer/app/providers/routes/paths.ts index 364fc2ef13..d860a22bb2 100644 --- a/src/renderer/app/providers/routes/paths.ts +++ b/src/renderer/app/providers/routes/paths.ts @@ -21,6 +21,7 @@ export const Paths = { // Settings SETTINGS: '/settings', NETWORK: '/settings/network', + CURRENCY: '/settings/currency', MATRIX: '/settings/matrix', // Staking diff --git a/src/renderer/app/providers/routes/routesConfig.tsx b/src/renderer/app/providers/routes/routesConfig.tsx index 960ebbf323..e3bb5fe10b 100644 --- a/src/renderer/app/providers/routes/routesConfig.tsx +++ b/src/renderer/app/providers/routes/routesConfig.tsx @@ -36,6 +36,7 @@ export const routesConfig: RouteObject[] = [ element: , children: [ { path: Paths.NETWORK, element: }, + { path: Paths.CURRENCY, element: }, { path: Paths.MATRIX, element: }, ], }, diff --git a/src/renderer/app/styles/theme/default.css b/src/renderer/app/styles/theme/default.css index ded06aeb19..a467e5f774 100644 --- a/src/renderer/app/styles/theme/default.css +++ b/src/renderer/app/styles/theme/default.css @@ -55,7 +55,7 @@ --alert-border: rgb(123, 41, 255); --alert-border-warning: rgb(246, 143, 7); --alert-border-negative: rgb(245, 33, 99); - --token-border: #c3c3cb; + --token-border: #5e5e69; --border-dark: #6e6e78; /* DIVIDER */ @@ -71,7 +71,7 @@ --left-navigation-menu-background: #ffffff; --main-app-background: rgba(69, 69, 137, 0.04); --token-container-background: #ffffff; - --token-background: #79797d; + --token-background: #363643; --input-background: #fff; --input-background-disabled: rgba(69, 69, 137, 0.04); --action-background-hover: rgba(69, 69, 137, 0.06); diff --git a/src/renderer/assets/currency/currencies.json b/src/renderer/assets/currency/currencies.json new file mode 100644 index 0000000000..73a0966c3c --- /dev/null +++ b/src/renderer/assets/currency/currencies.json @@ -0,0 +1,424 @@ +[ + { + "code": "USD", + "name": "United States Dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 0, + "coingeckoId": "usd" + }, + { + "code": "EUR", + "name": "Euro", + "symbol": "€", + "category": "fiat", + "popular": true, + "id": 1, + "coingeckoId": "eur" + }, + { + "code": "JPY", + "name": "Japanese Yen", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 2, + "coingeckoId": "jpy" + }, + { + "code": "CNY", + "name": "Chinese Yuan", + "symbol": "¥", + "category": "fiat", + "popular": true, + "id": 3, + "coingeckoId": "cny" + }, + { + "code": "TWD", + "name": "New Taiwan dollar", + "symbol": "$", + "category": "fiat", + "popular": true, + "id": 4, + "coingeckoId": "twd" + }, + { + "code": "RUB", + "name": "Russian Ruble", + "symbol": "₽", + "category": "fiat", + "popular": true, + "id": 5, + "coingeckoId": "rub" + }, + { + "code": "AED", + "name": "United Arab Emirates dirham", + "category": "fiat", + "popular": true, + "id": 6, + "coingeckoId": "aed" + }, + { + "code": "IDR", + "name": "Indonesian Rupiah", + "category": "fiat", + "popular": true, + "id": 7, + "coingeckoId": "idr" + }, + { + "code": "KRW", + "name": "South Korean won", + "symbol": "₩", + "category": "fiat", + "popular": true, + "id": 8, + "coingeckoId": "krw" + }, + { + "code": "ARS", + "name": "Argentine Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 9, + "coingeckoId": "ars" + }, + { + "code": "AUD", + "name": "Australian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 10, + "coingeckoId": "aud" + }, + { + "code": "BDT", + "name": "Bangladeshi Taka", + "category": "fiat", + "popular": false, + "id": 11, + "coingeckoId": "bdt" + }, + { + "code": "BHD", + "name": "Bahraini Dinar", + "category": "fiat", + "popular": false, + "id": 12, + "coingeckoId": "bhd" + }, + { + "code": "BMD", + "name": "Bermudan Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 13, + "coingeckoId": "bmd" + }, + { + "code": "BRL", + "name": "Brazilian Real", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 14, + "coingeckoId": "brl" + }, + { + "code": "CAD", + "name": "Canadian Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 15, + "coingeckoId": "cad" + }, + { + "code": "CHF", + "name": "Swiss Franc", + "category": "fiat", + "popular": false, + "id": 16, + "coingeckoId": "chf" + }, + { + "code": "CLP", + "name": "Chilean Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 17, + "coingeckoId": "clp" + }, + { + "code": "CZK", + "name": "Czech Koruna", + "symbol": "Kč", + "category": "fiat", + "popular": false, + "id": 18, + "coingeckoId": "czk" + }, + { + "code": "DKK", + "name": "Danish Krone", + "category": "fiat", + "popular": false, + "id": 19, + "coingeckoId": "dkk" + }, + { + "code": "GBP", + "name": "British Pound Sterling", + "symbol": "£", + "category": "fiat", + "popular": false, + "id": 20, + "coingeckoId": "gbp" + }, + { + "code": "HKD", + "name": "Hong Kong Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 21, + "coingeckoId": "hkd" + }, + { + "code": "HUF", + "name": "Hungarian Forint", + "category": "fiat", + "popular": false, + "id": 22, + "coingeckoId": "huf" + }, + { + "code": "ILS", + "name": "Israeli New Shekel", + "symbol": "₪", + "category": "fiat", + "popular": false, + "id": 23, + "coingeckoId": "ils" + }, + { + "code": "INR", + "name": "Indian Rupee", + "symbol": "₹", + "category": "fiat", + "popular": false, + "id": 24, + "coingeckoId": "inr" + }, + { + "code": "KDW", + "name": "Kuwaiti Dinar", + "category": "fiat", + "popular": false, + "id": 25, + "coingeckoId": "kdw" + }, + { + "code": "LKR", + "name": "Sri Lankan Rupee", + "category": "fiat", + "popular": false, + "id": 26, + "coingeckoId": "lkr" + }, + { + "code": "MMK", + "name": "Myanmar Kyat", + "category": "fiat", + "popular": false, + "id": 27, + "coingeckoId": "mmk" + }, + { + "code": "MXN", + "name": "Mexican Peso", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 28, + "coingeckoId": "mxn" + }, + { + "code": "MYR", + "name": "Malaysian Ringgit", + "category": "fiat", + "popular": false, + "id": 29, + "coingeckoId": "myr" + }, + { + "code": "NGN", + "name": "Nigerian Naira", + "symbol": "₦", + "category": "fiat", + "popular": false, + "id": 30, + "coingeckoId": "ngn" + }, + { + "code": "NOK", + "name": "Norwegian Krone", + "category": "fiat", + "popular": false, + "id": 31, + "coingeckoId": "nok" + }, + { + "code": "NZD", + "name": "New Zealand Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 32, + "coingeckoId": "nzd" + }, + { + "code": "PHP", + "name": "Philippine peso", + "symbol": "₱", + "category": "fiat", + "popular": false, + "id": 33, + "coingeckoId": "php" + }, + { + "code": "PKR", + "name": "Pakistani Rupee", + "category": "fiat", + "popular": false, + "id": 34, + "coingeckoId": "pkr" + }, + { + "code": "PLN", + "name": "Poland złoty", + "symbol": "zł", + "category": "fiat", + "popular": false, + "id": 35, + "coingeckoId": "pln" + }, + { + "code": "SAR", + "name": "Saudi Riyal", + "category": "fiat", + "popular": false, + "id": 36, + "coingeckoId": "sar" + }, + { + "code": "SEK", + "name": "Swedish Krona", + "category": "fiat", + "popular": false, + "id": 37, + "coingeckoId": "sek" + }, + { + "code": "SGD", + "name": "Singapore Dollar", + "symbol": "$", + "category": "fiat", + "popular": false, + "id": 38, + "coingeckoId": "sgd" + }, + { + "code": "THB", + "name": "Thai Baht", + "symbol": "฿", + "category": "fiat", + "popular": false, + "id": 39, + "coingeckoId": "thb" + }, + { + "code": "TRY", + "name": "Turkish lira", + "symbol": "₺", + "category": "fiat", + "popular": false, + "id": 40, + "coingeckoId": "try" + }, + { + "code": "UAH", + "name": "Ukrainian hryvnia", + "symbol": "₴", + "category": "fiat", + "popular": false, + "id": 41, + "coingeckoId": "uah" + }, + { + "code": "VEF", + "name": "Venezuelan bolívar", + "category": "fiat", + "popular": false, + "id": 42, + "coingeckoId": "vef" + }, + { + "code": "VND", + "name": "Vietnamese dong", + "symbol": "₫", + "category": "fiat", + "popular": false, + "id": 43, + "coingeckoId": "vnd" + }, + { + "code": "ZAR", + "name": "South African rand", + "category": "fiat", + "popular": false, + "id": 44, + "coingeckoId": "zar" + }, + { + "code": "XDR", + "name": "IMF Special Drawing Rights", + "category": "fiat", + "popular": false, + "id": 45, + "coingeckoId": "xdr" + }, + { + "code": "DOT", + "name": "Polkadot", + "category": "crypto", + "popular": true, + "id": 46, + "coingeckoId": "dot" + }, + { + "code": "BTC", + "name": "Bitcoin", + "symbol": "₿", + "category": "crypto", + "popular": true, + "id": 47, + "coingeckoId": "btc" + }, + { + "code": "ETH", + "name": "Ether", + "symbol": "Ξ", + "category": "crypto", + "popular": true, + "id": 48, + "coingeckoId": "eth" + } +] diff --git a/src/renderer/assets/images/arrows/swap-arrow.svg b/src/renderer/assets/images/arrows/swap-arrow.svg new file mode 100644 index 0000000000..9dc42d6833 --- /dev/null +++ b/src/renderer/assets/images/arrows/swap-arrow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/renderer/assets/images/functionals/currency.svg b/src/renderer/assets/images/functionals/currency.svg new file mode 100644 index 0000000000..c4fad23d6a --- /dev/null +++ b/src/renderer/assets/images/functionals/currency.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/renderer/components/layout/PrimaryLayout/Wallets/ActiveAccountCard.tsx b/src/renderer/components/layout/PrimaryLayout/Wallets/ActiveAccountCard.tsx index 0271abedba..27319d0aef 100644 --- a/src/renderer/components/layout/PrimaryLayout/Wallets/ActiveAccountCard.tsx +++ b/src/renderer/components/layout/PrimaryLayout/Wallets/ActiveAccountCard.tsx @@ -6,6 +6,7 @@ import { useI18n } from '@renderer/app/providers'; import { WalletDS } from '@renderer/shared/api/storage'; import { ChainsRecord } from './common/types'; import { Account, getActiveWalletType } from '@renderer/entities/account'; +import { WalletFiatBalance } from './WalletFiatBalance'; type Props = { activeAccounts: Account[]; @@ -25,6 +26,14 @@ const ActiveAccountCard = ({ activeAccounts, chains, wallets }: Props) => { const account = isMultishard ? null : activeAccounts[0]; const addressPrefix = account?.chainId ? chains[account.chainId]?.addressPrefix : SS58_DEFAULT_PREFIX; + const walletProps = isMultishard + ? { + walletId: multishardWallet?.id, + } + : { + accountId: account?.accountId, + }; + return (
{isMultishard && multishardWallet && ( @@ -46,6 +55,7 @@ const ActiveAccountCard = ({ activeAccounts, chains, wallets }: Props) => { {t(GroupLabels[walletType])}
+ diff --git a/src/renderer/components/layout/PrimaryLayout/Wallets/WalletFiatBalance.tsx b/src/renderer/components/layout/PrimaryLayout/Wallets/WalletFiatBalance.tsx new file mode 100644 index 0000000000..d03436f475 --- /dev/null +++ b/src/renderer/components/layout/PrimaryLayout/Wallets/WalletFiatBalance.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react'; +import { useUnit } from 'effector-react'; +import BigNumber from 'bignumber.js'; + +import { formatAmount, formatBalance, formatFiatBalance, totalAmount } from '@renderer/shared/lib/utils'; +import { FiatBalance } from '@renderer/entities/price/ui/FiatBalance'; +import { currencyModel, priceProviderModel } from '@renderer/entities/price'; +import { useI18n, useNetworkContext } from '@renderer/app/providers'; +import { useAccount } from '@renderer/entities/account'; +import { useBalance } from '@renderer/entities/asset'; +import { HexString } from '@renderer/domain/shared-kernel'; +import { Shimmering } from '@renderer/shared/ui'; + +BigNumber.config({ + ROUNDING_MODE: BigNumber.ROUND_DOWN, +}); + +type Props = { + className?: string; + walletId?: string; + accountId?: HexString; +}; + +export const WalletFiatBalance = ({ className, walletId, accountId }: Props) => { + const { t } = useI18n(); + const [fiatAmount, setFiatAmount] = useState(new BigNumber(0)); + const [isLoading, setIsLoading] = useState(true); + + const { getLiveAccounts } = useAccount(); + const { connections } = useNetworkContext(); + const { getLiveBalances } = useBalance(); + + const accounts = walletId && getLiveAccounts({ walletId }); + const accountIds = accounts ? accounts.map((a) => a.accountId) : accountId ? [accountId] : []; + + const balances = getLiveBalances(accountIds); + + const currency = useUnit(currencyModel.$activeCurrency); + const prices = useUnit(priceProviderModel.$assetsPrices); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + + useEffect(() => { + setIsLoading(true); + // TODO: Move logic to model https://app.clickup.com/t/8692tr8x0 + const totalFiatAmount = balances.reduce((acc, balance) => { + const asset = connections[balance.chainId]?.assets?.find((a) => a.assetId.toString() === balance.assetId); + + if (!prices || !asset?.priceId || !currency || !currency?.coingeckoId) return acc; + + const price = prices[asset.priceId][currency.coingeckoId]; + + if (price) { + const fiatBalance = new BigNumber(price.price).multipliedBy(new BigNumber(totalAmount(balance))); + const formattedFiatBalance = formatFiatBalance(fiatBalance.toString(), asset.precision); + + acc = acc.plus(new BigNumber(formatAmount(formattedFiatBalance, 2))); + } + + return acc; + }, new BigNumber(0)); + + if (balances.length > 0) { + setIsLoading(false); + setFiatAmount(totalFiatAmount); + } + }, [walletId, accountId, balances.length, currency, prices]); + + if (!fiatFlag) return null; + if (isLoading) return ; + + const { value: formattedValue, suffix } = formatBalance(fiatAmount.toString(), 2); + + const balanceValue = t('assetBalance.number', { + value: formattedValue, + maximumFractionDigits: 2, + }); + + return ; +}; diff --git a/src/renderer/components/layout/PrimaryLayout/Wallets/WalletGroup.tsx b/src/renderer/components/layout/PrimaryLayout/Wallets/WalletGroup.tsx index ffa4e0a4a0..9ebbf9c437 100644 --- a/src/renderer/components/layout/PrimaryLayout/Wallets/WalletGroup.tsx +++ b/src/renderer/components/layout/PrimaryLayout/Wallets/WalletGroup.tsx @@ -6,9 +6,10 @@ import { Icon, HelpText, BodyText, CaptionText } from '@renderer/shared/ui'; import { WalletType } from '@renderer/domain/shared-kernel'; import { GroupIcons, GroupLabels } from '@renderer/components/layout/PrimaryLayout/Wallets/common/constants'; import { useI18n } from '@renderer/app/providers'; -import { Account, AccountAddress } from '@renderer/entities/account'; +import { Account, AddressWithTwoLines } from '@renderer/entities/account'; import { isMultishardWalletItem } from '@renderer/components/layout/PrimaryLayout/Wallets/common/utils'; import { cnTw } from '@renderer/shared/lib/utils'; +import { WalletFiatBalance } from './WalletFiatBalance'; type Props = { type: WalletType; @@ -62,15 +63,21 @@ const WalletGroup = ({ type, wallets, onWalletClick }: Props) => { {(wallet as MultishardWallet).amount} {t('wallets.shards')} + ) : ( - +
+ {(wallet as Account).name}} + secondLine={ + + } + /> +
)} diff --git a/src/renderer/entities/account/ui/AddressWithName/AddressWithName.tsx b/src/renderer/entities/account/ui/AddressWithName/AddressWithName.tsx index e9304799b2..6e152acb6f 100644 --- a/src/renderer/entities/account/ui/AddressWithName/AddressWithName.tsx +++ b/src/renderer/entities/account/ui/AddressWithName/AddressWithName.tsx @@ -1,7 +1,7 @@ import { cnTw, toShortAddress, copyToClipboard } from '@renderer/shared/lib/utils'; -import { Identicon, IconButton, Truncate } from '@renderer/shared/ui'; +import { IconButton, Truncate } from '@renderer/shared/ui'; import { SigningType, AccountId, Address } from '@renderer/domain/shared-kernel'; -import { getAddress } from '@renderer/entities/account'; +import { AddressWithTwoLines, getAddress } from '@renderer/entities/account'; type AddressType = 'full' | 'short' | 'adaptive'; @@ -72,14 +72,14 @@ export const AddressWithName = ({ ); return ( -
- {showIcon && ( - - )} -
- {firstLine} - {secondLine} -
-
+ ); }; diff --git a/src/renderer/entities/account/ui/AddressWithTwoLines/AddressWithTwoLines.tsx b/src/renderer/entities/account/ui/AddressWithTwoLines/AddressWithTwoLines.tsx new file mode 100644 index 0000000000..42ed43b987 --- /dev/null +++ b/src/renderer/entities/account/ui/AddressWithTwoLines/AddressWithTwoLines.tsx @@ -0,0 +1,50 @@ +import { ReactNode } from 'react'; + +import { cnTw } from '@renderer/shared/lib/utils'; +import { Identicon } from '@renderer/shared/ui'; +import { SigningType, AccountId, Address } from '@renderer/domain/shared-kernel'; +import { getAddress } from '@renderer/entities/account'; + +type WithAccountId = { + accountId: AccountId; + addressPrefix?: number; +}; + +type WithAddress = { + address: Address; +}; + +type Props = { + className?: string; + signType?: SigningType; + size?: number; + canCopy?: boolean; + showIcon?: boolean; + firstLine: ReactNode; + secondLine: ReactNode; +} & (WithAccountId | WithAddress); + +export const AddressWithTwoLines = ({ + className, + signType, + size, + canCopy, + showIcon, + firstLine, + secondLine, + ...props +}: Props) => { + const currentAddress = getAddress(props); + + return ( +
+ {showIcon && ( + + )} +
+ {firstLine} + {secondLine} +
+
+ ); +}; diff --git a/src/renderer/entities/account/ui/index.ts b/src/renderer/entities/account/ui/index.ts index 78e306e09b..68b66a2d8a 100644 --- a/src/renderer/entities/account/ui/index.ts +++ b/src/renderer/entities/account/ui/index.ts @@ -3,3 +3,4 @@ export type { Props as AccountAddressProps } from './AccountAddress/AccountAddre export * from './AccountsList/AccountsList'; export * from './AddressWithExplorers/AddressWithExplorers'; export * from './AddressWithName/AddressWithName'; +export * from './AddressWithTwoLines/AddressWithTwoLines'; diff --git a/src/renderer/entities/asset/ui/AssetCard/AssetCard.tsx b/src/renderer/entities/asset/ui/AssetCard/AssetCard.tsx index df1c0db42f..b3dc701c96 100644 --- a/src/renderer/entities/asset/ui/AssetCard/AssetCard.tsx +++ b/src/renderer/entities/asset/ui/AssetCard/AssetCard.tsx @@ -1,5 +1,6 @@ import { KeyboardEvent } from 'react'; import { Link } from 'react-router-dom'; +import { useUnit } from 'effector-react'; import { BodyText, Icon, Shimmering } from '@renderer/shared/ui'; import { Asset, AssetBalance, AssetDetails, AssetIcon, Balance } from '@renderer/entities/asset'; @@ -9,6 +10,10 @@ import { useI18n } from '@renderer/app/providers'; import { Paths } from '../../../../app/providers/routes/paths'; import { createLink } from '../../../../app/providers/routes/utils'; import { ChainId } from '@renderer/domain/shared-kernel'; +// TODO: Move it to another layer https://app.clickup.com/t/8692tr8x0 +import { TokenPrice } from '@renderer/entities/price/ui/TokenPrice'; +import { AssetFiatBalance } from '@renderer/entities/price/ui/AssetFiatBalance'; +import { priceProviderModel } from '@renderer/entities/price'; type Props = { chainId: ChainId; @@ -19,6 +24,7 @@ type Props = { export const AssetCard = ({ chainId, asset, balance, canMakeActions }: Props) => { const { t } = useI18n(); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); const [isExpanded, toggleExpanded] = useToggle(); @@ -48,13 +54,24 @@ export const AssetCard = ({ chainId, asset, balance, canMakeActions }: Props) =>
- {asset.name} +
+ {asset.name} + +
+
+
+ {balance?.free ? ( + <> + + + + ) : ( + <> + + {fiatFlag && } + + )}
- {balance?.free ? ( - - ) : ( - - )} {canMakeActions && (
{ {label}
{value ? : }
+
{value ? : }
); }; diff --git a/src/renderer/entities/asset/ui/AssetIcon/AssetIcon.tsx b/src/renderer/entities/asset/ui/AssetIcon/AssetIcon.tsx index e41e0ada3f..7b1d4b3032 100644 --- a/src/renderer/entities/asset/ui/AssetIcon/AssetIcon.tsx +++ b/src/renderer/entities/asset/ui/AssetIcon/AssetIcon.tsx @@ -8,6 +8,7 @@ type Props = { className?: string; }; +// TODO add currency support export const AssetIcon = ({ src, name, size = 32, className }: Props) => { const [isImgLoaded, toggleImgLoaded] = useToggle(); diff --git a/src/renderer/entities/network/lib/__tests__/chainsService.test.ts b/src/renderer/entities/network/lib/__tests__/chainsService.test.ts index 368b1d25b3..ed90ddd661 100644 --- a/src/renderer/entities/network/lib/__tests__/chainsService.test.ts +++ b/src/renderer/entities/network/lib/__tests__/chainsService.test.ts @@ -1,3 +1,4 @@ +import { HexString } from '@renderer/domain/shared-kernel'; import { chainsService } from '../chainsService'; describe('service/chainsService', () => { @@ -19,4 +20,103 @@ describe('service/chainsService', () => { expect(data).toEqual([polkadot, kusama, parachain, threeDPass, testnet]); }); + + test('should sort chains with balances', () => { + const assets = [ + { + assetId: 1, + priceId: '1', + name: '', + symbol: '', + precision: 0, + icon: '', + }, + { + assetId: 2, + priceId: '2', + name: '', + symbol: '', + precision: 0, + icon: '', + }, + ]; + + const polkadot = { + name: 'Polkadot', + chainId: '0x00' as HexString, + assets, + nodes: [], + icon: '', + addressPrefix: 42, + }; + const kusama = { name: 'Kusama', chainId: '0x01' as HexString, assets, nodes: [], icon: '', addressPrefix: 42 }; + const threeDPass = { name: '3dPass', chainId: '0x02' as HexString, assets, nodes: [], icon: '', addressPrefix: 42 }; + const testnet = { + name: 'Westend', + chainId: '0x03' as HexString, + assets, + nodes: [], + icon: '', + addressPrefix: 42, + options: ['testnet'], + }; + const parachain = { name: 'Acala', chainId: '0x04' as HexString, assets, nodes: [], icon: '', addressPrefix: 42 }; + + const balances = [ + { + accountId: '0x00' as HexString, + chainId: '0x00' as HexString, + assetId: '1', + free: '2', + }, + { + accountId: '0x00' as HexString, + chainId: '0x00' as HexString, + assetId: '2', + free: '2', + }, + { + accountId: '0x00' as HexString, + chainId: '0x01' as HexString, + assetId: '2', + free: '2', + }, + { + accountId: '0x00' as HexString, + chainId: '0x02' as HexString, + assetId: '2', + free: '2', + }, + { + accountId: '0x00' as HexString, + chainId: '0x02' as HexString, + assetId: '2', + free: '1', + }, + ]; + + const assetsPrices = { + '1': { + usd: { + price: 1, + change: 0, + }, + }, + '2': { + usd: { + price: 0.1, + change: 0, + }, + }, + }; + + const data = chainsService.sortChainsByBalance( + [testnet, polkadot, threeDPass, parachain, kusama], + balances, + assetsPrices, + 'usd', + ); + + expect(data).toEqual([polkadot, threeDPass, kusama, parachain, testnet]); + }); }); diff --git a/src/renderer/entities/network/lib/chainsService.ts b/src/renderer/entities/network/lib/chainsService.ts index e2e168d991..89fff83139 100644 --- a/src/renderer/entities/network/lib/chainsService.ts +++ b/src/renderer/entities/network/lib/chainsService.ts @@ -1,16 +1,28 @@ import sortBy from 'lodash/sortBy'; import concat from 'lodash/concat'; -import keyBy from 'lodash/keyBy'; import orderBy from 'lodash/orderBy'; +import BigNumber from 'bignumber.js'; import { Chain } from '@renderer/entities/chain/model/chain'; import chainsProd from '@renderer/assets/chains/chains.json'; import chainsDev from '@renderer/assets/chains/chains_dev.json'; import { ChainId } from '@renderer/domain/shared-kernel'; -import { getRelaychainAsset, nonNullable, totalAmount, ZERO_BALANCE } from '@renderer/shared/lib/utils'; +import { + formatFiatBalance, + getRelaychainAsset, + nonNullable, + totalAmount, + ZERO_BALANCE, +} from '@renderer/shared/lib/utils'; import { Balance } from '@renderer/entities/asset/model/balance'; import { ChainLike } from './common/types'; import { isKusama, isPolkadot, isTestnet, isNameWithNumber } from './common/utils'; +import { PriceObject } from '@renderer/shared/api/price-provider'; +import { sumBalances } from '@renderer/pages/Assets/AssetsList/common/utils'; + +type ChainWithFiatBalance = Chain & { + fiatBalance: string; +}; const CHAINS: Record = { chains: chainsProd, @@ -70,15 +82,50 @@ function sortChains(chains: T[]): T[] { ); } -function sortChainsByBalance(chains: Chain[], balances: Balance[]): Chain[] { +const compareFiatBalances = (a: ChainWithFiatBalance, b: ChainWithFiatBalance) => { + return new BigNumber(b.fiatBalance).lt(new BigNumber(a.fiatBalance)) ? -1 : 1; +}; + +function sortChainsByBalance( + chains: Chain[], + balances: Balance[], + assetPrices: PriceObject | null, + currency?: string, +): Chain[] { + const chainsWithFiatBalance = [] as ChainWithFiatBalance[]; + const relaychains = { withBalance: [], noBalance: [] }; const parachains = { withBalance: [], noBalance: [] }; const numberchains = { withBalance: [], noBalance: [] }; const testnets = { withBalance: [], noBalance: [] }; - const balancesMap = keyBy(balances, (b) => `${b.chainId}_${b.assetId}`); + const balancesMap = balances.reduce>((acc, balance) => { + const key = `${balance.chainId}_${balance.assetId}`; + acc[key] = acc[key] ? sumBalances(acc[key], balance) : balance; + + return acc; + }, {}); chains.forEach((chain) => { + const fiatBalance = chain.assets.reduce((acc, a) => { + const amount = totalAmount(balancesMap[`${chain.chainId}_${a.assetId}`]); + const assetPrice = a.priceId && currency && assetPrices?.[a.priceId]?.[currency]?.price; + const fiatBalance = formatFiatBalance( + new BigNumber(amount).multipliedBy(assetPrice || 0).toString(), + a.precision, + ); + + return acc.plus(new BigNumber(fiatBalance)); + }, new BigNumber(0)); + + if (fiatBalance.gt(0) && !isTestnet(chain.options)) { + (chain as ChainWithFiatBalance).fiatBalance = fiatBalance.toString(); + + chainsWithFiatBalance.push(chain as ChainWithFiatBalance); + + return; + } + const hasBalance = chain.assets.some((a) => { return totalAmount(balancesMap[`${chain.chainId}_${a.assetId}`]) !== ZERO_BALANCE; }); @@ -97,6 +144,7 @@ function sortChainsByBalance(chains: Chain[], balances: Balance[]): Chain[] { }); return concat( + chainsWithFiatBalance.sort(compareFiatBalances), orderBy(relaychains.withBalance, 'name', ['desc']), orderBy(relaychains.noBalance, 'name', ['desc']), sortBy(parachains.withBalance, 'name'), diff --git a/src/renderer/entities/price/index.ts b/src/renderer/entities/price/index.ts new file mode 100644 index 0000000000..2840d01dba --- /dev/null +++ b/src/renderer/entities/price/index.ts @@ -0,0 +1,3 @@ +export { priceProviderModel } from './model/price-provider-model'; +export { currencyModel } from './model/currency-model'; +export { useCurrencyRate } from './lib/useCurrencyRate'; diff --git a/src/renderer/entities/price/lib/constants.ts b/src/renderer/entities/price/lib/constants.ts new file mode 100644 index 0000000000..fa0b3b9556 --- /dev/null +++ b/src/renderer/entities/price/lib/constants.ts @@ -0,0 +1,7 @@ +import { PriceApiProvider } from './types'; + +export const DEFAULT_FIAT_FLAG = true; +export const DEFAULT_CURRENCY_CODE = 'usd'; +export const DEFAULT_FIAT_PROVIDER = PriceApiProvider.COINGEKO; +export const DEFAULT_ASSETS_PRICES = {}; +export const ZERO_FIAT_BALANCE = '0.00'; diff --git a/src/renderer/entities/price/lib/types.ts b/src/renderer/entities/price/lib/types.ts new file mode 100644 index 0000000000..977633b110 --- /dev/null +++ b/src/renderer/entities/price/lib/types.ts @@ -0,0 +1,3 @@ +export const enum PriceApiProvider { + COINGEKO = 'coingeko', +} diff --git a/src/renderer/entities/price/lib/useCurrencyRate.ts b/src/renderer/entities/price/lib/useCurrencyRate.ts new file mode 100644 index 0000000000..1fee277be9 --- /dev/null +++ b/src/renderer/entities/price/lib/useCurrencyRate.ts @@ -0,0 +1,13 @@ +import { useUnit } from 'effector-react'; + +import { currencyModel, priceProviderModel } from '@renderer/entities/price'; + +export const useCurrencyRate = (assetId?: string, showCurrency?: boolean): number | null => { + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + const activeCurrency = useUnit(currencyModel.$activeCurrency); + const assetsPrices = useUnit(priceProviderModel.$assetsPrices); + + if (!showCurrency || !fiatFlag || !activeCurrency || !assetsPrices || !assetId) return null; + + return assetsPrices[assetId][activeCurrency.coingeckoId].price; +}; diff --git a/src/renderer/entities/price/model/__tests__/currency-model.test.ts b/src/renderer/entities/price/model/__tests__/currency-model.test.ts new file mode 100644 index 0000000000..e69ba7042f --- /dev/null +++ b/src/renderer/entities/price/model/__tests__/currency-model.test.ts @@ -0,0 +1,61 @@ +import { fork, allSettled } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { currencyModel } from '../currency-model'; +import { fiatService, CurrencyItem } from '@renderer/shared/api/price-provider'; + +describe('entities/price/model/currency-model', () => { + const config: CurrencyItem[] = [ + { + code: 'EUR', + name: 'Euro', + symbol: '€', + category: 'fiat', + popular: true, + id: 1, + coingeckoId: 'eur', + }, + { + code: 'USD', + name: 'United States Dollar', + symbol: '$', + category: 'fiat', + popular: true, + id: 0, + coingeckoId: 'usd', + }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should setup $currencyConfig on app start', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + + const scope = fork(); + expect(scope.getState(currencyModel.$currencyConfig)).toEqual([]); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(currencyModel.$currencyConfig)).toEqual(config); + }); + + test('should setup $activeCurrency on app start', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + jest.spyOn(fiatService, 'getActiveCurrencyCode').mockReturnValue('usd'); + + const scope = fork(); + expect(scope.getState(currencyModel.$activeCurrency)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(currencyModel.$activeCurrency)).toEqual(config[1]); + }); + + test('should change $activeCurrency when currencyChanged', async () => { + jest.spyOn(fiatService, 'getCurrencyConfig').mockReturnValue(config); + + const scope = fork(); + await allSettled(kernelModel.events.appStarted, { scope }); + await allSettled(currencyModel.events.currencyChanged, { scope, params: 1 }); + + expect(scope.getState(currencyModel.$activeCurrency)).toEqual(config[0]); + }); +}); diff --git a/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts b/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts new file mode 100644 index 0000000000..ed135839d4 --- /dev/null +++ b/src/renderer/entities/price/model/__tests__/price-provider-model.test.ts @@ -0,0 +1,117 @@ +import { fork, allSettled } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { fiatService, PriceObject, coingekoService, CurrencyItem } from '@renderer/shared/api/price-provider'; +import { priceProviderModel } from '../price-provider-model'; +import { PriceApiProvider } from '../../lib/types'; +import { currencyModel } from '../currency-model'; + +describe('entities/price/model/price-provider-model', () => { + const prices: PriceObject = { + kusama: { + usd: { price: 19.24, change: -4.745815232356294 }, + }, + }; + const config: CurrencyItem[] = [ + { + code: 'EUR', + name: 'Euro', + symbol: '€', + category: 'fiat', + popular: true, + id: 1, + coingeckoId: 'eur', + }, + { + code: 'USD', + name: 'United States Dollar', + symbol: '$', + category: 'fiat', + popular: true, + id: 0, + coingeckoId: 'usd', + }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('should setup $fiatFlag on app start', async () => { + jest.spyOn(fiatService, 'getFiatFlag').mockReturnValue(true); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$fiatFlag)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$fiatFlag)).toEqual(true); + }); + + test('should setup $priceProvider on app start', async () => { + const provider = PriceApiProvider.COINGEKO; + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(provider); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$priceProvider)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$priceProvider)).toEqual(provider); + }); + + test('should setup $assetsPrices on app start', async () => { + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(null); + jest.spyOn(fiatService, 'getAssetsPrices').mockReturnValue(prices); + + const scope = fork(); + expect(scope.getState(priceProviderModel.$assetsPrices)).toBeNull(); + await allSettled(kernelModel.events.appStarted, { scope }); + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(prices); + }); + + test('should change $fiatFlag when fiatFlagChanged', async () => { + jest.spyOn(fiatService, 'getFiatFlag').mockReturnValue(true); + + const scope = fork(); + await allSettled(kernelModel.events.appStarted, { scope }); + await allSettled(priceProviderModel.events.fiatFlagChanged, { scope, params: false }); + expect(scope.getState(priceProviderModel.$fiatFlag)).toEqual(false); + }); + + test('should change $priceProvider when priceProviderChanged', async () => { + jest.spyOn(fiatService, 'getPriceProvider').mockReturnValue(PriceApiProvider.COINGEKO); + + const scope = fork(); + await allSettled(priceProviderModel.events.priceProviderChanged, { scope, params: 'my_provider' }); + expect(scope.getState(priceProviderModel.$priceProvider)).toEqual('my_provider'); + }); + + test('should fetch $assetsPrices when assetsPricesRequested', async () => { + jest.spyOn(coingekoService, 'getPrice').mockResolvedValue(prices); + + const scope = fork({ + values: new Map() + .set(priceProviderModel.$priceProvider, PriceApiProvider.COINGEKO) + .set(currencyModel.$activeCurrency, 'usd'), + }); + await allSettled(priceProviderModel.events.assetsPricesRequested, { scope, params: { includeRates: false } }); + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(prices); + }); + + test('should update $assetPrices when currencyChanged', async () => { + const newPrices = { + kusama: { + eur: { price: 11.1, change: 22.2 }, + }, + }; + jest.spyOn(coingekoService, 'getPrice').mockResolvedValue(newPrices); + + const scope = fork({ + values: new Map() + .set(priceProviderModel.$assetsPrices, prices) + .set(currencyModel.$currencyConfig, config) + .set(priceProviderModel.$priceProvider, PriceApiProvider.COINGEKO), + }); + + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(prices); + await allSettled(currencyModel.events.currencyChanged, { scope, params: 1 }); + expect(scope.getState(priceProviderModel.$assetsPrices)).toEqual(newPrices); + }); +}); diff --git a/src/renderer/entities/price/model/currency-model.ts b/src/renderer/entities/price/model/currency-model.ts new file mode 100644 index 0000000000..12ef2fa87d --- /dev/null +++ b/src/renderer/entities/price/model/currency-model.ts @@ -0,0 +1,82 @@ +import { createEvent, createStore, createEffect, forward, sample } from 'effector'; + +import { kernelModel } from '@renderer/shared/core'; +import { CurrencyItem, fiatService } from '@renderer/shared/api/price-provider'; +import { DEFAULT_CURRENCY_CODE } from '../lib/constants'; + +const $currencyConfig = createStore([]); +const $activeCurrency = createStore(null); +const $activeCurrencyCode = createStore(null); + +const currencyChanged = createEvent(); + +const getCurrencyConfigFx = createEffect((): CurrencyItem[] => { + return fiatService.getCurrencyConfig(); +}); + +const getActiveCurrencyCodeFx = createEffect((): string => { + return fiatService.getActiveCurrencyCode(DEFAULT_CURRENCY_CODE); +}); + +const saveActiveCurrencyCodeFx = createEffect((currency: CurrencyItem) => { + fiatService.saveActiveCurrencyCode(currency.code); +}); + +type ChangeParams = { + id?: CurrencyItem['id']; + code?: CurrencyItem['code']; + config: CurrencyItem[]; +}; +const currencyChangedFx = createEffect(({ id, code, config }) => { + return config.find((currency) => { + const hasId = currency.id === id; + const hasCode = currency.code.toLowerCase() === code?.toLowerCase(); + + return hasId || hasCode; + }); +}); + +forward({ + from: kernelModel.events.appStarted, + to: [getActiveCurrencyCodeFx, getCurrencyConfigFx], +}); + +forward({ from: getActiveCurrencyCodeFx.doneData, to: $activeCurrencyCode }); + +forward({ from: getCurrencyConfigFx.doneData, to: $currencyConfig }); + +sample({ + clock: getCurrencyConfigFx.doneData, + source: $activeCurrencyCode, + filter: (code: CurrencyItem['code'] | null): code is CurrencyItem['code'] => Boolean(code), + fn: (code, config) => ({ code, config }), + target: currencyChangedFx, +}); + +sample({ + clock: currencyChanged, + source: $currencyConfig, + fn: (config, id) => ({ config, id }), + target: currencyChangedFx, +}); + +sample({ + clock: currencyChangedFx.doneData, + source: $activeCurrency, + filter: (prev, next) => prev?.id !== next?.id, + fn: (_, next) => next!, + target: [$activeCurrency, saveActiveCurrencyCodeFx], +}); + +export const currencyModel = { + $currencyConfig, + $activeCurrency, + events: { + currencyChanged, + }, + watch: { + currencyChangedDone: currencyChangedFx.done, + currencyChangedFail: currencyChangedFx.fail, + activeCurrencyLoaded: getActiveCurrencyCodeFx.done, + }, +}; diff --git a/src/renderer/entities/price/model/price-provider-model.ts b/src/renderer/entities/price/model/price-provider-model.ts new file mode 100644 index 0000000000..b6b45ea05c --- /dev/null +++ b/src/renderer/entities/price/model/price-provider-model.ts @@ -0,0 +1,107 @@ +import { createEvent, createStore, forward, createEffect, sample } from 'effector'; + +import { PriceApiProvider } from '../lib/types'; +import { DEFAULT_FIAT_PROVIDER, DEFAULT_ASSETS_PRICES, DEFAULT_FIAT_FLAG } from '../lib/constants'; +import { fiatService, coingekoService, PriceObject, PriceAdapter } from '@renderer/shared/api/price-provider'; +import { kernelModel } from '@renderer/shared/core'; +import { chainsService } from '@renderer/entities/network'; +import { nonNullable } from '@renderer/shared/lib/utils'; +import { currencyModel } from './currency-model'; + +const $fiatFlag = createStore(null); +const $priceProvider = createStore(null); +const $assetsPrices = createStore(null); + +const fiatFlagChanged = createEvent(); +const priceProviderChanged = createEvent(); +const assetsPricesRequested = createEvent<{ includeRates: boolean }>(); + +const getFiatFlagFx = createEffect((): boolean => { + return fiatService.getFiatFlag(DEFAULT_FIAT_FLAG); +}); + +const saveFiatFlagFx = createEffect((flag: boolean): boolean => { + return fiatService.saveFiatFlag(flag); +}); + +const getPriceProviderFx = createEffect((): PriceApiProvider => { + return fiatService.getPriceProvider(DEFAULT_FIAT_PROVIDER); +}); + +const savePriceProviderFx = createEffect((provider: PriceApiProvider): PriceApiProvider => { + return fiatService.savePriceProvider(provider); +}); + +type FetchPrices = { + provider: PriceApiProvider; + currencies: string[]; + includeRates: boolean; +}; +const fetchAssetsPricesFx = createEffect(({ provider, currencies, includeRates }) => { + const ProvidersMap: Record = { + [PriceApiProvider.COINGEKO]: coingekoService, + }; + + const priceIds = chainsService.getChainsData().reduce((acc, chain) => { + const ids = chain.assets.map((asset) => asset.priceId).filter(nonNullable); + acc.push(...ids); + + return acc; + }, []); + + return ProvidersMap[provider].getPrice(priceIds, currencies, includeRates); +}); + +const getAssetsPricesFx = createEffect((): PriceObject => { + return fiatService.getAssetsPrices(DEFAULT_ASSETS_PRICES); +}); + +const saveAssetsPricesFx = createEffect((prices: PriceObject): PriceObject => { + return fiatService.saveAssetsPrices(prices); +}); + +forward({ + from: kernelModel.events.appStarted, + to: [getFiatFlagFx, getPriceProviderFx, getAssetsPricesFx], +}); + +forward({ from: getFiatFlagFx.doneData, to: $fiatFlag }); + +forward({ from: getPriceProviderFx.doneData, to: $priceProvider }); + +forward({ from: getAssetsPricesFx.doneData, to: $assetsPrices }); + +sample({ + clock: [assetsPricesRequested, $priceProvider, currencyModel.$activeCurrency], + source: { provider: $priceProvider, currency: currencyModel.$activeCurrency }, + filter: ({ provider, currency }) => provider !== null && currency !== null, + fn: ({ provider, currency }) => { + return { provider: provider!, currencies: [currency!.coingeckoId], includeRates: true }; + }, + target: fetchAssetsPricesFx, +}); + +forward({ from: fiatFlagChanged, to: saveFiatFlagFx }); +forward({ from: saveFiatFlagFx.doneData, to: $fiatFlag }); + +forward({ from: priceProviderChanged, to: savePriceProviderFx }); +forward({ from: savePriceProviderFx.doneData, to: $priceProvider }); + +forward({ from: fetchAssetsPricesFx.doneData, to: saveAssetsPricesFx }); +forward({ from: saveAssetsPricesFx.doneData, to: $assetsPrices }); + +export const priceProviderModel = { + $fiatFlag, + $priceProvider, + $assetsPrices, + events: { + fiatFlagChanged, + priceProviderChanged, + assetsPricesRequested, + }, + watch: { + fiatFlagChangedDone: saveFiatFlagFx.done, + fiatFlagChangedFail: saveFiatFlagFx.fail, + fiatFlagLoaded: getFiatFlagFx.done, + }, +}; diff --git a/src/renderer/entities/price/ui/AssetFiatBalance.tsx b/src/renderer/entities/price/ui/AssetFiatBalance.tsx new file mode 100644 index 0000000000..61b8a4ab21 --- /dev/null +++ b/src/renderer/entities/price/ui/AssetFiatBalance.tsx @@ -0,0 +1,48 @@ +import { useStoreMap, useUnit } from 'effector-react'; +import BN from 'bignumber.js'; + +import { Shimmering } from '@renderer/shared/ui'; +import { priceProviderModel } from '../model/price-provider-model'; +import { currencyModel } from '../model/currency-model'; +import { formatBalance } from '@renderer/shared/lib/utils'; +import { FiatBalance } from './FiatBalance'; +import { ZERO_FIAT_BALANCE } from '../lib/constants'; +import { Asset } from '@renderer/entities/asset'; +import { useI18n } from '@renderer/app/providers'; + +type Props = { + asset: Asset; + amount?: string; + className?: string; +}; + +export const AssetFiatBalance = ({ asset, amount, className }: Props) => { + const { t } = useI18n(); + + const currency = useUnit(currencyModel.$activeCurrency); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + const price = useStoreMap(priceProviderModel.$assetsPrices, (prices) => { + if (!currency || !prices) return; + + return asset.priceId && prices[asset.priceId][currency.coingeckoId]; + }); + + if (!fiatFlag) return null; + + if (!asset.priceId || !amount) { + return ; + } + + if (!price) return ; + + const priceToShow = new BN(price.price).multipliedBy(new BN(amount)); + + const { value: formattedValue, suffix } = formatBalance(priceToShow.toString(), asset.precision); + + const balanceValue = t('assetBalance.number', { + value: formattedValue, + maximumFractionDigits: 2, + }); + + return ; +}; diff --git a/src/renderer/entities/price/ui/FiatBalance.tsx b/src/renderer/entities/price/ui/FiatBalance.tsx new file mode 100644 index 0000000000..cc2964d9cf --- /dev/null +++ b/src/renderer/entities/price/ui/FiatBalance.tsx @@ -0,0 +1,24 @@ +import { useUnit } from 'effector-react'; + +import { FootnoteText, Shimmering } from '@renderer/shared/ui'; +import { currencyModel } from '../model/currency-model'; +import { cnTw } from '@renderer/shared/lib/utils'; +import { Price } from './Price'; + +type Props = { + priceId?: string; + amount?: string; + className?: string; +}; + +export const FiatBalance = ({ amount, className }: Props) => { + const currency = useUnit(currencyModel.$activeCurrency); + + if (!amount) return ; + + return ( + + + + ); +}; diff --git a/src/renderer/entities/price/ui/Price.tsx b/src/renderer/entities/price/ui/Price.tsx new file mode 100644 index 0000000000..f9ea192fde --- /dev/null +++ b/src/renderer/entities/price/ui/Price.tsx @@ -0,0 +1,13 @@ +import { useI18n } from '@renderer/app/providers'; + +type Props = { + amount: string; + code: string; + symbol?: string; +}; + +export const Price = ({ amount, code, symbol }: Props) => { + const { t } = useI18n(); + + return <>{symbol ? t('price.withSymbol', { amount, symbol }) : t('price.withCode', { amount, code })}; +}; diff --git a/src/renderer/entities/price/ui/TokenPrice.tsx b/src/renderer/entities/price/ui/TokenPrice.tsx new file mode 100644 index 0000000000..039afaedac --- /dev/null +++ b/src/renderer/entities/price/ui/TokenPrice.tsx @@ -0,0 +1,46 @@ +import { useStoreMap, useUnit } from 'effector-react'; +import BN from 'bignumber.js'; + +import { FootnoteText, Shimmering } from '@renderer/shared/ui'; +import { priceProviderModel } from '../model/price-provider-model'; +import { currencyModel } from '../model/currency-model'; +import { Decimal } from '@renderer/shared/lib/utils'; +import { ZERO_FIAT_BALANCE } from '../lib/constants'; +import { FiatBalance } from './FiatBalance'; + +type Props = { + assetId?: string; + className?: string; +}; + +export const TokenPrice = ({ assetId, className }: Props) => { + const currency = useUnit(currencyModel.$activeCurrency); + const price = useStoreMap(priceProviderModel.$assetsPrices, (prices) => { + if (!currency || !prices) return; + + return assetId && prices[assetId]?.[currency.coingeckoId]; + }); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); + + if (!fiatFlag) return null; + + if (!assetId) { + return ; + } + + if (!price) return ; + + const isGrow = price.change >= 0; + const changeToShow = price.change && `${isGrow ? '+' : ''}${price.change.toFixed(2)}`; + const changeStyle = isGrow ? 'text-text-positive' : 'text-text-negative'; + + const priceToShow = new BN(price.price || 0).toFormat(Decimal.BIG_NUMBER); + + return ( +
+ + + {Boolean(price.change) && ({changeToShow}%)} +
+ ); +}; diff --git a/src/renderer/entities/transaction/ui/Deposit/Deposit.tsx b/src/renderer/entities/transaction/ui/Deposit/Deposit.tsx index 1397834ca5..27025ff0e0 100644 --- a/src/renderer/entities/transaction/ui/Deposit/Deposit.tsx +++ b/src/renderer/entities/transaction/ui/Deposit/Deposit.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, memo } from 'react'; import { Asset, AssetBalance } from '@renderer/entities/asset'; import { Threshold } from '@renderer/domain/shared-kernel'; import { useTransaction } from '@renderer/entities/transaction'; +import { AssetFiatBalance } from '@renderer/entities/price/ui/AssetFiatBalance'; type Props = { api: ApiPromise; @@ -15,7 +16,6 @@ type Props = { export const Deposit = memo(({ api, asset, threshold, className, onDepositChange }: Props) => { const { getTransactionDeposit } = useTransaction(); - const [deposit, setDeposit] = useState(''); useEffect(() => { @@ -25,5 +25,10 @@ export const Deposit = memo(({ api, asset, threshold, className, onDepositChange onDepositChange?.(txDeposit); }, [threshold, api]); - return ; + return ( +
+ + +
+ ); }); diff --git a/src/renderer/entities/transaction/ui/Fee/Fee.tsx b/src/renderer/entities/transaction/ui/Fee/Fee.tsx index 94fdb45e58..0bf7bfa65c 100644 --- a/src/renderer/entities/transaction/ui/Fee/Fee.tsx +++ b/src/renderer/entities/transaction/ui/Fee/Fee.tsx @@ -1,10 +1,13 @@ import { ApiPromise } from '@polkadot/api'; import { BN } from '@polkadot/util'; import { useEffect, useState, memo } from 'react'; +import { useUnit } from 'effector-react'; import { Asset, AssetBalance } from '@renderer/entities/asset'; import { Transaction, useTransaction } from '@renderer/entities/transaction'; import { Shimmering } from '@renderer/shared/ui'; +import { priceProviderModel } from '@renderer/entities/price'; +import { AssetFiatBalance } from '@renderer/entities/price/ui/AssetFiatBalance'; type Props = { api: ApiPromise; @@ -18,6 +21,7 @@ type Props = { export const Fee = memo(({ api, multiply = 1, asset, transaction, className, onFeeChange, onFeeLoading }: Props) => { const { getTransactionFee } = useTransaction(); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); const [fee, setFee] = useState(''); const [isLoading, setIsLoading] = useState(false); @@ -49,10 +53,20 @@ export const Fee = memo(({ api, multiply = 1, asset, transaction, className, onF }, [transaction, api]); if (isLoading) { - return ; + return ( +
+ + {fiatFlag && } +
+ ); } const totalFee = new BN(fee).muln(multiply).toString(); - return ; + return ( +
+ + +
+ ); }); diff --git a/src/renderer/entities/transaction/ui/XcmFee/XcmFee.tsx b/src/renderer/entities/transaction/ui/XcmFee/XcmFee.tsx index f6b4a4f41f..96da87f9f1 100644 --- a/src/renderer/entities/transaction/ui/XcmFee/XcmFee.tsx +++ b/src/renderer/entities/transaction/ui/XcmFee/XcmFee.tsx @@ -1,12 +1,15 @@ import { BN } from '@polkadot/util'; import { useEffect, useState, memo } from 'react'; import { ApiPromise } from '@polkadot/api'; +import { useUnit } from 'effector-react'; import { Asset, AssetBalance } from '@renderer/entities/asset'; import { Transaction } from '@renderer/entities/transaction'; import { Shimmering } from '@renderer/shared/ui'; import { estimateFee, XcmConfig } from '@renderer/shared/api/xcm'; import { toLocalChainId } from '@renderer/shared/lib/utils'; +import { priceProviderModel } from '@renderer/entities/price'; +import { AssetFiatBalance } from '@renderer/entities/price/ui/AssetFiatBalance'; type Props = { api?: ApiPromise; @@ -23,6 +26,7 @@ export const XcmFee = memo( ({ multiply = 1, config, asset, transaction, className, onFeeChange, onFeeLoading, api }: Props) => { const [fee, setFee] = useState('0'); const [isLoading, setIsLoading] = useState(false); + const fiatFlag = useUnit(priceProviderModel.$fiatFlag); const updateFee = (fee: string) => { setFee(fee); @@ -68,11 +72,21 @@ export const XcmFee = memo( }, [transaction]); if (isLoading) { - return ; + return ( +
+ + {fiatFlag && } +
+ ); } const totalFee = new BN(fee).muln(multiply).toString(); - return ; + return ( +
+ + +
+ ); }, ); diff --git a/src/renderer/features/currency/CurrencyForm/index.ts b/src/renderer/features/currency/CurrencyForm/index.ts new file mode 100644 index 0000000000..c177a51cf0 --- /dev/null +++ b/src/renderer/features/currency/CurrencyForm/index.ts @@ -0,0 +1 @@ +export { CurrencyForm } from './ui/CurrencyForm'; diff --git a/src/renderer/features/currency/CurrencyForm/model/__tests__/currency-form.test.ts b/src/renderer/features/currency/CurrencyForm/model/__tests__/currency-form.test.ts new file mode 100644 index 0000000000..582813ff3b --- /dev/null +++ b/src/renderer/features/currency/CurrencyForm/model/__tests__/currency-form.test.ts @@ -0,0 +1,52 @@ +import { fork, allSettled } from 'effector'; + +import { currencyFormModel } from '../currency-form'; +import { currencyModel, priceProviderModel } from '@renderer/entities/price'; + +describe('features/currency/model/currency-form', () => { + const config = [ + { id: 0, code: 'USD', popular: true, category: 'fiat' }, + { id: 1, code: 'EUR', popular: false, category: 'fiat' }, + { id: 2, code: 'RUB', popular: true, category: 'fiat' }, + { id: 3, code: 'ETH', popular: false, category: 'crypto' }, + ]; + + test('should submit new $fiatFlag & $activeCurrency', async () => { + const scope = fork({ + values: new Map().set(priceProviderModel.$fiatFlag, false).set(currencyModel.$currencyConfig, config), + }); + + const { fiatFlag, currency } = currencyFormModel.$currencyForm.fields; + await allSettled(fiatFlag.onChange, { scope, params: true }); + await allSettled(currency.onChange, { scope, params: 1 }); + await allSettled(currencyFormModel.$currencyForm.submit, { scope }); + + expect(scope.getState(currencyModel.$activeCurrency)).toEqual(config[1]); + expect(scope.getState(priceProviderModel.$fiatFlag)).toEqual(true); + }); + + test('should filter currencies', () => { + const scope = fork({ + values: new Map().set(currencyModel.$currencyConfig, config), + }); + + const { $cryptoCurrencies, $popularFiatCurrencies, $unpopularFiatCurrencies } = currencyFormModel; + + expect(scope.getState($cryptoCurrencies)).toEqual([config[3]]); + expect(scope.getState($popularFiatCurrencies)).toEqual([config[0], config[2]]); + expect(scope.getState($unpopularFiatCurrencies)).toEqual([config[1]]); + }); + + test('should set form initial values on formInitiated event', async () => { + const scope = fork({ + values: new Map().set(priceProviderModel.$fiatFlag, true).set(currencyModel.$activeCurrency, config[1]), + }); + + await allSettled(currencyFormModel.events.formInitiated, { scope }); + + const { fiatFlag, currency } = currencyFormModel.$currencyForm.fields; + + expect(scope.getState(fiatFlag.$value)).toEqual(true); + expect(scope.getState(currency.$value)).toEqual(config[1].id); + }); +}); diff --git a/src/renderer/features/currency/CurrencyForm/model/currency-form.ts b/src/renderer/features/currency/CurrencyForm/model/currency-form.ts new file mode 100644 index 0000000000..d4e576b323 --- /dev/null +++ b/src/renderer/features/currency/CurrencyForm/model/currency-form.ts @@ -0,0 +1,92 @@ +import { sample, createStore, createApi, attach, createEvent, combine } from 'effector'; +import { createForm } from 'effector-forms'; +import { spread, combineEvents } from 'patronum'; + +import { currencyModel, priceProviderModel } from '@renderer/entities/price'; +import { CurrencyItem } from '@renderer/shared/api/price-provider'; + +export type Callbacks = { + onSubmit: () => void; +}; + +const $callbacks = createStore(null); +const callbacksApi = createApi($callbacks, { + callbacksChanged: (state, props: Callbacks) => ({ ...state, ...props }), +}); + +const formInitiated = createEvent(); + +const $currencyForm = createForm({ + fields: { + fiatFlag: { init: false }, + currency: { init: 0 as CurrencyItem['id'] }, + }, + validateOn: ['submit'], +}); + +const $cryptoCurrencies = currencyModel.$currencyConfig.map((config) => { + return config.filter((c) => c.category === 'crypto'); +}); +const $popularFiatCurrencies = currencyModel.$currencyConfig.map((config) => { + return config.filter((c) => c.category === 'fiat' && c.popular); +}); +const $unpopularFiatCurrencies = currencyModel.$currencyConfig.map((config) => { + return config.filter((c) => c.category === 'fiat' && !c.popular); +}); + +const $isFormValid = combine( + $currencyForm.fields.currency.$isDirty, + $currencyForm.fields.fiatFlag.$isDirty, + (isCurrencyDirty, isFiatFlagDirty) => isFiatFlagDirty || isCurrencyDirty, +); + +type Params = { + fiatFlag: boolean | null; + currency: CurrencyItem | null; +}; + +sample({ + clock: [priceProviderModel.watch.fiatFlagLoaded, currencyModel.watch.activeCurrencyLoaded, formInitiated], + source: { + fiatFlag: priceProviderModel.$fiatFlag, + currency: currencyModel.$activeCurrency, + }, + fn: ({ fiatFlag, currency }: Params) => ({ fiatFlag: Boolean(fiatFlag), currency: currency?.id || 0 }), + target: $currencyForm.setInitialForm, +}); + +sample({ + clock: $currencyForm.submit, + source: { + fiatFlag: $currencyForm.fields.fiatFlag.$value, + currency: $currencyForm.fields.currency.$value, + }, + target: spread({ + targets: { + fiatFlag: priceProviderModel.events.fiatFlagChanged, + currency: currencyModel.events.currencyChanged, + }, + }), +}); + +sample({ + clock: combineEvents({ + events: [priceProviderModel.watch.fiatFlagChangedDone, currencyModel.watch.currencyChangedDone], + }), + target: attach({ + source: $callbacks, + effect: (state) => state?.onSubmit(), + }), +}); + +export const currencyFormModel = { + $currencyForm, + $cryptoCurrencies, + $popularFiatCurrencies, + $unpopularFiatCurrencies, + $isFormValid, + events: { + callbacksChanged: callbacksApi.callbacksChanged, + formInitiated, + }, +}; diff --git a/src/renderer/features/currency/CurrencyForm/ui/CurrencyForm.tsx b/src/renderer/features/currency/CurrencyForm/ui/CurrencyForm.tsx new file mode 100644 index 0000000000..5142e635ce --- /dev/null +++ b/src/renderer/features/currency/CurrencyForm/ui/CurrencyForm.tsx @@ -0,0 +1,90 @@ +import { FormEvent, useEffect } from 'react'; +import { useForm } from 'effector-forms'; +import { useUnit } from 'effector-react'; + +import { Switch, FootnoteText, HelpText, Button, Select } from '@renderer/shared/ui'; +import { useI18n } from '@renderer/app/providers'; +import { DropdownOption } from '@renderer/shared/ui/Dropdowns/common/types'; +import { CurrencyItem } from '@renderer/shared/api/price-provider'; +import { Callbacks, currencyFormModel } from '../model/currency-form'; + +const getCurrencyOption = (currency: CurrencyItem): DropdownOption => ({ + id: currency.id.toString(), + value: currency, + element: [currency.code, currency.symbol, currency.name].filter(Boolean).join(' • '), +}); + +type Props = Callbacks; +export const CurrencyForm = ({ onSubmit }: Props) => { + const { t } = useI18n(); + const isFormValid = useUnit(currencyFormModel.$isFormValid); + + useEffect(() => { + currencyFormModel.events.callbacksChanged({ onSubmit }); + }, [onSubmit]); + + useEffect(() => { + currencyFormModel.events.formInitiated(); + }, []); + + const { + submit, + fields: { fiatFlag, currency }, + } = useForm(currencyFormModel.$currencyForm); + + const cryptoCurrencies = useUnit(currencyFormModel.$cryptoCurrencies); + const popularFiatCurrencies = useUnit(currencyFormModel.$popularFiatCurrencies); + const unpopularFiatCurrencies = useUnit(currencyFormModel.$unpopularFiatCurrencies); + + const currenciesOptions: DropdownOption[] = [ + { + id: 'crypto', + element: {t('settings.currency.cryptocurrenciesLabel')}, + value: {} as CurrencyItem, + disabled: true, + }, + ...cryptoCurrencies.map(getCurrencyOption), + { + id: 'popular', + element: {t('settings.currency.popularFiatLabel')}, + value: {} as CurrencyItem, + disabled: true, + }, + ...popularFiatCurrencies.map(getCurrencyOption), + { + id: 'unpopular', + element: {t('settings.currency.unpopularFiatLabel')}, + value: {} as CurrencyItem, + disabled: true, + }, + ...unpopularFiatCurrencies.map(getCurrencyOption), + ]; + + const submitForm = (event: FormEvent) => { + event.preventDefault(); + submit(); + }; + + return ( +
+ +
+ {t('settings.currency.switchLabel')} + {t('settings.currency.switchHint')} +
+
+ + { - setActiveNetwork(chain); - setStakingNetwork(chain.value.chainId); - onNetworkChange(chain.value); - }} - /> +
+ {t('staking.overview.networkLabel')} + ); }; - -export default AmountInput; diff --git a/src/renderer/shared/ui/Modals/BaseModal/BaseModal.tsx b/src/renderer/shared/ui/Modals/BaseModal/BaseModal.tsx index 5c209f5eb6..bce517ffbe 100644 --- a/src/renderer/shared/ui/Modals/BaseModal/BaseModal.tsx +++ b/src/renderer/shared/ui/Modals/BaseModal/BaseModal.tsx @@ -35,7 +35,7 @@ const BaseModal = ({ return ( - + onClose()}>
@@ -62,7 +62,7 @@ const BaseModal = ({ size={20} className="absolute top-[18px] right-[14px] z-10" ariaLabel={t('basemodal.closeButton')} - onClick={onClose} + onClick={() => onClose()} /> )} diff --git a/src/renderer/shared/ui/index.ts b/src/renderer/shared/ui/index.ts index 5577d2262c..42296e8036 100644 --- a/src/renderer/shared/ui/index.ts +++ b/src/renderer/shared/ui/index.ts @@ -1,6 +1,6 @@ import Input from './Inputs/Input/Input'; import Plate from './Plate/Plate'; -import AmountInput from './Inputs/AmountInput/AmountInput'; +import { AmountInput } from './Inputs/AmountInput/AmountInput'; import PasswordInput from './Inputs/PasswordInput/PasswordInput'; import InputHint from './InputHint/InputHint'; import Button from './Buttons/Button/Button'; diff --git a/src/renderer/widgets/CurrencyModal/index.ts b/src/renderer/widgets/CurrencyModal/index.ts new file mode 100644 index 0000000000..1ee7008656 --- /dev/null +++ b/src/renderer/widgets/CurrencyModal/index.ts @@ -0,0 +1 @@ +export { CurrencyModal } from './ui/CurrencyModal'; diff --git a/src/renderer/widgets/CurrencyModal/ui/CurrencyModal.tsx b/src/renderer/widgets/CurrencyModal/ui/CurrencyModal.tsx new file mode 100644 index 0000000000..6e5275cb4e --- /dev/null +++ b/src/renderer/widgets/CurrencyModal/ui/CurrencyModal.tsx @@ -0,0 +1,33 @@ +import { BaseModal } from '@renderer/shared/ui'; +import { useToggle } from '@renderer/shared/lib/hooks'; +import { useI18n } from '@renderer/app/providers'; +import { DEFAULT_TRANSITION } from '@renderer/shared/lib/utils'; +import { CurrencyForm } from '@renderer/features/currency'; + +type Props = { + onClose: () => void; +}; + +export const CurrencyModal = ({ onClose }: Props) => { + const { t } = useI18n(); + + const [isModalOpen, toggleIsModalOpen] = useToggle(true); + + const closeFiatModal = () => { + toggleIsModalOpen(); + setTimeout(onClose, DEFAULT_TRANSITION); + }; + + return ( + + + + ); +}; diff --git a/src/renderer/widgets/SendAssetModal/ui/SendAssetModal.tsx b/src/renderer/widgets/SendAssetModal/ui/SendAssetModal.tsx index e6f39a70ee..cf6334648d 100644 --- a/src/renderer/widgets/SendAssetModal/ui/SendAssetModal.tsx +++ b/src/renderer/widgets/SendAssetModal/ui/SendAssetModal.tsx @@ -15,6 +15,7 @@ import { OperationTitle } from '@renderer/components/common'; import { Chain } from '@renderer/entities/chain'; import { useToggle } from '@renderer/shared/lib/hooks'; import * as sendAssetModel from '../model/send-asset'; +import { priceProviderModel } from '@renderer/entities/price'; const enum Step { INIT, @@ -51,6 +52,10 @@ export const SendAssetModal = ({ chain, asset }: Props) => { const { api, assets, addressPrefix, explorers } = connection; + useEffect(() => { + priceProviderModel.events.assetsPricesRequested({ includeRates: true }); + }, []); + useGate(sendAssetModel.PropsGate, { chain, asset, api }); useEffect(() => { diff --git a/src/renderer/widgets/SendAssetModal/ui/components/ActionSteps/Confirmation.tsx b/src/renderer/widgets/SendAssetModal/ui/components/ActionSteps/Confirmation.tsx index d59d5f543e..5a3498904f 100644 --- a/src/renderer/widgets/SendAssetModal/ui/components/ActionSteps/Confirmation.tsx +++ b/src/renderer/widgets/SendAssetModal/ui/components/ActionSteps/Confirmation.tsx @@ -11,8 +11,6 @@ import { Wallet, useWallet } from '@renderer/entities/wallet'; import { XcmFee } from '@renderer/entities/transaction/ui/XcmFee/XcmFee'; import { AssetXCM, XcmConfig } from '@renderer/shared/api/xcm'; -const AmountFontStyle = 'font-manrope text-text-primary text-[32px] leading-[36px] font-bold'; - type Props = { transaction: Transaction; account: Account | MultisigAccount; @@ -58,7 +56,7 @@ export const Confirmation = ({
)} - {transaction && } + {transaction && } {description && ( diff --git a/src/renderer/widgets/index.ts b/src/renderer/widgets/index.ts index cadca209ca..bf8b194a5d 100644 --- a/src/renderer/widgets/index.ts +++ b/src/renderer/widgets/index.ts @@ -1,3 +1,4 @@ export * from './ManageContactModal'; export * from './ReceiveAssetModal'; export * from './SendAssetModal'; +export * from './CurrencyModal'; diff --git a/src/shared/locale/en.json b/src/shared/locale/en.json index 4b291c770d..61f690ead9 100644 --- a/src/shared/locale/en.json +++ b/src/shared/locale/en.json @@ -396,6 +396,10 @@ "unstake": "Unstake" } }, + "price": { + "withCode": "{ amount } { code }", + "withSymbol": "{ symbol }{ amount }" + }, "qrReader": { "parsingLabel": "Parsing data", "parsingProgress": "{decoded} of {total}", @@ -411,6 +415,17 @@ "title": "Receive {asset} on" }, "settings": { + "currency": { + "cryptocurrenciesLabel": "Cryptocurrencies", + "modalTitle": "Currency", + "plateTitle": "Currency", + "popularFiatLabel": "Popular fiat currencies", + "save": "Save", + "selectPlaceholder": "Select currency", + "switchHint": "Nova Spektr is using CoinGecko to show currency", + "switchLabel": "Show currency values", + "unpopularFiatLabel": "Fiat currencies" + }, "matrix": { "badCredentialsError": "Invalid username or password", "badServerError": "Server is not a correct matrix server", @@ -663,6 +678,7 @@ "emptyFilterLabel": "No results", "networkDisabledDescription": "Please check the connection in Network Settings", "networkDisabledLabel": "Network is inactive", + "networkLabel": "Network", "networkPlaceholder": "Select network", "networkSettingsLink": "Network settings", "noAccountsLabel": "Your haven’t got any account to start staking", @@ -671,7 +687,7 @@ "noResultsDescription": "Please try another filter parameters", "noResultsLabel": "No staking found", "noValidatorsLabel": "No validators nominated", - "rewardsTableHeader": "Total rewards", + "rewardsTableHeader": "Rewards", "searchPlaceholder": "Search by account name", "stakeTableHeader": "Staked", "stakingAssetLabel": "Staking { asset } on", @@ -804,6 +820,8 @@ "signatoryLabel": "Signatory", "startSigningButton": "Sign with Polkadot Vault", "successMessage": "Operation Success", + "swapToCryptoModeAlt": "swap input to crypto", + "swapToCurrencyModeAlt": "swap input to currency", "title": "Transfer { asset } on", "xcmTitle": "Transfer { asset } from" }, diff --git a/src/shared/locale/ru.json b/src/shared/locale/ru.json index 64425036f2..3dea3106fb 100644 --- a/src/shared/locale/ru.json +++ b/src/shared/locale/ru.json @@ -396,6 +396,10 @@ "unstake": "Вывод из стейкинга" } }, + "price": { + "withCode": "{ amount } { code }", + "withSymbol": "{ symbol }{ amount }" + }, "qrReader": { "parsingLabel": "Обработка данных", "parsingProgress": "{decoded} из {total}", @@ -411,6 +415,17 @@ "title": "Получить {asset} на" }, "settings": { + "currency": { + "cryptocurrenciesLabel": "Cryptocurrencies", + "modalTitle": "Currency", + "plateTitle": "Currency", + "popularFiatLabel": "Popular fiat currencies", + "save": "Save", + "selectPlaceholder": "Select currency", + "switchHint": "Nova Spektr is using CoinGecko to show currency", + "switchLabel": "Show currency values", + "unpopularFiatLabel": "Fiat currencies" + }, "matrix": { "badCredentialsError": "Invalid username or password", "badServerError": "Server is not a correct matrix server", @@ -663,6 +678,7 @@ "emptyFilterLabel": "Результаты не найдены", "networkDisabledDescription": "Пожалуйста, проверьте соеднинение в настройках сети", "networkDisabledLabel": "Сеть неактивна", + "networkLabel": "Network", "networkPlaceholder": "Выберите сеть", "networkSettingsLink": "Настройки сети", "noAccountsLabel": "Не выбран ни один аккаунт", @@ -804,6 +820,8 @@ "signatoryLabel": "Подписант", "startSigningButton": "Подписать в Polkadot Vault", "successMessage": "Транзакция отправлена!", + "swapToCryptoModeAlt": "swap input to crypto", + "swapToCurrencyModeAlt": "swap input to currency", "title": "Перевод { asset } в сети", "xcmTitle": "Transfer { asset } from" },