Browse Source

feat(ui): add Request components

renovate/lint-staged-8.x
Tom Kirkpatrick 6 years ago
parent
commit
967a03d8ae
No known key found for this signature in database GPG Key ID: 72203A8EC5967EA8
  1. 368
      app/components/Request/Request.js
  2. 179
      app/components/Request/RequestSummary.js
  3. 2
      app/components/Request/index.js
  4. 22
      app/components/Request/messages.js
  5. 151
      stories/pages/request.stories.js

368
app/components/Request/Request.js

@ -0,0 +1,368 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Box, Flex } from 'rebass'
import { FormattedMessage, injectIntl } from 'react-intl'
import { convert } from 'lib/utils/btc'
import {
Bar,
Button,
CryptoAmountInput,
Dropdown,
FiatAmountInput,
Form,
Header,
Label,
Panel,
Text,
TextArea
} from 'components/UI'
import Lightning from 'components/Icon/Lightning'
import { RequestSummary } from '.'
import messages from './messages'
/**
* Request form.
*/
class Request extends React.Component {
state = {
currentStep: 'form'
}
static propTypes = {
/** Human readable chain name */
cryptoName: PropTypes.string.isRequired,
/** Current ticker data as provided by blockchain.info */
currentTicker: PropTypes.object.isRequired,
/** Currently selected cryptocurrency (key). */
cryptoCurrency: PropTypes.string.isRequired,
/** Ticker symbol of the currently selected cryptocurrency. */
cryptoCurrencyTicker: PropTypes.string.isRequired,
/** List of supported cryptocurrencies. */
cryptoCurrencies: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
})
).isRequired,
/** List of supported fiat currencies. */
fiatCurrencies: PropTypes.array.isRequired,
/** Currently selected fiat currency (key). */
fiatCurrency: PropTypes.string.isRequired,
/** Boolean indicating wether the form is being processed. If true, form buttons are disabled. */
isProcessing: PropTypes.bool,
/** Boolean indicating wether the invoice has already been paid. */
isPaid: PropTypes.bool,
/** Lightning Payment request. */
payReq: PropTypes.string,
/** Set the current cryptocurrency. */
setCryptoCurrency: PropTypes.func.isRequired,
/** Set the current fiat currency */
setFiatCurrency: PropTypes.func.isRequired,
/** Create an invoice using the supplied details */
createInvoice: PropTypes.func.isRequired
}
static defaultProps = {
isProcessing: false,
isPaid: false,
payReq: null
}
amountInput = React.createRef()
componentDidMount() {
this.focusAmountInput()
}
componentDidUpdate(prevProps) {
const { payReq } = this.props
const { currentStep } = this.state
if (payReq !== prevProps.payReq && currentStep === 'form') {
this.nextStep()
}
}
/**
* Liost of enabled form steps.
*/
steps = () => {
return ['form', 'summary']
}
/**
* Go back to previous form step.
*/
previousStep = () => {
const { currentStep } = this.state
const nextStep = Math.max(this.steps().indexOf(currentStep) - 1, 0)
if (currentStep !== nextStep) {
this.setState({ currentStep: this.steps()[nextStep] })
}
}
/**
* Progress to next form step.
*/
nextStep = () => {
const { currentStep } = this.state
const nextStep = Math.min(this.steps().indexOf(currentStep) + 1, this.steps().length - 1)
if (currentStep !== nextStep) {
this.setState({ currentStep: this.steps()[nextStep] })
}
}
/**
* Form submit handler.
* @param {Object} values submitted form values.
*/
onSubmit = values => {
const { cryptoCurrency, createInvoice } = this.props
createInvoice(values.amountCrypto, cryptoCurrency, values.memo)
}
/**
* Store the formApi on the component context to make it available at this.formApi.
*/
setFormApi = formApi => {
this.formApi = formApi
}
/**
* Focus the amount input.
*/
focusAmountInput = () => {
if (this.amountInput.current) {
this.amountInput.current.focus()
}
}
/**
* set the amountFiat field whenever the crypto amount changes.
*/
handleAmountCryptoChange = e => {
const { cryptoCurrency, currentTicker, fiatCurrency } = this.props
const lastPrice = currentTicker[fiatCurrency].last
const value = convert(cryptoCurrency, 'fiat', e.target.value, lastPrice)
this.formApi.setValue('amountFiat', value)
}
/**
* set the amountCrypto field whenever the fiat amount changes.
*/
handleAmountFiatChange = e => {
const { cryptoCurrency, currentTicker, fiatCurrency } = this.props
const lastPrice = currentTicker[fiatCurrency].last
const value = convert('fiat', cryptoCurrency, e.target.value, lastPrice)
this.formApi.setValue('amountCrypto', value)
}
/**
* Handle changes from the crypto currency dropdown.
*/
handleCryptoCurrencyChange = value => {
const { setCryptoCurrency } = this.props
setCryptoCurrency(value)
}
/**
* Handle changes from the fiat currency dropdown.
*/
handleFiatCurrencyChange = value => {
const { setFiatCurrency } = this.props
setFiatCurrency(value)
}
renderHelpText = () => {
return (
<Box mb={4}>
<Text textAlign="justify">
<FormattedMessage {...messages.description} />
</Text>
</Box>
)
}
renderAmountFields = () => {
const {
cryptoCurrency,
cryptoCurrencies,
currentTicker,
fiatCurrency,
fiatCurrencies
} = this.props
return (
<Box>
<Label htmlFor="amountCrypto" pb={2}>
<FormattedMessage {...messages.amount} />
</Label>
<Flex justifyContent="space-between" alignItems="flex-start" mb={3}>
<Flex width={6 / 13}>
<Box width={150}>
<CryptoAmountInput
field="amountCrypto"
name="amountCrypto"
currency={cryptoCurrency}
required
width={150}
validateOnChange
validateOnBlur
onChange={this.handleAmountCryptoChange}
forwardedRef={this.amountInput}
/>
</Box>
<Dropdown
activeKey={cryptoCurrency}
items={cryptoCurrencies}
onChange={this.handleCryptoCurrencyChange}
mt={3}
ml={2}
/>
</Flex>
<Text textAlign="center" mt={3} width={1 / 11}>
=
</Text>
<Flex width={6 / 13}>
<Box width={150} ml="auto">
<FiatAmountInput
field="amountFiat"
name="amountFiat"
currency={fiatCurrency}
currentTicker={currentTicker}
width={150}
onChange={this.handleAmountFiatChange}
/>
</Box>
<Dropdown
activeKey={fiatCurrency}
items={fiatCurrencies}
onChange={this.handleFiatCurrencyChange}
mt={3}
ml={2}
/>
</Flex>
</Flex>
</Box>
)
}
renderMemoField = () => {
const { intl } = this.props
return (
<Box>
<Box pb={2}>
<Label htmlFor="memo">
<FormattedMessage {...messages.memo} />
</Label>
</Box>
<TextArea
field="memo"
name="memo"
validateOnBlur
validateOnChange
placeholder={intl.formatMessage({ ...messages.memo_placeholder })}
width={1}
rows={3}
css={{ resize: 'vertical', 'min-height': '48px' }}
/>
</Box>
)
}
/**
* Form renderer.
*/
render() {
const {
createInvoice,
cryptoCurrency,
cryptoCurrencyTicker,
cryptoCurrencies,
currentTicker,
cryptoName,
fiatCurrencies,
fiatCurrency,
intl,
isProcessing,
isPaid,
payReq,
setCryptoCurrency,
setFiatCurrency,
...rest
} = this.props
const { currentStep } = this.state
return (
<Form
width={1}
css={{ height: '100%' }}
{...rest}
getApi={this.setFormApi}
onSubmit={this.onSubmit}
>
{({ formState }) => {
// Determine what the text should be for the next button.
let nextButtonText = intl.formatMessage({ ...messages.button_text })
if (formState.values.amountCrypto) {
nextButtonText = `${intl.formatMessage({
...messages.button_text
})} ${formState.values.amountCrypto} ${cryptoCurrencyTicker}`
}
return (
<Panel>
<Panel.Header>
<Header
title={`${intl.formatMessage({
...messages.title
})} ${cryptoName} (${cryptoCurrencyTicker})`}
subtitle={<FormattedMessage {...messages.subtitle} />}
logo={<Lightning height="45px" width="45px" />}
/>
</Panel.Header>
<Bar />
<Panel.Body>
{currentStep == 'form' ? (
<React.Fragment>
{this.renderHelpText()}
{this.renderAmountFields()}
{this.renderMemoField()}
</React.Fragment>
) : (
<RequestSummary
mt={-3}
// State
cryptoCurrency={cryptoCurrency}
cryptoCurrencies={cryptoCurrencies}
currentTicker={currentTicker}
payReq={payReq}
isPaid={isPaid}
// Dispatch
setCryptoCurrency={setCryptoCurrency}
setFiatCurrency={setFiatCurrency}
/>
)}
</Panel.Body>
{currentStep == 'form' && (
<Panel.Footer>
<Button
type="submit"
disabled={formState.pristine || formState.invalid || isProcessing}
processing={isProcessing}
mx="auto"
>
{nextButtonText}
</Button>
</Panel.Footer>
)}
</Panel>
)
}}
</Form>
)
}
}
export default injectIntl(Request)

