committed by
GitHub
31 changed files with 932 additions and 504 deletions
@ -0,0 +1,56 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
import styled from 'styled-components' |
|||
import { getCryptoCurrencyIcon } from '@ledgerhq/live-common/lib/react' |
|||
|
|||
import type { Currency } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import { rgba } from 'styles/helpers' |
|||
|
|||
import Box from 'components/base/Box' |
|||
|
|||
const CryptoIconWrapper = styled(Box).attrs({ |
|||
align: 'center', |
|||
justify: 'center', |
|||
bg: p => rgba(p.cryptoColor, 0.1), |
|||
color: p => p.cryptoColor, |
|||
})` |
|||
border-radius: 50%; |
|||
width: ${p => p.size || 40}px; |
|||
height: ${p => p.size || 40}px; |
|||
` |
|||
|
|||
export function CurrencyCircleIcon({ |
|||
currency, |
|||
size, |
|||
...props |
|||
}: { |
|||
currency: Currency, |
|||
size: number, |
|||
}) { |
|||
const Icon = getCryptoCurrencyIcon(currency) |
|||
return ( |
|||
<CryptoIconWrapper size={size} cryptoColor={currency.color} {...props}> |
|||
{Icon && <Icon size={size / 2} />} |
|||
</CryptoIconWrapper> |
|||
) |
|||
} |
|||
|
|||
function CurrencyBadge({ currency, ...props }: { currency: Currency }) { |
|||
return ( |
|||
<Box horizontal flow={3} {...props}> |
|||
<CurrencyCircleIcon size={40} currency={currency} /> |
|||
<Box> |
|||
<Box ff="Museo Sans|ExtraBold" color="dark" fontSize={2} style={{ letterSpacing: 2 }}> |
|||
{currency.ticker} |
|||
</Box> |
|||
<Box ff="Open Sans" color="dark" fontSize={5}> |
|||
{currency.name} |
|||
</Box> |
|||
</Box> |
|||
</Box> |
|||
) |
|||
} |
|||
|
|||
export default CurrencyBadge |
@ -0,0 +1,67 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
import styled from 'styled-components' |
|||
import { translate } from 'react-i18next' |
|||
|
|||
import type { T } from 'types/common' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import IconAngleLeft from 'icons/AngleLeft' |
|||
|
|||
const Container = styled(Box).attrs({ |
|||
alignItems: 'center', |
|||
color: 'dark', |
|||
ff: 'Museo Sans|Regular', |
|||
fontSize: 6, |
|||
justifyContent: 'center', |
|||
p: 5, |
|||
relative: true, |
|||
})`` |
|||
|
|||
const Back = styled(Box).attrs({ |
|||
horizontal: true, |
|||
align: 'center', |
|||
color: 'grey', |
|||
ff: 'Open Sans', |
|||
fontSize: 3, |
|||
p: 4, |
|||
})` |
|||
cursor: pointer; |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
|
|||
&:hover { |
|||
color: ${p => p.theme.colors.graphite}; |
|||
} |
|||
|
|||
&:active { |
|||
color: ${p => p.theme.colors.dark}; |
|||
} |
|||
` |
|||
|
|||
function ModalTitle({ |
|||
t, |
|||
onBack, |
|||
children, |
|||
...props |
|||
}: { |
|||
t: T, |
|||
onBack: any => void, |
|||
children: any, |
|||
}) { |
|||
return ( |
|||
<Container {...props}> |
|||
{onBack && ( |
|||
<Back onClick={onBack}> |
|||
<IconAngleLeft size={16} /> |
|||
{t('common:back')} |
|||
</Back> |
|||
)} |
|||
{children} |
|||
</Container> |
|||
) |
|||
} |
|||
|
|||
export default translate()(ModalTitle) |
@ -0,0 +1,30 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
import styled, { keyframes } from 'styled-components' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import IconLoader from 'icons/Loader' |
|||
|
|||
const rotate = keyframes` |
|||
0% { |
|||
transform: rotate(0deg); |
|||
} |
|||
100% { |
|||
transform: rotate(360deg); |
|||
} |
|||
` |
|||
|
|||
const Container = styled(Box)` |
|||
width: ${p => p.size}px; |
|||
height: ${p => p.size}px; |
|||
animation: ${rotate} 1.5s linear infinite; |
|||
` |
|||
|
|||
export default function Spinner({ size, ...props }: { size: number }) { |
|||
return ( |
|||
<Container size={size} {...props}> |
|||
<IconLoader size={size} /> |
|||
</Container> |
|||
) |
|||
} |
@ -1,27 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
|
|||
import type { CryptoCurrency } from '@ledgerhq/live-common/lib/types' |
|||
import type { T } from 'types/common' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Label from 'components/base/Label' |
|||
import SelectCurrency from 'components/SelectCurrency' |
|||
|
|||
type Props = { |
|||
onChangeCurrency: Function, |
|||
currency?: ?CryptoCurrency, |
|||
t: T, |
|||
} |
|||
|
|||
export default (props: Props) => ( |
|||
<Box flow={1}> |
|||
<Label>{props.t('common:currency')}</Label> |
|||
<SelectCurrency |
|||
placeholder={props.t('common:chooseWalletPlaceholder')} |
|||
onChange={props.onChangeCurrency} |
|||
value={props.currency} |
|||
/> |
|||
</Box> |
|||
) |
@ -1,51 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
|
|||
import type { Account } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import CheckBox from 'components/base/CheckBox' |
|||
import FormattedVal from 'components/base/FormattedVal' |
|||
|
|||
type Props = { |
|||
scannedAccounts: Account[], |
|||
selectedAccounts: Account[], |
|||
existingAccounts: Account[], |
|||
onToggleAccount?: Account => void, |
|||
} |
|||
|
|||
function StepImport(props: Props) { |
|||
const { scannedAccounts, selectedAccounts, existingAccounts, onToggleAccount } = props |
|||
return ( |
|||
<Box flow={4}> |
|||
<strong>(design is not yet integrated!)</strong> |
|||
{scannedAccounts.map(account => { |
|||
const isSelected = selectedAccounts.find(a => a.id === account.id) |
|||
const isExisting = existingAccounts.find(a => a.id === account.id && a.archived === false) |
|||
return ( |
|||
<Box |
|||
horizontal |
|||
key={account.id} |
|||
onClick={onToggleAccount && !isExisting ? () => onToggleAccount(account) : undefined} |
|||
> |
|||
<CheckBox isChecked={!!isSelected} onChange={onToggleAccount} /> |
|||
<Box grow fontSize={6} style={{ paddingLeft: 10 }}> |
|||
{account.name} |
|||
</Box> |
|||
<FormattedVal |
|||
alwaysShowSign={false} |
|||
color="warmGrey" |
|||
fontSize={6} |
|||
showCode |
|||
unit={account.currency.units[0]} |
|||
val={account.balance} |
|||
/> |
|||
</Box> |
|||
) |
|||
})} |
|||
</Box> |
|||
) |
|||
} |
|||
|
|||
export default StepImport |
@ -1,297 +0,0 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import { connect } from 'react-redux' |
|||
import { compose } from 'redux' |
|||
import { translate } from 'react-i18next' |
|||
import { createStructuredSelector } from 'reselect' |
|||
|
|||
import type { Account, CryptoCurrency } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import type { Device, T } from 'types/common' |
|||
|
|||
import { MODAL_ADD_ACCOUNT } from 'config/constants' |
|||
|
|||
import { closeModal } from 'reducers/modals' |
|||
import { |
|||
canCreateAccount, |
|||
getAccounts, |
|||
getVisibleAccounts, |
|||
getArchivedAccounts, |
|||
} from 'reducers/accounts' |
|||
|
|||
import { addAccount, updateAccount } from 'actions/accounts' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Breadcrumb from 'components/Breadcrumb' |
|||
import Button from 'components/base/Button' |
|||
import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal' |
|||
import StepConnectDevice from 'components/modals/StepConnectDevice' |
|||
import { getBridgeForCurrency } from 'bridge' |
|||
|
|||
import StepCurrency from './01-step-currency' |
|||
import StepImport from './03-step-import' |
|||
|
|||
const GET_STEPS = t => [ |
|||
{ label: t('addAccount:steps.currency.title'), Comp: StepCurrency }, |
|||
{ label: t('addAccount:steps.connectDevice.title'), Comp: StepConnectDevice }, |
|||
{ label: t('addAccount:steps.importProgress.title'), Comp: StepImport }, |
|||
{ label: t('addAccount:steps.importAccounts.title'), Comp: StepImport }, |
|||
] |
|||
|
|||
const mapStateToProps = createStructuredSelector({ |
|||
existingAccounts: getAccounts, |
|||
visibleAccounts: getVisibleAccounts, |
|||
archivedAccounts: getArchivedAccounts, |
|||
canCreateAccount, |
|||
}) |
|||
|
|||
const mapDispatchToProps = { |
|||
addAccount, |
|||
closeModal, |
|||
updateAccount, |
|||
} |
|||
|
|||
type Props = { |
|||
existingAccounts: Account[], |
|||
addAccount: Function, |
|||
visibleAccounts: Account[], |
|||
archivedAccounts: Account[], |
|||
canCreateAccount: boolean, |
|||
closeModal: Function, |
|||
t: T, |
|||
updateAccount: Function, |
|||
} |
|||
|
|||
type State = { |
|||
stepIndex: number, |
|||
|
|||
currency: ?CryptoCurrency, |
|||
deviceSelected: ?Device, |
|||
|
|||
selectedAccounts: Account[], |
|||
scannedAccounts: Account[], |
|||
|
|||
// TODO: what's that.
|
|||
fetchingCounterValues: boolean, |
|||
appStatus: ?string, |
|||
} |
|||
|
|||
const INITIAL_STATE = { |
|||
stepIndex: 0, |
|||
currency: null, |
|||
deviceSelected: null, |
|||
|
|||
selectedAccounts: [], |
|||
scannedAccounts: [], |
|||
|
|||
fetchingCounterValues: false, |
|||
appStatus: null, |
|||
} |
|||
|
|||
class AddAccountModal extends PureComponent<Props, State> { |
|||
state = INITIAL_STATE |
|||
|
|||
componentWillUnmount() { |
|||
this.handleReset() |
|||
} |
|||
|
|||
scanSubscription: * |
|||
|
|||
startScanAccountsDevice() { |
|||
const { visibleAccounts } = this.props |
|||
const { deviceSelected, currency } = this.state |
|||
|
|||
if (!deviceSelected || !currency) { |
|||
return |
|||
} |
|||
const bridge = getBridgeForCurrency(currency) |
|||
this.scanSubscription = bridge.scanAccountsOnDevice(currency, deviceSelected.path, { |
|||
next: account => { |
|||
if (!visibleAccounts.some(a => a.id === account.id)) { |
|||
this.setState(state => ({ |
|||
scannedAccounts: [...state.scannedAccounts, account], |
|||
})) |
|||
} |
|||
}, |
|||
complete: () => { |
|||
// we should be able to interrupt the scan too if you want to select early etc..
|
|||
// like imagine there are way too more accounts to scan, so you are not stuck here.
|
|||
this.setState({ stepIndex: 3 }) |
|||
}, |
|||
error: error => { |
|||
// TODO what to do ?
|
|||
console.error(error) |
|||
}, |
|||
}) |
|||
} |
|||
|
|||
canNext = () => { |
|||
const { stepIndex } = this.state |
|||
|
|||
if (stepIndex === 0) { |
|||
const { currency } = this.state |
|||
return currency !== null |
|||
} |
|||
|
|||
if (stepIndex === 1) { |
|||
const { deviceSelected, appStatus } = this.state |
|||
return deviceSelected !== null && appStatus === 'success' |
|||
} |
|||
|
|||
if (stepIndex === 3) { |
|||
const { selectedAccounts } = this.state |
|||
return selectedAccounts.length > 0 |
|||
} |
|||
|
|||
return false |
|||
} |
|||
|
|||
_steps = GET_STEPS(this.props.t) |
|||
|
|||
handleChangeDevice = d => this.setState({ deviceSelected: d }) |
|||
|
|||
handleToggleAccount = account => { |
|||
const { selectedAccounts } = this.state |
|||
const isSelected = selectedAccounts.find(a => a === account) |
|||
this.setState({ |
|||
selectedAccounts: isSelected |
|||
? selectedAccounts.filter(a => a !== account) |
|||
: [...selectedAccounts, account], |
|||
}) |
|||
} |
|||
|
|||
handleChangeCurrency = (currency: CryptoCurrency) => this.setState({ currency }) |
|||
|
|||
handleChangeStatus = (deviceStatus, appStatus) => this.setState({ appStatus }) |
|||
|
|||
handleImportAccount = () => { |
|||
const { addAccount } = this.props |
|||
const { selectedAccounts } = this.state |
|||
selectedAccounts.forEach(a => addAccount({ ...a, archived: false })) |
|||
this.setState({ selectedAccounts: [] }) |
|||
closeModal(MODAL_ADD_ACCOUNT) |
|||
} |
|||
|
|||
handleNextStep = () => { |
|||
const { stepIndex } = this.state |
|||
if (stepIndex >= this._steps.length - 1) { |
|||
return |
|||
} |
|||
this.setState({ stepIndex: stepIndex + 1 }) |
|||
} |
|||
|
|||
handleReset = () => { |
|||
this.setState(INITIAL_STATE) |
|||
if (this.scanSubscription) this.scanSubscription.unsubscribe() |
|||
} |
|||
|
|||
renderStep() { |
|||
const { t, existingAccounts } = this.props |
|||
const { stepIndex, scannedAccounts, currency, deviceSelected, selectedAccounts } = this.state |
|||
const step = this._steps[stepIndex] |
|||
if (!step) { |
|||
return null |
|||
} |
|||
const { Comp } = step |
|||
|
|||
const props = (predicate, props) => (predicate ? props : {}) |
|||
|
|||
const stepProps = { |
|||
t, |
|||
currency, |
|||
// STEP CURRENCY
|
|||
...props(stepIndex === 0, { |
|||
onChangeCurrency: this.handleChangeCurrency, |
|||
}), |
|||
// STEP CONNECT DEVICE
|
|||
...props(stepIndex === 1, { |
|||
deviceSelected, |
|||
onStatusChange: this.handleChangeStatus, |
|||
onChangeDevice: this.handleChangeDevice, |
|||
}), |
|||
// STEP ACCOUNT IMPORT PROGRESS
|
|||
...props(stepIndex === 2, { |
|||
selectedAccounts, |
|||
scannedAccounts, |
|||
existingAccounts, |
|||
}), |
|||
// STEP FINISH AND SELECT ACCOUNTS
|
|||
...props(stepIndex === 3, { |
|||
onToggleAccount: this.handleToggleAccount, |
|||
selectedAccounts, |
|||
scannedAccounts, |
|||
existingAccounts, |
|||
}), |
|||
} |
|||
|
|||
return <Comp {...stepProps} /> |
|||
} |
|||
|
|||
renderButton() { |
|||
const { t } = this.props |
|||
const { fetchingCounterValues, stepIndex, selectedAccounts } = this.state |
|||
|
|||
let onClick |
|||
|
|||
switch (stepIndex) { |
|||
case 1: |
|||
onClick = () => { |
|||
this.handleNextStep() |
|||
this.startScanAccountsDevice() |
|||
} |
|||
break |
|||
|
|||
case 3: |
|||
onClick = this.handleImportAccount |
|||
break |
|||
|
|||
default: |
|||
onClick = this.handleNextStep |
|||
} |
|||
|
|||
const props = { |
|||
primary: true, |
|||
disabled: fetchingCounterValues || !this.canNext(), |
|||
onClick, |
|||
children: fetchingCounterValues |
|||
? 'Fetching counterValues...' |
|||
: stepIndex === 3 |
|||
? t('addAccount:steps.importAccounts.cta', { |
|||
count: selectedAccounts.length, |
|||
}) |
|||
: t('common:next'), |
|||
} |
|||
|
|||
return <Button {...props} /> |
|||
} |
|||
|
|||
render() { |
|||
const { t } = this.props |
|||
const { stepIndex } = this.state |
|||
|
|||
return ( |
|||
<Modal |
|||
name={MODAL_ADD_ACCOUNT} |
|||
onHide={this.handleReset} |
|||
render={({ onClose }) => ( |
|||
<ModalBody onClose={onClose}> |
|||
<ModalTitle>{t('addAccount:title')}</ModalTitle> |
|||
<ModalContent> |
|||
<Breadcrumb mb={6} currentStep={stepIndex} items={this._steps} /> |
|||
{this.renderStep()} |
|||
</ModalContent> |
|||
{stepIndex !== 2 && ( |
|||
<ModalFooter> |
|||
<Box horizontal alignItems="center" justifyContent="flex-end"> |
|||
{this.renderButton()} |
|||
</Box> |
|||
</ModalFooter> |
|||
)} |
|||
</ModalBody> |
|||
)} |
|||
/> |
|||
) |
|||
} |
|||
} |
|||
export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(AddAccountModal) |
@ -0,0 +1,167 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import styled from 'styled-components' |
|||
import type { Account } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import { darken } from 'styles/helpers' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Radio from 'components/base/Radio' |
|||
import CryptoCurrencyIcon from 'components/CryptoCurrencyIcon' |
|||
import FormattedVal from 'components/base/FormattedVal' |
|||
import Input from 'components/base/Input' |
|||
import IconEdit from 'icons/Edit' |
|||
import IconCheck from 'icons/Check' |
|||
|
|||
type Props = { |
|||
account: Account, |
|||
isChecked: boolean, |
|||
isDisabled: boolean, |
|||
onClick: Account => void, |
|||
onAccountUpdate: Account => void, |
|||
} |
|||
|
|||
type State = { |
|||
isEditing: boolean, |
|||
accountNameCopy: string, |
|||
} |
|||
|
|||
export default class AccountRow extends PureComponent<Props, State> { |
|||
state = { |
|||
isEditing: false, |
|||
accountNameCopy: '', |
|||
} |
|||
|
|||
componentDidUpdate(prevProps: Props, prevState: State) { |
|||
const startedEditing = !prevState.isEditing && this.state.isEditing |
|||
if (startedEditing) { |
|||
this._input && this._input.handleSelectEverything() |
|||
} |
|||
} |
|||
|
|||
handleEditClick = (e: SyntheticEvent<any>) => { |
|||
this.handlePreventSubmit(e) |
|||
const { account } = this.props |
|||
this.setState({ isEditing: true, accountNameCopy: account.name }) |
|||
} |
|||
|
|||
handleSubmitName = (e: SyntheticEvent<any>) => { |
|||
this.handlePreventSubmit(e) |
|||
const { account, onAccountUpdate, isChecked, onClick } = this.props |
|||
const { accountNameCopy } = this.state |
|||
const updatedAccount = { ...account, name: accountNameCopy } |
|||
this.setState({ isEditing: false, accountNameCopy: '' }) |
|||
onAccountUpdate(updatedAccount) |
|||
if (!isChecked) { |
|||
onClick(updatedAccount) |
|||
} |
|||
} |
|||
|
|||
handlePreventSubmit = (e: SyntheticEvent<any>) => { |
|||
// prevent account row to be submitted
|
|||
e.preventDefault() |
|||
e.stopPropagation() |
|||
} |
|||
|
|||
handleChangeName = (accountNameCopy: string) => this.setState({ accountNameCopy }) |
|||
|
|||
handleReset = () => this.setState({ isEditing: false, accountNameCopy: '' }) |
|||
|
|||
_input = null |
|||
|
|||
render() { |
|||
const { account, isChecked, onClick, isDisabled } = this.props |
|||
const { isEditing, accountNameCopy } = this.state |
|||
|
|||
return ( |
|||
<AccountRowContainer onClick={() => onClick(account)} isDisabled={isDisabled}> |
|||
<CryptoCurrencyIcon currency={account.currency} size={16} color={account.currency.color} /> |
|||
<Box shrink grow ff="Open Sans|SemiBold" color="dark" fontSize={4}> |
|||
{isEditing ? ( |
|||
<Input |
|||
containerProps={{ style: { width: 260 } }} |
|||
value={accountNameCopy} |
|||
onChange={this.handleChangeName} |
|||
onClick={this.handlePreventSubmit} |
|||
onEnter={this.handleSubmitName} |
|||
onEsc={this.handleReset} |
|||
renderRight={ |
|||
<InputRight onClick={this.handleSubmitName}> |
|||
<IconCheck size={16} /> |
|||
</InputRight> |
|||
} |
|||
ref={input => (this._input = input)} |
|||
/> |
|||
) : ( |
|||
<div style={{ textOverflow: 'ellipsis', overflow: 'hidden' }}>{account.name}</div> |
|||
)} |
|||
</Box> |
|||
{!isEditing && ( |
|||
<Edit onClick={this.handleEditClick}> |
|||
<IconEdit size={13} /> |
|||
<span>{'edit name'}</span> |
|||
</Edit> |
|||
)} |
|||
<FormattedVal |
|||
val={account.balance} |
|||
unit={account.unit} |
|||
showCode |
|||
fontSize={4} |
|||
color="grey" |
|||
/> |
|||
<Radio isChecked={isChecked || isDisabled} /> |
|||
</AccountRowContainer> |
|||
) |
|||
} |
|||
} |
|||
|
|||
const AccountRowContainer = styled(Box).attrs({ |
|||
horizontal: true, |
|||
align: 'center', |
|||
bg: 'lightGrey', |
|||
px: 3, |
|||
flow: 3, |
|||
})` |
|||
height: 48px; |
|||
border-radius: 4px; |
|||
|
|||
opacity: ${p => (p.isDisabled ? 0.5 : 1)}; |
|||
pointer-events: ${p => (p.isDisabled ? 'none' : 'auto')}; |
|||
|
|||
&:hover { |
|||
cursor: pointer; |
|||
background-color: ${p => darken(p.theme.colors.lightGrey, 0.015)}; |
|||
} |
|||
|
|||
&:active { |
|||
background-color: ${p => darken(p.theme.colors.lightGrey, 0.03)}; |
|||
} |
|||
` |
|||
|
|||
const Edit = styled(Box).attrs({ |
|||
color: 'wallet', |
|||
fontSize: 3, |
|||
horizontal: true, |
|||
align: 'center', |
|||
flow: 1, |
|||
py: 1, |
|||
})` |
|||
display: none; |
|||
${AccountRowContainer}:hover & { |
|||
display: flex; |
|||
} |
|||
&:hover { |
|||
text-decoration: underline; |
|||
} |
|||
` |
|||
|
|||
const InputRight = styled(Box).attrs({ |
|||
bg: 'wallet', |
|||
color: 'white', |
|||
align: 'center', |
|||
justify: 'center', |
|||
shrink: 0, |
|||
})` |
|||
width: 40px; |
|||
` |
@ -0,0 +1,224 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import { compose } from 'redux' |
|||
import { connect } from 'react-redux' |
|||
import { translate } from 'react-i18next' |
|||
|
|||
import type { Currency, Account } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import type { T, Device } from 'types/common' |
|||
|
|||
import { getCurrentDevice } from 'reducers/devices' |
|||
import { getAccounts } from 'reducers/accounts' |
|||
import { addAccount } from 'actions/accounts' |
|||
import { closeModal } from 'reducers/modals' |
|||
|
|||
import Modal, { ModalContent, ModalTitle, ModalFooter, ModalBody } from 'components/base/Modal' |
|||
import Box from 'components/base/Box' |
|||
import Breadcrumb from 'components/Breadcrumb' |
|||
|
|||
import StepChooseCurrency, { StepChooseCurrencyFooter } from './steps/01-step-choose-currency' |
|||
import StepConnectDevice, { StepConnectDeviceFooter } from './steps/02-step-connect-device' |
|||
import StepImport, { StepImportFooter } from './steps/03-step-import' |
|||
import StepFinish from './steps/04-step-finish' |
|||
|
|||
const createSteps = ({ t }: { t: T }) => [ |
|||
{ |
|||
id: 'chooseCurrency', |
|||
label: t('importAccounts:breadcrumb.informations'), |
|||
component: StepChooseCurrency, |
|||
footer: StepChooseCurrencyFooter, |
|||
onBack: null, |
|||
hideFooter: false, |
|||
}, |
|||
{ |
|||
id: 'connectDevice', |
|||
label: t('importAccounts:breadcrumb.connectDevice'), |
|||
component: StepConnectDevice, |
|||
footer: StepConnectDeviceFooter, |
|||
onBack: ({ transitionTo }: StepProps) => transitionTo('chooseCurrency'), |
|||
hideFooter: false, |
|||
}, |
|||
{ |
|||
id: 'import', |
|||
label: t('importAccounts:breadcrumb.import'), |
|||
component: StepImport, |
|||
footer: StepImportFooter, |
|||
onBack: ({ transitionTo }: StepProps) => transitionTo('chooseCurrency'), |
|||
hideFooter: false, |
|||
}, |
|||
{ |
|||
id: 'finish', |
|||
label: t('importAccounts:breadcrumb.finish'), |
|||
component: StepFinish, |
|||
footer: null, |
|||
onBack: null, |
|||
hideFooter: true, |
|||
}, |
|||
] |
|||
|
|||
type Props = { |
|||
t: T, |
|||
currentDevice: ?Device, |
|||
existingAccounts: Account[], |
|||
closeModal: string => void, |
|||
addAccount: Account => void, |
|||
} |
|||
|
|||
type StepId = 'chooseCurrency' | 'connectDevice' | 'import' | 'finish' |
|||
|
|||
type ScanStatus = 'idle' | 'scanning' | 'error' | 'finished' |
|||
|
|||
type State = { |
|||
stepId: StepId, |
|||
isAppOpened: boolean, |
|||
currency: ?Currency, |
|||
|
|||
// scan process
|
|||
scannedAccounts: Account[], |
|||
checkedAccountsIds: string[], |
|||
scanStatus: ScanStatus, |
|||
err: ?Error, |
|||
} |
|||
|
|||
export type StepProps = { |
|||
t: T, |
|||
currency: ?Currency, |
|||
currentDevice: ?Device, |
|||
isAppOpened: boolean, |
|||
transitionTo: StepId => void, |
|||
setState: any => void, |
|||
onClickImport: void => Promise<void>, |
|||
onCloseModal: void => void, |
|||
|
|||
// scan process
|
|||
scannedAccounts: Account[], |
|||
existingAccounts: Account[], |
|||
checkedAccountsIds: string[], |
|||
scanStatus: ScanStatus, |
|||
err: ?Error, |
|||
} |
|||
|
|||
const mapStateToProps = state => ({ |
|||
currentDevice: getCurrentDevice(state), |
|||
existingAccounts: getAccounts(state), |
|||
}) |
|||
|
|||
const mapDispatchToProps = { |
|||
addAccount, |
|||
closeModal, |
|||
} |
|||
|
|||
const INITIAL_STATE = { |
|||
stepId: 'chooseCurrency', |
|||
isAppOpened: false, |
|||
currency: null, |
|||
scannedAccounts: [], |
|||
checkedAccountsIds: [], |
|||
err: null, |
|||
scanStatus: 'idle', |
|||
} |
|||
|
|||
class ImportAccounts extends PureComponent<Props, State> { |
|||
state = INITIAL_STATE |
|||
STEPS = createSteps({ |
|||
t: this.props.t, |
|||
}) |
|||
|
|||
transitionTo = stepId => { |
|||
let nextState = { stepId } |
|||
if (stepId === 'chooseCurrency') { |
|||
nextState = { ...INITIAL_STATE } |
|||
} |
|||
this.setState(nextState) |
|||
} |
|||
|
|||
handleClickImport = async () => { |
|||
const { addAccount } = this.props |
|||
const { scannedAccounts, checkedAccountsIds } = this.state |
|||
const accountsIdsMap = checkedAccountsIds.reduce((acc, cur) => { |
|||
acc[cur] = true |
|||
return acc |
|||
}, {}) |
|||
const accountsToImport = scannedAccounts.filter(account => accountsIdsMap[account.id] === true) |
|||
for (let i = 0; i < accountsToImport.length; i++) { |
|||
await idleCallback() |
|||
addAccount(accountsToImport[i]) |
|||
} |
|||
this.transitionTo('finish') |
|||
} |
|||
|
|||
handleCloseModal = () => { |
|||
const { closeModal } = this.props |
|||
closeModal('importAccounts') |
|||
} |
|||
|
|||
render() { |
|||
const { t, currentDevice, existingAccounts } = this.props |
|||
const { |
|||
stepId, |
|||
currency, |
|||
isAppOpened, |
|||
scannedAccounts, |
|||
checkedAccountsIds, |
|||
scanStatus, |
|||
err, |
|||
} = this.state |
|||
|
|||
const stepIndex = this.STEPS.findIndex(s => s.id === stepId) |
|||
const step = this.STEPS[stepIndex] |
|||
|
|||
if (!step) { |
|||
throw new Error(`ImportAccountsModal: step ${stepId} doesn't exists`) |
|||
} |
|||
|
|||
const { component: StepComponent, footer: StepFooter, hideFooter, onBack } = step |
|||
|
|||
const stepProps: StepProps = { |
|||
t, |
|||
currency, |
|||
currentDevice, |
|||
existingAccounts, |
|||
scannedAccounts, |
|||
checkedAccountsIds, |
|||
scanStatus, |
|||
err, |
|||
isAppOpened, |
|||
onClickImport: this.handleClickImport, |
|||
onCloseModal: this.handleCloseModal, |
|||
transitionTo: this.transitionTo, |
|||
setState: (...args) => this.setState(...args), |
|||
} |
|||
|
|||
return ( |
|||
<Modal |
|||
name="importAccounts" |
|||
preventBackdropClick |
|||
onHide={() => this.setState({ ...INITIAL_STATE })} |
|||
render={({ onClose }) => ( |
|||
<ModalBody onClose={onClose}> |
|||
<ModalTitle onBack={onBack ? () => onBack(stepProps) : void 0}> |
|||
{t('importAccounts:title')} |
|||
</ModalTitle> |
|||
<ModalContent> |
|||
<Breadcrumb mb={6} currentStep={stepIndex} items={this.STEPS} /> |
|||
<StepComponent {...stepProps} /> |
|||
</ModalContent> |
|||
{!hideFooter && ( |
|||
<ModalFooter horizontal align="center" justify="flex-end" style={{ height: 80 }}> |
|||
{StepFooter ? <StepFooter {...stepProps} /> : <Box>footer</Box>} |
|||
</ModalFooter> |
|||
)} |
|||
</ModalBody> |
|||
)} |
|||
/> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default compose(connect(mapStateToProps, mapDispatchToProps), translate())(ImportAccounts) |
|||
|
|||
function idleCallback() { |
|||
return new Promise(resolve => window.requestIdleCallback(resolve)) |
|||
} |
@ -0,0 +1,26 @@ |
|||
// @flow
|
|||
|
|||
import React, { Fragment } from 'react' |
|||
|
|||
import SelectCurrency from 'components/SelectCurrency' |
|||
import Button from 'components/base/Button' |
|||
import CurrencyBadge from 'components/base/CurrencyBadge' |
|||
|
|||
import type { StepProps } from '../index' |
|||
|
|||
function StepChooseCurrency({ currency, setState }: StepProps) { |
|||
return <SelectCurrency onChange={currency => setState({ currency })} value={currency} /> |
|||
} |
|||
|
|||
export function StepChooseCurrencyFooter({ transitionTo, currency, t }: StepProps) { |
|||
return ( |
|||
<Fragment> |
|||
{currency && <CurrencyBadge mr="auto" currency={currency} />} |
|||
<Button primary disabled={!currency} onClick={() => transitionTo('connectDevice')}> |
|||
{t('common:next')} |
|||
</Button> |
|||
</Fragment> |
|||
) |
|||
} |
|||
|
|||
export default StepChooseCurrency |
@ -0,0 +1,53 @@ |
|||
// @flow
|
|||
|
|||
import React, { Fragment } from 'react' |
|||
import { Trans } from 'react-i18next' |
|||
|
|||
import Button from 'components/base/Button' |
|||
import Box from 'components/base/Box' |
|||
import ConnectDevice from 'components/modals/StepConnectDevice' |
|||
import { CurrencyCircleIcon } from 'components/base/CurrencyBadge' |
|||
|
|||
import type { StepProps } from '../index' |
|||
|
|||
function StepConnectDevice({ t, currency, currentDevice, setState }: StepProps) { |
|||
if (!currency) { |
|||
throw new Error('No currency given') |
|||
} |
|||
return ( |
|||
<Fragment> |
|||
<Box align="center" mb={6}> |
|||
<CurrencyCircleIcon mb={3} size={40} currency={currency} /> |
|||
<Box ff="Open Sans" fontSize={4} color="dark" textAlign="center" style={{ width: 370 }}> |
|||
<Trans i18nKey="importAccounts:connectDevice.desc" parent="div"> |
|||
{`You're about to import your `} |
|||
<strong style={{ fontWeight: 'bold' }}>{`${currency.name} (${ |
|||
currency.ticker |
|||
})`}</strong>
|
|||
{` account(s) from your Ledger device. Please follow the steps below:`} |
|||
</Trans> |
|||
</Box> |
|||
</Box> |
|||
<ConnectDevice |
|||
t={t} |
|||
deviceSelected={currentDevice} |
|||
currency={currency} |
|||
onStatusChange={s => { |
|||
if (s === 'connected') { |
|||
setState({ isAppOpened: true }) |
|||
} |
|||
}} |
|||
/> |
|||
</Fragment> |
|||
) |
|||
} |
|||
|
|||
export function StepConnectDeviceFooter({ t, transitionTo, isAppOpened }: StepProps) { |
|||
return ( |
|||
<Button primary disabled={!isAppOpened} onClick={() => transitionTo('import')}> |
|||
{t('common:next')} |
|||
</Button> |
|||
) |
|||
} |
|||
|
|||
export default StepConnectDevice |
@ -0,0 +1,168 @@ |
|||
// @flow
|
|||
|
|||
import React, { PureComponent } from 'react' |
|||
import type { Account } from '@ledgerhq/live-common/lib/types' |
|||
|
|||
import { getBridgeForCurrency } from 'bridge' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Button from 'components/base/Button' |
|||
import Spinner from 'components/base/Spinner' |
|||
import IconExchange from 'icons/Exchange' |
|||
|
|||
import AccountRow from '../AccountRow' |
|||
|
|||
import type { StepProps } from '../index' |
|||
|
|||
class StepImport extends PureComponent<StepProps> { |
|||
componentDidMount() { |
|||
this.startScanAccountsDevice() |
|||
} |
|||
|
|||
componentWillUnmount() { |
|||
if (this.scanSubscription) { |
|||
this.scanSubscription.unsubscribe() |
|||
} |
|||
} |
|||
|
|||
scanSubscription = null |
|||
|
|||
startScanAccountsDevice() { |
|||
const { currency, currentDevice, setState } = this.props |
|||
try { |
|||
if (!currency) { |
|||
throw new Error('No currency to scan') |
|||
} |
|||
|
|||
if (!currentDevice) { |
|||
throw new Error('No device') |
|||
} |
|||
|
|||
const bridge = getBridgeForCurrency(currency) |
|||
|
|||
// TODO: use the real device
|
|||
const devicePath = currentDevice.path |
|||
|
|||
setState({ scanStatus: 'scanning' }) |
|||
|
|||
this.scanSubscription = bridge.scanAccountsOnDevice(currency, devicePath, { |
|||
next: account => { |
|||
const { scannedAccounts } = this.props |
|||
const hasAlreadyBeenScanned = !!scannedAccounts.find(a => account.id === a.id) |
|||
if (!hasAlreadyBeenScanned) { |
|||
setState({ scannedAccounts: [...scannedAccounts, account] }) |
|||
} |
|||
}, |
|||
complete: () => setState({ scanStatus: 'finished' }), |
|||
error: err => setState({ scanStatus: 'error', err }), |
|||
}) |
|||
} catch (err) { |
|||
setState({ scanStatus: 'error', err }) |
|||
} |
|||
} |
|||
|
|||
handleRetry = () => { |
|||
if (this.scanSubscription) { |
|||
this.scanSubscription.unsubscribe() |
|||
this.scanSubscription = null |
|||
} |
|||
this.handleResetState() |
|||
this.startScanAccountsDevice() |
|||
} |
|||
|
|||
handleResetState = () => { |
|||
const { setState } = this.props |
|||
setState({ |
|||
scanStatus: 'idle', |
|||
err: null, |
|||
scannedAccounts: [], |
|||
checkedAccountsIds: [], |
|||
}) |
|||
} |
|||
|
|||
handleToggleAccount = (account: Account) => { |
|||
const { checkedAccountsIds, setState } = this.props |
|||
const isChecked = checkedAccountsIds.find(id => id === account.id) !== undefined |
|||
if (isChecked) { |
|||
setState({ checkedAccountsIds: checkedAccountsIds.filter(id => id !== account.id) }) |
|||
} else { |
|||
setState({ checkedAccountsIds: [...checkedAccountsIds, account.id] }) |
|||
} |
|||
} |
|||
|
|||
handleAccountUpdate = (updatedAccount: Account) => { |
|||
const { scannedAccounts, setState } = this.props |
|||
setState({ |
|||
scannedAccounts: scannedAccounts.map(account => { |
|||
if (account.id !== updatedAccount.id) { |
|||
return account |
|||
} |
|||
return updatedAccount |
|||
}), |
|||
}) |
|||
} |
|||
|
|||
render() { |
|||
const { scanStatus, err, scannedAccounts, checkedAccountsIds, existingAccounts } = this.props |
|||
|
|||
return ( |
|||
<Box> |
|||
{err && <Box shrink>{err.message}</Box>} |
|||
|
|||
<Box flow={2}> |
|||
{scannedAccounts.map(account => { |
|||
const isChecked = checkedAccountsIds.find(id => id === account.id) !== undefined |
|||
const existingAccount = existingAccounts.find(a => a.id === account.id) |
|||
const isDisabled = existingAccount !== undefined |
|||
return ( |
|||
<AccountRow |
|||
key={account.id} |
|||
account={existingAccount || account} |
|||
isChecked={isChecked} |
|||
isDisabled={isDisabled} |
|||
onClick={this.handleToggleAccount} |
|||
onAccountUpdate={this.handleAccountUpdate} |
|||
/> |
|||
) |
|||
})} |
|||
{scanStatus === 'scanning' && ( |
|||
<Box |
|||
horizontal |
|||
bg="lightGrey" |
|||
borderRadius={3} |
|||
px={3} |
|||
align="center" |
|||
justify="center" |
|||
style={{ height: 48 }} |
|||
> |
|||
<Spinner color="grey" size={24} /> |
|||
</Box> |
|||
)} |
|||
</Box> |
|||
|
|||
<Box horizontal mt={2}> |
|||
{['error', 'finished'].includes(scanStatus) && ( |
|||
<Button ml="auto" small outline onClick={this.handleRetry}> |
|||
<Box horizontal flow={2} align="center"> |
|||
<IconExchange size={13} /> |
|||
<span>{'retry'}</span> |
|||
</Box> |
|||
</Button> |
|||
)} |
|||
</Box> |
|||
</Box> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default StepImport |
|||
|
|||
export const StepImportFooter = ({ scanStatus, onClickImport, checkedAccountsIds }: StepProps) => ( |
|||
<Button |
|||
primary |
|||
disabled={scanStatus !== 'finished' || checkedAccountsIds.length === 0} |
|||
onClick={() => onClickImport()} |
|||
> |
|||
{'Import accounts'} |
|||
</Button> |
|||
) |
@ -0,0 +1,25 @@ |
|||
// @flow
|
|||
|
|||
import React from 'react' |
|||
|
|||
import Box from 'components/base/Box' |
|||
import Button from 'components/base/Button' |
|||
import IconCheckCircle from 'icons/CheckCircle' |
|||
|
|||
import type { StepProps } from '../index' |
|||
|
|||
function StepFinish({ onCloseModal }: StepProps) { |
|||
return ( |
|||
<Box align="center" py={6}> |
|||
<Box color="positiveGreen" mb={4}> |
|||
<IconCheckCircle size={40} /> |
|||
</Box> |
|||
<Box mb={4}>{'Great success!'}</Box> |
|||
<Button primary onClick={onCloseModal}> |
|||
{'Close'} |
|||
</Button> |
|||
</Box> |
|||
) |
|||
} |
|||
|
|||
export default StepFinish |
@ -0,0 +1,6 @@ |
|||
title: Import accounts |
|||
breadcrumb: |
|||
informations: Informations |
|||
connectDevice: Connect device |
|||
import: Import |
|||
finish: End |
Loading…
Reference in new issue