diff --git a/.gitignore b/.gitignore index 06763a1a1f..08ad263456 100644 --- a/.gitignore +++ b/.gitignore @@ -116,5 +116,7 @@ dist src/renderer/generated/ tests/specs/__image_snapshots__/__diff_output__ tests/tmp + +# Yalc .yalc yalc.lock diff --git a/package.json b/package.json index ffd5526098..3dbd8a4401 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@ledgerhq/hw-transport-http": "^5.26.0", "@ledgerhq/hw-transport-node-hid-singleton": "^5.26.0", "@ledgerhq/ledger-core": "6.9.1", - "@ledgerhq/live-common": "^15.5.0-ethjs.6", + "@ledgerhq/live-common": "15.5.0-beta.8", "@ledgerhq/logs": "^5.26.0", "@tippyjs/react": "^4.0.2", "@trust/keyto": "^1.0.1", diff --git a/src/renderer/Default.js b/src/renderer/Default.js index 38ce2a40c8..bc55bb1dac 100644 --- a/src/renderer/Default.js +++ b/src/renderer/Default.js @@ -14,6 +14,7 @@ import Manager from "~/renderer/screens/manager"; import Exchange from "~/renderer/screens/exchange"; import Account from "~/renderer/screens/account"; import Asset from "~/renderer/screens/asset"; +import Lend from "~/renderer/screens/lend"; import Box from "~/renderer/components/Box/Box"; import ListenDevices from "~/renderer/components/ListenDevices"; import ExportLogsButton from "~/renderer/components/ExportLogsButton"; @@ -99,6 +100,7 @@ const Default = () => { } /> } /> } /> + } /> } /> = styled.div.attrs(({ state } transition: opacity 200ms cubic-bezier(0.3, 1, 0.5, 0.8); `; -const ModalsLayer = ({ visibleModals }: *) => ( - 0} - appear - mountOnEnter - unmountOnExit - timeout={{ - appear: 100, - enter: 100, - exit: 200, - }} - > - {state => ( - - {/* {// Will only render at the end of december +const ModalsLayer = ({ visibleModals }: *) => { + const filteredModals = visibleModals.filter( + ({ name, MODAL_SHOW_ONCE }) => !MODAL_SHOW_ONCE || !global.sessionStorage.getItem(name), + ); + filteredModals.forEach( + ({ name, MODAL_SHOW_ONCE }) => + MODAL_SHOW_ONCE && global.sessionStorage.setItem(name, Date.now()), + ); + return ( + 0} + appear + mountOnEnter + unmountOnExit + timeout={{ + appear: 100, + enter: 100, + exit: 200, + }} + > + {state => ( + + {/* {// Will only render at the end of december isSnowTime() ? : null} */} - {visibleModals.map(({ name, ...data }, i) => { - const ModalComponent = modals[name]; - return ; - })} - - )} - -); + {filteredModals.map(({ name, ...data }, i) => { + const ModalComponent = modals[name]; + return ; + })} + + )} + + ); +}; const visibleModalsSelector = createSelector(modalsStateSelector, state => Object.keys(state) diff --git a/src/renderer/actions/general.js b/src/renderer/actions/general.js index 7811e52f5a..ceeee19c37 100644 --- a/src/renderer/actions/general.js +++ b/src/renderer/actions/general.js @@ -4,6 +4,7 @@ import type { BigNumber } from "bignumber.js"; import type { OutputSelector } from "reselect"; import { createSelector } from "reselect"; import type { Currency, AccountLikeArray, Account } from "@ledgerhq/live-common/lib/types"; +import { findCompoundToken } from "@ledgerhq/live-common/lib/currencies"; import { isAccountDelegating } from "@ledgerhq/live-common/lib/families/tezos/bakers"; import { nestedSortAccounts, @@ -65,6 +66,14 @@ export const flattenSortAccountsSelector: OutputSelector< AccountLikeArray, > = createSelector(accountsSelector, sortAccountsComparatorSelector, flattenSortAccounts); +export const flattenSortAccountsCompoundOnlySelector: OutputSelector< + State, + void, + AccountLikeArray, +> = createSelector(flattenSortAccountsSelector, accounts => + accounts.filter(acc => (accounts.type === "TokenAccount" ? !!findCompoundToken(acc) : false)), +); + export const flattenSortAccountsEnforceHideEmptyTokenSelector: OutputSelector< State, void, diff --git a/src/renderer/actions/settings.js b/src/renderer/actions/settings.js index 96d60f7273..750ba30820 100644 --- a/src/renderer/actions/settings.js +++ b/src/renderer/actions/settings.js @@ -106,6 +106,10 @@ export const setDeepLinkUrl = (url: ?string) => ({ payload: url, }); +export const setFirstTimeLend = () => ({ + type: "SET_FIRST_TIME_LEND", +}); + export const setSwapProviders = (swapProviders?: AvailableProvider[]) => ({ type: "SETTINGS_SET_SWAP_PROVIDERS", swapProviders, diff --git a/src/renderer/components/BadgeLabel.js b/src/renderer/components/BadgeLabel.js new file mode 100644 index 0000000000..4d105a362d --- /dev/null +++ b/src/renderer/components/BadgeLabel.js @@ -0,0 +1,38 @@ +// @flow +import React from "react"; +import styled from "styled-components"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import Box from "./Box/Box"; +import Text from "./Text"; + +type Props = { + children?: React$Node, + uppercase?: boolean, + innerStyle?: any, +}; + +const Container: ThemedComponent<{}> = styled(Box).attrs(() => ({ + px: 2, + my: 1, + borderRadius: 4, + bg: "blueTransparentBackground", +}))` + display: inline-block; +`; + +const TextContainer: ThemedComponent<{ uppercase: boolean }> = styled(Text).attrs(() => ({ + ff: "Inter|Bold", + fontSize: 2, + color: "wallet", +}))` + text-transform: ${p => (p.uppercase ? "uppercase" : "initial")}; +`; + +const BadgeLabel = ({ children, uppercase = true, innerStyle = {} }: Props) => + children ? ( + + {children} + + ) : null; + +export default BadgeLabel; diff --git a/src/renderer/components/CryptoCurrencyIcon.js b/src/renderer/components/CryptoCurrencyIcon.js index b26569899f..e94967903c 100644 --- a/src/renderer/components/CryptoCurrencyIcon.js +++ b/src/renderer/components/CryptoCurrencyIcon.js @@ -1,7 +1,7 @@ // @flow import React from "react"; import styled, { withTheme } from "styled-components"; -import { getCryptoCurrencyIcon } from "@ledgerhq/live-common/lib/react"; +import { getCryptoCurrencyIcon, getTokenCurrencyIcon } from "@ledgerhq/live-common/lib/react"; import type { Currency } from "@ledgerhq/live-common/lib/types"; import { getCurrencyColor } from "~/renderer/getCurrencyColor"; import { mix } from "~/renderer/styles/helpers"; @@ -60,10 +60,16 @@ const CryptoCurrencyIcon = ({ currency, circle, size, overrideColor, inactive, t return null; } if (currency.type === "TokenCurrency") { + const TokenIconCurrency = getTokenCurrencyIcon && getTokenCurrencyIcon(currency); + return ( - {currency.ticker[0]} + {TokenIconCurrency ? ( + + ) : ( + currency.ticker[0] + )} ); diff --git a/src/renderer/components/FeeSliderField.js b/src/renderer/components/FeeSliderField.js index f9f2381445..2485d442b8 100644 --- a/src/renderer/components/FeeSliderField.js +++ b/src/renderer/components/FeeSliderField.js @@ -21,7 +21,7 @@ type Props = { value: BigNumber, onChange: BigNumber => void, unit: Unit, - error: Error, + error: ?Error, defaultValue: BigNumber, }; diff --git a/src/renderer/components/InfoBox.js b/src/renderer/components/InfoBox.js index 30e71f4d5f..bd8cc10445 100644 --- a/src/renderer/components/InfoBox.js +++ b/src/renderer/components/InfoBox.js @@ -11,24 +11,31 @@ import { FakeLink } from "./Link"; type Props = { children: React$Node, onLearnMore?: () => void, + learnMoreLabel?: React$Node, horizontal?: boolean, }; -export default function InfoBox({ children: description, onLearnMore, horizontal = true }: Props) { +export default function InfoBox({ + children: description, + onLearnMore, + learnMoreLabel, + horizontal = true, +}: Props) { const { t } = useTranslation(); + const label = learnMoreLabel || t("common.learnMore"); return ( - - - + + + {description} {onLearnMore && ( - {t("common.learnMore")} + {label} )} diff --git a/src/renderer/components/InputCurrency.js b/src/renderer/components/InputCurrency.js index fa6663cb7f..b73b3c54d6 100644 --- a/src/renderer/components/InputCurrency.js +++ b/src/renderer/components/InputCurrency.js @@ -61,6 +61,7 @@ type Props = { onChangeUnit: Unit => void, locale: string, forwardedRef: ?ElementRef, + placeholder?: string, }; type State = { @@ -191,7 +192,15 @@ class InputCurrency extends PureComponent { }; render() { - const { renderRight, showAllDigits, unit, subMagnitude, locale, ...rest } = this.props; + const { + renderRight, + showAllDigits, + unit, + subMagnitude, + locale, + placeholder, + ...rest + } = this.props; const { displayValue } = this.state; return ( @@ -207,7 +216,8 @@ class InputCurrency extends PureComponent { placeholder={ displayValue ? "" - : format(unit, BigNumber(0), { + : placeholder || + format(unit, BigNumber(0), { locale, isFocused: false, showAllDigits, diff --git a/src/renderer/components/MainSideBar/index.js b/src/renderer/components/MainSideBar/index.js index 161ad7e5f3..79cb4a6ae0 100644 --- a/src/renderer/components/MainSideBar/index.js +++ b/src/renderer/components/MainSideBar/index.js @@ -12,7 +12,7 @@ import { sidebarCollapsedSelector, lastSeenDeviceSelector } from "~/renderer/red import { isNavigationLocked } from "~/renderer/reducers/application"; import { openModal } from "~/renderer/actions/modals"; -import { setSidebarCollapsed } from "~/renderer/actions/settings"; +import { setFirstTimeLend, setSidebarCollapsed } from "~/renderer/actions/settings"; import useExperimental from "~/renderer/hooks/useExperimental"; @@ -25,6 +25,7 @@ import IconReceive from "~/renderer/icons/Receive"; import IconSend from "~/renderer/icons/Send"; import IconExchange from "~/renderer/icons/Exchange"; import IconChevron from "~/renderer/icons/ChevronRight"; +import IconLending from "~/renderer/icons/Lending"; import IconExperimental from "~/renderer/icons/Experimental"; import IconSwap from "~/renderer/icons/Swap"; @@ -182,6 +183,7 @@ const MainSideBar = () => { const noAccounts = useSelector(accountsSelector).length === 0; const hasStarredAccounts = useSelector(starredAccountsSelector).length > 0; const displayBlueDot = useManagerBlueDot(lastSeenDevice); + const firstTimeLend = useSelector(state => state.settings.firstTimeLend); const handleCollapse = useCallback(() => { dispatch(setSidebarCollapsed(!collapsed)); @@ -212,6 +214,13 @@ const MainSideBar = () => { push("/exchange"); }, [push]); + const handleClickLend = useCallback(() => { + if (firstTimeLend) { + dispatch(setFirstTimeLend()); + } + push("/lend"); + }, [push, firstTimeLend, dispatch]); + const handleClickSwap = useCallback(() => { push("/swap"); }, [push]); @@ -306,6 +315,16 @@ const MainSideBar = () => { isActive={location.pathname === "/swap"} collapsed={secondAnim} /> + : null} + /> void, onClose?: void => void, render?: (?RenderProps) => any, @@ -37,6 +38,7 @@ class ModalBody extends PureComponent { onClose, title, subTitle, + headerStyle, render, renderFooter, renderProps, @@ -46,10 +48,9 @@ class ModalBody extends PureComponent { // For `renderFooter` returning falsy values, we need to resolve first. const renderedFooter = renderFooter && renderFooter(renderProps); - return ( <> - + {title || null} diff --git a/src/renderer/components/Modal/ModalHeader.js b/src/renderer/components/Modal/ModalHeader.js index 44cde4d457..23585c812f 100644 --- a/src/renderer/components/Modal/ModalHeader.js +++ b/src/renderer/components/Modal/ModalHeader.js @@ -3,6 +3,7 @@ import React from "react"; import styled from "styled-components"; import { useTranslation } from "react-i18next"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; import Box from "~/renderer/components/Box"; import Text from "~/renderer/components/Text"; @@ -11,16 +12,6 @@ import Tabbable from "~/renderer/components/Box/Tabbable"; import IconCross from "~/renderer/icons/Cross"; import IconAngleLeft from "~/renderer/icons/AngleLeft"; -const MODAL_HEADER_STYLE = { - display: "flex", - alignItems: "center", - justifyContent: "space-between", - padding: 10, - position: "relative", - flexDirection: "row", - minHeight: 66, -}; - const TitleContainer = styled(Box).attrs(() => ({ vertical: true, }))` @@ -93,20 +84,32 @@ const ModalHeaderAction = styled(Tabbable).attrs(() => ({ : ""} `; +const Container: ThemedComponent<{ hasTitle: boolean }> = styled(Box).attrs(() => ({ + horizontal: true, + alignItems: "center", + justifyContent: "space-between", + p: 2, + relative: true, +}))` + min-height: ${p => (p.hasTitle ? 66 : 0)}px; +`; + const ModalHeader = ({ children, subTitle, onBack, onClose, + style = {}, }: { children: any, subTitle?: React$Node, onBack?: void => void, onClose?: void => void, + style?: *, }) => { const { t } = useTranslation(); return ( -
+ {onBack ? ( @@ -117,24 +120,20 @@ const ModalHeader = ({ ) : (
)} - - {subTitle && {subTitle}} - {children} - - + {children || subTitle ? ( + + {subTitle && {subTitle}} + {children} + + ) : null} {onClose ? ( - + ) : (
)} -
+ ); }; diff --git a/src/renderer/components/OperationsList/ConfirmationCheck.js b/src/renderer/components/OperationsList/ConfirmationCheck.js index 1624e5bd79..7b62def6cf 100644 --- a/src/renderer/components/OperationsList/ConfirmationCheck.js +++ b/src/renderer/components/OperationsList/ConfirmationCheck.js @@ -101,6 +101,10 @@ const iconsComponent = { OPT_IN: IconPlus, OPT_OUT: IconTrash, CLOSE_ACCOUNT: IconTrash, + // TODO: add correct icons + REDEEM: IconSend, + SUPPLY: IconSend, + APPROVE: IconSend, }; class ConfirmationCheck extends PureComponent<{ diff --git a/src/renderer/components/OperationsList/index.js b/src/renderer/components/OperationsList/index.js index 67a8dbdb68..af98a0eb68 100644 --- a/src/renderer/components/OperationsList/index.js +++ b/src/renderer/components/OperationsList/index.js @@ -53,6 +53,7 @@ type Props = { withAccount?: boolean, withSubAccounts?: boolean, title?: string, + filterOperation?: (Operation, AccountLike) => boolean, }; type State = { @@ -93,6 +94,7 @@ export class OperationsList extends PureComponent { title, withAccount, withSubAccounts, + filterOperation, } = this.props; const { nbToShow } = this.state; @@ -102,8 +104,12 @@ export class OperationsList extends PureComponent { } const groupedOperations = account - ? groupAccountOperationsByDay(account, { count: nbToShow, withSubAccounts }) - : groupAccountsOperationsByDay(accounts, { count: nbToShow, withSubAccounts }); + ? groupAccountOperationsByDay(account, { count: nbToShow, withSubAccounts, filterOperation }) + : groupAccountsOperationsByDay(accounts, { + count: nbToShow, + withSubAccounts, + filterOperation, + }); const all = flattenAccounts(accounts || []).concat([account, parentAccount].filter(Boolean)); const accountsMap = keyBy(all, "id"); diff --git a/src/renderer/components/SelectAccount.js b/src/renderer/components/SelectAccount.js index 7dd433936d..a43c28beec 100644 --- a/src/renderer/components/SelectAccount.js +++ b/src/renderer/components/SelectAccount.js @@ -125,7 +125,7 @@ type Props = OwnProps & { accounts: Account[], }; -const RawSelectAccount = ({ +export const RawSelectAccount = ({ accounts, onChange, value, diff --git a/src/renderer/components/SideBar/SideBarListItem.js b/src/renderer/components/SideBar/SideBarListItem.js index c6c7e29218..803df68948 100644 --- a/src/renderer/components/SideBar/SideBarListItem.js +++ b/src/renderer/components/SideBar/SideBarListItem.js @@ -57,8 +57,10 @@ class SideBarListItem extends PureComponent { {!!Icon && } - {renderedLabel} - {!!desc && desc(this.props)} + + {renderedLabel} + {!!desc && desc(this.props)} + {NotifComponent} diff --git a/src/renderer/components/Stepper/Breadcrumb.js b/src/renderer/components/Stepper/Breadcrumb.js index 108269a0ca..7f52529f47 100644 --- a/src/renderer/components/Stepper/Breadcrumb.js +++ b/src/renderer/components/Stepper/Breadcrumb.js @@ -10,6 +10,12 @@ import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; import Step from "./Step"; +const Container: ThemedComponent<{}> = styled(Box)` + position: sticky; + top: -20px; + z-index: 2; +`; + const Wrapper: ThemedComponent<{}> = styled(Box).attrs(() => ({ horizontal: true, alignItems: "center", @@ -84,52 +90,54 @@ class Breadcrumb extends PureComponent { const start = 100 / itemsLength / 2; return ( - - - {items - .filter(i => !i.excludeFromBreadcrumb) - .map((item, i) => { - let status = "next"; - - const stepIndex = parseInt(currentStep, 10); - - if (i === stepIndex) { - status = "active"; - } - - if (i < stepIndex) { - status = "valid"; - } - - if (stepsErrors.includes(i)) { - status = "error"; - } - - if (stepsDisabled.includes(i)) { - status = "disable"; - } - - return ( - - {item.label} - - ); - })} - - 0 - ? [ - stepsDisabled[0] === 0 ? 0 : indexToPercent(stepsDisabled[0] + 1, itemsLength), - indexToPercent(stepsDisabled[stepsDisabled.length - 1], itemsLength), - ] - : null - } - current={!currentStep ? 100 : indexToPercent(currentStep, itemsLength)} - /> - + + + + {items + .filter(i => !i.excludeFromBreadcrumb) + .map((item, i) => { + let status = "next"; + + const stepIndex = parseInt(currentStep, 10); + + if (i === stepIndex) { + status = "active"; + } + + if (i < stepIndex) { + status = "valid"; + } + + if (stepsErrors.includes(i)) { + status = "error"; + } + + if (stepsDisabled.includes(i)) { + status = "disable"; + } + + return ( + + {item.label} + + ); + })} + + 0 + ? [ + stepsDisabled[0] === 0 ? 0 : indexToPercent(stepsDisabled[0] + 1, itemsLength), + indexToPercent(stepsDisabled[stepsDisabled.length - 1], itemsLength), + ] + : null + } + current={!currentStep ? 100 : indexToPercent(currentStep, itemsLength)} + /> + + ); } } diff --git a/src/renderer/components/TabBar.js b/src/renderer/components/TabBar.js index 50fea635c5..127fbd9f8c 100644 --- a/src/renderer/components/TabBar.js +++ b/src/renderer/components/TabBar.js @@ -6,14 +6,6 @@ import { Trans } from "react-i18next"; import { Base } from "~/renderer/components/Button"; import Text from "~/renderer/components/Text"; -const Tabs: ThemedComponent<*> = styled.div` - height: ${p => p.theme.sizes.topBarHeight}px; - display: flex; - flex-direction: row; - position: relative; - align-items: flex-end; -`; - const Tab = styled(Base)` padding: 0 16px 4px 16px; border-radius: 0; @@ -27,40 +19,61 @@ const Tab = styled(Base)` } `; -const TabIndicator = styled.span.attrs(({ currentRef = {} }) => ({ +const TabIndicator = styled.span.attrs(({ currentRef = {}, index, short }) => ({ style: { - width: `${currentRef.clientWidth - 32}px`, + width: `${currentRef.clientWidth - (short && index === 0 ? 16 : 32)}px`, transform: `translateX(${currentRef.offsetLeft}px)`, }, }))` height: 3px; position: absolute; bottom: 0; - left: 16px; + left: ${p => (p.short && p.index === 0 ? 0 : "16px")}; background-color: ${p => p.theme.colors.palette.primary.main}; transition: all 0.3s ease-in-out; `; +const Tabs: ThemedComponent<{ short: boolean }> = styled.div` + height: ${p => p.theme.sizes.topBarHeight}px; + display: flex; + flex-direction: row; + position: relative; + align-items: flex-end; + + ${Tab}:first-child { + ${p => (p.short ? "padding-left: 0;" : "")} + } +`; + type Props = { tabs: string[], onIndexChange: number => void, defaultIndex?: number, + index?: number, + short?: boolean, }; -const TabBar = ({ tabs, onIndexChange, defaultIndex = 0 }: Props) => { +const TabBar = ({ + tabs, + onIndexChange, + defaultIndex = 0, + short = false, + index: propsIndex, +}: Props) => { const tabRefs = useRef([]); const [index, setIndex] = useState(defaultIndex); - const [mounted, setMounted] = useState(false); + const i = !isNaN(propsIndex) && propsIndex !== undefined ? propsIndex : index; + useEffect(() => { setMounted(true); }, []); const updateIndex = useCallback( - i => { - setIndex(i); - onIndexChange(i); + j => { + setIndex(j); + onIndexChange(j); }, [setIndex, onIndexChange], ); @@ -70,21 +83,23 @@ const TabBar = ({ tabs, onIndexChange, defaultIndex = 0 }: Props) => { }; return ( - - {tabs.map((tab, i) => ( + + {tabs.map((tab, j) => ( updateIndex(i)} + ref={setTabRef(j)} + key={`TAB_${j}_${tab}`} + active={j === i} + tabIndex={j} + onClick={() => updateIndex(j)} > ))} - {mounted && tabRefs.current[index] && } + {mounted && tabRefs.current[i] && ( + + )} ); }; diff --git a/src/renderer/components/Text.js b/src/renderer/components/Text.js index d1a6ca1ceb..48e55315a7 100644 --- a/src/renderer/components/Text.js +++ b/src/renderer/components/Text.js @@ -1,7 +1,7 @@ // @flow import styled from "styled-components"; -import { fontSize, fontWeight, textAlign, color, space } from "styled-system"; +import { fontSize, fontWeight, textAlign, color, space, lineHeight } from "styled-system"; import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; import fontFamily from "~/renderer/styles/styled/fontFamily"; @@ -14,6 +14,7 @@ const Text: ThemedComponent<{ mt?: number, mb?: number, align?: "DEPRECATED: USE textAlign INSTEAD", + lineHeight?: string, }> = styled.span` ${fontFamily}; ${fontSize}; @@ -21,6 +22,7 @@ const Text: ThemedComponent<{ ${color}; ${fontWeight}; ${space}; + ${lineHeight}; `; export default Text; diff --git a/src/renderer/families/ethereum/GasPriceField.js b/src/renderer/families/ethereum/GasPriceField.js index dcc0502c4a..18309fe5e1 100644 --- a/src/renderer/families/ethereum/GasPriceField.js +++ b/src/renderer/families/ethereum/GasPriceField.js @@ -13,12 +13,13 @@ type Props = { transaction: Transaction, status: TransactionStatus, onChange: Transaction => void, + displayError?: boolean, }; const fallbackGasPrice = inferDynamicRange(BigNumber(10e9)); let lastNetworkGasPrice; // local cache of last value to prevent extra blinks -const FeesField = ({ onChange, account, transaction, status }: Props) => { +const FeesField = ({ onChange, account, transaction, status, displayError = true }: Props) => { invariant(transaction.family === "ethereum", "FeeField: ethereum family expected"); const bridge = getAccountBridge(account); @@ -45,7 +46,7 @@ const FeesField = ({ onChange, account, transaction, status }: Props) => { value={gasPrice} onChange={onGasPriceChange} unit={units.length > 1 ? units[1] : units[0]} - error={status.errors.gasPrice} + error={displayError ? status.errors.gasPrice : null} /> ); }; diff --git a/src/renderer/families/ethereum/TransactionConfirmFields.js b/src/renderer/families/ethereum/TransactionConfirmFields.js new file mode 100644 index 0000000000..1632ab9ecf --- /dev/null +++ b/src/renderer/families/ethereum/TransactionConfirmFields.js @@ -0,0 +1,40 @@ +// @flow + +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; + +import type { Transaction } from "@ledgerhq/live-common/lib/types"; + +import WarnBox from "~/renderer/components/WarnBox"; + +const Warning = ({ + transaction, + recipientWording, +}: { + transaction: Transaction, + recipientWording: string, +}) => { + invariant(transaction.family === "ethereum", "ethereum transaction"); + + switch (transaction.mode) { + case "compound.withdraw": + case "compound.supply": + case "erc20.approve": + return ( + + + + ); + default: + return ( + + + + ); + } +}; + +export default { + warning: Warning, +}; diff --git a/src/renderer/families/ethereum/operationDetails.js b/src/renderer/families/ethereum/operationDetails.js new file mode 100644 index 0000000000..5a4ad2f116 --- /dev/null +++ b/src/renderer/families/ethereum/operationDetails.js @@ -0,0 +1,34 @@ +// @flow +import React from "react"; +import toPairs from "lodash/toPairs"; +import { Trans } from "react-i18next"; +import type { AccountLike } from "@ledgerhq/live-common/lib/types"; + +import Box from "~/renderer/components/Box"; +import { OpDetailsTitle, OpDetailsData } from "~/renderer/modals/OperationDetails/styledComponents"; + +type OperationDetailsExtraProps = { + extra: { [key: string]: string }, + type: string, + account: ?AccountLike, +}; + +const OperationDetailsExtra = ({ extra, type }: OperationDetailsExtraProps) => { + const entries = toPairs(extra); + // $FlowFixMe + return (type === "REDEEM" || type === "SUPPLY" + ? entries.filter(([key]) => !["compoundValue", "rate"].includes(key)) + : entries + ).map(([key, value]) => ( + + + + + {value} + + )); +}; + +export default { + OperationDetailsExtra, +}; diff --git a/src/renderer/icons/AmountUp.js b/src/renderer/icons/AmountUp.js new file mode 100644 index 0000000000..20fda846eb --- /dev/null +++ b/src/renderer/icons/AmountUp.js @@ -0,0 +1,20 @@ +// @flow + +import React from "react"; + +const AmountUp = ({ size, color = "currentColor" }: { size: number, color?: string }) => ( + + + +); + +export default AmountUp; diff --git a/src/renderer/icons/Lending.js b/src/renderer/icons/Lending.js new file mode 100644 index 0000000000..e7915095bf --- /dev/null +++ b/src/renderer/icons/Lending.js @@ -0,0 +1,53 @@ +// @flow +import React from "react"; + +const LendingIcon = ({ size = 16 }: { size: number }) => ( + + + + + + + + +); + +export default LendingIcon; diff --git a/src/renderer/icons/Minus.js b/src/renderer/icons/Minus.js new file mode 100644 index 0000000000..0bad837b7b --- /dev/null +++ b/src/renderer/icons/Minus.js @@ -0,0 +1,16 @@ +// @flow +import React from "react"; + +export default function Medal({ size, color = "currentColor" }: { size: number, color?: string }) { + return ( + + + + ); +} diff --git a/src/renderer/icons/UpdateCircle.js b/src/renderer/icons/UpdateCircle.js new file mode 100644 index 0000000000..1c1b0370b9 --- /dev/null +++ b/src/renderer/icons/UpdateCircle.js @@ -0,0 +1,16 @@ +// @flow + +import React from "react"; + +const UpdateCircle = ({ size = 16, color = "currentColor" }: { size: number, color?: string }) => ( + + + +); + +export default UpdateCircle; diff --git a/src/renderer/images/compound.svg b/src/renderer/images/compound.svg new file mode 100644 index 0000000000..9c4e5f094b --- /dev/null +++ b/src/renderer/images/compound.svg @@ -0,0 +1,318 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/images/lending-illu-1.svg b/src/renderer/images/lending-illu-1.svg new file mode 100644 index 0000000000..87537d2c47 --- /dev/null +++ b/src/renderer/images/lending-illu-1.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/images/lending-illu-2.svg b/src/renderer/images/lending-illu-2.svg new file mode 100644 index 0000000000..c65166aadb --- /dev/null +++ b/src/renderer/images/lending-illu-2.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/images/lending-illu-3.svg b/src/renderer/images/lending-illu-3.svg new file mode 100644 index 0000000000..8db8373eb2 --- /dev/null +++ b/src/renderer/images/lending-illu-3.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/renderer/images/lending-info-1.svg b/src/renderer/images/lending-info-1.svg new file mode 100644 index 0000000000..1944f91ead --- /dev/null +++ b/src/renderer/images/lending-info-1.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/images/lending-info-2.svg b/src/renderer/images/lending-info-2.svg new file mode 100644 index 0000000000..5ddbe14021 --- /dev/null +++ b/src/renderer/images/lending-info-2.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/images/lending-info-3.svg b/src/renderer/images/lending-info-3.svg new file mode 100644 index 0000000000..3920771972 --- /dev/null +++ b/src/renderer/images/lending-info-3.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/images/lending-terms.svg b/src/renderer/images/lending-terms.svg new file mode 100644 index 0000000000..7aabf4d7e8 --- /dev/null +++ b/src/renderer/images/lending-terms.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.js b/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.js index 9e87c07f74..7cda8b4206 100644 --- a/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.js +++ b/src/renderer/modals/AddAccounts/steps/StepChooseCurrency.js @@ -8,7 +8,6 @@ import { supportLinkByTokenType } from "~/config/urls"; import TrackPage from "~/renderer/analytics/TrackPage"; import SelectCurrency from "~/renderer/components/SelectCurrency"; import Button from "~/renderer/components/Button"; -import ExternalLinkButton from "~/renderer/components/ExternalLinkButton"; import Box from "~/renderer/components/Box"; import CurrencyBadge from "~/renderer/components/CurrencyBadge"; import TokenTips from "~/renderer/components/TokenTips"; @@ -19,6 +18,10 @@ import { openModal } from "~/renderer/actions/modals"; const StepChooseCurrency = ({ currency, setCurrency }: StepProps) => { const currencies = useMemo(() => listSupportedCurrencies().concat(listTokens()), []); + const isToken = currency && currency.type === "TokenCurrency"; + // $FlowFixMe + const url = isToken ? supportLinkByTokenType[currency.tokenType] : null; + return ( <> {currency ? : null} @@ -33,6 +36,7 @@ const StepChooseCurrency = ({ currency, setCurrency }: StepProps) => { tokenType: currency.tokenType.toUpperCase(), currency: currency.parentCurrency.name, }} + learnMoreLink={url} /> ) : null} @@ -50,9 +54,6 @@ export const StepChooseCurrencyFooter = ({ const dispatch = useDispatch(); const isToken = currency && currency.type === "TokenCurrency"; - // $FlowFixMe - const url = isToken ? supportLinkByTokenType[currency.tokenType] : null; - // $FlowFixMe const parentCurrency = isToken && currency.parentCurrency; @@ -101,15 +102,6 @@ export const StepChooseCurrencyFooter = ({ {currency && } {isToken ? ( - {url ? ( - - ) : null} - {parentCurrency ? ( + ))} + + + + )} + /> + )} + /> + ); +} + +const IllustrationSection = styled.div` + width: 100%; + height: 150px; + position: relative; + overflow: visible; +`; + +const IllustrationContainer = styled.div` + position: absolute; + width: calc(100% + ${p => p.theme.space[6]}px); + height: calc(100% + ${p => p.theme.space[6]}px); + top: -${p => p.theme.space[4]}px; + left: -${p => p.theme.space[4]}px; + background-color: ${p => p.bgColor}; + display: flex; + align-items: flex-end; + justify-content: center; + flex-direction: row; +`; diff --git a/src/renderer/modals/index.js b/src/renderer/modals/index.js index 6835054862..fd84a086ff 100644 --- a/src/renderer/modals/index.js +++ b/src/renderer/modals/index.js @@ -40,6 +40,17 @@ import MODAL_ALGORAND_OPT_IN from "../families/algorand/OptInFlowModal"; import MODAL_ALGORAND_CLAIM_REWARDS from "../families/algorand/Rewards/ClaimRewardsFlowModal"; import MODAL_ALGORAND_EARN_REWARDS_INFO from "../families/algorand/Rewards/EarnRewardsInfoModal"; +// Lending +import MODAL_LEND_MANAGE from "../screens/lend/modals/ManageLend"; +import MODAL_LEND_ENABLE_INFO from "../screens/lend/modals/EnableInfoModal"; +import MODAL_LEND_SUPPLY from "../screens/lend/modals/Supply"; +import MODAL_LEND_SELECT_ACCOUNT from "../screens/lend/modals/SelectAccountStep"; +import MODAL_LEND_ENABLE_FLOW from "../screens/lend/modals/Enable"; +import MODAL_LEND_WITHDRAW_FLOW from "../screens/lend/modals/Withdraw"; +import MODAL_LEND_NO_ETHEREUM_ACCOUNT from "../screens/lend/modals/NoEthereumAccount"; + +import MODAL_LEND_HIGH_FEES from "../screens/lend/modals/HighFeesModal"; + const modals: { [_: string]: React$ComponentType } = { MODAL_EXPORT_OPERATIONS, MODAL_CONFIRM, @@ -76,6 +87,15 @@ const modals: { [_: string]: React$ComponentType } = { MODAL_ALGORAND_CLAIM_REWARDS, MODAL_ALGORAND_EARN_REWARDS_INFO, MODAL_HELP, + // Lending + MODAL_LEND_MANAGE, + MODAL_LEND_ENABLE_INFO, + MODAL_LEND_ENABLE_FLOW, + MODAL_LEND_SELECT_ACCOUNT, + MODAL_LEND_SUPPLY, + MODAL_LEND_WITHDRAW_FLOW, + MODAL_LEND_HIGH_FEES, + MODAL_LEND_NO_ETHEREUM_ACCOUNT, MODAL_SWAP, MODAL_SWAP_OPERATION_DETAILS, }; diff --git a/src/renderer/reducers/accounts.js b/src/renderer/reducers/accounts.js index c6f59a6a37..aaf8786812 100644 --- a/src/renderer/reducers/accounts.js +++ b/src/renderer/reducers/accounts.js @@ -3,7 +3,13 @@ import { createSelector, createSelectorCreator, defaultMemoize } from "reselect"; import type { OutputSelector } from "reselect"; import { handleActions } from "redux-actions"; -import type { Account, AccountLike } from "@ledgerhq/live-common/lib/types"; +import type { + Account, + AccountLike, + TokenAccount, + CryptoCurrency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types"; import { flattenAccounts, clearAccount, @@ -87,6 +93,38 @@ export const shallowAccountsSelector: OutputSelector< Account[], > = shallowAccountsSelectorCreator(accountsSelector, a => a); +export const subAccountByCurrencyOrderedSelector: OutputSelector< + State, + { currency: CryptoCurrency | TokenCurrency }, + Array<{ parentAccount: ?Account, account: AccountLike }>, +> = createSelector( + accountsSelector, + (_, { currency }: { currency: CryptoCurrency | TokenCurrency }) => currency, + (accounts, currency) => { + const flatAccounts = flattenAccounts(accounts); + return flatAccounts + .filter( + account => + (account.type === "TokenAccount" ? account.token.id : account.currency.id) === + currency.id, + ) + .map(account => ({ + account, + parentAccount: + account.type === "TokenAccount" && account.parentId + ? accounts.find(fa => fa.type === "Account" && fa.id === account.parentId) + : {}, + })) + .sort((a, b) => + a.account.balance.gt(b.account.balance) + ? -1 + : a.account.balance.eq(b.account.balance) + ? 0 + : 1, + ); + }, +); + // FIXME we might reboot this idea later! export const activeAccountsSelector = accountsSelector; @@ -145,6 +183,14 @@ export const accountSelector: OutputSelector< (accounts, accountId) => accounts.find(a => a.id === accountId), ); +export const getAccountById: OutputSelector< + State, + {}, + (id: string) => ?Account, +> = createSelector(accountsSelector, accounts => (accountId: string) => + accounts.find(a => a.id === accountId), +); + export const migratableAccountsSelector = (s: *): Account[] => s.accounts.filter(canBeMigrated); export const starredAccountsSelector: OutputSelector< diff --git a/src/renderer/reducers/settings.js b/src/renderer/reducers/settings.js index 1a24e21ac6..2469cf3efc 100644 --- a/src/renderer/reducers/settings.js +++ b/src/renderer/reducers/settings.js @@ -106,6 +106,7 @@ export type SettingsState = { starredAccountIds?: string[], blacklistedTokenIds: string[], deepLinkUrl: ?string, + firstTimeLend: boolean, swapProviders?: AvailableProvider[], showClearCacheBanner: boolean, }; @@ -148,6 +149,7 @@ const INITIAL_STATE: SettingsState = { lastSeenDevice: null, blacklistedTokenIds: [], deepLinkUrl: null, + firstTimeLend: false, swapProviders: [], showClearCacheBanner: false, }; @@ -236,6 +238,10 @@ const handlers: Object = { ...state, deepLinkUrl, }), + SET_FIRST_TIME_LEND: (state: SettingsState) => ({ + ...state, + firstTimeLend: false, + }), SETTINGS_SET_SWAP_PROVIDERS: (state: SettingsState, { swapProviders }) => ({ ...state, swapProviders, diff --git a/src/renderer/screens/account/AccountHeaderActions.js b/src/renderer/screens/account/AccountHeaderActions.js index 53c271dfbe..114255ce84 100644 --- a/src/renderer/screens/account/AccountHeaderActions.js +++ b/src/renderer/screens/account/AccountHeaderActions.js @@ -14,6 +14,7 @@ import { getMainAccount, getAccountCurrency, } from "@ledgerhq/live-common/lib/account"; +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; import type { TFunction } from "react-i18next"; import { rgba } from "~/renderer/styles/helpers"; import { openModal } from "~/renderer/actions/modals"; @@ -31,6 +32,7 @@ import IconSwap from "~/renderer/icons/Swap"; import DropDownSelector from "~/renderer/components/DropDownSelector"; import Button from "~/renderer/components/Button"; import Text from "~/renderer/components/Text"; +import Redelegate from "~/renderer/icons/Redelegate"; import IconAngleDown from "~/renderer/icons/AngleDown"; import IconAngleUp from "~/renderer/icons/AngleUp"; @@ -65,9 +67,16 @@ type OwnProps = { type Props = OwnProps & { t: TFunction, openModal: Function, + isCompoundEnabled?: boolean, }; -const AccountHeaderActions = ({ account, parentAccount, openModal, t }: Props) => { +const AccountHeaderActions = ({ + account, + parentAccount, + openModal, + t, + isCompoundEnabled, +}: Props) => { const mainAccount = getMainAccount(account, parentAccount); const PerFamily = perFamily[mainAccount.currency.family]; const decorators = perFamilyAccountActions[mainAccount.currency.family]; @@ -75,6 +84,11 @@ const AccountHeaderActions = ({ account, parentAccount, openModal, t }: Props) = const ReceiveAction = (decorators && decorators.ReceiveAction) || ReceiveActionDefault; const currency = getAccountCurrency(account); const availableOnExchange = isCurrencySupported(currency); + // @TODO adjust condition of availability for lending + const summary = + account.type === "TokenAccount" && makeCompoundSummaryForAccount(account, parentAccount); + const availableOnCompound = !!summary; + const availableOnSwap = useSelector(swapSupportedCurrenciesSelector); const history = useHistory(); @@ -88,6 +102,10 @@ const AccountHeaderActions = ({ account, parentAccount, openModal, t }: Props) = }); }, [currency, history, mainAccount]); + const onLend = useCallback(() => { + openModal("MODAL_LEND_MANAGE", { account, parentAccount, currency: currency }); + }, [openModal, account, parentAccount, currency]); + const onSwap = useCallback(() => { history.push({ pathname: "/swap", @@ -113,6 +131,18 @@ const AccountHeaderActions = ({ account, parentAccount, openModal, t }: Props) = }, ] : []), + ...(availableOnCompound + ? [ + { + key: "Lend", + onClick: onLend, + event: "Lend Crypto Account Button", + eventProperties: { currencyName: currency.name }, + icon: Redelegate, + label: , + }, + ] + : []), ...(availableOnSwap.includes(currency) ? [ { diff --git a/src/renderer/screens/account/BalanceSummary.js b/src/renderer/screens/account/BalanceSummary.js index 1c282cecbc..1afdbd4cbe 100644 --- a/src/renderer/screens/account/BalanceSummary.js +++ b/src/renderer/screens/account/BalanceSummary.js @@ -13,6 +13,7 @@ import type { TokenAccount, PortfolioRange, AccountPortfolio, + TokenCurrency, } from "@ledgerhq/live-common/lib/types"; import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; import Chart from "~/renderer/components/Chart2"; @@ -21,6 +22,8 @@ import FormattedVal from "~/renderer/components/FormattedVal"; import AccountBalanceSummaryHeader from "./AccountBalanceSummaryHeader"; import { discreetModeSelector } from "~/renderer/reducers/settings"; +import AccountLendingFooter from "~/renderer/screens/lend/Account/AccountBalanceSummaryFooter"; + import perFamilyAccountBalanceSummaryFooter from "~/renderer/generated/AccountBalanceSummaryFooter"; type OwnProps = { @@ -33,6 +36,8 @@ type OwnProps = { countervalueFirst: boolean, setCountervalueFirst: boolean => void, mainAccount: ?Account, + isCompoundEnabled?: boolean, + ctoken: ?TokenCurrency, }; type Props = { @@ -82,6 +87,7 @@ class AccountBalanceSummary extends PureComponent { render() { const { account, + parentAccount, balanceHistoryWithCountervalue: { history, countervalueAvailable, @@ -96,6 +102,8 @@ class AccountBalanceSummary extends PureComponent { setCountervalueFirst, discreetMode, mainAccount, + isCompoundEnabled, + ctoken, } = this.props; const displayCountervalue = countervalueFirst && countervalueAvailable; @@ -150,6 +158,15 @@ class AccountBalanceSummary extends PureComponent { discreetMode={discreetMode} /> )} + {isCompoundEnabled && account.type === "TokenAccount" && parentAccount && ctoken && ( + + )} ); } diff --git a/src/renderer/screens/account/index.js b/src/renderer/screens/account/index.js index d5591b1fd1..a5d891f8c4 100644 --- a/src/renderer/screens/account/index.js +++ b/src/renderer/screens/account/index.js @@ -8,6 +8,8 @@ import type { TFunction } from "react-i18next"; import { Redirect } from "react-router"; import type { Currency, AccountLike, Account } from "@ledgerhq/live-common/lib/types"; import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import { isCompoundTokenSupported } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import { findCompoundToken } from "@ledgerhq/live-common/lib/currencies"; import { getCurrencyColor } from "~/renderer/getCurrencyColor"; import { accountSelector } from "~/renderer/reducers/accounts"; import { @@ -35,6 +37,7 @@ import AccountHeader from "./AccountHeader"; import AccountHeaderActions from "./AccountHeaderActions"; import EmptyStateAccount from "./EmptyStateAccount"; import TokenList from "./TokensList"; +import CompoundBodyHeader from "~/renderer/screens/lend/Account/AccountBodyHeader"; const mapStateToProps = ( state, @@ -93,6 +96,9 @@ const AccountPage = ({ return ; } + const ctoken = account.type === "TokenAccount" && findCompoundToken(account.token); + const isCompoundEnabled = ctoken && isCompoundTokenSupported(ctoken); + const currency = getAccountCurrency(account); const color = getCurrencyColor(currency, bgColor); @@ -123,11 +129,16 @@ const AccountPage = ({ range={selectedTimeRange} countervalueFirst={countervalueFirst} setCountervalueFirst={setCountervalueFirst} + isCompoundEnabled={isCompoundEnabled} + ctoken={ctoken} /> {AccountBodyHeader ? ( ) : null} + {isCompoundEnabled && account.type === "TokenAccount" && parentAccount ? ( + + ) : null} {account.type === "Account" ? ( ) : null} diff --git a/src/renderer/screens/exchange/hooks.js b/src/renderer/screens/exchange/hooks.js index 6fb5333b4a..78c24e6bed 100644 --- a/src/renderer/screens/exchange/hooks.js +++ b/src/renderer/screens/exchange/hooks.js @@ -6,7 +6,7 @@ import { useCurrenciesByMarketcap } from "@ledgerhq/live-common/lib/currencies/s import { supportedCurrenciesIds } from "./config"; import useEnv from "@ledgerhq/live-common/lib/hooks/useEnv"; import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/live-common/lib/types/currencies"; -import type { Account } from "@ledgerhq/live-common/lib/types/account"; +import type { AccountLike } from "@ledgerhq/live-common/lib/types/account"; import { useSelector } from "react-redux"; import { blacklistedTokenIdsSelector } from "~/renderer/reducers/settings"; @@ -35,8 +35,8 @@ export const useCoinifyCurrencies = () => { export const getAccountsForCurrency = ( currency: CryptoCurrency | TokenCurrency, - allAccounts: Account[], -): Account[] => { + allAccounts: AccountLike[], +): AccountLike[] => { return allAccounts.filter( account => (account.type === "TokenAccount" ? account.token.id : account.currency.id) === currency.id, diff --git a/src/renderer/screens/lend/Account/AccountBalanceSummaryFooter.js b/src/renderer/screens/lend/Account/AccountBalanceSummaryFooter.js new file mode 100644 index 0000000000..dea3bc221e --- /dev/null +++ b/src/renderer/screens/lend/Account/AccountBalanceSummaryFooter.js @@ -0,0 +1,139 @@ +// @flow + +import React from "react"; +import styled from "styled-components"; +import { useSelector } from "react-redux"; +import { Trans } from "react-i18next"; +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { listCurrentRates } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import { useDiscreetMode } from "~/renderer/components/Discreet"; + +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import type { Account, TokenAccount, TokenCurrency } from "@ledgerhq/live-common/lib/types"; + +import Box from "~/renderer/components/Box/Box"; +import Text from "~/renderer/components/Text"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import ToolTip from "~/renderer/components/Tooltip"; +import { localeSelector } from "~/renderer/reducers/settings"; + +const Wrapper: ThemedComponent<*> = styled(Box).attrs(() => ({ + horizontal: true, + mt: 4, + p: 5, + pb: 0, +}))` + border-top: 1px solid ${p => p.theme.colors.palette.text.shade10}; +`; + +const BalanceDetail = styled(Box).attrs(() => ({ + flex: 1.25, + vertical: true, + alignItems: "start", +}))``; + +const TitleWrapper = styled(Box).attrs(() => ({ horizontal: true, alignItems: "center", mb: 1 }))``; + +const Title = styled(Text).attrs(() => ({ + fontSize: 4, + ff: "Inter|Medium", + color: "palette.text.shade60", +}))` + line-height: ${p => p.theme.space[4]}px; + margin-right: ${p => p.theme.space[1]}px; +`; + +const AmountValue = styled(Text).attrs(() => ({ + fontSize: 6, + ff: "Inter|SemiBold", + color: "palette.text.shade100", +}))``; + +type Props = { + account: TokenAccount, + parentAccount: Account, + countervalue: any, + ctoken: TokenCurrency, +}; + +const AccountBalanceSummaryFooter = ({ account, parentAccount, countervalue, ctoken }: Props) => { + const discreet = useDiscreetMode(); + const locale = useSelector(localeSelector); + if (!account.compoundBalance) return null; + + const summary = makeCompoundSummaryForAccount(account, parentAccount); + + if (!summary) return null; + + const { accruedInterests, totalSupplied, allTimeEarned } = summary; + + const formatConfig = { + disableRounding: false, + alwaysShowSign: false, + showCode: true, + discreet, + locale, + }; + + const unit = getAccountUnit(account); + + const formattedAccruedInterests = formatCurrencyUnit(unit, accruedInterests, formatConfig); + const formattedTotalSupplied = formatCurrencyUnit(unit, totalSupplied, formatConfig); + const formattedAllTimeEarned = formatCurrencyUnit(unit, allTimeEarned, formatConfig); + const rates = listCurrentRates(); + + const rate = rates.find(r => r.ctoken.id === ctoken.id); + + return ( + + + }> + + + <Trans i18nKey="lend.account.amountSupplied" /> + + + + + {formattedTotalSupplied || "–"} + + + }> + + + <Trans i18nKey="lend.account.currencyAPY" /> + + + + + {rate?.supplyAPY || "-"} + + + }> + + + <Trans i18nKey="lend.account.accruedInterests" /> + + + + + {formattedAccruedInterests || "–"} + + + }> + + + <Trans i18nKey="lend.account.interestEarned" /> + + + + + {formattedAllTimeEarned || "–"} + + + ); +}; + +export default AccountBalanceSummaryFooter; diff --git a/src/renderer/screens/lend/Account/AccountBodyHeader.js b/src/renderer/screens/lend/Account/AccountBodyHeader.js new file mode 100644 index 0000000000..5544ce0fda --- /dev/null +++ b/src/renderer/screens/lend/Account/AccountBodyHeader.js @@ -0,0 +1,126 @@ +// @flow +import React, { useCallback } from "react"; +import { useDispatch } from "react-redux"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import type { TokenAccount, Account } from "@ledgerhq/live-common/lib/types"; + +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; +import { getAccountCurrency } from "@ledgerhq/live-common/lib/account"; + +import { openModal } from "~/renderer/actions/modals"; +import Text from "~/renderer/components/Text"; +import Button from "~/renderer/components/Button"; +import Box, { Card } from "~/renderer/components/Box"; +import LinkWithExternalIcon from "~/renderer/components/LinkWithExternalIcon"; +import Header from "./Header"; +import { RowOpened, RowClosed } from "./Row"; + +type Props = { + account: TokenAccount, + parentAccount: Account, +}; + +const Wrapper = styled(Box).attrs(() => ({ + p: 3, + mt: 24, + mb: 6, +}))` + border: 1px dashed ${p => p.theme.colors.palette.text.shade20}; + border-radius: 4px; + justify-content: space-between; + align-items: center; +`; + +const Loans = ({ account, parentAccount }: Props) => { + const dispatch = useDispatch(); + const currency = getAccountCurrency(account); + + const summary = makeCompoundSummaryForAccount(account, parentAccount); + const lendingDisabled = false; + + const onLending = useCallback(() => { + dispatch( + openModal("MODAL_LEND_HIGH_FEES", { + nextModal: ["MODAL_LEND_ENABLE_INFO", { account, parentAccount, currency }], + }), + ); + }, [dispatch, account, parentAccount, currency]); + + return ( + <> + + + + + + {summary && summary.opened.length > 0 ? ( + +
+ {summary + ? summary.opened.map((opened, index) => ( + + )) + : null} + + ) : ( + + + + + + + } + onClick={() => { + // @TODO replace with correct support URL + // openURL(urls.compound); + }} + /> + + + + + + + )} + {summary && summary.closed.length > 0 ? ( + <> + + + + + + +
+ {summary + ? summary.closed.map((closed, index) => ( + + )) + : null} + + + ) : null} + + ); +}; + +const AccountBodyHeader = ({ account, parentAccount }: Props) => { + if (account.balance.isZero()) return null; + + return ; +}; + +export default AccountBodyHeader; diff --git a/src/renderer/screens/lend/Account/Header.js b/src/renderer/screens/lend/Account/Header.js new file mode 100644 index 0000000000..1ecc0a7ad8 --- /dev/null +++ b/src/renderer/screens/lend/Account/Header.js @@ -0,0 +1,46 @@ +// @flow + +import React from "react"; +import styled from "styled-components"; +import { Trans } from "react-i18next"; +import Text from "~/renderer/components/Text"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +export const Wrapper: ThemedComponent<{}> = styled.div` + display: flex; + flex-direction: row; + padding: 16px 20px; + border-bottom: 1px solid ${p => p.theme.colors.palette.divider}; +`; + +export const TableLine: ThemedComponent<{}> = styled(Text).attrs(() => ({ + ff: "Inter|SemiBold", + color: "palette.text.shade60", + fontSize: 3, +}))` + flex: 1.25; + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + justify-content: flex-start; + &:last-child { + flex: 0.5; + } +`; + +const Header = ({ type }: { type: "open" | "close" }) => ( + + + + + + + + + + + +); + +export default Header; diff --git a/src/renderer/screens/lend/Account/Row.js b/src/renderer/screens/lend/Account/Row.js new file mode 100644 index 0000000000..49b40eb0d3 --- /dev/null +++ b/src/renderer/screens/lend/Account/Row.js @@ -0,0 +1,122 @@ +// @flow +import React, { useMemo } from "react"; +import styled from "styled-components"; +import { useSelector } from "react-redux"; +import moment from "moment"; +import type { OpenedLoan, ClosedLoan } from "@ledgerhq/live-common/lib/compound/types"; +import type { TokenAccount } from "@ledgerhq/live-common/lib/types"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +import { TableLine } from "./Header"; +import Discreet, { useDiscreetMode } from "~/renderer/components/Discreet"; +import { localeSelector } from "~/renderer/reducers/settings"; + +const Wrapper: ThemedComponent<*> = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 16px 20px; +`; + +const Column: ThemedComponent<{ clickable?: boolean }> = styled(TableLine).attrs(p => ({ + ff: "Inter|SemiBold", + color: p.strong ? "palette.text.shade100" : "palette.text.shade80", + fontSize: 3, +}))` + cursor: ${p => (p.clickable ? "pointer" : "cursor")}; +`; + +type OpenedProps = { + opened: OpenedLoan, + account: TokenAccount, +}; + +export const RowOpened = ({ opened, account }: OpenedProps) => { + const locale = useSelector(localeSelector); + const unit = getAccountUnit(account); + const discreet = useDiscreetMode(); + + const formatConfig = useMemo( + () => ({ + disableRounding: false, + alwaysShowSign: false, + showCode: true, + discreet, + locale, + }), + [discreet, locale], + ); + + const amountRedeemed = useMemo( + () => formatCurrencyUnit(unit, opened.amountSupplied, formatConfig), + [unit, opened.amountSupplied, formatConfig], + ); + + const interestEarned = useMemo( + () => formatCurrencyUnit(unit, opened.interestsEarned, formatConfig), + [unit, opened.interestsEarned, formatConfig], + ); + + const date = useMemo(() => moment(opened.startingDate).fromNow(), [opened.startingDate]); + + return ( + + + {amountRedeemed} + + + {interestEarned} + + {date} + + ); +}; + +type ClosedProps = { + closed: ClosedLoan, + account: TokenAccount, +}; + +export const RowClosed = ({ closed, account }: ClosedProps) => { + const locale = useSelector(localeSelector); + const unit = getAccountUnit(account); + const discreet = useDiscreetMode(); + + const formatConfig = useMemo( + () => ({ + disableRounding: false, + alwaysShowSign: false, + showCode: true, + discreet, + locale, + }), + [discreet, locale], + ); + + const amountRedeemed = useMemo( + () => + formatCurrencyUnit(unit, closed.amountSupplied.plus(closed.interestsEarned), formatConfig), + [unit, closed, formatConfig], + ); + + const interestEarned = useMemo( + () => formatCurrencyUnit(unit, closed.interestsEarned, formatConfig), + [unit, closed, formatConfig], + ); + + const date = useMemo(() => moment(closed.endDate).format(), [closed.endDate]); + + return ( + + + {amountRedeemed} + + + {interestEarned} + + {date} + + ); +}; diff --git a/src/renderer/screens/lend/Closed/ClosedLoans.js b/src/renderer/screens/lend/Closed/ClosedLoans.js new file mode 100644 index 0000000000..a735b6ac42 --- /dev/null +++ b/src/renderer/screens/lend/Closed/ClosedLoans.js @@ -0,0 +1,180 @@ +// @flow +import React from "react"; +import styled from "styled-components"; +import type { + ClosedLoansHistory, + ClosedLoanHistory, +} from "@ledgerhq/live-common/lib/compound/types"; +import { useTranslation } from "react-i18next"; +import { getAccountName } from "@ledgerhq/live-common/lib/account"; +import Box from "~/renderer/components/Box"; +import Card from "~/renderer/components/Box/Card"; +import Text from "~/renderer/components/Text"; +import Ellipsis from "~/renderer/components/Ellipsis"; +import CounterValue from "~/renderer/components/CounterValue"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import CryptoCurrencyIcon from "~/renderer/components/CryptoCurrencyIcon"; +import ToolTip from "~/renderer/components/Tooltip"; + +const Header = styled(Box)` + border-bottom: 1px solid ${p => p.theme.colors.palette.divider}; + + > * { + flex-basis: 25%; + } + + > *:last-child { + text-align: right; + } +`; + +const RowContent = styled(Box)` + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + padding: 10px 24px; + + > * { + flex-basis: 25%; + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + max-width: 25%; + } + + &:hover { + background: ${p => p.theme.colors.palette.background.default}; + } +`; + +const RowAccount = styled(Box)` + margin-left: 12px; + align-items: flex-start; + /* the calc here uses the margin left + icon size */ + max-width: calc(90% - 12px - 32px); +`; + +const Amount = styled(Box)` + flex-direction: column; + align-items: flex-start; + flex-basis: 25%; + max-width: 25%; +`; + +const Date = styled(Box)` + flex-direction: column; + align-items: flex-end; +`; + +type RowProps = { + loan: ClosedLoanHistory, +}; + +const Row = ({ loan }: RowProps) => { + const { account, parentAccount, amountSupplied, interestsEarned, endDate } = loan; + const { token } = account; + const name = getAccountName(account); + + const totalRedeemed = amountSupplied.plus(interestsEarned); + + return ( + + + + + + {parentAccount?.name} + + + + {name} + + + + + + + + + + + + + + + + + + + + + + + {endDate.toDateString()} + + + + ); +}; + +type Props = { + loans: ClosedLoansHistory, +}; + +const ClosedLoans = ({ loans }: Props) => { + const { t } = useTranslation(); + + return ( + +
+ + {t("lend.headers.closed.assetLended")} + + + {t("lend.headers.closed.amountRedeemed")} + + + {t("lend.headers.closed.interestsEarned")} + + + {t("lend.headers.closed.date")} + +
+ {loans.map((l, i) => ( + + ))} +
+ ); +}; + +export default ClosedLoans; diff --git a/src/renderer/screens/lend/Closed/index.js b/src/renderer/screens/lend/Closed/index.js new file mode 100644 index 0000000000..c63a472e12 --- /dev/null +++ b/src/renderer/screens/lend/Closed/index.js @@ -0,0 +1,37 @@ +// @flow +import React from "react"; +import { useTranslation } from "react-i18next"; +import type { CompoundAccountSummary } from "@ledgerhq/live-common/lib/compound/types"; +import { makeClosedHistoryForAccounts } from "@ledgerhq/live-common/lib/compound/logic"; +import type { AccountLikeArray } from "@ledgerhq/live-common/lib/types"; +import Box from "~/renderer/components/Box"; +import EmptyState from "../EmptyState"; +import ClosedLoans from "./ClosedLoans"; + +type Props = { + accounts: AccountLikeArray, + summaries: CompoundAccountSummary[], + navigateToCompoundDashboard: () => void, +}; + +const Closed = ({ accounts, summaries, navigateToCompoundDashboard }: Props) => { + const closedLoans = makeClosedHistoryForAccounts(summaries); + const { t } = useTranslation(); + + return ( + + {closedLoans.length > 0 ? ( + + ) : ( + + )} + + ); +}; + +export default Closed; diff --git a/src/renderer/screens/lend/Dashboard/ActiveAccounts.js b/src/renderer/screens/lend/Dashboard/ActiveAccounts.js new file mode 100644 index 0000000000..b72642e4f6 --- /dev/null +++ b/src/renderer/screens/lend/Dashboard/ActiveAccounts.js @@ -0,0 +1,241 @@ +// @flow + +import React, { useCallback } from "react"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; +import { useDispatch } from "react-redux"; +import { getAccountCurrency, getAccountName } from "@ledgerhq/live-common/lib/account"; +import type { CompoundAccountSummary } from "@ledgerhq/live-common/lib/compound/types"; +import { getAccountCapabilities } from "@ledgerhq/live-common/lib/compound/logic"; + +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import Box from "~/renderer/components/Box"; +import Card from "~/renderer/components/Box/Card"; +import Text from "~/renderer/components/Text"; +import Ellipsis from "~/renderer/components/Ellipsis"; +import CounterValue from "~/renderer/components/CounterValue"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import CryptoCurrencyIcon from "~/renderer/components/CryptoCurrencyIcon"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import ToolTip from "~/renderer/components/Tooltip"; +import ChevronRight from "~/renderer/icons/ChevronRight"; +import { StatusPill } from "./Pill"; + +import { openModal } from "~/renderer/actions/modals"; + +const Header = styled(Box)` + border-bottom: 1px solid ${p => p.theme.colors.palette.divider}; + align-items: center; + + > * { + flex-basis: 25%; + align-items: center; + } + + > *:nth-child(2), + > *:nth-child(3) { + flex-basis: 20%; + } + + > *:nth-child(4) { + flex-basis: 15%; + justify-content: center; + } + + > *:last-child { + flex-basis: 20%; + text-align: right; + } +`; + +const RowContent = styled(Box)` + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + padding: 10px 24px; + + > * { + flex-basis: 25%; + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + max-width: 25%; + } + + &:hover { + background: ${p => p.theme.colors.palette.background.default}; + } +`; + +const RowAccount = styled(Box)` + margin-left: 12px; + align-items: flex-start; + /* the calc here uses the margin left + icon size */ + max-width: calc(90% - 12px - 32px); +`; + +const Amount = styled(Box)` + flex-direction: column; + align-items: flex-start; + flex-basis: 20%; + max-width: 20%; +`; + +const Status = styled(Box)` + flex-basis: 15%; + justify-content: center; +`; + +const Action: ThemedComponent<{}> = styled.div` + flex-basis: 20%; + justify-content: flex-end; + cursor: pointer; + color: ${p => p.theme.colors.palette.text.shade50}; +`; + +const IconWrapper = styled.div` + margin-left: 4px; +`; + +type RowProps = { + summary: CompoundAccountSummary, +}; + +const Row = ({ summary }: RowProps) => { + const { account, parentAccount, totalSupplied, accruedInterests } = summary; + const { token } = account; + const { t } = useTranslation(); + const dispatch = useDispatch(); + const name = getAccountName(account); + const currency = getAccountCurrency(account); + const capabilities = getAccountCapabilities(account); + + const openManageModal = useCallback(() => { + dispatch(openModal("MODAL_LEND_MANAGE", { ...summary })); + }, [dispatch, summary]); + + return ( + + + + + + {parentAccount?.name || name} + + + + {currency.name} + + + + + + + + + + + + + + + + + + + + + {capabilities?.status ? : null} + + + + {t("common.manage")} + +
+ +
+
+
+
+ ); +}; + +type Props = { + summaries: CompoundAccountSummary[], +}; + +const ActiveAccounts = ({ summaries }: Props) => { + const { t } = useTranslation(); + + return ( + +
+ + {t("lend.headers.active.accounts")} + + + + {t("lend.headers.active.amountSupplied")} + + + + + + + + {t("lend.headers.active.accruedInterests")} + + + + + + + + {t("lend.headers.active.status")} + + + + + + + {t("lend.headers.active.actions")} + +
+ {summaries.map(s => ( + + ))} +
+ ); +}; + +export default ActiveAccounts; diff --git a/src/renderer/screens/lend/Dashboard/EmptyState.js b/src/renderer/screens/lend/Dashboard/EmptyState.js new file mode 100644 index 0000000000..a16a213543 --- /dev/null +++ b/src/renderer/screens/lend/Dashboard/EmptyState.js @@ -0,0 +1,38 @@ +// @flow +import React from "react"; +import { useTranslation } from "react-i18next"; +import styled from "styled-components"; + +import Box from "~/renderer/components/Box"; +import Text from "~/renderer/components/Text"; +import LinkWithExternal from "~/renderer/components/LinkWithExternalIcon"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +const Container: ThemedComponent<*> = styled(Box).attrs(() => ({ + py: 2, + px: 3, +}))` + border-radius: 4px; + border: 1px dashed ${p => p.theme.colors.palette.text.shade50}; +`; + +const EmptyState = () => { + const { t } = useTranslation(); + return ( + + + {t("lend.emptyState.active.title")} + + + + {t("lend.emptyState.active.description")} + + + + {}} label={t("lend.emptyState.active.cta")} /> + + + ); +}; + +export default EmptyState; diff --git a/src/renderer/screens/lend/Dashboard/Pill.js b/src/renderer/screens/lend/Dashboard/Pill.js new file mode 100644 index 0000000000..bf4434275f --- /dev/null +++ b/src/renderer/screens/lend/Dashboard/Pill.js @@ -0,0 +1,85 @@ +// @flow +import React from "react"; +import { useTranslation } from "react-i18next"; +import type { CompoundAccountStatus } from "@ledgerhq/live-common/lib/compound/types"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import Text from "~/renderer/components/Text"; +import ToolTip from "~/renderer/components/Tooltip"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; + +const colorMap = { + ENABLING: { + background: "palette.text.shade10", + text: "palette.text.shade80", + }, + INACTIVE: { + background: "blueTransparentBackground", + text: "wallet", + }, + SUPPLYING: { + background: "wallet", + text: "white", + }, + EARNING: { + background: "wallet", + text: "white", + }, +}; + +const Wrapper: ThemedComponent<{}> = styled(Box)` + padding: 4px 8px; + border-radius: 64px; +`; + +const Pill = ({ + children, + background = "blueTransparentBackground", + color = "wallet", + fontSize = 2, +}: { + children: React$Node, + background?: string, + color?: string, + fontSize?: number, +}) => { + return ( + + + {children} + + + ); +}; + +type Props = { + type: CompoundAccountStatus, +}; + +export const StatusPill = ({ type }: Props) => { + const { t } = useTranslation(); + if (!type) return null; + const { background, text } = colorMap[type]; + + const Wrapper = type === "ENABLING" || type === "INACTIVE" ? ToolTip : null; + + return Wrapper ? ( + + + {type.toUpperCase()} + + + ) : ( + + {type.toUpperCase()} + + ); +}; + +export default Pill; diff --git a/src/renderer/screens/lend/Dashboard/Rates.js b/src/renderer/screens/lend/Dashboard/Rates.js new file mode 100644 index 0000000000..c83bc15011 --- /dev/null +++ b/src/renderer/screens/lend/Dashboard/Rates.js @@ -0,0 +1,239 @@ +// @flow +import React, { useCallback, useMemo } from "react"; +import styled from "styled-components"; +import { BigNumber } from "bignumber.js"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import type { AccountLikeArray } from "@ledgerhq/live-common/lib/types"; +import type { CurrentRate } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import { getCryptoCurrencyById, formatShort } from "@ledgerhq/live-common/lib/currencies"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import { flattenSortAccountsSelector } from "~/renderer/actions/general"; +import { isAcceptedLendingTerms } from "~/renderer/terms"; +import Box from "~/renderer/components/Box"; +import Card from "~/renderer/components/Box/Card"; +import Text from "~/renderer/components/Text"; +import Button from "~/renderer/components/Button"; +import Ellipsis from "~/renderer/components/Ellipsis"; +import CryptoCurrencyIcon from "~/renderer/components/CryptoCurrencyIcon"; +import ToolTip from "~/renderer/components/Tooltip"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import { openModal } from "~/renderer/actions/modals"; +import Pill from "./Pill"; + +const Header = styled(Box)` + border-bottom: 1px solid ${p => p.theme.colors.palette.divider}; + align-items: center; + + > * { + flex-basis: 20%; + align-items: center; + } + + > *:first-child { + flex-basis: 25%; + max-width: 25%; + } + + > *:nth-child(4) { + flex-basis: 15%; + text-align: center; + } + + > *:last-child { + text-align: right; + } +`; + +const RowContent = styled(Box)` + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + padding: 10px 24px; + + > * { + flex-basis: 20%; + display: flex; + align-items: center; + flex-direction: row; + box-sizing: border-box; + max-width: 20%; + } + + > *:first-child { + flex-basis: 25%; + max-width: 25%; + } + + > *:nth-child(4) { + flex-basis: 15%; + max-width: 15%; + text-align: center; + } + + &:hover { + background: ${p => p.theme.colors.palette.background.default}; + } +`; + +const RowAccount = styled(Box)` + margin-left: 12px; + align-items: flex-start; + /* the calc here uses the margin left + icon size */ + max-width: calc(90% - 12px - 32px); +`; + +const Amount = styled(Box)` + flex-direction: column; + align-items: flex-start; +`; + +const Action: ThemedComponent<{}> = styled.div` + justify-content: flex-end; + cursor: pointer; + color: ${p => p.theme.colors.palette.text.shade50}; +`; + +const CompactButton = styled(Button)` + padding: 7px 8px; + height: auto; +`; + +const IconWrapper = styled.div` + margin-left: 4px; +`; + +const Row = ({ data, accounts }: { data: CurrentRate, accounts: AccountLikeArray }) => { + const { token, totalSupply, supplyAPY } = data; + const { t } = useTranslation(); + const dispatch = useDispatch(); + const eth = getCryptoCurrencyById("ethereum"); + + const openManageModal = useCallback(() => { + const account = accounts.find(a => a.type === "TokenAccount" && a.token.id === token.id); + const parentAccount = accounts.find(a => account?.parentId === a.id); + const ethAccount = accounts.find(a => a.type === "Account" && a.currency.id === eth.id); + if (!account && ethAccount) { + dispatch(openModal("MODAL_RECEIVE", { currency: token, account: ethAccount })); + } else if (!ethAccount) { + dispatch(openModal("MODAL_LEND_NO_ETHEREUM_ACCOUNT")); + } else if (isAcceptedLendingTerms()) { + dispatch( + openModal("MODAL_LEND_SELECT_ACCOUNT", { + account, + parentAccount, + currency: token, + nextStep: "MODAL_LEND_ENABLE_FLOW", + }), + ); + } else { + dispatch(openModal("MODAL_LEND_ENABLE_INFO", { account, parentAccount, currency: token })); + } + }, [dispatch, accounts, token, eth]); + + const grossSupply = useMemo((): string => { + return formatShort(token.units[0], totalSupply); + }, [token, totalSupply]); + + const totalBalance = useMemo(() => { + return accounts.reduce((total, account) => { + if (account.type !== "TokenAccount") return total; + if (account.token.id !== token.id) return total; + + return total.plus(account.spendableBalance); + }, BigNumber(0)); + }, [token.id, accounts]); + + return ( + + + + + + + {token.name} + + + + + + + + + + + + {grossSupply} + + + + {supplyAPY} + + + + {t("lend.lendAsset")} + + + + ); +}; + +const Rates = ({ rates }: { rates: CurrentRate[] }) => { + const { t } = useTranslation(); + const accounts = useSelector(flattenSortAccountsSelector); + return ( + +
+ + {t("lend.headers.rates.allAssets")} + + + + {t("lend.headers.rates.totalBalance")} + + + + + + + + {t("lend.headers.rates.grossSupply")} + + + + + + + + {t("lend.headers.rates.currentAPY")} + + + + + + + {t("lend.headers.rates.actions")} + +
+ {rates.map(r => ( + + ))} +
+ ); +}; + +export default Rates; diff --git a/src/renderer/screens/lend/Dashboard/index.js b/src/renderer/screens/lend/Dashboard/index.js new file mode 100644 index 0000000000..6938ac134d --- /dev/null +++ b/src/renderer/screens/lend/Dashboard/index.js @@ -0,0 +1,63 @@ +// @flow +import React, { useEffect, useCallback } from "react"; +import { useTranslation } from "react-i18next"; + +import { useDispatch } from "react-redux"; +import { useHistory } from "react-router-dom"; + +import type { CompoundAccountSummary } from "@ledgerhq/live-common/lib/compound/types"; +import type { CurrentRate } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import Box from "~/renderer/components/Box"; +import Text from "~/renderer/components/Text"; +import EmptyState from "./EmptyState"; +import ActiveAccounts from "./ActiveAccounts"; +import Rates from "./Rates"; +import { openModal } from "~/renderer/actions/modals"; +import { isAcceptedLendingTerms } from "~/renderer/terms"; + +const Dashboard = ({ + summaries, + rates, +}: { + summaries: CompoundAccountSummary[], + rates: CurrentRate[], +}) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const history = useHistory(); + const isAcceptedTerms = isAcceptedLendingTerms(); + + // handle backdrop closing of modal in context of not accepting terms of lending + const onCloseTermsModal = useCallback(() => { + const hasAcceptedTerms = isAcceptedLendingTerms(); + !hasAcceptedTerms ? history.goBack() : dispatch(openModal("MODAL_LEND_HIGH_FEES")); + }, [history, dispatch]); + + // if user has not accepted terms of lending show terms modal + useEffect(() => { + !isAcceptedTerms + ? dispatch( + openModal("MODAL_LEND_ENABLE_INFO", { onlyTerms: true, onClose: onCloseTermsModal }), + ) + : dispatch(openModal("MODAL_LEND_HIGH_FEES", { MODAL_SHOW_ONCE: true })); + }, [dispatch, isAcceptedTerms, onCloseTermsModal]); + + return ( + + + {t("lend.assets")} + + + + + + {t("lend.active")} + + + {summaries.length > 0 ? : } + + + ); +}; + +export default Dashboard; diff --git a/src/renderer/screens/lend/EmptyState.js b/src/renderer/screens/lend/EmptyState.js new file mode 100644 index 0000000000..0c05567b9a --- /dev/null +++ b/src/renderer/screens/lend/EmptyState.js @@ -0,0 +1,48 @@ +// @flow + +import React from "react"; +import styled from "styled-components"; +import Box from "~/renderer/components/Box"; +import Card from "~/renderer/components/Box/Card"; +import Button from "~/renderer/components/Button"; +import Text from "~/renderer/components/Text"; +import Compound from "~/renderer/images/compound.svg"; + +type Props = { + title: React$Node, + description: React$Node, + buttonLabel: React$Node, + onClick: () => void, +}; + +const EmptyState = ({ title, description, buttonLabel, onClick }: Props) => { + return ( + + + + + + {title} + + + + + {description} + + + + + + + + ); +}; + +const CompoundImg = styled.img.attrs(() => ({ src: Compound }))` + width: 72px; + height: auto; +`; + +export default EmptyState; diff --git a/src/renderer/screens/lend/History/index.js b/src/renderer/screens/lend/History/index.js new file mode 100644 index 0000000000..3f8570b108 --- /dev/null +++ b/src/renderer/screens/lend/History/index.js @@ -0,0 +1,42 @@ +// @flow +import React from "react"; +import { useTranslation } from "react-i18next"; +import type { AccountLikeArray, AccountLike, Operation } from "@ledgerhq/live-common/lib/types"; +import { findCompoundToken } from "@ledgerhq/live-common/lib/currencies"; +import { isCompoundTokenSupported } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import Box from "~/renderer/components/Box"; +import OperationsList from "~/renderer/components/OperationsList"; +import EmptyState from "../EmptyState"; + +type Props = { + navigateToCompoundDashboard: () => void, + accounts: AccountLikeArray, +}; + +const History = ({ navigateToCompoundDashboard, accounts }: Props) => { + const { t } = useTranslation(); + + const filterOperation = (op: Operation, acc: AccountLike) => { + if (acc.type !== "TokenAccount") return false; + const ctoken = findCompoundToken(acc.token); + if (!ctoken) return false; + return isCompoundTokenSupported(ctoken) && ["REDEEM", "SUPPLY"].includes(op.type); + }; + + return ( + + {history.length ? ( + + ) : ( + + )} + + ); +}; + +export default History; diff --git a/src/renderer/screens/lend/WithdrawableBanner.js b/src/renderer/screens/lend/WithdrawableBanner.js new file mode 100644 index 0000000000..9aa91c2aae --- /dev/null +++ b/src/renderer/screens/lend/WithdrawableBanner.js @@ -0,0 +1,74 @@ +// @flow +import React, { useCallback } from "react"; +import styled from "styled-components"; +import { useTranslation } from "react-i18next"; +import type { Account, TokenAccount } from "@ledgerhq/live-common/lib/types"; +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; +import { urls } from "~/config/urls"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import { openURL } from "~/renderer/linking"; +import Box from "~/renderer/components/Box"; +import Text from "~/renderer/components/Text"; +import { FakeLink } from "~/renderer/components/Link"; + +const Container: ThemedComponent<{}> = styled(Box).attrs(() => ({ + horizontal: true, + py: 1, + px: 2, + bg: "palette.text.shade10", +}))` + border-radius: 4px; + align-items: center; +`; + +type Props = { + account: TokenAccount, + parentAccount: ?Account, +}; + +const WithdrawableBanner = ({ account, parentAccount }: Props) => { + const { t } = useTranslation(); + + const onClickHelp = useCallback(() => { + openURL(urls.maxSpendable); + }, []); + + const accountUnit = getAccountUnit(account); + const summary = makeCompoundSummaryForAccount(account); + + if (!summary) return null; + + const { totalSupplied } = summary; + console.log(summary); + + return ( + + + + {t("lend.withdraw.steps.amount.maxWithdrawble")} + + + ~ + + + {totalSupplied.gt(0) ? ( + + ) : null} + + + {t("common.learnMore")} + + + ); +}; + +export default WithdrawableBanner; diff --git a/src/renderer/screens/lend/index.js b/src/renderer/screens/lend/index.js new file mode 100644 index 0000000000..3b7e3312a8 --- /dev/null +++ b/src/renderer/screens/lend/index.js @@ -0,0 +1,83 @@ +// @flow + +import React, { useCallback, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import { listCurrentRates } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import { getCryptoCurrencyById } from "@ledgerhq/live-common/lib/currencies"; +import { flattenSortAccountsSelector } from "~/renderer/actions/general"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import TabBar from "~/renderer/components/TabBar"; +import { prepareCurrency } from "~/renderer/bridge/cache"; +import { useCompoundSummaries } from "./useCompoundSummaries"; + +import Dashboard from "./Dashboard"; +import Closed from "./Closed"; +import History from "./History"; + +const tabs = [ + { + title: "lend.tabs.dashboard", + component: Dashboard, + }, + { + title: "lend.tabs.closed", + component: Closed, + }, + { + title: "lend.tabs.history", + component: History, + }, +]; + +const Lend = () => { + const { t } = useTranslation(); + const [activeTabIndex, setActiveTabIndex] = useState(0); + const accounts = useSelector(flattenSortAccountsSelector); + const summaries = useCompoundSummaries(accounts); + const [rates, setRates] = useState(() => listCurrentRates()); + + const Component = tabs[activeTabIndex].component; + + useEffect(() => { + prepareCurrency(getCryptoCurrencyById("ethereum")).then(() => { + const newRates = listCurrentRates(); + setRates(newRates); + }); + }, []); + + const navigateToCompoundDashboard = useCallback(() => setActiveTabIndex(0), [setActiveTabIndex]); + + return ( + + + + + {t("lend.title")} + + + {t("lend.description")} + + + + t(tab.title))} + onIndexChange={setActiveTabIndex} + short + /> + + + + + + ); +}; + +export default Lend; diff --git a/src/renderer/screens/lend/modals/Enable/Body.js b/src/renderer/screens/lend/modals/Enable/Body.js new file mode 100644 index 0000000000..b130ec97b8 --- /dev/null +++ b/src/renderer/screens/lend/modals/Enable/Body.js @@ -0,0 +1,206 @@ +// @flow +import React, { useState, useCallback, useMemo } from "react"; +import { compose } from "redux"; +import { connect, useDispatch } from "react-redux"; +import { Trans, withTranslation } from "react-i18next"; +import { createStructuredSelector } from "reselect"; + +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import { findCompoundToken } from "@ledgerhq/live-common/lib/currencies"; + +import type { Account, AccountLike, Operation } from "@ledgerhq/live-common/lib/types"; +import type { TFunction } from "react-i18next"; +import type { StepId, StepProps, St } from "./types"; + +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; + +import Track from "~/renderer/analytics/Track"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import { closeModal, openModal } from "~/renderer/actions/modals"; + +import Stepper from "~/renderer/components/Stepper"; +import StepAmount, { StepAmountFooter } from "./steps/StepAmount"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import logger from "~/logger/logger"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: AccountLike, + parentAccount: Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "amount", + label: , + component: StepAmount, + footer: StepAmountFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + onBack: ({ transitionTo }: StepProps) => transitionTo("amount"), + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + name, + // $FlowFixMe + params, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, parentAccount } = params; + const bridge = getAccountBridge(account, parentAccount); + const ctoken = findCompoundToken(account.token); + + const t = bridge.createTransaction(account); + + const transaction = bridge.updateTransaction(t, { + recipient: ctoken?.contractAddress || "", + mode: "erc20.approve", + useAllAmount: true, + gasPrice: null, + userGasLimit: null, + subAccountId: account.id, + }); + + return { account, parentAccount, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + onChangeStepId("amount"); + setTransactionError(null); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const statusError = useMemo(() => status.errors && Object.values(status.errors)[0], [ + status.errors, + ]); + + const error = + transactionError || bridgeError || (statusError instanceof Error ? statusError : null); + + const stepperProps = { + title: t("lend.enable.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps: [], + disabledSteps: [], + hideBreadcrumb: !!error, + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/screens/lend/modals/Enable/index.js b/src/renderer/screens/lend/modals/Enable/index.js new file mode 100644 index 0000000000..e50d8e0528 --- /dev/null +++ b/src/renderer/screens/lend/modals/Enable/index.js @@ -0,0 +1,69 @@ +// @flow + +import React, { PureComponent } from "react"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { Account, AccountLike } from "@ledgerhq/live-common/lib/types"; + +import type { StepId } from "./types"; +import { accountSelector } from "~/renderer/reducers/accounts"; +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "amount", +}; + +type Props = { name: string, account: AccountLike, parentAccount: Account }; + +class EnableFlowModal extends PureComponent { + state = INITIAL_STATE; + + handleReset = () => this.setState({ ...INITIAL_STATE }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + handleReset = () => + this.setState({ + stepId: "amount", + }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + render() { + const { stepId } = this.state; + const { name, account, parentAccount } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +const mapStateToProps = createStructuredSelector({ + parentAccount: accountSelector, +}); + +const m: React$ComponentType = connect(mapStateToProps)(EnableFlowModal); + +export default m; diff --git a/src/renderer/screens/lend/modals/Enable/steps/StepAmount.js b/src/renderer/screens/lend/modals/Enable/steps/StepAmount.js new file mode 100644 index 0000000000..0db22e7c97 --- /dev/null +++ b/src/renderer/screens/lend/modals/Enable/steps/StepAmount.js @@ -0,0 +1,193 @@ +// @flow +import invariant from "invariant"; +import React, { useCallback } from "react"; +import styled from "styled-components"; +import { BigNumber } from "bignumber.js"; +import { Trans } from "react-i18next"; +import { useSelector } from "react-redux"; +import { localeSelector } from "~/renderer/reducers/settings"; + +import type { StepProps } from "../types"; +import { getAccountCurrency, getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; + +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; + +import ErrorBanner from "~/renderer/components/ErrorBanner"; +import BadgeLabel from "~/renderer/components/BadgeLabel"; +import Text from "~/renderer/components/Text"; +import Spoiler from "~/renderer/components/Spoiler"; +import Label from "~/renderer/components/Label"; +import InputCurrency from "~/renderer/components/InputCurrency"; +import Switch from "~/renderer/components/Switch"; +import GasPriceField from "~/renderer/families/ethereum/GasPriceField"; +import GasLimitField from "~/renderer/families/ethereum/GasLimitField"; +import ToolTip from "~/renderer/components/Tooltip"; +import InfoCircle from "~/renderer/icons/InfoCircle"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; + +const InputRight = styled(Box).attrs(() => ({ + ff: "Inter|Medium", + color: "palette.text.shade60", + fontSize: 4, + justifyContent: "center", + alignItems: "center", + horizontal: true, +}))` + padding: ${p => p.theme.space[2]}px; +`; + +export default function StepAmount({ + account, + parentAccount, + onChangeTransaction, + transaction, + status, + error, + bridgePending, + t, +}: StepProps) { + invariant(account && transaction, "account and transaction required"); + const { amount, useAllAmount } = transaction; + const locale = useSelector(localeSelector); + + const name = account?.name || parentAccount?.name; + const currency = getAccountCurrency(account); + const unit = getAccountUnit(account); + + const formattedAmount = + amount && + formatCurrencyUnit(unit, amount, { + locale, + showAllDigits: false, + disableRounding: false, + showCode: true, + }); + + const bridge = getAccountBridge(account, parentAccount); + + const onChangeAmount = useCallback( + (amount?: BigNumber) => { + onChangeTransaction( + bridge.updateTransaction(transaction, { + amount, + }), + ); + }, + [bridge, transaction, onChangeTransaction], + ); + + const updateNoLimit = useCallback( + (useAllAmount: boolean) => + onChangeTransaction( + bridge.updateTransaction(transaction, { + amount: BigNumber(0), + useAllAmount, + }), + ), + [bridge, transaction, onChangeTransaction], + ); + + return ( + + + {error ? : null} + + + + + + + + + }> + + + + }> + + + + + + {unit.code}} + /> + + + + + + + + + + + + ); +} + +export function StepAmountFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const hasErrors = Object.keys(errors).length; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + ); +} diff --git a/src/renderer/screens/lend/modals/Enable/steps/StepConfirmation.js b/src/renderer/screens/lend/modals/Enable/steps/StepConfirmation.js new file mode 100644 index 0000000000..fcf01b3bbc --- /dev/null +++ b/src/renderer/screens/lend/modals/Enable/steps/StepConfirmation.js @@ -0,0 +1,129 @@ +// @flow + +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import { multiline } from "~/renderer/styles/helpers"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import RetryButton from "~/renderer/components/RetryButton"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; + +import type { StepProps } from "../types"; +import Update from "~/renderer/icons/UpdateCircle"; +import InfoBox from "~/renderer/components/InfoBox"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; + min-height: 220px; +`; + +const IconContainer: ThemedComponent<{}> = styled(Box).attrs(() => ({ + width: 56, + height: 56, + borderRadius: "50%", + bg: "blueTransparentBackground", + justifyContent: "center", + alignItems: "center", + color: "wallet", + mb: 2, +}))``; + +const Title: ThemedComponent<{}> = styled(Box).attrs(() => ({ + ff: "Inter|SemiBold", + fontSize: 5, + mt: 2, +}))` + text-align: center; + word-break: break-word; +`; + +const Text: ThemedComponent<{}> = styled(Box).attrs(() => ({ + ff: "Inter", + fontSize: 4, + mt: 2, + mb: 4, +}))` + text-align: center; +`; + +function StepConfirmation({ + account, + t, + transaction, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + const onLearnMore = useCallback(() => { + // @TODO redirect to support page + }, []); + + if (optimisticOperation) { + return ( + + + + + + + + <Trans i18nKey="lend.enable.steps.confirmation.success.title" /> + + {multiline(t("lend.enable.steps.confirmation.success.text"))} + + + + + ); + } + + if (error) { + return ( + + + {signed ? ( + } + /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + account, + parentAccount, + onRetry, + error, + openModal, + onClose, + transitionTo, + t, +}: StepProps) { + return ( + + + {error ? : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/screens/lend/modals/Enable/types.js b/src/renderer/screens/lend/modals/Enable/types.js new file mode 100644 index 0000000000..d66aa090b1 --- /dev/null +++ b/src/renderer/screens/lend/modals/Enable/types.js @@ -0,0 +1,36 @@ +// @flow +import type { TFunction } from "react-i18next"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Step } from "~/renderer/components/Stepper"; + +import type { + Account, + AccountLike, + TransactionStatus, + Operation, +} from "@ledgerhq/live-common/lib/types"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/ethereum/types"; +export type StepId = "amount" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: AccountLike, + parentAccount: Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: *, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/screens/lend/modals/EnableInfoModal/index.js b/src/renderer/screens/lend/modals/EnableInfoModal/index.js new file mode 100644 index 0000000000..22db923cf7 --- /dev/null +++ b/src/renderer/screens/lend/modals/EnableInfoModal/index.js @@ -0,0 +1,284 @@ +// @flow +import React, { useCallback, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import styled from "styled-components"; + +import { useDispatch } from "react-redux"; + +import type { + Account, + AccountLike, + CryptoCurrency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types"; + +import LendingTermsIllu from "~/renderer/images/lending-terms.svg"; +import LendingTermsIllu1 from "~/renderer/images/lending-illu-1.svg"; +import LendingTermsIllu2 from "~/renderer/images/lending-illu-2.svg"; +import LendingTermsIllu3 from "~/renderer/images/lending-illu-3.svg"; +import LendingInfoIllu1 from "~/renderer/images/lending-info-1.svg"; +import LendingInfoIllu2 from "~/renderer/images/lending-info-2.svg"; +import LendingInfoIllu3 from "~/renderer/images/lending-info-3.svg"; + +import { closeModal, openModal } from "~/renderer/actions/modals"; +import { openURL } from "~/renderer/linking"; + +import Text from "~/renderer/components/Text"; +import Box from "~/renderer/components/Box"; +import CheckBox from "~/renderer/components/CheckBox"; +import { FakeLink } from "~/renderer/components/Link"; +import { isAcceptedLendingTerms, acceptLendingTerms } from "~/renderer/terms"; +import TutorialModal from "~/renderer/modals/TutorialModal"; + +type Props = { + name?: string, + account: AccountLike, + parentAccount: ?Account, + currency: CryptoCurrency | TokenCurrency, + onlyTerms?: boolean, + onClose?: () => void, + ... +}; + +export default function LendTermsModal({ + name, + account, + parentAccount, + currency, + onlyTerms, + onClose, + ...rest +}: Props) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const isAcceptedTerms = isAcceptedLendingTerms(); + + const [accepted, setAccepted] = useState(isAcceptedTerms); + const onSwitchAccept = useCallback(() => setAccepted(!accepted), [accepted]); + + const handleOnClose = useCallback(() => { + dispatch(closeModal(name)); + }, [name, dispatch]); + + const acceptTerms = useCallback(() => { + acceptLendingTerms(); + onlyTerms && handleOnClose(); + }, [onlyTerms, handleOnClose]); + + const onFinish = useCallback(() => { + handleOnClose(); + dispatch( + openModal(account ? "MODAL_LEND_ENABLE_FLOW" : "MODAL_LEND_SELECT_ACCOUNT", { + ...rest, + account, + parentAccount, + accountId: parentAccount ? parentAccount.id : null, + currency, + nextStep: "MODAL_LEND_ENABLE_FLOW", + cta: t("common.continue"), + }), + ); + }, [handleOnClose, dispatch, rest, currency, t, account, parentAccount]); + + const onTermsLinkClick = useCallback(() => { + // @TODO replace this URL with the correct one + openURL("https://ledger.com"); + }, []); + + return ( + + + + + + + ), + title: , + subtitle: , + description: , + onFinish: acceptTerms, + footer: ( + + + + + + + + + ), + continueDisabled: !accepted, + }, + ]), + { + illustration: ( + <> + + + + + + + ), + title: ( + + + + ), + subtitle: ( + <> + +
+ + + ), + description: , + previousDisabled: true, + }, + { + illustration: ( + <> + + + + + + + ), + title: ( + + + + ), + subtitle: , + description: , + }, + { + illustration: ( + <> + + + + + + + ), + title: ( + + + + ), + subtitle: , + description: , + onFinish, + }, + ]} + /> + ); +} + +const LendingTermsImg = styled.img.attrs(() => ({ src: LendingTermsIllu }))` + width: 315px; + height: auto; + ${p => (p.overlay ? `filter: brightness(0.8) grayscale(0.2);` : "")} +`; + +const LendingTermsImg1 = styled.img.attrs(() => ({ src: LendingTermsIllu1 }))` + width: 60px; + height: auto; + position: absolute; + top: 50px; + left: 60px; + animation: ${p => p.theme.animations.fadeInUp}; + opacity: 0; + transform: translateY(66%); + animation-delay: 0.5s; + animation-duration: 0.9s; +`; + +const LendingTermsImg2 = styled.img.attrs(() => ({ src: LendingTermsIllu2 }))` + width: 55px; + height: auto; + position: absolute; + top: -5px; + left: 50%; + animation: ${p => p.theme.animations.fadeInUp}; + opacity: 0; + transform: translateY(66%); + animation-delay: 0.4s; + animation-duration: 1s; +`; + +const LendingTermsImg3 = styled.img.attrs(() => ({ src: LendingTermsIllu3 }))` + width: 45px; + height: auto; + position: absolute; + top: 100px; + right: 75px; + animation: ${p => p.theme.animations.fadeInUp}; + opacity: 0; + transform: translateY(66%); + animation-delay: 0.6s; + animation-duration: 0.8s; +`; + +const LendingInfoImg1 = styled.img.attrs(() => ({ src: LendingInfoIllu1 }))` + width: 170px; + height: auto; + position: absolute; + top: 40px; + left: 50%; + animation: ${p => p.theme.animations.fadeIn}; + opacity: 0; + transform: translateX(-50%); + animation-delay: 0.2s; + animation-duration: 0.4s; +`; + +const LendingInfoImg2 = styled.img.attrs(() => ({ src: LendingInfoIllu2 }))` + width: 170px; + height: auto; + position: absolute; + top: 40px; + left: 50%; + animation: ${p => p.theme.animations.fadeIn}; + opacity: 0; + transform: translateX(-50%); + animation-delay: 0.2s; + animation-duration: 0.4s; +`; + +const LendingInfoImg3 = styled.img.attrs(() => ({ src: LendingInfoIllu3 }))` + width: 170px; + height: auto; + position: absolute; + top: 40px; + left: 50%; + animation: ${p => p.theme.animations.fadeIn}; + opacity: 0; + transform: translateX(-50%); + animation-delay: 0.2s; + animation-duration: 0.4s; +`; + +const TitleSpacer = styled.span` + margin-left: 5px; +`; diff --git a/src/renderer/screens/lend/modals/HighFeesModal/index.js b/src/renderer/screens/lend/modals/HighFeesModal/index.js new file mode 100644 index 0000000000..ec07fcc2d8 --- /dev/null +++ b/src/renderer/screens/lend/modals/HighFeesModal/index.js @@ -0,0 +1,79 @@ +// @flow +import React, { memo, useCallback } from "react"; +import styled from "styled-components"; +import { Trans } from "react-i18next"; + +import { useDispatch } from "react-redux"; +import { closeModal, openModal } from "~/renderer/actions/modals"; + +import AmountUp from "~/renderer/icons/AmountUp"; +import Modal, { ModalBody } from "~/renderer/components/Modal/index"; +import Box from "~/renderer/components/Box/Box"; +import Button from "~/renderer/components/Button"; +import Text from "~/renderer/components/Text"; + +const AmountUpWrapper = styled.div` + padding: ${p => p.theme.space[3]}px; + box-sizing: content-box; + border-radius: 100%; + display: flex; + justify-content: center; + align-items: center; + align-content: center; + color: ${p => p.theme.colors.warning}; + background-color: ${p => p.theme.colors.lightWarning}; +`; + +type Props = { + name: string, + nextModal?: [string, *], + ... +}; + +const HighFeesModal = ({ name, nextModal, ...rest }: Props) => { + const dispatch = useDispatch(); + const handleClose = useCallback(() => { + dispatch(closeModal(name)); + nextModal && dispatch(openModal(...nextModal)); + }, [dispatch, name, nextModal]); + return ( + ( + } + noScroll + render={onClose => ( + + + + + + + + + + + + + + )} + renderFooter={() => ( + + + + + )} + /> + )} + /> + ); +}; + +export default memo(HighFeesModal); diff --git a/src/renderer/screens/lend/modals/ManageLend/index.js b/src/renderer/screens/lend/modals/ManageLend/index.js new file mode 100644 index 0000000000..d920e2f083 --- /dev/null +++ b/src/renderer/screens/lend/modals/ManageLend/index.js @@ -0,0 +1,328 @@ +// @flow + +import React, { useCallback } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import styled, { css } from "styled-components"; +import { Trans, useTranslation } from "react-i18next"; +import { BigNumber } from "bignumber.js"; +import { getAccountCapabilities, getEnablingOp } from "@ledgerhq/live-common/lib/compound/logic"; + +import type { Account, TokenAccount, Unit, TokenCurrency } from "@ledgerhq/live-common/lib/types"; +import type { + CompoundAccountSummary, + CompoundAccountStatus, +} from "@ledgerhq/live-common/lib/compound/types"; + +import { localeSelector } from "~/renderer/reducers/settings"; + +import { getAccountCurrency, getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; + +import { openModal } from "~/renderer/actions/modals"; +import Box from "~/renderer/components/Box"; +import Modal, { ModalBody } from "~/renderer/components/Modal"; +import ArrowRight from "~/renderer/icons/ArrowRight"; +import Minus from "~/renderer/icons/Minus"; +import Text from "~/renderer/components/Text"; +import InfoBox from "~/renderer/components/InfoBox"; + +const IconWrapper = styled.div` + width: 32px; + height: 32px; + border-radius: 32px; + background-color: ${p => p.theme.colors.palette.action.hover}; + color: ${p => p.theme.colors.palette.primary.main}; + display: flex; + justify-content: center; + align-items: center; +`; + +const ManageButton = styled.button` + min-height: 88px; + padding: 16px; + margin: 5px 0; + border-radius: 4px; + border: 1px solid ${p => p.theme.colors.palette.divider}; + background-color: rgba(0, 0, 0, 0); + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + &:hover { + border: 1px solid ${p => p.theme.colors.palette.primary.main}; + } + + ${p => + p.disabled + ? css` + pointer-events: none; + cursor: auto; + ${IconWrapper} { + background-color: ${p.theme.colors.palette.action.active}; + color: ${p.theme.colors.palette.text.shade20}; + } + ${Title} { + color: ${p.theme.colors.palette.text.shade50}; + } + ${Description} { + color: ${p.theme.colors.palette.text.shade30}; + } + ` + : ` + cursor: pointer; + `}; +`; + +const InfoWrapper = styled(Box).attrs(() => ({ + vertical: true, + flex: 1, + ml: 3, + textAlign: "start", +}))``; + +const Title = styled(Text).attrs(() => ({ + ff: "Inter|SemiBold", + fontSize: 4, +}))``; + +const Description = styled(Text).attrs(({ isPill }) => ({ + ff: isPill ? "Inter|SemiBold" : "Inter|Regular", + fontSize: isPill ? 2 : 3, + color: "palette.text.shade60", +}))` + ${p => + p.isPill + ? ` + text-transform: uppercase; + ` + : ""} +`; + +type Props = { + name?: string, + account: TokenAccount, + parentAccount: ?Account, + ... +} & CompoundAccountSummary; + +const ManageModal = ({ name, account, parentAccount, totalSupplied, status, ...rest }: Props) => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const currency = getAccountCurrency(account); + + const onSelectAction = useCallback( + (name: string, onClose: () => void, nextStep?: string, cta?: string) => { + onClose(); + dispatch( + openModal(name, { + parentAccount, + accountId: parentAccount?.id && account.parentId, + account, + currency, + nextStep, + cta, + }), + ); + }, + [dispatch, account, parentAccount, currency], + ); + + const locale = useSelector(localeSelector); + const unit = getAccountUnit(account); + + const capabilities = getAccountCapabilities(account); + if (!capabilities) return null; + if (currency.type !== "TokenCurrency") return null; + const { canSupply, canWithdraw } = capabilities; + + return ( + ( + } + noScroll + render={() => ( + <> + + + + + + onSelectAction("MODAL_LEND_SUPPLY", onClose)} + > + + + + + + <Trans i18nKey="lend.manage.supply.title" /> + + + + + + + onSelectAction("MODAL_LEND_WITHDRAW_FLOW", onClose)} + > + + + + + + <Trans i18nKey="lend.manage.withdraw.title" /> + + + + + + + + + )} + renderFooter={undefined} + /> + )} + /> + ); +}; + +const Banner = ({ + capabilities, + locale, + onClose, + t, + onSelectAction, + unit, + totalSupplied, + currency, + account, + parentAccount, +}: { + capabilities: { + canSupply: boolean, + canSupplyMax: Boolean, + enabledAmount: BigNumber, + enabledAmountIsUnlimited: boolean, + canSupplyMax: boolean, + status: CompoundAccountStatus, + }, + locale: string, + unit: Unit, + currency: TokenCurrency, + onSelectAction: (name: string, onClose: () => void, nextStep?: string, cta?: string) => void, + onClose: () => void, + t: any, + totalSupplied: BigNumber, + account: TokenAccount, + parentAccount: ?Account, +}) => { + const dispatch = useDispatch(); + const { enabledAmount, enabledAmountIsUnlimited, status, canSupplyMax } = capabilities; + + const label = + enabledAmountIsUnlimited && totalSupplied.eq(0) ? ( + + ) : !canSupplyMax ? ( + + ) : status === "ENABLING" ? ( + + ) : ( + + ); + + const text = + enabledAmountIsUnlimited && totalSupplied.gt(0) ? ( + + + + ) : enabledAmountIsUnlimited && totalSupplied.eq(0) ? ( + + ) : enabledAmount.gt(0) ? ( + + + + ) : enabledAmount.gt(0) && totalSupplied.eq(0) ? ( + + ) : !canSupplyMax ? ( + + ) : status === "ENABLING" ? ( + + ) : status === null ? ( + + ) : ( +
+ ); + + const action = useCallback(() => { + if (status === "ENABLING") { + const op = getEnablingOp(account); + if (!op) return; + return dispatch( + openModal("MODAL_OPERATION_DETAILS", { + operationId: op.id, + accountId: account.id, + parentId: parentAccount?.id, + }), + ); + } + + return onSelectAction( + "MODAL_LEND_ENABLE_FLOW", + onClose, + undefined, + t("lend.enable.steps.selectAccount.cta"), + ); + }, [status, onClose, onSelectAction, t, dispatch, account, parentAccount]); + + return ( + + {text} + + ); +}; + +export default ManageModal; diff --git a/src/renderer/screens/lend/modals/NoEthereumAccount/index.js b/src/renderer/screens/lend/modals/NoEthereumAccount/index.js new file mode 100644 index 0000000000..5b318899c7 --- /dev/null +++ b/src/renderer/screens/lend/modals/NoEthereumAccount/index.js @@ -0,0 +1,90 @@ +// @flow +import React, { memo, useCallback } from "react"; +import styled from "styled-components"; +import { Trans } from "react-i18next"; +import { useDispatch } from "react-redux"; +import type { TokenCurrency } from "@ledgerhq/live-common/lib/types"; +import { closeModal, openModal } from "~/renderer/actions/modals"; + +import AmountUp from "~/renderer/icons/AmountUp"; +import Modal, { ModalBody } from "~/renderer/components/Modal/index"; +import Box from "~/renderer/components/Box/Box"; +import Button from "~/renderer/components/Button"; +import Text from "~/renderer/components/Text"; + +const AmountUpWrapper = styled.div` + padding: ${p => p.theme.space[3]}px; + box-sizing: content-box; + border-radius: 100%; + display: flex; + justify-content: center; + align-items: center; + align-content: center; + color: ${p => p.theme.colors.wallet}; + background-color: ${p => p.theme.colors.blueTransparentBackground}; +`; + +type Props = { + currency: TokenCurrency, + ... +}; + +const NoEthereumAccountModal = ({ currency, ...rest }: Props) => { + const dispatch = useDispatch(); + + const handleClose = useCallback(() => { + dispatch(closeModal("MODAL_LEND_NO_ETHEREUM_ACCOUNT")); + }, [dispatch]); + + const handleAddAccount = useCallback(() => { + handleClose(); + dispatch(openModal("MODAL_ADD_ACCOUNTS", { currency })); + }, [dispatch, handleClose, currency]); + + return ( + ( + } + noScroll + render={onClose => ( + + + + + + + + + + + + + + + + )} + renderFooter={() => ( + + + + + )} + /> + )} + /> + ); +}; + +export default memo(NoEthereumAccountModal); diff --git a/src/renderer/screens/lend/modals/SelectAccountStep.js b/src/renderer/screens/lend/modals/SelectAccountStep.js new file mode 100644 index 0000000000..f8719a5bfd --- /dev/null +++ b/src/renderer/screens/lend/modals/SelectAccountStep.js @@ -0,0 +1,237 @@ +// @flow + +import React, { useCallback, useState } from "react"; +import { useDispatch, connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { useTranslation, Trans } from "react-i18next"; + +import { + getAccountCurrency, + getAccountUnit, + getAccountName, +} from "@ledgerhq/live-common/lib/account"; + +import type { + Account, + AccountLike, + TokenAccount, + CryptoCurrency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types"; + +import { openModal, closeModal } from "~/renderer/actions/modals"; +import Box from "~/renderer/components/Box"; +import Modal, { ModalBody } from "~/renderer/components/Modal"; +import { subAccountByCurrencyOrderedSelector } from "~/renderer/reducers/accounts"; +import Button from "~/renderer/components/Button"; +import Label from "~/renderer/components/Label"; +import Select from "~/renderer/components/Select"; +import CryptoCurrencyIcon from "~/renderer/components/CryptoCurrencyIcon"; +import Ellipsis from "~/renderer/components/Ellipsis"; +import FormattedVal from "~/renderer/components/FormattedVal"; +import { getAccountCapabilities } from "@ledgerhq/live-common/lib/compound/logic"; +import ToolTip from "~/renderer/components/Tooltip"; +import CheckCircle from "~/renderer/icons/CheckCircle"; + +export function AccountOption({ + account, + parentAccount, + isValue, + disabled, + displayBadge = true, +}: { + account: TokenAccount, + parentAccount: ?Account, + isValue?: boolean, + disabled?: boolean, + displayBadge?: boolean, +}) { + const currency = getAccountCurrency(account); + const unit = getAccountUnit(account); + const capabilities = getAccountCapabilities(account); + const name = getAccountName(parentAccount || account); + const isEnabled = + capabilities && + ((capabilities.enabledAmount && capabilities.enabledAmount.gt(0)) || + capabilities.enabledAmountIsUnlimited); + + return ( + + +
+ + {name} + +
+ + + + {displayBadge ? ( + + {isEnabled && ( + }> + + + )} + + ) : null} +
+ ); +} + +export const renderValue = ({ + data, +}: { + data: { + account: TokenAccount, + parentAccount: ?Account, + }, +}) => + data ? : null; + +export const renderValueNoBadge = ({ + data, +}: { + data: { + account: TokenAccount, + parentAccount: ?Account, + }, +}) => + data ? ( + + ) : null; + +export const renderOption = ({ + data, +}: { + data: { + account: TokenAccount, + parentAccount: ?Account, + }, +}) => (data ? : null); + +export const renderOptionNoBadge = ({ + data, +}: { + data: { + account: TokenAccount, + parentAccount: ?Account, + }, +}) => + data ? ( + + ) : null; + +export const getOptionValue = (option?: { account: TokenAccount }) => option?.account?.id; + +type Props = { + name?: string, + currency: CryptoCurrency | TokenCurrency, + accounts: Array<{ parentAccount: ?Account, account: AccountLike }>, + nextStep: string, + cta: React$Node, + ... +}; + +const SelectAccountStepModal = ({ + name, + currency, + accounts, + nextStep, + cta = , + ...rest +}: Props) => { + const dispatch = useDispatch(); + const { t } = useTranslation(); + + const [account, setAccount] = useState(accounts[0]); + + const onClose = useCallback(() => { + dispatch(closeModal(name)); + }, [name, dispatch]); + + const onNext = useCallback(() => { + const { account: innerAccount, parentAccount } = account; + if (innerAccount.type !== "TokenAccount") return; + const capabilities = getAccountCapabilities(innerAccount); + const isEnabled = + capabilities && + ((capabilities.enabledAmount && capabilities.enabledAmount.gt(0)) || + capabilities.enabledAmountIsUnlimited); + onClose(); + dispatch( + openModal(isEnabled ? "MODAL_LEND_SUPPLY" : nextStep, { + ...rest, + currency, + account: innerAccount, + accountId: innerAccount.parentId ? innerAccount.parentId : null, + parentAccount, + }), + ); + }, [onClose, account, dispatch, nextStep, rest, currency]); + + const onChangeAccount = useCallback( + a => { + setAccount(a); + }, + [setAccount], + ); + + if (!accounts || accounts.length <= 0) return null; + + return ( + ( + ( + + + + t("common.selectAccountNoOption", { accountName: inputValue }) + } + onChange={onChangeAccount} + /> + + + + + {account.spendableBalance.gt(0) ? ( + + + + + + ) : null} + + setFocused(true)} + renderLeft={{unit.code}} + renderRight={ + + {options.map(({ label, value }) => ( + onChangeAmount(value)} + > + {label} + + ))} + + } + /> + + + + {parentAccount && transaction ? ( + + + + + + + ) : null} + + +
+ ); +} + +const mapStateToProps = createStructuredSelector({ + collection: subAccountByCurrencyOrderedSelector, +}); + +const m: React$ComponentType = connect(mapStateToProps)(StepAmount); + +export default m; + +export function StepAmountFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + // const { errors } = status; + // const hasErrors = Object.keys(errors).length; + // const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + + + + ); +} diff --git a/src/renderer/screens/lend/modals/Supply/steps/StepConfirmation.js b/src/renderer/screens/lend/modals/Supply/steps/StepConfirmation.js new file mode 100644 index 0000000000..bc29e9e7ea --- /dev/null +++ b/src/renderer/screens/lend/modals/Supply/steps/StepConfirmation.js @@ -0,0 +1,125 @@ +// @flow + +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import { multiline } from "~/renderer/styles/helpers"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import RetryButton from "~/renderer/components/RetryButton"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; +import InfoBox from "~/renderer/components/InfoBox"; +import type { StepProps } from "../types"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; + min-height: 220px; +`; + +function StepConfirmation({ + account, + t, + transaction, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + const onLearnMore = useCallback(() => { + // @TODO redirect to support page + }, []); + + if (optimisticOperation) { + return ( + + + + + + + + + + + ); + } + + if (error) { + return ( + + + {signed ? ( + } + /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + account, + parentAccount, + onRetry, + optimisticOperation, + error, + openModal, + onClose, + transitionTo, + t, +}: StepProps) { + const concernedOperation = optimisticOperation + ? optimisticOperation.subOperations && optimisticOperation.subOperations.length > 0 + ? optimisticOperation.subOperations[0] + : optimisticOperation + : null; + return ( + + + {concernedOperation ? ( + // FIXME make a standalone component! + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/screens/lend/modals/Supply/types.js b/src/renderer/screens/lend/modals/Supply/types.js new file mode 100644 index 0000000000..5934dcb702 --- /dev/null +++ b/src/renderer/screens/lend/modals/Supply/types.js @@ -0,0 +1,44 @@ +// @flow +import type { TFunction } from "react-i18next"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Step } from "~/renderer/components/Stepper"; + +import type { + Account, + AccountLike, + TransactionStatus, + Operation, + CryptoCurrency, + TokenCurrency, +} from "@ledgerhq/live-common/lib/types"; + +import type { Transaction } from "@ledgerhq/live-common/lib/families/ethereum/types"; + +export type StepId = "amount" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: ?Account, + accounts: ?(AccountLike[]), + currency: CryptoCurrency | TokenCurrency, + parentAccount: ?Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + onChangeAccount: (nextAccount: AccountLike, nextParentAccount?: Account) => void, + optimisticOperation: *, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onUpdateTransaction: (updater: (Transaction) => void) => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/screens/lend/modals/Withdraw/Body.js b/src/renderer/screens/lend/modals/Withdraw/Body.js new file mode 100644 index 0000000000..53bf6536e0 --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/Body.js @@ -0,0 +1,205 @@ +// @flow +import React, { useState, useCallback, useMemo } from "react"; +import { compose } from "redux"; +import { connect, useDispatch } from "react-redux"; +import { Trans, withTranslation } from "react-i18next"; +import { createStructuredSelector } from "reselect"; +import type { TFunction } from "react-i18next"; + +import { UserRefusedOnDevice } from "@ledgerhq/errors"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import { addPendingOperation } from "@ledgerhq/live-common/lib/account"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { SyncSkipUnderPriority } from "@ledgerhq/live-common/lib/bridge/react"; +import useBridgeTransaction from "@ledgerhq/live-common/lib/bridge/useBridgeTransaction"; +import type { Account, AccountLike, Operation } from "@ledgerhq/live-common/lib/types"; + +import logger from "~/logger/logger"; +import { updateAccountWithUpdater } from "~/renderer/actions/accounts"; +import Track from "~/renderer/analytics/Track"; +import { getCurrentDevice } from "~/renderer/reducers/devices"; +import { closeModal, openModal } from "~/renderer/actions/modals"; +import Stepper from "~/renderer/components/Stepper"; +import StepAmount, { StepAmountFooter } from "./steps/StepAmount"; +import GenericStepConnectDevice from "~/renderer/modals/Send/steps/GenericStepConnectDevice"; +import StepConfirmation, { StepConfirmationFooter } from "./steps/StepConfirmation"; +import type { StepId, StepProps, St } from "./types"; + +type OwnProps = {| + stepId: StepId, + onClose: () => void, + onChangeStepId: StepId => void, + params: { + account: AccountLike, + parentAccount: Account, + }, + name: string, +|}; + +type StateProps = {| + t: TFunction, + device: ?Device, + accounts: Account[], + device: ?Device, + closeModal: string => void, + openModal: string => void, +|}; + +type Props = OwnProps & StateProps; + +const steps: Array = [ + { + id: "amount", + label: , + component: StepAmount, + footer: StepAmountFooter, + }, + { + id: "connectDevice", + label: , + component: GenericStepConnectDevice, + onBack: ({ transitionTo }: StepProps) => transitionTo("amount"), + }, + { + id: "confirmation", + label: , + component: StepConfirmation, + footer: StepConfirmationFooter, + }, +]; + +const mapStateToProps = createStructuredSelector({ + device: getCurrentDevice, +}); + +const mapDispatchToProps = { + closeModal, + openModal, +}; + +const Body = ({ + t, + stepId, + device, + closeModal, + openModal, + onChangeStepId, + name, + // $FlowFixMe + params, +}: Props) => { + const [optimisticOperation, setOptimisticOperation] = useState(null); + const [transactionError, setTransactionError] = useState(null); + const [signed, setSigned] = useState(false); + const dispatch = useDispatch(); + + const { + transaction, + setTransaction, + account, + parentAccount, + status, + bridgeError, + bridgePending, + } = useBridgeTransaction(() => { + const { account, parentAccount } = params; + + const bridge = getAccountBridge(account, parentAccount); + const t = bridge.createTransaction(account); + + const transaction = bridge.updateTransaction(t, { + mode: "compound.withdraw", + useAllAmount: true, + subAccountId: account.id, + }); + + return { account, parentAccount, transaction }; + }); + + const handleCloseModal = useCallback(() => { + closeModal(name); + }, [closeModal, name]); + + const handleStepChange = useCallback(e => onChangeStepId(e.id), [onChangeStepId]); + + const handleRetry = useCallback(() => { + onChangeStepId("amount"); + }, [onChangeStepId]); + + const handleTransactionError = useCallback((error: Error) => { + if (!(error instanceof UserRefusedOnDevice)) { + logger.critical(error); + } + setTransactionError(error); + }, []); + + const handleOperationBroadcasted = useCallback( + (optimisticOperation: Operation) => { + if (!account) return; + dispatch( + updateAccountWithUpdater(account.id, account => + addPendingOperation(account, optimisticOperation), + ), + ); + setOptimisticOperation(optimisticOperation); + setTransactionError(null); + }, + [account, dispatch], + ); + + const statusError = useMemo(() => status.errors && Object.values(status.errors)[0], [ + status.errors, + ]); + + const error = + transactionError || bridgeError || (statusError instanceof Error ? statusError : null); + + const errorSteps = []; + + if (transactionError) { + errorSteps.push(2); + } else if (error) { + errorSteps.push(0); + } + + const stepperProps = { + title: t("lend.withdraw.title"), + device, + account, + parentAccount, + transaction, + signed, + stepId, + steps, + errorSteps, + disabledSteps: [], + hideBreadcrumb: false, + onRetry: handleRetry, + onStepChange: handleStepChange, + onClose: handleCloseModal, + error, + status, + optimisticOperation, + openModal, + setSigned, + onChangeTransaction: setTransaction, + onOperationBroadcasted: handleOperationBroadcasted, + onTransactionError: handleTransactionError, + t, + bridgePending, + }; + + return ( + + + + + ); +}; + +const C: React$ComponentType = compose( + connect(mapStateToProps, mapDispatchToProps), + withTranslation(), +)(Body); + +export default C; diff --git a/src/renderer/screens/lend/modals/Withdraw/fields/AmountField.js b/src/renderer/screens/lend/modals/Withdraw/fields/AmountField.js new file mode 100644 index 0000000000..0d5f57ff15 --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/fields/AmountField.js @@ -0,0 +1,142 @@ +// @flow +import invariant from "invariant"; +import React, { useCallback, useMemo } from "react"; +import { Trans } from "react-i18next"; +import styled from "styled-components"; +import { useSelector } from "react-redux"; + +import { BigNumber } from "bignumber.js"; + +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; +import { getAccountBridge } from "@ledgerhq/live-common/lib/bridge"; +import { getAccountUnit } from "@ledgerhq/live-common/lib/account"; +import { formatCurrencyUnit } from "@ledgerhq/live-common/lib/currencies"; +import type { TFunction } from "react-i18next"; +import type { Account, TokenAccount, TransactionStatus } from "@ledgerhq/live-common/lib/types"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/ethereum/types"; + +import { localeSelector } from "~/renderer/reducers/settings"; +import Label from "~/renderer/components/Label"; +import Box from "~/renderer/components/Box"; +import InputCurrency from "~/renderer/components/InputCurrency"; +import Text from "~/renderer/components/Text"; +import Switch from "~/renderer/components/Switch"; + +const InputRight = styled(Box).attrs(() => ({ + ff: "Inter|Medium", + color: "palette.text.shade60", + fontSize: 4, + alignItems: "center", + horizontal: true, +}))` + padding: ${p => p.theme.space[2]}px; +`; + +type Props = { + t: TFunction, + account: ?TokenAccount, + parentAccount: ?Account, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + bridgePending: boolean, +}; + +const AmountField = ({ + account, + parentAccount, + onChangeTransaction, + transaction, + status, + bridgePending, + t, +}: Props) => { + const locale = useSelector(localeSelector); + invariant(account && transaction && account.spendableBalance, "account and transaction required"); + + const bridge = getAccountBridge(account, parentAccount); + + const defaultUnit = getAccountUnit(account); + const capabilities = makeCompoundSummaryForAccount(account, parentAccount); + + const onChange = useCallback( + (value: BigNumber) => { + onChangeTransaction( + bridge.updateTransaction(transaction, { + useAllAmount: false, + amount: value, + }), + ); + }, + [bridge, transaction, onChangeTransaction], + ); + + const onChangeSendMax = useCallback( + (useAllAmount: boolean) => { + onChangeTransaction( + bridge.updateTransaction(transaction, { + useAllAmount, + amount: useAllAmount ? BigNumber(0) : capabilities?.totalSupplied || BigNumber(0), + }), + ); + }, + [bridge, transaction, onChangeTransaction, capabilities], + ); + + const amountAvailable = useMemo( + () => + formatCurrencyUnit(defaultUnit, capabilities?.totalSupplied, { + disableRounding: true, + showAllDigits: false, + showCode: true, + locale, + }), + [capabilities, defaultUnit, locale], + ); + + if (!status) return null; + const { errors, warnings } = status; + const { amount } = errors; + const { useAllAmount, amount: txAmount } = transaction; + + return ( + + + {defaultUnit.code}} + /> + + ); +}; + +export default AmountField; diff --git a/src/renderer/screens/lend/modals/Withdraw/index.js b/src/renderer/screens/lend/modals/Withdraw/index.js new file mode 100644 index 0000000000..31f872181d --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/index.js @@ -0,0 +1,60 @@ +// @flow + +import React, { PureComponent } from "react"; +import Modal from "~/renderer/components/Modal"; +import Body from "./Body"; +import type { Account, AccountLike } from "@ledgerhq/live-common/lib/types"; + +import type { StepId } from "./types"; +type State = { + stepId: StepId, +}; + +const INITIAL_STATE = { + stepId: "amount", +}; + +type Props = { name: string, account: AccountLike, parentAccount: Account }; + +class WithdrawFlowModal extends PureComponent { + state = INITIAL_STATE; + + handleReset = () => this.setState({ ...INITIAL_STATE }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + handleReset = () => + this.setState({ + stepId: "amount", + }); + + handleStepChange = (stepId: StepId) => this.setState({ stepId }); + + render() { + const { stepId } = this.state; + const { name } = this.props; + + const isModalLocked = ["connectDevice", "confirmation"].includes(stepId); + + return ( + ( + + )} + /> + ); + } +} + +export default WithdrawFlowModal; diff --git a/src/renderer/screens/lend/modals/Withdraw/steps/StepAmount.js b/src/renderer/screens/lend/modals/Withdraw/steps/StepAmount.js new file mode 100644 index 0000000000..e38da61ba4 --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/steps/StepAmount.js @@ -0,0 +1,97 @@ +// @flow +import invariant from "invariant"; +import React from "react"; +import { Trans } from "react-i18next"; + +import type { StepProps } from "../types"; + +import TrackPage from "~/renderer/analytics/TrackPage"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; + +import Spoiler from "~/renderer/components/Spoiler"; +import GasPriceField from "~/renderer/families/ethereum/GasPriceField"; +import GasLimitField from "~/renderer/families/ethereum/GasLimitField"; +import AccountFooter from "~/renderer/modals/Send/AccountFooter"; +import AmountField from "../fields/AmountField"; +import WithdrawableBanner from "../../../WithdrawableBanner"; + +export default function StepAmount({ + account, + parentAccount, + onChangeTransaction, + transaction, + status, + error, + bridgePending, + t, +}: StepProps) { + invariant(account && transaction, "account and transaction required"); + + return ( + + + + {account && transaction ? ( + + ) : null} + + + + }> + {parentAccount && transaction ? ( + + + + + + + ) : null} + + + + ); +} + +export function StepAmountFooter({ + transitionTo, + account, + parentAccount, + onClose, + status, + bridgePending, + transaction, +}: StepProps) { + invariant(account, "account required"); + const { errors } = status; + const hasErrors = Object.keys(errors).length; + const canNext = !bridgePending && !hasErrors; + + return ( + <> + + + + ); +} diff --git a/src/renderer/screens/lend/modals/Withdraw/steps/StepConfirmation.js b/src/renderer/screens/lend/modals/Withdraw/steps/StepConfirmation.js new file mode 100644 index 0000000000..4bf066ea62 --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/steps/StepConfirmation.js @@ -0,0 +1,117 @@ +// @flow + +import React from "react"; +import { Trans } from "react-i18next"; +import styled, { withTheme } from "styled-components"; + +import { SyncOneAccountOnMount } from "@ledgerhq/live-common/lib/bridge/react"; +import TrackPage from "~/renderer/analytics/TrackPage"; +import type { ThemedComponent } from "~/renderer/styles/StyleProvider"; +import { multiline } from "~/renderer/styles/helpers"; +import Box from "~/renderer/components/Box"; +import Button from "~/renderer/components/Button"; +import RetryButton from "~/renderer/components/RetryButton"; +import ErrorDisplay from "~/renderer/components/ErrorDisplay"; +import BroadcastErrorDisclaimer from "~/renderer/components/BroadcastErrorDisclaimer"; + +import type { StepProps } from "../types"; +import SuccessDisplay from "~/renderer/components/SuccessDisplay"; + +const Container: ThemedComponent<{ shouldSpace?: boolean }> = styled(Box).attrs(() => ({ + alignItems: "center", + grow: true, + color: "palette.text.shade100", +}))` + justify-content: ${p => (p.shouldSpace ? "space-between" : "center")}; + min-height: 220px; +`; + +function StepConfirmation({ + account, + t, + transaction, + optimisticOperation, + error, + theme, + device, + signed, +}: StepProps & { theme: * }) { + if (optimisticOperation) { + return ( + + + + } + description={multiline(t("lend.withdraw.steps.confirmation.success.text"))} + /> + + ); + } + + if (error) { + return ( + + + {signed ? ( + } + /> + ) : null} + + + ); + } + + return null; +} + +export function StepConfirmationFooter({ + account, + parentAccount, + onRetry, + optimisticOperation, + error, + openModal, + onClose, + transitionTo, + t, +}: StepProps) { + const concernedOperation = optimisticOperation + ? optimisticOperation.subOperations && optimisticOperation.subOperations.length > 0 + ? optimisticOperation.subOperations[0] + : optimisticOperation + : null; + return ( + + + {concernedOperation ? ( + // FIXME make a standalone component! + + ) : error ? ( + + ) : null} + + ); +} + +export default withTheme(StepConfirmation); diff --git a/src/renderer/screens/lend/modals/Withdraw/types.js b/src/renderer/screens/lend/modals/Withdraw/types.js new file mode 100644 index 0000000000..67804957cf --- /dev/null +++ b/src/renderer/screens/lend/modals/Withdraw/types.js @@ -0,0 +1,36 @@ +// @flow +import type { TFunction } from "react-i18next"; +import type { Device } from "@ledgerhq/live-common/lib/hw/actions/types"; +import type { Step } from "~/renderer/components/Stepper"; + +import type { + Account, + TokenAccount, + TransactionStatus, + Operation, +} from "@ledgerhq/live-common/lib/types"; +import type { Transaction } from "@ledgerhq/live-common/lib/families/ethereum/types"; +export type StepId = "amount" | "connectDevice" | "confirmation"; + +export type StepProps = { + t: TFunction, + transitionTo: string => void, + device: ?Device, + account: TokenAccount, + parentAccount: Account, + onRetry: void => void, + onClose: () => void, + openModal: (key: string, config?: any) => void, + optimisticOperation: *, + error: *, + signed: boolean, + transaction: ?Transaction, + status: TransactionStatus, + onChangeTransaction: Transaction => void, + onTransactionError: Error => void, + onOperationBroadcasted: Operation => void, + setSigned: boolean => void, + bridgePending: boolean, +}; + +export type St = Step; diff --git a/src/renderer/screens/lend/useCompoundSummaries.js b/src/renderer/screens/lend/useCompoundSummaries.js new file mode 100644 index 0000000000..39ec737f37 --- /dev/null +++ b/src/renderer/screens/lend/useCompoundSummaries.js @@ -0,0 +1,43 @@ +// @flow +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import type { AccountLikeArray, TokenAccount } from "@ledgerhq/live-common/lib/types"; +import type { CompoundAccountSummary } from "@ledgerhq/live-common/lib/compound/types"; +import { makeCompoundSummaryForAccount } from "@ledgerhq/live-common/lib/compound/logic"; +import { findCompoundToken } from "@ledgerhq/live-common/lib/currencies"; +import { isCompoundTokenSupported } from "@ledgerhq/live-common/lib/families/ethereum/modules/compound"; +import { accountsSelector } from "~/renderer/reducers/accounts"; + +const makeSummaries = (accounts: AccountLikeArray): CompoundAccountSummary[] => + accounts + .map(acc => { + if (acc.type !== "TokenAccount") return; + const ctoken = findCompoundToken(acc.token); + if (!ctoken) return; + + if (!isCompoundTokenSupported(ctoken)) return; + + const parentAccount = accounts.find(a => a.id === acc.parentId); + if (!parentAccount || parentAccount.type !== "Account") return; + const summary = makeCompoundSummaryForAccount(acc, parentAccount); + return summary; + }) + .filter(Boolean); + +export function useCompoundSummaries(accounts: AccountLikeArray): CompoundAccountSummary[] { + const summaries = useMemo(() => makeSummaries(accounts), [accounts]); + return summaries; +} + +export function useCompoundSummary(account: TokenAccount): ?CompoundAccountSummary { + const accounts = useSelector(accountsSelector); + const ctoken = findCompoundToken(account.token); + if (!ctoken) return; + + if (!isCompoundTokenSupported(ctoken)) return; + + const parentAccount = accounts.find(a => a.id === account.parentId); + if (!parentAccount || parentAccount.type !== "Account") return; + const summary = makeCompoundSummaryForAccount(account, parentAccount); + return summary; +} diff --git a/src/renderer/styles/theme.js b/src/renderer/styles/theme.js index c8812b6e0d..4022e47ea0 100644 --- a/src/renderer/styles/theme.js +++ b/src/renderer/styles/theme.js @@ -68,6 +68,7 @@ const colors = { blueTransparentBackground: "rgba(100, 144, 241, 0.15)", pillActiveBackground: "rgba(100, 144, 241, 0.1)", lightRed: "rgba(234, 46, 73, 0.1)", + lightWarning: "rgba(245, 127, 23, 0.1)", white: "#ffffff", experimentalBlue: "#165edb", marketUp_eastern: "#ea2e49", @@ -124,10 +125,22 @@ const fadeInGrowX = keyframes` } `; +const fadeInUp = keyframes` + 0% { + opacity: 0; + transform: translateY(66%); + } + 100% { + opacity: 1; + transform: translateY(0%); + } + `; + const animations = { fadeIn: props => css`${fadeIn} ${animationLength} ${easings.outQuadratic} forwards`, fadeOut: props => css`${fadeOut} ${animationLength} ${easings.outQuadratic} forwards`, fadeInGrowX: props => css`${fadeInGrowX} 0.6s ${easings.outQuadratic} forwards`, + fadeInUp: props => css`${fadeInUp} ${animationLength} ${easings.outQuadratic} forwards`, }; const overflow = { diff --git a/src/renderer/terms.js b/src/renderer/terms.js index 33bf525d1c..ae6bf334e9 100644 --- a/src/renderer/terms.js +++ b/src/renderer/terms.js @@ -7,15 +7,24 @@ const rawURL = "https://raw.githubusercontent.com/LedgerHQ/ledger-live-desktop/m export const url = "https://github.com/LedgerHQ/ledger-live-desktop/blob/master/TERMS.md"; const currentTermsRequired = "2019-12-04"; +const currentLendingTermsRequired = "2020-11-10"; export function isAcceptedTerms() { return global.localStorage.getItem("acceptedTermsVersion") === currentTermsRequired; } +export function isAcceptedLendingTerms() { + return global.localStorage.getItem("acceptedLendingTermsVersion") === currentLendingTermsRequired; +} + export function acceptTerms() { return global.localStorage.setItem("acceptedTermsVersion", currentTermsRequired); } +export function acceptLendingTerms() { + return global.localStorage.setItem("acceptedLendingTermsVersion", currentLendingTermsRequired); +} + export async function load() { const { data } = await network({ url: rawURL }); return data; diff --git a/static/i18n/en/app.json b/static/i18n/en/app.json index a828a610a7..39669fc329 100644 --- a/static/i18n/en/app.json +++ b/static/i18n/en/app.json @@ -11,6 +11,7 @@ "delete": "Delete", "launch": "Launch", "continue": "Continue", + "previous": "Previous", "learnMore": "Learn more", "help": "Help", "skipThisStep": "Skip this step", @@ -77,6 +78,9 @@ "DELEGATE": "Delegated", "REDELEGATE": "Redelegated", "UNDELEGATE": "Undelegated", + "SUPPLY": "Deposited", + "REDEEM": "Withdrawn", + "APPROVE": "Enabled", "VOTE": "Voted", "FREEZE": "Frozen", "UNFREEZE": "Unfrozen", @@ -116,8 +120,9 @@ "stars": "Starred accounts", "accounts": "Accounts", "manager": "Manager", + "exchange": "Buy crypto", "swap": "Swap", - "exchange": "Buy crypto" + "lend": "Lend crypto" }, "stars": { "placeholder": "Accounts that you star on the <1><0>Accounts page will now appear here!", @@ -243,7 +248,7 @@ }, "settings": { "title": "Edit account", - "advancedLogs": "Advanced logs", + "advancedLogs": "Advanced", "advancedTips": "Your xpub is privacy-sensitive data. Use with caution, especially when disclosing to third parties.", "accountName": { "title": "Account name", @@ -291,6 +296,238 @@ "tab": "History" } }, + "lend": { + "title": "Lend crypto", + "description": "Deposit your crypto on the Compound protocol to start earning interest.", + "tabs": { + "dashboard": "Dashboard", + "closed": "Closed loans", + "history": "History" + }, + "assets": "Assets to lend", + "active": "Approved accounts", + "lendAsset": "Deposit", + "highFeesModal": { + "title": "High fees on Ethereum", + "description": "Due to congestion on the Ethereum network, you may experience high fees when issuing transactions.", + "cta": "Got it" + }, + "account": { + "amountSupplied": "Amount supplied", + "amountSuppliedTooltip": "Amount lent to the network", + "currencyAPY": "Currency APY", + "currencyAPYTooltip": "Actual return rate of a supply by the end of the year if the deposit is continuously compounded.", + "accruedInterests": "Accrued interests", + "accruedInterestsTooltip": "Interest being generated", + "interestEarned": "Interest earned", + "interestEarnedTooltip": "--! NEED TEXT HERE !--", + "openLoans": "Open loans", + "closedLoans": "Closed loans", + "amountRedeemed": "Amount redeemed", + "amountRedeemedTooltip": "--! NEED TEXT HERE !--", + "date": "Date", + "info": "You can lend assets directly from your {{currency}} account and earn passive revenue.", + "howCompoundWorks": "How does lending on Compound work?", + "lend": "Lend {{currency}}" + }, + "emptyState": { + "active": { + "title": "Lending", + "description": "You can lend assets directly from your Ethereum accounts and earn passive revenue.", + "cta": "How does lending on Compound work?" + }, + "closed": { + "title": "Your closed loans swaps will appear here", + "description": "You have not made any loans yet.", + "cta": "Lend now" + }, + "history": { + "title": "History", + "description": "View the history of all your loan transactions.", + "cta": "Lend now" + } + }, + "headers": { + "active": { + "accounts": "Account", + "amountSupplied": "Current loan", + "amountSuppliedTooltip": "Amount lent to the network", + "accruedInterests": "Interest earned", + "accruedInterestsTooltip": "Interest being generated", + "status": "Status", + "statusTooltip": "Current account status", + "actions": "Actions" + }, + "status": { + "enablingTooltip": "Your transaction is broadcasting", + "toSupplyTooltip": "You can now supply your assets" + }, + "closed": { + "assetLended": "Asset lended", + "amountRedeemed": "Withdrawal amount", + "interestsEarned": "Interest earned", + "date": "Date" + }, + "rates": { + "allAssets": "Asset", + "totalBalance": "Available balance", + "totalBalanceTooltip": "Total balance of all the accounts excluding the amount supplied.", + "grossSupply": "Gross supply", + "grossSupplyTooltip": "Total amount of assets deposited on the network", + "currentAPY": "Current APY", + "currentAPYTooltip": "Actual return rate of a supply by the end of the year if the deposit is continuously compounded.", + "actions": "Actions" + } + }, + "manage": { + "cta": "Manage lending", + "title": "Manage loan", + "enable": { + "approve": "Approve", + "approveLess": "Approve less", + "approveMore": "Approve more", + "viewDetails": "View details", + "info": "You can supply <0>{{amount}} out of the <1>{{enabled}} {{assetName}} initially enabled.", + "infoNoLimit": "You have supplied <0>{{supplied}} out of a no limit approved account.", + "notSupplied": "You have approved <0>{{enabled}} on this account.", + "notSuppliedNoLimit": "You have approved this account by an unlimited amount.", + "enabling": "The approval transaction allowing you to lend is pending", + "notEnabled": "Before lending assets to the Compound protocol, you need to approve your account.", + "notEnoughApproved": "You don't have enough balance approved to supply assets." + }, + "supply": { + "title": "Deposit", + "description": "Enter the amount of assets to lend to the protocol." + }, + "withdraw": { + "title": "Withdraw", + "description": "Withdraw assets from the protocol to your Ledger account." + } + }, + "enable": { + "title": "Enable account", + "steps": { + "selectAccount": { + "title": "Select account", + "selectLabel": "Account to lend from", + "cta": "Approve", + "alreadyEnabled": "This account is approved", + "notEnabled": "You need to pay fees to approve this account" + }, + "amount": { + "title": "Amount", + "summary": "I grant access to <0>{{contractName}} smart contract on my account <0>{{accountName}} for a <0>{{amount}} amount", + "limit": "limited {{amount}}", + "noLimit": "no limit {{assetName}}", + "contractName": "Compound {{currencyName}}", + "advanced": "Advanced", + "amountLabel": "Amount to enable", + "amountLabelTooltip": "This limits the amount available to the smart contract." + }, + "connectDevice": { + "title": "Device" + }, + "confirmation": { + "title": "Confirmation", + "success": { + "title": "Operation sent successfully", + "text": "The approval was sent to the network for confirmation. You’ll be able to issue loans once it is confirmed.", + "done": "Close", + "cta": "View details", + "info": "Transactions can take some time to be displayed in an explorer and to be confirmed." + }, + "pending": { + "title": "Enabling your assets..." + }, + "broadcastError": "Your transaction may have failed. Please wait a moment then check the transaction history before trying again." + } + } + }, + "withdraw": { + "title": "Withdraw assets", + "steps": { + "amount": { + "title": "Amount", + "advanced": "Advanced", + "amountLabel": "Amount to withdraw", + "available": "Available balance: <0>{{amountAvailable}}", + "withdrawAll": "Withdraw Max", + "placeholder": "Withdraw Max", + "maxWithdrawble": "Maximum amount to withdraw is" + }, + "connectDevice": { + "title": "Device" + }, + "confirmation": { + "title": "Confirmation", + "success": { + "title": "Withdrawal sent successfully", + "text": "Your assets will be available once the network has confirmed the withdrawal.", + "done": "Close", + "cta": "View details" + }, + "pending": { + "title": "Withdrawing your assets..." + }, + "broadcastError": "Your transaction may have failed. Please wait a moment then check the transaction history before trying again." + } + } + }, + "info": { + "terms": { + "title": "Lend Crypto", + "subtitle": "Lend assets on the Compound protocol", + "description": "The Compound protocol allows you to lend and borrow assets on the Ethereum network. You can lend assets and earn interest directly from your Ledger acccount.", + "switchLabel": "I have read, understand and accept the <0>Terms & Conditions." + }, + "steps": { + "title": "Step <0>{{step}} / {{total}}", + "1": { + "subtitle": "Approve an account to allow the protocol to", + "subtitle2": "process future loans.", + "description": "You need to authorize the Compound smart contract to transfer up to a certain amount of assets to the protocol. Approving an account gives permission to the protocol to process future loans." + }, + "2": { + "subtitle": "Supply assets to earn interest", + "description": "Once an account is approved, you can select the amount of assets you want to lend and issue a transaction to the protocol. Interests accrue immediately after the transaction is confirmed." + }, + "3": { + "subtitle": "Withdraw assets at any time", + "description": "You can withdraw your assets and earned interest at any time, partially or entirely, directly from your Ledger account." + } + } + }, + "supply": { + "title": "Deposit assets", + "steps": { + "amount": { + "title": "Amount", + "selectedAccount": "Selected account", + "amountToSupply": "Amount to deposit", + "available": "Available balance" + }, + "device": { + "title": "Device" + }, + "confirmation": { + "title": "Confirmation", + "success": { + "title": "Deposit sent successfully", + "text": "You will start earning interest once the network has confirmed the deposit.", + "done": "Close", + "cta": "View details", + "info": "Transactions can take some time to be displayed in an explorer and to be confirmed." + }, + "broadcastError": "Your transaction may have failed. Please wait a moment then check the transaction history before trying again." + } + } + }, + "noEthAccount": { + "title": "Please create an ETH account", + "description": "<0>{{ asset }}({{ ticker }}) is an Ethereum ERC-20 token. To lend {{ ticker }}, install the Ethereum app and create an Ethereum account", + "cta": "Add account" + } + }, "accounts": { "title": "Accounts", "noResultFound": "No accounts found.", @@ -1887,6 +2124,7 @@ "TransactionConfirm": { "title": "Please confirm the operation on your device to finalize it", "warning": "Always verify the address displayed on your device exactly matches the one given by the {{recipientWording}}", + "secureContract": "Verify the deposit details on your device before sending it. The contract address is provided securely so you don’t have to verify it.", "warningWording": {}, "titleWording": { "send": "Please confirm on your device to finalize the operation", diff --git a/yarn.lock b/yarn.lock index 8c9b83788b..2e30939f9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1523,10 +1523,10 @@ node-pre-gyp "^0.15.0" node-pre-gyp-github "^1.4.3" -"@ledgerhq/live-common@^15.5.0-ethjs.6": - version "15.5.0-ethjs.6" - resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-15.5.0-ethjs.6.tgz#aaf4cf2fed11c298da6a371e135c88a41e7fd776" - integrity sha512-8mWuqQzAX0l+F0OfCTLnFcEwT16jL7r8z14mk/TotLGyv7pV5IfS0OlqFPYThwKpzmx6YybsUDiYn5DfGrljfQ== +"@ledgerhq/live-common@15.5.0-beta.8": + version "15.5.0-beta.8" + resolved "https://registry.yarnpkg.com/@ledgerhq/live-common/-/live-common-15.5.0-beta.8.tgz#cf1d74af3367d2ec77c95dc5f7abcd5309264eb4" + integrity sha512-9/uiBx+9SaPGTQjQ3dju2ttC9qxe8kej8yMzscKDrNHY5WP4e4toYYFXDRsL78MZpoPyELYAxiNlidz6H4+FHQ== dependencies: "@ledgerhq/compressjs" "1.3.2" "@ledgerhq/cryptoassets" "5.27.2"