179
app/components/Request/RequestSummary.js

@ -0,0 +1,179 @@
import React from 'react'
import PropTypes from 'prop-types'
import { Box, Flex } from 'rebass'
import { FormattedMessage, FormattedRelative, injectIntl } from 'react-intl'
import { decodePayReq } from 'lib/utils/crypto'
import { showNotification } from 'lib/utils/notifications'
import copy from 'copy-to-clipboard'
import { Bar, Button, Dropdown, QRCode, Text, Truncate } from 'components/UI'
import Value from 'components/Value'
import { PaySummaryRow } from '../Pay'
import messages from './messages'
class RequestSummary extends React.Component {
state = {
isExpired: null,
timer: null
}
static propTypes = {
/** Currently selected cryptocurrency (key). */
cryptoCurrency: PropTypes.string.isRequired,
/** List of supported cryptocurrencies. */
cryptoCurrencies: PropTypes.arrayOf(
PropTypes.shape({
key: PropTypes.string.isRequired,
name: PropTypes.string.isRequired
})
).isRequired,
/** Boolean indicating wether the invoice has already been paid. */
isPaid: PropTypes.bool,
/** Lightning Payment request. */
payReq: PropTypes.string.isRequired,
/** Set the current cryptocurrency. */
setCryptoCurrency: PropTypes.func.isRequired
}
static defaultProps = {
isPaid: false
}
componentDidMount() {
const { payReq } = this.props
let invoice
try {
invoice = decodePayReq(payReq)
const expiresIn = invoice.timeExpireDate * 1000 - Date.now()
if (expiresIn >= 0) {
this.setState({ isExpired: false })
const timer = setInterval(() => this.setState({ isExpired: true }), expiresIn)
this.setState({ timer })
} else {
this.setState({ isExpired: true })
}
} catch (e) {
return null
}
}
componentWillUnmount() {
const { timer } = this.state
clearInterval(timer)
}
copyPaymentRequest = () => {
const { intl, payReq } = this.props
copy(payReq)
showNotification(
intl.formatMessage({ ...messages.address_notification_title }),
intl.formatMessage({ ...messages.copied_notification_description })
)
}
render() {
const {
cryptoCurrency,
cryptoCurrencies,
isPaid,
payReq,
setCryptoCurrency,
...rest
} = this.props
const { isExpired } = this.state
let invoice
try {
invoice = decodePayReq(payReq)
} catch (e) {
return null
}
const { satoshis } = invoice
const descriptionTag = invoice.tags.find(tag => tag.tagName === 'description') || {}
const memo = descriptionTag.data
return (
<Box {...rest}>
{memo && (
<React.Fragment>
<PaySummaryRow left={<FormattedMessage {...messages.memo} />} right={memo} /> <Bar />{' '}
</React.Fragment>
)}
<PaySummaryRow
left={<FormattedMessage {...messages.amount} />}
right={
<Flex alignItems="center" justifyContent="flex-end">
<Value value={satoshis} currency={cryptoCurrency} />
<Dropdown
activeKey={cryptoCurrency}
items={cryptoCurrencies}
onChange={setCryptoCurrency}
justify="right"
ml={2}
/>
</Flex>
}
/>
<Bar />
<PaySummaryRow
left={<FormattedMessage {...messages.qrcode} />}
right={
<Text>
<QRCode value={payReq} size="125px" />
</Text>
}
/>
<Bar />
<PaySummaryRow
left={<FormattedMessage {...messages.ln_invoice} />}
right=<React.Fragment>
<Text
fontSize="xs"
fontWeight="normal"
mb={2}
css={{ 'word-wrap': 'break-word' }}
className="hint--bottom-left"
data-hint={payReq}
>
<Truncate text={payReq} maxlen={40} />
</Text>
<Button type="button" size="small" onClick={this.copyPaymentRequest}>
<FormattedMessage {...messages.copy_button_text} />
</Button>
</React.Fragment>
/>
<Bar />
<PaySummaryRow
left={<FormattedMessage {...messages.status} />}
right={
<React.Fragment>
<Text color={isPaid || !isExpired ? 'superGreen' : 'superRed'} fontWeight="normal">
{isExpired ? 'Expired ' : 'Expires '}
<FormattedRelative value={invoice.timeExpireDateString} updateInterval={1000} />
</Text>
<Text
color={isPaid ? 'superGreen' : isExpired ? 'superRed' : 'grey'}
fontWeight="normal"
>
{isPaid ? (
<FormattedMessage {...messages.paid} />
) : (
<FormattedMessage {...messages.not_paid} />
)}
</Text>
</React.Fragment>
}
/>
</Box>
)
}
}
export default injectIntl(RequestSummary)

