feat: transactions page (#342)

* feat: transactions page

* fix: remove unused txHash function

* refactor: rewrite transactions sql queries

* fix: use left instead of inner join on txs

* fix: change expandable table logic

* fix: add other coins

* refactor: move log download function to component

* refactor: use name values in RadioGroup

* fix: assorted fixes

* feat: virtualize expandable table

* fix: clean up imports

* fix: remove border radius

* fix: move formatting out of CopyToClipboard

And use CSS instead of JS to format.

* fix: remove customer's last name formatting

This was using lodash's string case functions, which produce unwanted
results if, for instance, a user has a double-barrel last name.
This commit is contained in:
Rafael Taranto 2019-12-12 13:55:52 +00:00 committed by Josh Harvey
parent 41d8b7afe1
commit 8334bd274f
38 changed files with 1225 additions and 226 deletions

View file

@ -7,8 +7,9 @@ const got = require('got')
const supportLogs = require('../support_logs') const supportLogs = require('../support_logs')
const machineLoader = require('../machine-loader') const machineLoader = require('../machine-loader')
const logs = require('../logs') const logs = require('../logs')
const serverLogs = require('./server-logs') const transactions = require('./transactions')
const serverLogs = require('./server-logs')
const supervisor = require('./supervisor') const supervisor = require('./supervisor')
const funding = require('./funding') const funding = require('./funding')
const config = require('./config') const config = require('./config')
@ -80,6 +81,12 @@ app.get('/api/server_logs', (req, res, next) => {
.catch(next) .catch(next)
}) })
app.get('/api/txs', (req, res, next) => {
return transactions.batch()
.then(r => res.send(r))
.catch(next)
})
function dbNotify () { function dbNotify () {
return got.post('http://localhost:3030/dbChange') return got.post('http://localhost:3030/dbChange')
.catch(e => console.error('Error: lamassu-server not responding')) .catch(e => console.error('Error: lamassu-server not responding'))

View file

@ -0,0 +1,110 @@
const _ = require('lodash/fp')
const db = require('../db')
const machineLoader = require('../machine-loader')
const tx = require('../tx')
const cashInTx = require('../cash-in/cash-in-tx')
const { REDEEMABLE_AGE } = require('../cash-out/cash-out-helper')
const NUM_RESULTS = 1000
function addNames (txs) {
return machineLoader.getMachineNames()
.then(machines => {
const addName = tx => {
const machine = _.find(['deviceId', tx.deviceId], machines)
const name = machine ? machine.name : 'Unpaired'
return _.set('machineName', name, tx)
}
return _.map(addName, txs)
})
}
const camelize = _.mapKeys(_.camelCase)
function batch () {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']),
_.take(NUM_RESULTS), _.map(camelize), addNames)
const cashInSql = `select 'cashIn' as tx_class, txs.*,
c.phone as customer_phone,
c.id_card_data_number as customer_id_card_data_number,
c.id_card_data_expiration as customer_id_card_data_expiration,
c.id_card_data as customer_id_card_data,
c.name as customer_name,
c.front_camera_path as customer_front_camera_path,
c.id_card_photo_path as customer_id_card_photo_path,
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
from cash_in_txs as txs
left outer join customers c on txs.customer_id = c.id
order by created desc limit $2`
const cashOutSql = `select 'cashOut' as tx_class,
txs.*,
actions.tx_hash,
c.phone as customer_phone,
c.id_card_data_number as customer_id_card_data_number,
c.id_card_data_expiration as customer_id_card_data_expiration,
c.id_card_data as customer_id_card_data,
c.name as customer_name,
c.front_camera_path as customer_front_camera_path,
c.id_card_photo_path as customer_id_card_photo_path,
(extract(epoch from (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $2 as expired
from cash_out_txs txs
inner join cash_out_actions actions on txs.id = actions.tx_id
and actions.action = 'provisionAddress'
left outer join customers c on txs.customer_id = c.id
order by created desc limit $1`
return Promise.all([db.any(cashInSql, [cashInTx.PENDING_INTERVAL, NUM_RESULTS]), db.any(cashOutSql, [NUM_RESULTS, REDEEMABLE_AGE])])
.then(packager)
}
function single (txId) {
const packager = _.flow(_.compact, _.map(camelize), addNames)
const cashInSql = `select 'cashIn' as tx_class, txs.*,
c.phone as customer_phone,
c.id_card_data_number as customer_id_card_data_number,
c.id_card_data_expiration as customer_id_card_data_expiration,
c.id_card_data as customer_id_card_data,
c.name as customer_name,
c.front_camera_path as customer_front_camera_path,
c.id_card_photo_path as customer_id_card_photo_path,
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
from cash_in_txs as txs
left outer join customers c on txs.customer_id = c.id
where id=$2`
const cashOutSql = `select 'cashOut' as tx_class,
txs.*,
actions.tx_hash,
c.phone as customer_phone,
c.id_card_data_number as customer_id_card_data_number,
c.id_card_data_expiration as customer_id_card_data_expiration,
c.id_card_data as customer_id_card_data,
c.name as customer_name,
c.front_camera_path as customer_front_camera_path,
c.id_card_photo_path as customer_id_card_photo_path,
(extract(epoch from (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $2 as expired
from cash_out_txs txs
inner join cash_out_actions actions on txs.id = actions.tx_id
and actions.action = 'provisionAddress'
left outer join customers c on txs.customer_id = c.id
where id=$1`
return Promise.all([
db.oneOrNone(cashInSql, [cashInTx.PENDING_INTERVAL, txId]),
db.oneOrNone(cashOutSql, [txId, REDEEMABLE_AGE])
])
.then(packager)
.then(_.head)
}
function cancel (txId) {
return tx.cancel(txId)
.then(() => single(txId))
}
module.exports = { batch, single, cancel }

View file

@ -5762,7 +5762,6 @@
"version": "6.26.0", "version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"dev": true,
"requires": { "requires": {
"core-js": "^2.4.0", "core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0" "regenerator-runtime": "^0.11.0"
@ -7533,7 +7532,6 @@
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz", "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.2.0.tgz",
"integrity": "sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w==", "integrity": "sha512-eOZERzvCmxS8HWzugj4Uxl8OJxa7T2k1Gi0X5qavwydHIfuSHq2dTD09LOg/XyGq4Zpb5IsR/2OJ5lbOegz78w==",
"dev": true,
"requires": { "requires": {
"toggle-selection": "^1.0.6" "toggle-selection": "^1.0.6"
} }
@ -7541,8 +7539,7 @@
"core-js": { "core-js": {
"version": "2.6.5", "version": "2.6.5",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz", "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.5.tgz",
"integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A==", "integrity": "sha512-klh/kDpwX8hryYL14M9w/xei6vrv6sE8gTHDG7/T/+SEovB/G4ejwcfE/CBzO6Edsu+OETZMZ3wcX/EjUkrl5A=="
"dev": true
}, },
"core-js-compat": { "core-js-compat": {
"version": "3.1.4", "version": "3.1.4",
@ -11018,9 +11015,9 @@
"dev": true "dev": true
}, },
"handlebars": { "handlebars": {
"version": "4.4.3", "version": "4.5.3",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.4.3.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.5.3.tgz",
"integrity": "sha512-B0W4A2U1ww3q7VVthTKfh+epHx+q4mCt6iK+zEAzbMBpWQAwxCeKxEGpj/1oQTpzPXDNSOG7hmG14TsISH50yw==", "integrity": "sha512-3yPecJoJHK/4c6aZhSvxOyG4vJKDshV36VHp0iVCDVh7o9w2vwi3NSnL2MMPj3YdduqaBcu7cGbggJQM0br9xA==",
"dev": true, "dev": true,
"requires": { "requires": {
"neo-async": "^2.6.0", "neo-async": "^2.6.0",
@ -17276,9 +17273,9 @@
} }
}, },
"react": { "react": {
"version": "16.10.2", "version": "16.12.0",
"resolved": "https://registry.npmjs.org/react/-/react-16.10.2.tgz", "resolved": "https://registry.npmjs.org/react/-/react-16.12.0.tgz",
"integrity": "sha512-MFVIq0DpIhrHFyqLU0S3+4dIcBhhOvBE8bJ/5kHPVOVaGdo0KuiQzpcjCPsf585WvhypqtrMILyoE2th6dT+Lw==", "integrity": "sha512-fglqy3k5E+81pA8s+7K0/T3DBCF0ZDOher1elBFzF7O6arXJgzyu/FW+COxFvAWXJoJN9KIZbT2LXlukwphYTA==",
"requires": { "requires": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"object-assign": "^4.1.1", "object-assign": "^4.1.1",
@ -17336,6 +17333,15 @@
"tinycolor2": "^1.4.1" "tinycolor2": "^1.4.1"
} }
}, },
"react-copy-to-clipboard": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.2.tgz",
"integrity": "sha512-/2t5mLMMPuN5GmdXo6TebFa8IoFxZ+KTDDqYhcDm0PhkgEzSxVvIX26G20s1EB02A4h2UZgwtfymZ3lGJm0OLg==",
"requires": {
"copy-to-clipboard": "^3",
"prop-types": "^15.5.8"
}
},
"react-dev-utils": { "react-dev-utils": {
"version": "9.1.0", "version": "9.1.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-9.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-9.1.0.tgz",
@ -17672,8 +17678,7 @@
"react-lifecycles-compat": { "react-lifecycles-compat": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==", "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
"dev": true
}, },
"react-popper": { "react-popper": {
"version": "1.3.4", "version": "1.3.4",
@ -19128,6 +19133,19 @@
} }
} }
}, },
"react-virtualized": {
"version": "9.21.2",
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.2.tgz",
"integrity": "sha512-oX7I7KYiUM7lVXQzmhtF4Xg/4UA5duSA+/ZcAvdWlTLFCoFYq1SbauJT5gZK9cZS/wdYR6TPGpX/dqzvTqQeBA==",
"requires": {
"babel-runtime": "^6.26.0",
"clsx": "^1.0.1",
"dom-helpers": "^5.0.0",
"loose-envify": "^1.3.0",
"prop-types": "^15.6.0",
"react-lifecycles-compat": "^3.0.4"
}
},
"reactcss": { "reactcss": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz",
@ -19556,8 +19574,7 @@
"regenerator-runtime": { "regenerator-runtime": {
"version": "0.11.1", "version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==", "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
"dev": true
}, },
"regenerator-transform": { "regenerator-transform": {
"version": "0.14.1", "version": "0.14.1",
@ -21866,8 +21883,7 @@
"toggle-selection": { "toggle-selection": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=", "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI="
"dev": true
}, },
"toidentifier": { "toidentifier": {
"version": "1.0.0", "version": "1.0.0",

View file

@ -16,9 +16,11 @@
"lodash": "4.17.15", "lodash": "4.17.15",
"moment": "2.24.0", "moment": "2.24.0",
"qrcode.react": "0.9.3", "qrcode.react": "0.9.3",
"react": "^16.10.2", "react": "^16.12.0",
"react-copy-to-clipboard": "^5.0.2",
"react-dom": "^16.10.2", "react-dom": "^16.10.2",
"react-router-dom": "5.1.2", "react-router-dom": "5.1.2",
"react-virtualized": "^9.21.2",
"yup": "0.27.0" "yup": "0.27.0"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,17 +1,17 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import FileSaver from 'file-saver'
import classnames from 'classnames' import classnames from 'classnames'
import { toInteger } from 'lodash/fp' import { get, compose } from 'lodash/fp'
import moment from 'moment' import moment from 'moment'
import FileSaver from 'file-saver'
import { makeStyles } from '@material-ui/core' import { makeStyles } from '@material-ui/core'
import { ReactComponent as Arrow } from '../styling/icons/arrow/download_logs.svg' import { ReactComponent as Arrow } from '../styling/icons/arrow/download_logs.svg'
import typographyStyles from '../components/typography/styles' import typographyStyles from './typography/styles'
import { primaryColor, offColor, zircon } from '../styling/variables' import { primaryColor, offColor, zircon } from '../styling/variables'
import { Link } from './buttons' import { Link } from './buttons'
import { RadioGroup } from './inputs' import { RadioGroup } from './inputs'
import Popover from './Popover' import Popper from './Popper'
import DateRangePicker from './date-range-picker/DateRangePicker' import DateRangePicker from './date-range-picker/DateRangePicker'
const { info1, label1, label2, h4 } = typographyStyles const { info1, label1, label2, h4 } = typographyStyles
@ -121,99 +121,109 @@ const styles = {
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const LogsDownloaderPopover = ({ id, open, anchorEl, onClose, logsResponse, ...props }) => { const LogsDownloaderPopover = ({ id, name, open, anchorEl, getTimestamp, logs, title, ...props }) => {
const [radioButtons, setRadioButtons] = useState(0) const radioButtonAll = 'all'
const radioButtonRange = 'range'
const [selectedRadio, setSelectedRadio] = useState(radioButtonAll)
const [range, setRange] = useState(null) const [range, setRange] = useState(null)
const classes = useStyles() const classes = useStyles()
const dateRangePickerClasses = { const dateRangePickerClasses = {
[classes.dateRangePickerShowing]: radioButtons === 1, [classes.dateRangePickerShowing]: selectedRadio === radioButtonRange,
[classes.dateRangePickerHidden]: radioButtons === 0 [classes.dateRangePickerHidden]: selectedRadio === radioButtonAll
}
const formatDateFile = date => {
return moment(date).format('YYYY-MM-DD_HH-mm')
} }
const handleRadioButtons = (event) => { const handleRadioButtons = (event) => {
setRadioButtons(toInteger(event.target.value)) compose(setSelectedRadio, get('target.value'))
const radio = event.target.value
setSelectedRadio(radio)
} }
const handleRangeChange = (from, to) => { const handleRangeChange = (from, to) => {
setRange({ from, to }) setRange({ from, to })
} }
const downloadLogs = (range, logs) => {
if (!range) return
if (range.from && !range.to) range.to = moment()
const formatDateFile = date => {
return moment(date).format('YYYY-MM-DD_HH-mm')
}
if (selectedRadio === radioButtonAll) {
const text = logs.map(it => JSON.stringify(it)).join('\n')
const blob = new window.Blob([text], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(blob, `${formatDateFile(new Date())}_${name}`)
return
}
if (selectedRadio === radioButtonRange) {
const text = logs.filter((log) => moment(getTimestamp(log)).isBetween(range.from, range.to, 'day', '[]')).map(it => JSON.stringify(it)).join('\n')
const blob = new window.Blob([text], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(blob, `${formatDateFile(range.from)}_${formatDateFile(range.to)}_${name}`)
}
}
const radioButtonOptions = [{ label: 'All logs', value: radioButtonAll }, { label: 'Date range', value: radioButtonRange }]
return ( return (
<Popover <Popper
id={id} id={id}
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
onClose={onClose} placement='bottom'
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
> >
<div className={classes.popoverContent}> <div className={classes.popoverContent}>
<div className={classes.popoverHeader}> <div className={classes.popoverHeader}>
Download logs {title}
</div> </div>
<div className={classes.radioButtonsContainer}> <div className={classes.radioButtonsContainer}>
<RadioGroup <RadioGroup
name='logs-select' name='logs-select'
value={radioButtons} value={selectedRadio}
labels={['All logs', 'Date range']} options={radioButtonOptions}
ariaLabel='logs-select' ariaLabel='logs-select'
onChange={handleRadioButtons} onChange={handleRadioButtons}
className={classes.radioButtons} className={classes.radioButtons}
/> />
</div> </div>
<div className={classnames(dateRangePickerClasses)}> {selectedRadio === radioButtonRange && (
<div className={classes.dateContainerWrapper}> <div className={classnames(dateRangePickerClasses)}>
{range && ( <div className={classes.dateContainerWrapper}>
<> {range && (
<DateContainer date={range.from}>From</DateContainer> <>
<div className={classes.arrowContainer}> <DateContainer date={range.from}>From</DateContainer>
<Arrow className={classes.arrow} /> <div className={classes.arrowContainer}>
</div> <Arrow className={classes.arrow} />
<DateContainer date={range.to}>To</DateContainer> </div>
</> <DateContainer date={range.to}>To</DateContainer>
)} </>
)}
</div>
<DateRangePicker
maxDate={moment()}
onRangeChange={handleRangeChange}
/>
</div> </div>
<DateRangePicker )}
maxDate={moment()}
onRangeChange={handleRangeChange}
/>
</div>
<div className={classes.download}> <div className={classes.download}>
<Link <Link
color='primary' color='primary'
onClick={() => { onClick={() => downloadLogs(range, logs)}
if (radioButtons === 0) {
const text = logsResponse.data.logs.map(it => JSON.stringify(it)).join('\n')
const blob = new window.Blob([text], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(blob, `${formatDateFile(new Date())}_server`)
} else if (radioButtons === 1 && range.from && range.to) {
const text = logsResponse.data.logs.filter((log) => moment(log.timestamp).isBetween(range.from, range.to, 'day', '[]')).map(it => JSON.stringify(it)).join('\n')
const blob = new window.Blob([text], {
type: 'text/plain;charset=utf-8'
})
FileSaver.saveAs(blob, `${formatDateFile(range.from)}_${formatDateFile(range.to)}_server`)
}
}}
> >
Download Download
</Link> </Link>
</div> </div>
</div> </div>
</Popover> </Popper>
) )
} }

View file

@ -1,46 +0,0 @@
import React from 'react'
import { makeStyles, Popover as MaterialPopover } from '@material-ui/core'
const arrowHeight = 10
const styles = {
arrow: {
width: 0,
height: 0,
position: 'absolute',
borderStyle: 'solid',
margin: 5,
borderWidth: [[0, 15, arrowHeight, 15]],
borderLeftColor: 'transparent',
borderRightColor: 'transparent',
borderTopColor: 'transparent',
top: arrowHeight * -1,
left: 116,
marginTop: 0,
marginBottom: 0,
borderColor: '#ffffff'
},
paper: {
overflow: 'visible'
}
}
const useStyles = makeStyles(styles)
const Popover = ({ children, ...props }) => {
const classes = useStyles()
return (
<MaterialPopover
classes={{
paper: classes.paper
}}
{...props}
>
{children}
<div className={classes.arrow} />
</MaterialPopover>
)
}
export default Popover

View file

@ -0,0 +1,107 @@
import React, { useState } from 'react'
import classnames from 'classnames'
import { merge } from 'lodash/fp'
import { makeStyles, Popper as MaterialPopper, Paper } from '@material-ui/core'
import { white } from '../styling/variables'
const Popover = ({ children, bgColor = white, arrowSize = 7, ...props }) => {
const [arrowRef, setArrowRef] = useState(null)
const styles = {
popover: {
zIndex: 1000,
backgroundColor: bgColor,
borderRadius: 4
},
arrow: {
position: 'absolute',
fontSize: arrowSize,
width: '3em',
height: '3em'
},
arrowBottom: {
top: 0,
width: 0,
height: 0,
borderLeft: [['2em', 'solid', 'transparent']],
borderRight: [['2em', 'solid', 'transparent']],
borderBottom: [['2em', 'solid', bgColor]],
marginTop: '-1.9em'
},
arrowTop: {
bottom: 0,
width: 0,
height: 0,
borderLeft: [['2em', 'solid', 'transparent']],
borderRight: [['2em', 'solid', 'transparent']],
borderTop: [['2em', 'solid', bgColor]],
marginBottom: '-1.9em'
},
arrowRight: {
left: 0,
width: 0,
height: 0,
borderTop: [['2em', 'solid', 'transparent']],
borderBottom: [['2em', 'solid', 'transparent']],
borderRight: [['2em', 'solid', bgColor]],
marginLeft: '-1.9em'
},
arrowLeft: {
right: 0,
width: 0,
height: 0,
borderTop: [['2em', 'solid', 'transparent']],
borderBottom: [['2em', 'solid', 'transparent']],
borderLeft: [['2em', 'solid', bgColor]],
marginRight: '-1.9em'
},
root: {
backgroundColor: bgColor
}
}
const useStyles = makeStyles(styles)
const classes = useStyles()
const arrowClasses = {
[classes.arrow]: true,
[classes.arrowBottom]: props.placement === 'bottom',
[classes.arrowTop]: props.placement === 'top',
[classes.arrowRight]: props.placement === 'right',
[classes.arrowLeft]: props.placement === 'left'
}
const modifiers = merge(props.modifiers, {
flip: {
enabled: false
},
preventOverflow: {
enabled: true,
boundariesElement: 'scrollParent'
},
arrow: {
enabled: true,
element: arrowRef
}
})
return (
<>
<MaterialPopper
disablePortal={false}
modifiers={modifiers}
className={classes.popover}
{...props}
>
<Paper className={classes.root}>
<span className={classnames(arrowClasses)} ref={setArrowRef} />
{children}
</Paper>
</MaterialPopper>
</>
)
}
export default Popover

View file

@ -15,7 +15,10 @@ const styles = {
}, },
primary, primary,
buttonIcon: { buttonIcon: {
margin: 'auto' margin: 'auto',
'& svg': {
overflow: 'visible'
}
}, },
buttonIconActive: {} // required to extend primary buttonIconActive: {} // required to extend primary
} }

View file

@ -0,0 +1,113 @@
import React, { useState, memo } from 'react'
import classnames from 'classnames'
import { makeStyles } from '@material-ui/core/styles'
import Popover from '../Popper'
import { subheaderColor, subheaderDarkColor, offColor } from '../../styling/variables'
import typographyStyles from '../typography/styles'
const { info2 } = typographyStyles
const colors = (color1, color2, color3) => {
return {
backgroundColor: color1,
'&:hover': {
backgroundColor: color2
},
'&:active': {
backgroundColor: color3
}
}
}
const styles = {
idButton: {
width: 34,
height: 28,
display: 'flex',
borderRadius: 4,
padding: 0,
border: 'none',
cursor: 'pointer'
},
buttonIcon: {
margin: 'auto',
lineHeight: 1,
'& svg': {
overflow: 'visible'
}
},
closed: {
extend: colors(subheaderColor, subheaderDarkColor, offColor)
},
open: {
extend: colors(offColor, offColor, offColor)
},
popoverContent: {
extend: info2,
padding: 8,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 4,
'& img': {
maxHeight: 145
}
}
}
const useStyles = makeStyles(styles)
const IDButton = memo(({ name, className, Icon, InverseIcon, popoverWidth = 152, children, ...props }) => {
const [anchorEl, setAnchorEl] = useState(null)
const classes = useStyles()
const open = Boolean(anchorEl)
const id = open ? `simple-popper-${name}` : undefined
const classNames = {
[classes.idButton]: true,
[classes.primary]: true,
[classes.open]: open,
[classes.closed]: !open
}
const iconClassNames = {
[classes.buttonIcon]: true
}
const handleClick = event => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
return (
<>
<button aria-describedby={id} onClick={handleClick} className={classnames(classNames, className)} {...props}>
{Icon && !open && <div className={classnames(iconClassNames)}><Icon /></div>}
{InverseIcon && open &&
<div className={classnames(iconClassNames)}>
<InverseIcon />
</div>}
</button>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
arrowSize={3}
placement='top'
>
<div className={classes.popoverContent}>
<div>{children}</div>
</div>
</Popover>
</>
)
})
export default IDButton

View file

@ -3,5 +3,6 @@ import Link from './Link'
import SimpleButton from './SimpleButton' import SimpleButton from './SimpleButton'
import ActionButton from './ActionButton' import ActionButton from './ActionButton'
import FeatureButton from './FeatureButton' import FeatureButton from './FeatureButton'
import IDButton from './IDButton'
export { Button, Link, SimpleButton, ActionButton, FeatureButton } export { Button, Link, SimpleButton, ActionButton, FeatureButton, IDButton }

View file

@ -1,99 +1,122 @@
import React, { useState, useEffect } from 'react' import React, { useState } from 'react'
import { map, set } from 'lodash/fp'
import classnames from 'classnames' import classnames from 'classnames'
import uuidv1 from 'uuid/v1' import { AutoSizer, List, CellMeasurer, CellMeasurerCache } from 'react-virtualized'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { Table, THead, Tr, TBody, Td, Th } from '../fake-table/Table' import { THead, Tr, Td, Th } from '../fake-table/Table'
import { ReactComponent as ExpandClosedIcon } from '../../styling/icons/action/expand/closed.svg' import { ReactComponent as ExpandClosedIcon } from '../../styling/icons/action/expand/closed.svg'
import { ReactComponent as ExpandOpenIcon } from '../../styling/icons/action/expand/open.svg' import { ReactComponent as ExpandOpenIcon } from '../../styling/icons/action/expand/open.svg'
import { mainWidth } from '../../styling/variables' import { mainWidth } from '../../styling/variables'
const styles = { const styles = {
hideDetailsRow: {
display: 'none'
},
expandButton: { expandButton: {
border: 'none', border: 'none',
backgroundColor: 'transparent', backgroundColor: 'transparent',
cursor: 'pointer', cursor: 'pointer',
padding: 4 padding: 4
},
row: {
borderRadius: 0
} }
} }
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const ExpRow = ({ id, columns, details, sizes, expanded, className, expandRow, ...props }) => { const ExpRow = ({ id, columns, details, expanded, className, expandRow, ...props }) => {
const classes = useStyles() const classes = useStyles()
const detailsRowClasses = {
[classes.detailsRow]: true,
[classes.hideDetailsRow]: expanded
}
return ( return (
<> <>
<Tr className={classnames(className)} {...props}> <Tr className={classnames(classes.row, className)} {...props}>
{columns.map((col, idx) => ( {columns.slice(0, -1).map((col, idx) => (
<Td key={uuidv1()} size={sizes[idx]} className={col.className} textAlign={col.textAlign}>{col.value}</Td> <Td key={idx} size={col.size} className={col.className} textAlign={col.textAlign}>{col.value}</Td>
))} ))}
<Td size={sizes[sizes.length - 1]}> <Td size={columns[columns.length - 1].size}>
<button onClick={() => expandRow(id)} className={classes.expandButton}> <button onClick={() => expandRow(id)} className={classes.expandButton}>
{expanded && <ExpandOpenIcon />} {expanded && <ExpandOpenIcon />}
{!expanded && <ExpandClosedIcon />} {!expanded && <ExpandClosedIcon />}
</button></Td> </button>
</Tr>
<Tr className={classnames(detailsRowClasses)}>
<Td size={mainWidth}>
{details}
</Td> </Td>
</Tr> </Tr>
{expanded && (
<Tr className={classes.detailsRow}>
<Td size={mainWidth}>
{details}
</Td>
</Tr>
)}
</> </>
) )
} }
/* headers = [{ value, className, textAlign }] /* rows = [{ columns = [{ name, value, className, textAlign, size }], details, className, error, errorMessage }]
* rows = [{ columns = [{ value, className, textAlign }], details, className, error, errorMessage }] * Don't forget to include the size of the last (expand button) column!
*/ */
const ExpTable = ({ headers = [], rows = [], sizes = [], className, ...props }) => { const ExpTable = ({ rows = [], className, ...props }) => {
const [rowStates, setRowStates] = useState(null) const [expanded, setExpanded] = useState(null)
useEffect(() => {
setRowStates(rows && rows.map((x) => { return { id: x.id, expanded: false } }))
}, [rows])
const expandRow = (id) => { const expandRow = (id) => {
setRowStates(map(r => set('expanded', r.id === id ? !r.expanded : false, r))) setExpanded(id === expanded ? null : id)
}
if (!rows) return null
const cache = new CellMeasurerCache({
defaultHeight: 62,
fixedWidth: true
})
function rowRenderer ({ index, isScrolling, key, parent, style }) {
return (
<CellMeasurer
cache={cache}
columnIndex={0}
key={key}
parent={parent}
rowIndex={index}
>
<div style={style}>
<ExpRow
id={index}
columns={rows[index].columns}
details={rows[index].details}
expanded={index === expanded}
className={rows[index].className}
expandRow={expandRow}
error={rows[index].error}
errorMessage={rows[index].errorMessage}
/>
</div>
</CellMeasurer>
)
} }
return ( return (
<Table className={classnames(className)}> <>
<THead> <div>
{headers.map((header, idx) => ( <THead>
<Th key={uuidv1()} size={sizes[idx]} className={header.className} textAlign={header.textAlign}>{header.value}</Th> {rows[0].columns.map((c, idx) => (
))} <Th key={idx} size={c.size} className={c.className} textAlign={c.textAlign}>{c.name}</Th>
</THead> ))}
<TBody> </THead>
{rowStates && rowStates.map((r, idx) => { </div>
const row = rows[idx] <div style={{ flex: '1 1 auto' }}>
<AutoSizer disableWidth>
return ( {({ height }) => (
<ExpRow <List
key={uuidv1()} {...props}
id={r.id} height={height}
columns={row.columns} width={mainWidth}
details={row.details} rowCount={rows.length}
sizes={sizes} rowHeight={cache.rowHeight}
expanded={r.expanded} rowRenderer={rowRenderer}
className={row.className} overscanRowCount={50}
expandRow={expandRow} deferredMeasurementCache={cache}
error={row.error}
errorMessage={row.errorMessage}
/> />
) )}
})} </AutoSizer>
</TBody> </div>
</Table> </>
) )
} }

View file

@ -28,13 +28,15 @@ const Label = withStyles({
} }
})(props => <FormControlLabel {...props} />) })(props => <FormControlLabel {...props} />)
const RadioGroup = ({ name, value, labels, ariaLabel, onChange, className, ...props }) => { /* options = [{ label, value }]
*/
const RadioGroup = ({ name, value, options, ariaLabel, onChange, className, ...props }) => {
return ( return (
<> <>
{labels && ( {options && (
<MaterialRadioGroup aria-label={ariaLabel} name={name} value={value} onChange={onChange} className={classnames(className)}> <MaterialRadioGroup aria-label={ariaLabel} name={name} value={value} onChange={onChange} className={classnames(className)}>
{labels.map((label, idx) => ( {options.map((options, idx) => (
<Label key={idx} value={idx} control={<GreenRadio />} label={label} /> <Label key={idx} value={options.value} control={<GreenRadio />} label={options.label} />
))} ))}
</MaterialRadioGroup> </MaterialRadioGroup>
)} )}

View file

@ -14,7 +14,7 @@ export default {
}, },
select: { select: {
width: WIDTH, width: WIDTH,
zIndex: 1000, zIndex: 2,
'& label': { '& label': {
extend: label1, extend: label1,
color: offColor, color: offColor,

View file

@ -6,14 +6,14 @@ import { spacer } from '../../styling/variables'
const useStyles = makeStyles({ const useStyles = makeStyles({
td: { td: {
padding: `0 ${spacer * 3}px` padding: [[0, spacer * 3]]
}, },
alignRight: { alignRight: {
textAlign: 'right' textAlign: 'right'
} }
}) })
const TableCell = memo(({ rightAlign, className, children, ...props }) => { const TableCell = memo(({ colspan, rightAlign, className, children, ...props }) => {
const classes = useStyles() const classes = useStyles()
const styles = { const styles = {
[classes.td]: true, [classes.td]: true,
@ -21,7 +21,7 @@ const TableCell = memo(({ rightAlign, className, children, ...props }) => {
} }
return ( return (
<td className={classnames(styles, className)} {...props}> <td colSpan={colspan} className={classnames(styles, className)} {...props}>
{children} {children}
</td> </td>
) )

View file

@ -15,13 +15,21 @@ const useStyles = makeStyles({
textAlign: 'left', textAlign: 'left',
color: white, color: white,
padding: `0 ${spacer * 3}px` padding: `0 ${spacer * 3}px`
},
alignRight: {
textAlign: 'right'
} }
}) })
const TableHeaderCell = memo(({ children, className, ...props }) => { const TableHeaderCell = memo(({ rightAlign, children, className, ...props }) => {
const classes = useStyles() const classes = useStyles()
const styles = {
[classes.th]: true,
[classes.alignRight]: rightAlign
}
return ( return (
<th {...props} className={classnames(classes.th, className)}> <th {...props} className={classnames(styles, className)}>
{children} {children}
</th> </th>
) )

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react' import React, { useState } from 'react'
import { concat, uniq, merge } from 'lodash/fp' import { concat, uniq, merge, find } from 'lodash/fp'
import moment from 'moment' import moment from 'moment'
import useAxios from '@use-hooks/axios' import useAxios from '@use-hooks/axios'
import { makeStyles } from '@material-ui/core' import { makeStyles } from '@material-ui/core'
@ -10,7 +10,7 @@ import { FeatureButton, SimpleButton } from '../components/buttons'
import { Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '../components/table' import { Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '../components/table'
import { Select } from '../components/inputs' import { Select } from '../components/inputs'
import Uptime from '../components/Uptime' import Uptime from '../components/Uptime'
import LogsDowloaderPopover from '../components/LogsDownloaderPopover' import LogsDowloaderPopover from '../components/LogsDownloaderPopper'
import { ReactComponent as Download } from '../styling/icons/button/download/zodiac.svg' import { ReactComponent as Download } from '../styling/icons/button/download/zodiac.svg'
import { ReactComponent as DownloadActive } from '../styling/icons/button/download/white.svg' import { ReactComponent as DownloadActive } from '../styling/icons/button/download/white.svg'
import { offColor } from '../styling/variables' import { offColor } from '../styling/variables'
@ -108,11 +108,7 @@ const Logs = () => {
}) })
const handleOpenRangePicker = (event) => { const handleOpenRangePicker = (event) => {
setAnchorEl(event.currentTarget) setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleCloseRangePicker = () => {
setAnchorEl(null)
} }
const open = Boolean(anchorEl) const open = Boolean(anchorEl)
@ -133,11 +129,13 @@ const Logs = () => {
onClick={handleOpenRangePicker} onClick={handleOpenRangePicker}
/> />
<LogsDowloaderPopover <LogsDowloaderPopover
title='Download logs'
name='server-logs'
id={id} id={id}
open={open} open={open}
anchorEl={anchorEl} anchorEl={anchorEl}
logsResponse={logsResponse} logs={logsResponse.data.logs}
onClose={handleCloseRangePicker} getTimestamp={(log) => log.timestamp}
/> />
<SimpleButton className={classes.button} disabled={loading} onClick={sendSnapshot}> <SimpleButton className={classes.button} disabled={loading} onClick={sendSnapshot}>
Share with Lamassu Share with Lamassu

View file

@ -0,0 +1,68 @@
import React, { useState, useEffect } from 'react'
import classnames from 'classnames'
import { CopyToClipboard as ReactCopyToClipboard } from 'react-copy-to-clipboard'
import { replace } from 'lodash/fp'
import { makeStyles } from '@material-ui/core/styles'
import { cpcStyles } from './Transactions.styles'
import Popover from '../../components/Popper'
import { ReactComponent as CopyIcon } from '../../styling/icons/action/copy/copy.svg'
import { comet } from '../../styling/variables'
const CopyToClipboard = ({ className, children, ...props }) => {
const [anchorEl, setAnchorEl] = useState(null)
useEffect(() => {
if (anchorEl) setTimeout(() => setAnchorEl(null), 3000)
}, [anchorEl])
const useStyles = makeStyles(cpcStyles)
const classes = useStyles()
const handleClick = event => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleClose = () => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
const id = open ? 'simple-popper' : undefined
return (
<div className={classes.wrapper}>
{children && (
<>
<div className={classnames(classes.address, className)}>
{children}
</div>
<div className={classes.buttonWrapper}>
<ReactCopyToClipboard
text={replace(/\s/g, '')(children)}
>
<button aria-describedby={id} onClick={(event) => handleClick(event)}><CopyIcon /></button>
</ReactCopyToClipboard>
</div>
<Popover
id={id}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
arrowSize={3}
bgColor={comet}
placement='top'
>
<div className={classes.popoverContent}>
<div>Copied to clipboard!</div>
</div>
</Popover>
</>
)}
</div>
)
}
export default CopyToClipboard

View file

@ -0,0 +1,199 @@
import React, { memo } from 'react'
import classnames from 'classnames'
import moment from 'moment'
import BigNumber from 'bignumber.js'
import { startCase, lowerCase } from 'lodash/fp'
import { makeStyles } from '@material-ui/core/styles'
import { detailsRowStyles, labelStyles } from './Transactions.styles'
import CopyToClipboard from './CopyToClipboard'
import toUnit from './tx'
import { IDButton } from '../../components/buttons'
import { ReactComponent as TxInIcon } from '../../styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from '../../styling/icons/direction/cash-out.svg'
import { ReactComponent as CamIdIcon } from '../../styling/icons/ID/photo/zodiac.svg'
import { ReactComponent as CamIdInverseIcon } from '../../styling/icons/ID/photo/white.svg'
import { ReactComponent as CardIdIcon } from '../../styling/icons/ID/card/zodiac.svg'
import { ReactComponent as CardIdInverseIcon } from '../../styling/icons/ID/card/white.svg'
import { ReactComponent as PhoneIdIcon } from '../../styling/icons/ID/phone/zodiac.svg'
import { ReactComponent as PhoneIdInverseIcon } from '../../styling/icons/ID/phone/white.svg'
const Label = ({ children }) => {
const useStyles = makeStyles(labelStyles)
const classes = useStyles()
return <div className={classes.label}>{children}</div>
}
const DetailsRow = ({ tx, ...props }) => {
const useStyles = makeStyles(detailsRowStyles)
const classes = useStyles()
const addr = tx.toAddress
const txHash = tx.txHash
const fiat = Number.parseFloat(tx.fiat)
const crypto = toUnit(new BigNumber(tx.cryptoAtoms), tx.cryptoCode).toFormat(5)
const commissionPercentage = Number.parseFloat(tx.commissionPercentage, 2)
const commission = tx.txClass === 'cashOut' ? fiat * commissionPercentage : fiat * commissionPercentage + Number.parseFloat(tx.fee)
const customer = tx.customerIdCardData && {
name: startCase(lowerCase(`${tx.customerIdCardData.firstName} ${tx.customerIdCardData.lastName}`)),
age: moment().diff(moment(tx.customerIdCardData.dateOfBirth), 'years'),
country: tx.customerIdCardData.country,
idCardNumber: tx.customerIdCardData.documentNumber,
idCardExpirationDate: moment(tx.customerIdCardData.expirationDate).format('DD-MM-YYYY')
}
const formatAddress = (address = '') => {
return address.replace(/(.{5})/g, '$1 ')
}
return (
<>
<div className={classes.wrapper}>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col1)}> {/* Column 1 */}
<div className={classes.innerRow}>
<div>
<Label>Direction</Label>
<div>
<span className={classes.txIcon}>{tx.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />}</span>
<span>{tx.txClass === 'cashOut' ? 'Cash-out' : 'Cash-in'}</span>
</div>
</div>
<div className={classes.availableIds}>
<Label>Available IDs</Label>
<div>
{tx.customerPhone && (
<IDButton
name='phone'
Icon={PhoneIdIcon}
InverseIcon={PhoneIdInverseIcon}
>
{tx.customerPhone}
</IDButton>
)}
{tx.customerIdCardPhotoPath && !tx.customerIdCardData && (
<IDButton
name='card'
Icon={CardIdIcon}
InverseIcon={CardIdInverseIcon}
>
<img src={tx.customerIdCardPhotoPath} />
</IDButton>
)}
{tx.customerIdCardData && (
<IDButton
name='card'
Icon={CardIdIcon}
InverseIcon={CardIdInverseIcon}
>
<div className={classes.idCardDataCard}>
<div>
<div>
<Label>Name</Label>
<div>{customer.name}</div>
</div>
<div>
<Label>Age</Label>
<div>{customer.age}</div>
</div>
<div>
<Label>Country</Label>
<div>{customer.country}</div>
</div>
</div>
<div>
<div>
<Label>ID number</Label>
<div>{customer.idCardNumber}</div>
</div>
<div>
<Label>Gender</Label>
<div />
</div>
<div>
<Label>Expiration date</Label>
<div>{customer.idCardExpirationDate}</div>
</div>
</div>
</div>
</IDButton>
)}
{tx.customerIdCameraPath && (
<IDButton
name='cam'
Icon={CamIdIcon}
InverseIcon={CamIdInverseIcon}
>
<img src={tx.customerIdCameraPath} />
</IDButton>
)}
</div>
</div>
</div>
</div>
<div className={classnames(classes.col, classes.col2)}> {/* Column 2 */}
<div className={classes.innerRow}>
<div>
<Label>Exchange rate</Label>
<div>{`1 ${tx.cryptoCode} = ${Number(fiat / crypto).toFixed(3)} ${tx.fiatCode}`}</div>
</div>
<div className={classes.commissionWrapper}>
<Label>Commission</Label>
<div>{`${commission} ${tx.fiatCode} (${commissionPercentage * 100} %)`}</div>
</div>
</div>
</div>
<div className={classnames(classes.col, classes.col3)}> {/* Column 3 */}
<div className={classnames(classes.innerRow)}>
<div style={{ height: 43.4 }}>
{/* Export to PDF */}
</div>
</div>
</div>
</div>
<div className={classnames(classes.row)}>
<div className={classnames(classes.col, classes.col1)}> {/* Column 1 */}
<div className={classes.innerRow}>
<div>
<Label>BTC address</Label>
<div>
<CopyToClipboard className={classes.cryptoAddr}>
{formatAddress(addr)}
</CopyToClipboard>
</div>
</div>
</div>
</div>
<div className={classnames(classes.col, classes.col2)}> {/* Column 2 */}
<div className={classes.innerRow}>
<div>
<Label>Transaction ID</Label>
<div>
<CopyToClipboard className={classes.txId}>
{txHash}
</CopyToClipboard>
</div>
</div>
</div>
</div>
<div className={classnames(classes.col, classes.col3)}> {/* Column 3 */}
<div className={classes.innerRow}>
<div>
<Label>Session ID</Label>
<CopyToClipboard className={classes.sessionId}>
{tx.id}
</CopyToClipboard>
</div>
</div>
</div>
</div>
</div>
</>
)
}
export default memo(DetailsRow, (prev, next) => prev.tx.id === next.tx.id)

View file

@ -0,0 +1,162 @@
import React, { useState } from 'react'
import moment from 'moment'
import BigNumber from 'bignumber.js'
import { upperCase } from 'lodash/fp'
import { makeStyles } from '@material-ui/core/styles'
import useAxios from '@use-hooks/axios'
import { mainStyles } from './Transactions.styles'
import DetailsRow from './DetailsCard'
import toUnit from './tx'
import Title from '../../components/Title'
import ExpTable from '../../components/expandable-table/ExpTable'
import LogsDowloaderPopover from '../../components/LogsDownloaderPopper'
import { FeatureButton } from '../../components/buttons'
import { ReactComponent as TxInIcon } from '../../styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from '../../styling/icons/direction/cash-out.svg'
import { ReactComponent as Download } from '../../styling/icons/button/download/zodiac.svg'
import { ReactComponent as DownloadInverseIcon } from '../../styling/icons/button/download/white.svg'
const Transactions = () => {
const [anchorEl, setAnchorEl] = useState(null)
const useStyles = makeStyles(mainStyles)
const classes = useStyles()
const { response: txResponse } = useAxios({
url: 'http://localhost:8070/api/txs/',
method: 'GET',
trigger: []
})
const formatCustomerName = (customer) => {
const { firstName, lastName } = customer
return `${upperCase(firstName.slice(0, 1))}. ${lastName}`
}
const getCustomerDisplayName = (tx) => {
if (tx.customerName) return tx.customerName
if (tx.customerIdCardData) return formatCustomerName(tx.customerIdCardData)
return tx.customerPhone
}
const rows = txResponse && txResponse.data.map(tx => {
const customerName = getCustomerDisplayName(tx)
return {
id: tx.id,
columns: [
{
name: '',
value: tx.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />,
size: 62
},
{
name: 'Machine',
value: tx.machineName,
className: classes.overflowTd,
size: 180
},
{
name: 'Customer',
value: customerName,
className: classes.overflowTd,
size: 162
},
{
name: 'Cash',
value: `${Number.parseFloat(tx.fiat)} ${tx.fiatCode}`,
textAlign: 'right',
size: 110
},
{
name: 'Crypto',
value: `${toUnit(new BigNumber(tx.cryptoAtoms), tx.cryptoCode).toFormat(5)} ${tx.cryptoCode}`,
textAlign: 'right',
size: 141
},
{
name: 'Address',
value: tx.toAddress,
className: classes.overflowTd,
size: 136
},
{
name: 'Date (UTC)',
value: moment.utc(tx.created).format('YYYY-MM-D'),
textAlign: 'right',
size: 124
},
{
name: 'Time (UTC)',
value: moment.utc(tx.created).format('HH:mm:ss'),
textAlign: 'right',
size: 124
},
{
name: '', // Trade
value: '',
size: 90
},
{
size: 71
}
],
details: (
<DetailsRow tx={tx} />
)
}
})
const handleOpenRangePicker = (event) => {
setAnchorEl(anchorEl ? null : event.currentTarget)
}
const handleCloseRangePicker = () => {
setAnchorEl(null)
}
const open = Boolean(anchorEl)
const id = open ? 'date-range-popover' : undefined
return (
<>
<div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}>
<Title>Transactions</Title>
{txResponse && (
<div className={classes.buttonsWrapper}>
<FeatureButton
Icon={Download}
InverseIcon={DownloadInverseIcon}
aria-describedby={id}
variant='contained'
onClick={handleOpenRangePicker}
/>
<LogsDowloaderPopover
title='Download logs'
name='transactions'
id={id}
open={open}
anchorEl={anchorEl}
logs={txResponse.data}
getTimestamp={(tx) => tx.created}
onClose={handleCloseRangePicker}
/>
</div>
)}
</div>
<div className={classes.headerLabels}>
<div><TxOutIcon /><span>Cash-out</span></div>
<div><TxInIcon /><span>Cash-in</span></div>
</div>
</div>
<ExpTable rows={rows} />
</>
)
}
export default Transactions

View file

@ -0,0 +1,160 @@
import baseStyles from '../Logs.styles'
import { offColor, white } from '../../styling/variables'
import typographyStyles from '../../components/typography/styles'
const { label1, mono, p } = typographyStyles
const { titleWrapper, titleAndButtonsContainer, buttonsWrapper } = baseStyles
const cpcStyles = {
wrapper: {
extend: mono,
display: 'flex',
alignItems: 'center',
height: 32
},
address: {
lineBreak: 'anywhere'
},
buttonWrapper: {
'& button': {
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer'
}
},
popoverContent: {
extend: label1,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: white,
borderRadius: 4,
padding: [[5, 9]]
}
}
const detailsRowStyles = {
wrapper: {
display: 'flex',
flexDirection: 'column'
},
col: {
display: 'flex',
flexDirection: 'column'
},
col1: {
width: 413
},
col2: {
width: 506
},
col3: {
width: 233
},
innerRow: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between'
},
row: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
margin: [[25, 0]]
},
mono: {
extend: mono
},
txIcon: {
marginRight: 10
},
availableIds: {
width: 110,
marginRight: 61,
'& > div': {
display: 'flex',
flexDirection: 'row',
'& button': {
'&:first-child': {
marginRight: 4
},
'&:last-child': {
marginLeft: 4
},
'&:only-child': {
margin: 0
},
'&:nth-child(2):last-child': {
margin: 0
}
}
}
},
commissionWrapper: {
width: 110,
marginRight: 155
},
idCardDataCard: {
extend: p,
display: 'flex',
padding: [[11, 8]],
'& > div': {
display: 'flex',
flexDirection: 'column',
'& > div': {
width: 144,
height: 37,
marginBottom: 15,
'&:last-child': {
marginBottom: 0
}
}
}
},
cryptoAddr: {
width: 252
},
txId: {
width: 346
},
sessionId: {
width: 184
}
}
const labelStyles = {
label: {
extend: label1,
color: offColor,
marginBottom: 4
}
}
const mainStyles = {
titleWrapper,
titleAndButtonsContainer,
buttonsWrapper,
headerLabels: {
display: 'flex',
flexDirection: 'row',
'& div': {
display: 'flex',
alignItems: 'center'
},
'& > div:first-child': {
marginRight: 24
},
'& span': {
extend: label1,
marginLeft: 6
}
},
overflowTd: {
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis'
}
}
export { cpcStyles, detailsRowStyles, labelStyles, mainStyles }

View file

@ -0,0 +1,54 @@
import { find } from 'lodash/fp'
const CRYPTO_CURRENCIES = [
{
cryptoCode: 'BTC',
display: 'Bitcoin',
code: 'bitcoin',
unitScale: 8
},
{
cryptoCode: 'ETH',
display: 'Ethereum',
code: 'ethereum',
unitScale: 18
},
{
cryptoCode: 'LTC',
display: 'Litecoin',
code: 'litecoin',
unitScale: 8
},
{
cryptoCode: 'DASH',
display: 'Dash',
code: 'dash',
unitScale: 8
},
{
cryptoCode: 'ZEC',
display: 'Zcash',
code: 'zcash',
unitScale: 8
},
{
cryptoCode: 'BCH',
display: 'Bitcoin Cash',
code: 'bitcoincash',
unitScale: 8
}
]
function getCryptoCurrency (cryptoCode) {
const cryptoCurrency = find(['cryptoCode', cryptoCode], CRYPTO_CURRENCIES)
if (!cryptoCurrency) throw new Error(`Unsupported crypto: ${cryptoCode}`)
return cryptoCurrency
}
function toUnit (cryptoAtoms, cryptoCode) {
const cryptoRec = getCryptoCurrency(cryptoCode)
const unitScale = cryptoRec.unitScale
return cryptoAtoms.shiftedBy(-unitScale)
}
export default toUnit

View file

@ -7,6 +7,7 @@ import Logs from '../pages/Logs'
import Locales from '../pages/Locales' import Locales from '../pages/Locales'
import Funding from '../pages/Funding' import Funding from '../pages/Funding'
import ServerLogs from '../pages/ServerLogs' import ServerLogs from '../pages/ServerLogs'
import Transactions from '../pages/Transactions/Transactions'
const tree = [ const tree = [
{ key: 'transactions', label: 'Transactions', route: '/transactions' }, { key: 'transactions', label: 'Transactions', route: '/transactions' },
@ -61,6 +62,7 @@ const Routes = () => (
<Route path='/maintenance/logs' component={Logs} /> <Route path='/maintenance/logs' component={Logs} />
<Route path='/maintenance/funding' component={Funding} /> <Route path='/maintenance/funding' component={Funding} />
<Route path='/maintenance/server-logs' component={ServerLogs} /> <Route path='/maintenance/server-logs' component={ServerLogs} />
<Route path='/transactions' component={Transactions} />
</Switch> </Switch>
) )

View file

@ -9,4 +9,4 @@
<line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#5F668A" stroke-width="1.6"></line> <line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#5F668A" stroke-width="1.6"></line>
<polygon id="Stroke-5" stroke="#5F668A" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon> <polygon id="Stroke-5" stroke="#5F668A" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 975 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#FF584A" stroke-width="1.6"></line> <line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#FF584A" stroke-width="1.6"></line>
<polygon id="Stroke-5" stroke="#FF584A" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon> <polygon id="Stroke-5" stroke="#FF584A" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 976 B

After

Width:  |  Height:  |  Size: 977 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#FFFFFF" stroke-width="1.6"></line> <line x1="11.7857143" y1="7.2" x2="18.8571429" y2="7.2" id="Stroke-4" stroke="#FFFFFF" stroke-width="1.6"></line>
<polygon id="Stroke-5" stroke="#FFFFFF" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon> <polygon id="Stroke-5" stroke="#FFFFFF" stroke-width="1.6" points="3.14285714 11.2 8.64285714 11.2 8.64285714 4 3.14285714 4"></polygon>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 975 B

Before After
Before After

View file

@ -13,4 +13,4 @@
</g> </g>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 984 B

After

Width:  |  Height:  |  Size: 985 B

Before After
Before After

View file

@ -6,4 +6,4 @@
<g id="icon/ID/phone/comet" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g id="icon/ID/phone/comet" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#5F668A" stroke-width="1.6"></path> <path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#5F668A" stroke-width="1.6"></path>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -6,4 +6,4 @@
<g id="icon/ID/phone/tomato" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g id="icon/ID/phone/tomato" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#FF584A" stroke-width="1.6"></path> <path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#FF584A" stroke-width="1.6"></path>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -6,4 +6,4 @@
<g id="icon/ID/phone/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g id="icon/ID/phone/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#FFFFFF" stroke-width="1.6"></path> <path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#FFFFFF" stroke-width="1.6"></path>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -6,4 +6,4 @@
<g id="icon/ID/phone/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round"> <g id="icon/ID/phone/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#1B2559" stroke-width="1.6"></path> <path d="M6.47150618,12.52898 C9.939556,15.9970298 13.7804112,16.1146315 15.4756355,15.9586292 C16.0220434,15.9090285 16.5308507,15.6578249 16.9188563,15.2698193 L19.0004862,13.1881894 L17.0220577,11.210561 L15.0436293,10.5505516 L13.7244104,11.8697705 C13.7244104,11.8697705 12.4059914,13.1881894 9.10914407,9.89054208 C5.81229671,6.59449473 7.13071565,5.27527578 7.13071565,5.27527578 L8.4499346,3.95605683 L7.78992512,1.97842842 L5.81229671,0 L3.73066681,2.0816299 C3.34186123,2.46963548 3.09145763,2.97844279 3.04105691,3.52485063 C2.88585468,5.22007499 3.00345637,9.06013015 6.47150618,12.52898 Z" id="Stroke-1-Copy" stroke="#1B2559" stroke-width="1.6"></path>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="4px" viewBox="0 0 16 4" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="18px" height="6px" viewBox="0 0 18 6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com --> <!-- Generator: Sketch 56.3 (81716) - https://sketch.com -->
<title>icon/actions/expand/closed</title> <title>icon/action/expand/closed</title>
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<g id="icon/actions/expand/closed" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Styleguide" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="show/closed-copy-3" stroke="#1B2559" stroke-width="1.5"> <g id="icon/action/expand/closed" transform="translate(1.000000, 1.000000)" stroke="#1B2559" stroke-width="1.5">
<circle id="Oval-4" cx="14" cy="2" r="2"></circle> <circle id="Oval-4" cx="14" cy="2" r="2"></circle>
<circle id="Oval-4-Copy" cx="8" cy="2" r="2"></circle> <circle id="Oval-4-Copy" cx="8" cy="2" r="2"></circle>
<circle id="Oval-4-Copy-2" cx="2" cy="2" r="2"></circle> <circle id="Oval-4-Copy-2" cx="2" cy="2" r="2"></circle>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 733 B

After

Width:  |  Height:  |  Size: 766 B

Before After
Before After

View file

@ -1,13 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<svg width="16px" height="4px" viewBox="0 0 16 4" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> <svg width="18px" height="6px" viewBox="0 0 18 6" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com --> <!-- Generator: Sketch 56.3 (81716) - https://sketch.com -->
<title>icon/actions/expand/open</title> <title>icon/action/expand/open</title>
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<g id="icon/actions/expand/open" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Styleguide" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="show/closed-copy-3" fill="#1B2559" stroke="#1B2559" stroke-width="1.5"> <g id="icon/action/expand/open" transform="translate(1.000000, 1.000000)" fill="#1B2559" stroke="#1B2559" stroke-width="1.5">
<circle id="Oval-4" cx="14" cy="2" r="2"></circle> <circle id="Oval-4" cx="14" cy="2" r="2"></circle>
<circle id="Oval-4-Copy" cx="8" cy="2" r="2"></circle> <circle id="Oval-4-Copy" cx="8" cy="2" r="2"></circle>
<circle id="Oval-4-Copy-2" cx="2" cy="2" r="2"></circle> <circle id="Oval-4-Copy-2" cx="2" cy="2" r="2"></circle>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 744 B

After

Width:  |  Height:  |  Size: 777 B

Before After
Before After

View file

@ -7,4 +7,4 @@
<circle id="Oval" stroke="#FFFFFF" cx="6" cy="6" r="6"></circle> <circle id="Oval" stroke="#FFFFFF" cx="6" cy="6" r="6"></circle>
<polyline id="Stroke-13" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" points="4 6.66666667 5 8 8 4"></polyline> <polyline id="Stroke-13" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" points="4 6.66666667 5 8 8 4"></polyline>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 665 B

After

Width:  |  Height:  |  Size: 666 B

Before After
Before After

View file

@ -9,4 +9,4 @@
</g> </g>
<polyline id="Stroke-13" stroke="#1B2559" stroke-linecap="round" stroke-linejoin="round" points="4 6.66666667 5 8 8 4"></polyline> <polyline id="Stroke-13" stroke="#1B2559" stroke-linecap="round" stroke-linejoin="round" points="4 6.66666667 5 8 8 4"></polyline>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 709 B

After

Width:  |  Height:  |  Size: 710 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<line x1="0" y1="0" x2="10" y2="10" id="Stroke-3"></line> <line x1="0" y1="0" x2="10" y2="10" id="Stroke-3"></line>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 732 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<line x1="0" y1="0" x2="12" y2="12" id="Stroke-3"></line> <line x1="0" y1="0" x2="12" y2="12" id="Stroke-3"></line>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 710 B

After

Width:  |  Height:  |  Size: 711 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<polyline id="Stroke-3" points="4.2002 9.601 3.0002 10.8 4.2002 12"></polyline> <polyline id="Stroke-3" points="4.2002 9.601 3.0002 10.8 4.2002 12"></polyline>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 905 B

After

Width:  |  Height:  |  Size: 906 B

Before After
Before After

View file

@ -9,4 +9,4 @@
<polyline id="Stroke-3" points="4.2002 9.601 3.0002 10.8 4.2002 12"></polyline> <polyline id="Stroke-3" points="4.2002 9.601 3.0002 10.8 4.2002 12"></polyline>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 907 B

After

Width:  |  Height:  |  Size: 908 B

Before After
Before After