2
app/components/Request/index.js

@ -0,0 +1,2 @@
export Request from './Request'
export RequestSummary from './RequestSummary'

22
app/components/Request/messages.js

@ -0,0 +1,22 @@
import { defineMessages } from 'react-intl'
/* eslint-disable max-len */
export default defineMessages({
amount: 'Amount',
button_text: 'Request',
copy_button_text: 'Copy invoice',
address_notification_title: 'Address copied',
copied_notification_description: 'Payment address has been copied to your clipboard',
ln_invoice: 'Lightning Invoice',
total: 'Total',
memo: 'Memo',
memo_placeholder: 'For example "Dinner last night"',
not_paid: 'not paid',
paid: 'paid',
qrcode: 'QR-Code',
status: 'Request Status',
title: 'Request',
subtitle: 'through the Lightning Network',
description:
'You can request Bitcoin (BTC) through the Lightning Network. Just enter the Amount you want to request in the field below. Zap will generate a QR-Code and a Lightning invoice after.'
})

151
stories/pages/request.stories.js

@ -0,0 +1,151 @@
/* eslint-disable max-len */
import React from 'react'
import { storiesOf } from '@storybook/react'
import { action } from '@storybook/addon-actions'
import { text } from '@storybook/addon-knobs'
import { State, Store } from '@sambego/storybook-state'
import lightningPayReq from 'bolt11'
import { convert } from 'lib/utils/btc'
import { Modal, Page } from 'components/UI'
import { Request, RequestSummary } from 'components/Request'
const delay = time => new Promise(resolve => setTimeout(() => resolve(), time))
const store = new Store({
chain: 'bitcoin',
network: 'testnet',
cryptoName: 'Bitcoin',
cryptoCurrency: 'btc',
cryptoCurrencyTicker: 'BTC',
cryptoCurrencies: [
{
key: 'btc',
name: 'BTC'
},
{
key: 'bits',
name: 'bits'
},
{
key: 'sats',
name: 'satoshis'
}
],
fiatCurrency: 'USD',
fiatCurrencies: ['USD', 'EUR', 'GBP'],
currentTicker: {
USD: {
last: 6477.78
},
EUR: {
last: 5656.01
},
GBP: {
last: 5052.73
}
}
})
const mockCreateInvoice = async (amount, currency, memo = '') => {
action('mockCreateInvoice')
const satoshis = convert(currency, 'sats', amount)
store.set({ isProcessing: true })
await delay(500)
var encoded = lightningPayReq.encode({
coinType: 'bitcoin',
satoshis,
tags: [
{
tagName: 'purpose_commit_hash',
data: '3925b6f67e2c340036ed12093dd44e0368df1b6ea26c53dbe4811f58fd5db8c1'
},
{
tagName: 'payment_hash',
data: '0001020304050607080900010203040506070809000102030405060708090102'
},
{
tagName: 'expire_time',
data: 30
},
{
tagName: 'description',
data: memo
}
]
})
var privateKeyHex = 'e126f68f7eafcc8b74f54d269fe206be715000f94dac067d1c04a8ca3b2db734'
var signed = lightningPayReq.sign(encoded, privateKeyHex)
store.set({ payReq: signed.paymentRequest })
store.set({ isProcessing: false })
}
const setCryptoCurrency = key => {
const items = store.get('cryptoCurrencies')
const item = items.find(i => i.key === key)
store.set({ cryptoCurrency: item.key })
store.set({ cryptoCurrencyTicker: item.name })
}
const setFiatCurrency = key => {
store.set({ fiatCurrency: key })
}
storiesOf('Containers.Request', module)
.add('Request', () => {
return (
<Page css={{ height: 'calc(100vh - 40px)' }}>
<Modal onClose={action('clicked')}>
<State store={store}>
<Request
width={9 / 16}
mx="auto"
// State
cryptoCurrency={store.get('cryptoCurrency')}
cryptoCurrencyTicker={store.get('cryptoCurrencyTicker')}
cryptoCurrencies={store.get('cryptoCurrencies')}
currentTicker={store.get('currentTicker')}
cryptoName={store.get('cryptoName')}
fiatCurrency={store.get('fiatCurrency')}
fiatCurrencies={store.get('fiatCurrencies')}
isProcessing={store.get('isProcessing')}
isPaid={store.get('isPaid')}
payReq={store.get('payReq')}
// Dispatch
createInvoice={mockCreateInvoice}
setCryptoCurrency={setCryptoCurrency}
setFiatCurrency={setFiatCurrency}
/>
</State>
</Modal>
</Page>
)
})
.add('RequestSummary', () => {
store.set({
payReq: text(
'Lightning Invoice',
'lntb10170n1pda7tarpp59kjlzct447ttxper43kek78lhwgxk4gy8nfvpjdr7yzkscu2ds5qdzy2pshjmt9de6zqen0wgsrzvp3xus8q6tcv4k8xgrpwss8xct5daeks6tn9ecxcctrv5hqxqzjccqp2yvpzcn2xazu9rt8nrhn2xf6nyrj8fsfw9hafsf0p80trypu4tp58km5mn7wz50uh06kxf4t8kdj64f86u6l5ksl75r500zl7urhacxspcm4ye9'
)
})
return (
<Page css={{ height: 'calc(100vh - 40px)' }}>
<Modal onClose={action('clicked')}>
<State store={store}>
<RequestSummary
// State
cryptoCurrency={store.get('cryptoCurrency')}
cryptoCurrencies={store.get('cryptoCurrencies')}
currentTicker={store.get('currentTicker')}
payReq={store.get('payReq')}
// Dispatch
setCryptoCurrency={setCryptoCurrency}
setFiatCurrency={setFiatCurrency}
/>
</State>
</Modal>
</Page>
)
})
Loading…
Cancel
Save