Merge branch 'dev' into fix/machine_upairing

This commit is contained in:
Nikola Ubavić 2021-12-24 23:49:55 +01:00 committed by GitHub
commit 29dc519a52
126 changed files with 2007 additions and 1281 deletions

View file

@ -45,6 +45,7 @@ keypool=10000
prune=4000
daemon=0
addresstype=p2sh-segwit
changetype=bech32
walletrbf=1
bind=0.0.0.0:8332
rpcport=8333`

View file

@ -29,8 +29,8 @@ const BINARIES = {
dir: 'bitcoin-22.0/bin'
},
ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.12-6c4dc6c3.tar.gz',
dir: 'geth-linux-amd64-1.10.12-6c4dc6c3'
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.13-7a0c19f8.tar.gz',
dir: 'geth-linux-amd64-1.10.13-7a0c19f8'
},
ZEC: {
url: 'https://z.cash/downloads/zcash-4.5.1-1-linux64-debian-stretch.tar.gz',
@ -45,8 +45,8 @@ const BINARIES = {
dir: 'litecoin-0.18.1/bin'
},
BCH: {
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v23.1.0/bitcoin-cash-node-23.1.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-cash-node-23.1.0/bin',
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v24.0.0/bitcoin-cash-node-24.0.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-cash-node-24.0.0/bin',
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
},
XMR: {

View file

@ -44,5 +44,6 @@ connections=40
keypool=10000
prune=4000
daemon=0
addresstype=p2sh-segwit`
addresstype=p2sh-segwit
changetype=bech32`
}

View file

@ -4,9 +4,9 @@ const _ = require('lodash/fp')
const uuid = require('uuid')
function createCashboxBatch (deviceId, cashboxCount) {
if (_.isEqual(0, cashboxCount)) throw new Error('Cashbox is empty. Cashbox batch could not be created.')
const sql = `INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-in-empty') RETURNING *`
const sql2 = `
if (_.isEqual(0, cashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.')
const sql = `INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty') RETURNING *`
const sql2 = `
UPDATE bills SET cashbox_batch_id=$1
FROM cash_in_txs
WHERE bills.cash_in_txs_id = cash_in_txs.id AND
@ -24,12 +24,12 @@ function updateMachineWithBatch (machineContext, oldCashboxCount) {
const isCassetteAmountWithinRange = _.inRange(constants.CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES, constants.CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES + 1, _.size(machineContext.cassettes))
if (!isValidContext && !isCassetteAmountWithinRange)
throw new Error('Insufficient info to create a new cashbox batch')
if (_.isEqual(0, oldCashboxCount)) throw new Error('Cashbox is empty. Cashbox batch could not be created.')
if (_.isEqual(0, oldCashboxCount)) throw new Error('Cash box is empty. Cash box batch could not be created.')
return db.tx(t => {
const deviceId = machineContext.deviceId
const batchId = uuid.v4()
const q1 = t.none(`INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-in-empty')`, [batchId, deviceId])
const q1 = t.none(`INSERT INTO cashbox_batches (id, device_id, created, operation_type) VALUES ($1, $2, now(), 'cash-box-empty')`, [batchId, deviceId])
const q2 = t.none(`UPDATE bills SET cashbox_batch_id=$1 FROM cash_in_txs
WHERE bills.cash_in_txs_id = cash_in_txs.id AND
cash_in_txs.device_id = $2 AND
@ -68,4 +68,10 @@ function getBillsByBatchId (id) {
return db.any(sql, [id])
}
module.exports = { createCashboxBatch, updateMachineWithBatch, getBatches, getBillsByBatchId, editBatchById }
module.exports = {
createCashboxBatch,
updateMachineWithBatch,
getBatches,
getBillsByBatchId,
editBatchById
}

View file

@ -683,18 +683,18 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
*/
function getCustomerById (id) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
c.suspended_until > now() AS is_suspended,
c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
c.front_camera_path, c.front_camera_override, c.front_camera_at,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes,
row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs,

View file

@ -66,7 +66,7 @@ function addName (pings, events, config) {
const statuses = [
getStatus(
_.first(pings[machine.deviceId]),
_.first(checkStuckScreen(events, machine.name))
_.first(checkStuckScreen(events, machine))
)
]

View file

@ -2,7 +2,7 @@ const bills = require('../../services/bills')
const resolvers = {
Query: {
bills: () => bills.getBills()
bills: (...[, { filters }]) => bills.getBills(filters)
}
}

View file

@ -2,14 +2,15 @@ const { gql } = require('apollo-server-express')
const typeDef = gql`
type Bill {
id: ID
fiat: Int
deviceId: ID
created: Date
cashbox: Int
cashboxBatchId: ID
}
type Query {
bills: [Bill] @auth
bills(filters: JSONObject): [Bill] @auth
}
`

View file

@ -1,23 +1,27 @@
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const db = require('../../db')
// Get all bills with device id
const getBills = () => {
return Promise.reject(new Error('This functionality hasn\'t been implemented yet'))
/* return db.any(`
SELECT d.device_id, b.fiat, b.created, d.cashbox
FROM cash_in_txs
INNER JOIN bills AS b ON b.cash_in_txs_id = cash_in_txs.id
INNER JOIN devices as d ON d.device_id = cash_in_txs.device_id
ORDER BY device_id, created DESC`
)
.then(res => {
return res.map(item => ({
fiat: item.fiat,
deviceId: item.device_id,
cashbox: item.cashbox,
created: item.created
}))
}) */
const getBills = filters => {
const deviceStatement = !_.isNil(filters.deviceId) ? `WHERE device_id = ${pgp.as.text(filters.deviceId)}` : ``
const batchStatement = filter => {
switch (filter) {
case 'none':
return `WHERE b.cashbox_batch_id IS NULL`
case 'any':
return `WHERE b.cashbox_batch_id IS NOT NULL`
default:
return _.isNil(filter) ? `` : `WHERE b.cashbox_batch_id = ${pgp.as.text(filter)}`
}
}
const sql = `SELECT b.id, b.fiat, b.fiat_code, b.created, b.cashbox_batch_id, cit.device_id AS device_id FROM bills b LEFT OUTER JOIN (
SELECT id, device_id FROM cash_in_txs ${deviceStatement}
) AS cit ON cit.id = b.cash_in_txs_id ${batchStatement(filters.batch)}`
return db.any(sql)
.then(res => _.map(_.mapKeys(_.camelCase), res))
}
module.exports = {

View file

@ -56,7 +56,7 @@ function batch (
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
INNER JOIN devices d ON txs.device_id = d.device_id
LEFT JOIN devices d ON txs.device_id = d.device_id
WHERE txs.created >= $2 AND txs.created <= $3 ${
id !== null ? `AND txs.device_id = $6` : ``
}
@ -87,7 +87,7 @@ function batch (
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
INNER JOIN devices d ON txs.device_id = d.device_id
LEFT JOIN devices d ON txs.device_id = d.device_id
WHERE txs.created >= $2 AND txs.created <= $3 ${
id !== null ? `AND txs.device_id = $6` : ``
}
@ -130,7 +130,10 @@ function simplifiedBatch (data) {
const getCryptoAmount = it => coinUtils.toUnit(BN(it.cryptoAtoms), it.cryptoCode).toString()
const getProfit = it => {
const getCommissionFee = it => BN(it.commissionPercentage).times(BN(it.fiat))
const discountValue = _.isNil(it.discount) ? BN(100) : BN(100).minus(it.discount)
const discountPercentage = BN(discountValue).div(100)
const commissionPercentage = BN(it.commissionPercentage).times(discountPercentage)
const getCommissionFee = it => BN(commissionPercentage).times(BN(it.fiat))
if (!it.cashInFee) return getCommissionFee(it)
return getCommissionFee(it).plus(BN(it.cashInFee))
}

View file

@ -85,12 +85,8 @@ function buildAlerts (pings, balances, events, devices) {
alerts.general = _.filter(r => !r.deviceId, balances)
_.forEach(device => {
const deviceId = device.deviceId
const deviceName = device.name
const deviceEvents = events.filter(function (eventRow) {
return eventRow.device_id === deviceId
})
const ping = pings[deviceId] || []
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
const stuckScreen = checkStuckScreen(events, device)
alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter(
['deviceId', deviceId],
@ -98,7 +94,7 @@ function buildAlerts (pings, balances, events, devices) {
), alerts.devices)
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
alerts.deviceNames[deviceId] = deviceName
alerts.deviceNames[deviceId] = device.name
}, devices)
return alerts
@ -110,12 +106,13 @@ function checkPings (devices) {
return _.zipObject(deviceIds)(pings)
}
function checkStuckScreen (deviceEvents, machineName) {
const sortedEvents = _.sortBy(
utils.getDeviceTime,
_.map(utils.parseEventNote, deviceEvents)
)
const lastEvent = _.last(sortedEvents)
function checkStuckScreen (deviceEvents, machine) {
const lastEvent = _.pipe(
_.filter(e => e.device_id === machine.deviceId),
_.sortBy(utils.getDeviceTime),
_.map(utils.parseEventNote),
_.last
)(deviceEvents)
if (!lastEvent) return []
@ -125,6 +122,7 @@ function checkStuckScreen (deviceEvents, machineName) {
if (isIdle) return []
const age = Math.floor(lastEvent.age)
const machineName = machine.name
if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }]
return []

View file

@ -22,7 +22,7 @@ function unpair (deviceId) {
return db.tx(t =>
t.none(`INSERT INTO unpaired_devices(id, device_id, name, model, paired, unpaired)
SELECT $1, $2, d.name, d.model, d.created, now()
FROM devices d
FROM devices d
WHERE device_id=$2`
, [uuid.v4(), deviceId])
.then(() => {

View file

@ -18,27 +18,10 @@ module.exports.up = function (next) {
}
loadLatest(OLD_SETTINGS_LOADER_SCHEMA_VERSION)
.then(async settings => {
if (_.isEmpty(settings.config)) {
return {
settings,
machines: []
}
}
return {
settings,
machines: await machineLoader.getMachineNames(settings.config)
}
})
.then(({ settings, machines }) => {
if (_.isEmpty(settings.config)) {
return next()
}
const sql = machines
? machines.map(m => `update devices set name = '${m.name}' where device_id = '${m.deviceId}'`)
: []
return db.multi(sql, () => migrateConfig(settings))
})
.then(settings => _.isEmpty(settings.config)
? next()
: migrateConfig(settings)
)
.catch(err => {
if (err.message === 'lamassu-server is not configured') {
return next()

View file

@ -3,15 +3,15 @@ var db = require('./db')
exports.up = function (next) {
var sqls = [
`CREATE TYPE cashbox_batch_type AS ENUM(
'cash-in-empty',
'cash-out-1-refill',
'cash-out-1-empty',
'cash-out-2-refill',
'cash-out-2-empty',
'cash-out-3-refill',
'cash-out-3-empty',
'cash-out-4-refill',
'cash-out-4-empty'
'cash-box-empty',
'cash-cassette-1-refill',
'cash-cassette-1-empty',
'cash-cassette-2-refill',
'cash-cassette-2-empty',
'cash-cassette-3-refill',
'cash-cassette-3-empty',
'cash-cassette-4-refill',
'cash-cassette-4-empty'
)`,
`ALTER TABLE cashbox_batches ADD COLUMN operation_type cashbox_batch_type NOT NULL`,
`ALTER TABLE cashbox_batches ADD COLUMN bill_count_override SMALLINT`,

View file

@ -6889,11 +6889,14 @@
}
},
"apollo-upload-client": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-16.0.0.tgz",
"integrity": "sha512-aLhYucyA0T8aBEQ5g+p13qnR9RUyL8xqb8FSZ7e/Kw2KUOsotLUlFluLobqaE7JSUFwc6sKfXIcwB7y4yEjbZg==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-13.0.0.tgz",
"integrity": "sha512-lJ9/bk1BH1lD15WhWRha2J3+LrXrPIX5LP5EwiOUHv8PCORp4EUrcujrA3rI5hZeZygrTX8bshcuMdpqpSrvtA==",
"requires": {
"extract-files": "^11.0.0"
"@babel/runtime": "^7.9.2",
"apollo-link": "^1.2.12",
"apollo-link-http-common": "^0.2.14",
"extract-files": "^8.0.0"
}
},
"apollo-utilities": {
@ -12617,9 +12620,9 @@
}
},
"extract-files": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-11.0.0.tgz",
"integrity": "sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ=="
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-8.1.0.tgz",
"integrity": "sha512-PTGtfthZK79WUMk+avLmwx3NGdU8+iVFXC2NMGxKsn0MnihOG2lvumj+AZo8CTwTrwjXDgZ5tztbRlEdRjBonQ=="
},
"extsprintf": {
"version": "1.3.0",
@ -27096,6 +27099,11 @@
"resolved": "https://registry.npmjs.org/typeforce/-/typeforce-1.18.0.tgz",
"integrity": "sha512-7uc1O8h1M1g0rArakJdf0uLRSSgFcYexrVoKo+bzJd32gd4gDy2L/Z+8/FjPnU9ydY3pEnVPtr9FyscYY60K1g=="
},
"ua-parser-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.2.tgz",
"integrity": "sha512-00y/AXhx0/SsnI51fTc0rLRmafiGOM4/O+ny10Ps7f+j/b8p/ZY11ytMgznXkOVo4GQ+KwQG5UQLkLGirsACRg=="
},
"unfetch": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-4.2.0.tgz",

View file

@ -14,7 +14,7 @@
"apollo-link": "^1.2.14",
"apollo-link-error": "^1.1.13",
"apollo-link-http": "^1.5.17",
"apollo-upload-client": "^16.0.0",
"apollo-upload-client": "^13.0.0",
"axios": "0.21.1",
"base-64": "^1.0.0",
"bignumber.js": "9.0.0",
@ -47,6 +47,7 @@
"react-use": "15.3.2",
"react-virtualized": "^9.21.2",
"sanctuary": "^2.0.1",
"ua-parser-js": "^1.0.2",
"uuid": "^7.0.2",
"yup": "0.32.9"
},

View file

@ -153,7 +153,6 @@ const NotificationCenter = ({
{!loading && buildNotifications()}
</div>
</div>
<div className={classes.background} />
</>
)
}

View file

@ -8,22 +8,16 @@ import {
} from 'src/styling/variables'
const styles = {
background: {
position: 'absolute',
width: '100vw',
height: '100vh',
left: 0,
top: 0,
zIndex: -1,
container: {
'@media only screen and (max-width: 1920px)': {
width: '30vw'
},
width: '40vw',
height: '110vh',
right: 0,
backgroundColor: white,
boxShadow: '0 0 14px 0 rgba(0, 0, 0, 0.24)'
},
container: {
left: -200,
top: -42,
backgroundColor: white,
height: '110vh'
},
header: {
display: 'flex',
justifyContent: 'space-between'
@ -39,7 +33,7 @@ const styles = {
},
notificationIcon: ({ buttonCoords, xOffset }) => ({
position: 'absolute',
top: buttonCoords ? buttonCoords.y - 1 : 0,
top: buttonCoords ? buttonCoords.y : 0,
left: buttonCoords ? buttonCoords.x - xOffset : 0,
cursor: 'pointer',
background: 'transparent',
@ -54,21 +48,33 @@ const styles = {
backgroundColor: zircon
},
notificationsList: {
width: 440,
height: '90vh',
maxHeight: '100vh',
marginTop: spacer * 3,
marginLeft: 0,
marginRight: -50,
overflowY: 'auto',
overflowX: 'hidden',
backgroundColor: white,
zIndex: 10
},
notificationRow: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'flex-start',
position: 'relative',
marginBottom: spacer / 2,
paddingTop: spacer * 1.5
paddingTop: spacer * 1.5,
'& > *': {
marginRight: 10
},
'& > *:last-child': {
marginRight: 0
}
},
notificationContent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
},
unread: {
backgroundColor: spring3
@ -79,6 +85,9 @@ const styles = {
marginLeft: spacer * 3
}
},
readIconWrapper: {
flexGrow: 1
},
unreadIcon: {
marginLeft: spacer,
marginTop: 5,

View file

@ -1,4 +1,3 @@
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import prettyMs from 'pretty-ms'
@ -8,7 +7,6 @@ import React from 'react'
import { Label1, Label2, TL2 } from 'src/components/typography'
import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg'
import { ReactComponent as Transaction } from 'src/styling/icons/arrow/transaction.svg'
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg'
import styles from './NotificationCenter.styles'
@ -54,36 +52,26 @@ const NotificationRow = ({
[classes.unreadIcon]: !read
}
return (
<Grid
container
<div
className={classnames(
classes.notificationRow,
!read && valid ? classes.unread : ''
)}>
<Grid item xs={2} className={classes.notificationRowIcon}>
{icon}
</Grid>
<Grid item container xs={7} direction="row">
<Grid item xs={12}>
<Label2 className={classes.notificationTitle}>
{notificationTitle}
</Label2>
</Grid>
<Grid item xs={12}>
<TL2 className={classes.notificationBody}>{message}</TL2>
</Grid>
<Grid item xs={12}>
<Label1 className={classes.notificationSubtitle}>{age}</Label1>
</Grid>
</Grid>
<Grid item xs={3} style={{ zIndex: 1 }}>
<div className={classes.notificationRowIcon}>{icon}</div>
<div className={classes.notificationContent}>
<Label2 className={classes.notificationTitle}>
{notificationTitle}
</Label2>
<TL2 className={classes.notificationBody}>{message}</TL2>
<Label1 className={classes.notificationSubtitle}>{age}</Label1>
</div>
<div className={classes.readIconWrapper}>
<div
onClick={() => toggleClear(id)}
className={classnames(iconClass)}
/>
</Grid>
{!valid && <StripesSvg className={classes.stripes} />}
</Grid>
</div>
</div>
)
}

View file

@ -79,6 +79,7 @@ const Tr = ({
onClick,
error,
errorMessage,
shouldShowError,
children,
className,
size,
@ -99,7 +100,9 @@ const Tr = ({
<Card className={classnames(classNames, className)} onClick={onClick}>
<CardContent classes={cardClasses}>
<div className={classes.mainContent}>{children}</div>
{error && <div className={classes.errorContent}>{errorMessage}</div>}
{error && shouldShowError && (
<div className={classes.errorContent}>{errorMessage}</div>
)}
</CardContent>
</Card>
</>

View file

@ -9,10 +9,12 @@ const styles = {
borderRadius: '4px'
},
focus: {
color: primaryColor,
border: '2px solid',
borderColor: primaryColor,
borderRadius: '4px'
borderRadius: '4px',
'&:focus': {
outline: 'none'
}
},
error: {
borderColor: errorColor

View file

@ -4,6 +4,7 @@ import React from 'react'
import Chip from 'src/components/Chip'
import { Info2, Label1, Label2 } from 'src/components/typography'
import { numberToFiatAmount } from 'src/utils/number'
import { cashboxStyles, gridStyles } from './Cashbox.styles'
@ -64,11 +65,9 @@ const CashIn = ({ currency, notes, total }) => {
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
</div>
<div className={classes.innerRow}>
{/* Feature on hold until this can be calculated
<Label1 className={classes.noMarginText}>
{total} {currency.code}
</Label1>
*/}
</div>
</div>
</div>
@ -112,7 +111,7 @@ const CashOut = ({
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{notes * denomination} {currency.code}
{numberToFiatAmount(notes * denomination)} {currency.code}
</Label1>
</div>
</div>

View file

@ -103,7 +103,7 @@ const Header = memo(({ tree, user }) => {
const handleClick = event => {
const coords = notifCenterButtonRef.current.getBoundingClientRect()
setNotifButtonCoords({ x: coords.x, y: coords.y })
setNotifButtonCoords({ x: coords.x, y: coords.y + 5 })
setAnchorEl(anchorEl ? null : event.currentTarget)
document.querySelector('#root').classList.add('root-notifcenter-open')
@ -132,7 +132,7 @@ const Header = memo(({ tree, user }) => {
return (
<NavLink
key={idx}
to={it.route || it.children[0].route}
to={!R.isNil(it.children) ? it.children[0].route : it.route}
isActive={match => {
if (!match) return false
setActive(it)
@ -173,10 +173,16 @@ const Header = memo(({ tree, user }) => {
anchorEl={anchorEl}
className={classes.popper}
disablePortal={false}
placement="bottom-end"
modifiers={{
offset: {
enabled: true,
offset: '100vw'
},
preventOverflow: {
enabled: true,
boundariesElement: 'viewport'
boundariesElement: 'viewport',
padding: 0
}
}}>
<NotificationCenter

View file

@ -171,7 +171,7 @@ const styles = {
hasUnread: {
position: 'absolute',
top: 4,
left: 182,
left: 186,
width: '9px',
height: '9px',
backgroundColor: secondaryColor,

View file

@ -28,6 +28,7 @@ const useStyles = makeStyles(styles)
const Row = ({
id,
index,
elements,
data,
width,
@ -48,9 +49,11 @@ const Row = ({
[classes.row]: true,
[classes.expanded]: expanded
}
return (
<div className={classes.rowWrapper}>
<div className={classnames({ [classes.before]: expanded && id !== 0 })}>
<div
className={classnames({ [classes.before]: expanded && index !== 0 })}>
<Tr
size={size}
className={classnames(trClasses)}
@ -58,8 +61,9 @@ const Row = ({
expandable && expandRow(id, data)
onClick && onClick(data)
}}
error={data.error}
errorMessage={data.errorMessage}>
error={data.error || data.hasError}
shouldShowError={false}
errorMessage={data.errorMessage || data.hasError}>
{elements.map(({ view = it => it?.toString(), ...props }, idx) => (
<Td key={idx} {...props}>
{view(data)}
@ -142,6 +146,7 @@ const DataTable = ({
width={width}
size={rowSize}
id={data[index].id ? data[index].id : index}
index={index}
expWidth={expWidth}
elements={elements}
data={data[index]}

View file

@ -129,7 +129,7 @@ export default {
confirmationCode: {
extend: base,
fontSize: codeInputFontSize,
fontFamily: fontPrimary,
fontFamily: fontSecondary,
fontWeight: 900
},
inline: {

View file

@ -15,6 +15,7 @@ import { ReactComponent as DashLogo } from 'src/styling/logos/icon-dash-colour.s
import { ReactComponent as EthereumLogo } from 'src/styling/logos/icon-ethereum-colour.svg'
import { ReactComponent as LitecoinLogo } from 'src/styling/logos/icon-litecoin-colour.svg'
import { ReactComponent as ZCashLogo } from 'src/styling/logos/icon-zcash-colour.svg'
import { numberToFiatAmount } from 'src/utils/number'
import styles from './ATMWallet.styles'
@ -51,9 +52,6 @@ const GET_OPERATOR_BY_USERNAME = gql`
}
`
const formatCurrency = amount =>
amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
const CHIPS_PER_ROW = 6
const Assets = ({ balance, wallets, currency }) => {
@ -69,7 +67,7 @@ const Assets = ({ balance, wallets, currency }) => {
<P className={classes.fieldHeader}>Available balance</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(balance)}
{numberToFiatAmount(balance)}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -81,7 +79,7 @@ const Assets = ({ balance, wallets, currency }) => {
<P className={classes.fieldHeader}>Total balance in wallets</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(walletFiatSum())}
{numberToFiatAmount(walletFiatSum())}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -93,7 +91,7 @@ const Assets = ({ balance, wallets, currency }) => {
<P className={classes.fieldHeader}>Total assets</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(balance)}
{numberToFiatAmount(balance)}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -144,17 +142,11 @@ const WalletInfoChip = ({ wallet, currency }) => {
<div className={classes.walletValueWrapper}>
<Label2 className={classes.fieldHeader}>{wallet.name} value</Label2>
<Label2 className={classes.walletValue}>
{wallet.amount.toFixed(1).toLocaleString('en-US', {
maximumFractionDigits: 2
})}{' '}
{wallet.cryptoCode}
{numberToFiatAmount(wallet.amount.toFixed(1))} {wallet.cryptoCode}
</Label2>
<Label2 className={classes.fieldHeader}>Hedged value</Label2>
<Label2 className={classes.walletValue}>
{wallet.fiatValue.toLocaleString('en-US', {
maximumFractionDigits: 2
})}{' '}
{currency}
{numberToFiatAmount(wallet.fiatValue)} {currency}
</Label2>
</div>
</Paper>

View file

@ -9,13 +9,11 @@ import { Tooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
import { H4, Info2, P } from 'src/components/typography'
import { numberToFiatAmount } from 'src/utils/number'
import { formatDate } from 'src/utils/timezones'
import styles from './Accounting.styles'
const formatCurrency = amount =>
amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
const useStyles = makeStyles(styles)
const GET_OPERATOR_BY_USERNAME = gql`
@ -64,7 +62,7 @@ const Assets = ({ balance, hedgingReserve, currency }) => {
<P className={classes.fieldHeader}>Pazuz fiat balance</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(balance)}
{numberToFiatAmount(balance)}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -76,7 +74,7 @@ const Assets = ({ balance, hedgingReserve, currency }) => {
<P className={classes.fieldHeader}>Hedging reserve</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(hedgingReserve)}
{numberToFiatAmount(hedgingReserve)}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -88,7 +86,7 @@ const Assets = ({ balance, hedgingReserve, currency }) => {
<P className={classes.fieldHeader}>Available balance</P>
<div className={classes.totalAssetWrapper}>
<Info2 noMargin className={classes.fieldValue}>
{formatCurrency(balance - hedgingReserve)}
{numberToFiatAmount(balance - hedgingReserve)}
</Info2>
<Info2 noMargin className={classes.fieldCurrency}>
{R.toUpper(currency)}
@ -114,7 +112,7 @@ const Accounting = () => {
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const loading = operatorLoading && configLoading
const loading = operatorLoading || configLoading
const operatorData = R.path(['operatorByUsername'], opData)
@ -143,7 +141,7 @@ const Accounting = () => {
size: 'sm',
textAlign: 'right',
view: it =>
`${formatCurrency(it.fiatAmount)} ${R.toUpper(it.fiatCurrency)}`
`${numberToFiatAmount(it.fiatAmount)} ${R.toUpper(it.fiatCurrency)}`
},
{
header: 'Balance after operation',
@ -151,7 +149,9 @@ const Accounting = () => {
size: 'sm',
textAlign: 'right',
view: it =>
`${formatCurrency(it.fiatBalanceAfter)} ${R.toUpper(it.fiatCurrency)}`
`${numberToFiatAmount(it.fiatBalanceAfter)} ${R.toUpper(
it.fiatCurrency
)}`
},
{
header: 'Date',
@ -170,26 +170,22 @@ const Accounting = () => {
]
return (
!loading && (
<>
<TitleSection title="Accounting" />
<Assets
balance={
operatorData.fiatBalances[operatorData.preferredFiatCurrency]
}
hedgingReserve={operatorData.hedgingReserve ?? 0}
currency={operatorData.preferredFiatCurrency}
/>
<H4 className={classes.tableTitle}>Fiat balance history</H4>
<DataTable
loading={false}
emptyText="No transactions so far"
elements={elements}
data={operatorData.fundings ?? []}
rowSize="sm"
/>
</>
)
<>
<TitleSection title="Accounting" />
<Assets
balance={operatorData.fiatBalances[operatorData.preferredFiatCurrency]}
hedgingReserve={operatorData.hedgingReserve ?? 0}
currency={operatorData.preferredFiatCurrency}
/>
<H4 className={classes.tableTitle}>Fiat balance history</H4>
<DataTable
loading={loading}
emptyText="No transactions so far"
elements={elements}
data={operatorData.fundings ?? []}
rowSize="sm"
/>
</>
)
}

View file

@ -14,6 +14,7 @@ import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import Sidebar from 'src/components/layout/Sidebar'
import { Info2, P } from 'src/components/typography'
import { ReactComponent as CameraIcon } from 'src/styling/icons/ID/photo/zodiac.svg'
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
@ -70,8 +71,18 @@ const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
Scan QR code with your new cryptomat
</Info2>
<div className={classes.qrCodeWrapper}>
<div>
<QRCode size={240} fgColor={primaryColor} value={qrCode} />
<div className={classes.qrCodeImageWrapper}>
<QRCode
size={280}
fgColor={primaryColor}
includeMargin
value={qrCode}
className={classes.qrCodeBorder}
/>
<div className={classes.qrCodeScanMessage}>
<CameraIcon />
<P noMargin>Snap a picture and scan</P>
</div>
</div>
<div className={classes.qrTextWrapper}>
<div className={classes.qrTextInfoWrapper}>

View file

@ -126,6 +126,23 @@ const styles = {
},
errorMessage: {
color: errorColor
},
qrCodeImageWrapper: {
display: 'flex',
flexDirection: 'column',
backgroundColor: 'white',
border: `5px solid ${primaryColor}`,
padding: 5,
borderRadius: 15
},
qrCodeScanMessage: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
margin: [[0, 0, 20, 20]],
'& > p': {
marginLeft: 10
}
}
}

View file

@ -13,6 +13,7 @@ import { ReactComponent as DownIcon } from 'src/styling/icons/dashboard/down.svg
import { ReactComponent as EqualIcon } from 'src/styling/icons/dashboard/equal.svg'
import { ReactComponent as UpIcon } from 'src/styling/icons/dashboard/up.svg'
import { fromNamespace } from 'src/utils/config'
import { numberToFiatAmount } from 'src/utils/number'
import { DAY, WEEK, MONTH } from 'src/utils/time'
import styles from './Analytics.styles'
@ -97,9 +98,7 @@ const OverviewEntry = ({ label, value, oldValue, currency }) => {
<div className={classes.overviewEntry}>
<P noMargin>{label}</P>
<Info2 noMargin className={classes.overviewFieldWrapper}>
<span>
{value.toLocaleString('en-US', { maximumFractionDigits: 2 })}
</span>
<span>{numberToFiatAmount(value)}</span>
{!!currency && ` ${currency}`}
</Info2>
<span className={classes.overviewGrowth}>
@ -107,7 +106,7 @@ const OverviewEntry = ({ label, value, oldValue, currency }) => {
{R.lt(growthRate, 0) && <DownIcon height={10} />}
{R.equals(growthRate, 0) && <EqualIcon height={10} />}
<P noMargin className={classnames(growthClasses)}>
{growthRate.toLocaleString('en-US', { maximumFractionDigits: 2 })}%
{numberToFiatAmount(growthRate)}%
</P>
</span>
</div>

View file

@ -6,6 +6,7 @@ import React, { memo } from 'react'
import { Info2, Label3, P } from 'src/components/typography'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { numberToFiatAmount } from 'src/utils/number'
import { singularOrPlural } from 'src/utils/string'
import { formatDate, formatDateNonUtc } from 'src/utils/timezones'
@ -13,9 +14,6 @@ import styles from './GraphTooltip.styles'
const useStyles = makeStyles(styles)
const formatCurrency = amount =>
amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
const GraphTooltip = ({
coords,
data,
@ -67,7 +65,7 @@ const GraphTooltip = ({
{singularOrPlural(R.length(data), 'transaction', 'transactions')}
</P>
<P noMargin className={classes.dotOtTransactionVolume}>
{formatCurrency(transactions.volume)} {currency} in volume
{numberToFiatAmount(transactions.volume)} {currency} in volume
</P>
<div className={classes.dotOtTransactionClasses}>
<Label3 noMargin>

View file

@ -14,8 +14,10 @@ import React, { useContext } from 'react'
import AppContext from 'src/AppContext'
import TitleSection from 'src/components/layout/TitleSection'
import { H4, Label2, P, Info2 } from 'src/components/typography'
import { numberToFiatAmount } from 'src/utils/number'
import styles from './Assets.styles'
const useStyles = makeStyles(styles)
const GET_OPERATOR_BY_USERNAME = gql`
@ -105,7 +107,7 @@ const AssetsAmountTable = ({ title, data = [], numToRender }) => {
</Cell>
<Cell align="right">
<P>{`${selectAmountPrefix(asset)}
${formatCurrency(Math.abs(asset.amount))} ${
${numberToFiatAmount(Math.abs(asset.amount))} ${
asset.currency
}`}</P>
</Cell>
@ -117,7 +119,9 @@ const AssetsAmountTable = ({ title, data = [], numToRender }) => {
<Info2>{`Total ${R.toLower(title)}`}</Info2>
</Cell>
<Cell align="right">
<Info2>{`${formatCurrency(totalAmount)} ${currency}`}</Info2>
<Info2>{`${numberToFiatAmount(
totalAmount
)} ${currency}`}</Info2>
</Cell>
</TableRow>
</TableBody>
@ -128,9 +132,6 @@ const AssetsAmountTable = ({ title, data = [], numToRender }) => {
)
}
const formatCurrency = amount =>
amount.toLocaleString('en-US', { maximumFractionDigits: 2 })
const Assets = () => {
const classes = useStyles()
const { userData } = useContext(AppContext)

View file

@ -1,6 +1,7 @@
import { useMutation, useLazyQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles'
import base64 from 'base-64'
import { Form, Formik } from 'formik'
import gql from 'graphql-tag'
import React, { useContext, useState } from 'react'
import { useHistory } from 'react-router-dom'
@ -120,14 +121,20 @@ const Input2FAState = ({ state, dispatch }) => {
<TL1 className={classes.info}>
Enter your two-factor authentication code
</TL1>
<CodeInput
name="2fa"
value={state.twoFAField}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={state.twoFAField}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
<button onClick={handleSubmit} className={classes.enterButton} />
</Form>
</Formik>
<div className={classes.twofaFooter}>
{errorMessage && <P className={classes.errorMessage}>{errorMessage}</P>}
<Button onClick={handleSubmit} buttonClassName={classes.loginButton}>

View file

@ -1,6 +1,7 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Grid } from '@material-ui/core'
import Paper from '@material-ui/core/Paper'
import { Form, Formik } from 'formik'
import gql from 'graphql-tag'
import QRCode from 'qrcode.react'
import React, { useReducer, useState } from 'react'
@ -101,6 +102,20 @@ const Reset2FA = () => {
return null
}
const handleSubmit = () => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
reset2FA({
variables: {
token: token,
userID: state.userID,
code: twoFAConfirmation
}
})
}
return (
<Grid
container
@ -152,33 +167,30 @@ const Reset2FA = () => {
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
<button
onClick={handleSubmit}
className={classes.enterButton}
/>
</Form>
</Formik>
</div>
<div className={classes.twofaFooter}>
{getErrorMsg() && (
<P className={classes.errorMessage}>{getErrorMsg()}</P>
)}
<Button
onClick={() => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
reset2FA({
variables: {
token: token,
userID: state.userID,
code: twoFAConfirmation
}
})
}}
onClick={handleSubmit}
buttonClassName={classes.loginButton}>
Done
</Button>

View file

@ -1,6 +1,7 @@
import { useMutation, useQuery, useLazyQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles'
import base64 from 'base-64'
import { Form, Formik } from 'formik'
import gql from 'graphql-tag'
import QRCode from 'qrcode.react'
import React, { useContext, useState } from 'react'
@ -125,6 +126,14 @@ const Setup2FAState = ({ state, dispatch }) => {
return null
}
const handleSubmit = () => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
setup2FA(mutationOptions)
}
return (
secret &&
otpauth && (
@ -159,28 +168,26 @@ const Setup2FAState = ({ state, dispatch }) => {
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
<button onClick={handleSubmit} className={classes.enterButton} />
</Form>
</Formik>
</div>
<div className={classes.twofaFooter}>
{getErrorMsg() && (
<P className={classes.errorMessage}>{getErrorMsg()}</P>
)}
<Button
onClick={() => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
setup2FA(mutationOptions)
}}
buttonClassName={classes.loginButton}>
<Button onClick={handleSubmit} buttonClassName={classes.loginButton}>
Done
</Button>
</div>

View file

@ -100,6 +100,9 @@ const styles = {
},
error: {
color: errorColor
},
enterButton: {
display: 'none'
}
}

View file

@ -1,4 +1,4 @@
import { spacer, white, errorColor } from 'src/styling/variables'
import { spacer, white } from 'src/styling/variables'
const styles = {
grid: {
flex: 1,
@ -32,7 +32,7 @@ const styles = {
marginLeft: 8
},
error: {
color: errorColor
marginTop: 20
}
}

View file

@ -5,6 +5,7 @@ import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { Link } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
@ -32,7 +33,10 @@ const BlackListModal = ({
LTC: 'LPKvbjwV1Kaksktzkr7TMK3FQtQEEe6Wqa',
DASH: 'XqQ7gU8eM76rEfey726cJpT2RGKyJyBrcn',
ZEC: 't1KGyyv24eL354C9gjveBGEe8Xz9UoPKvHR',
BCH: 'qrd6za97wm03lfyg82w0c9vqgc727rhemg5yd9k3dm'
BCH: 'qrd6za97wm03lfyg82w0c9vqgc727rhemg5yd9k3dm',
USDT: '0x5754284f345afc66a98fbb0a0afe71e0f007b949',
XMR:
'888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'
}
return (
@ -53,9 +57,8 @@ const BlackListModal = ({
.trim()
.required('An address is required')
})}
onSubmit={({ address }, { resetForm }) => {
onSubmit={({ address }) => {
handleAddToBlacklist(address.trim())
resetForm()
}}>
<Form id="address-form">
<H3 className={classes.modalTitle}>
@ -63,7 +66,6 @@ const BlackListModal = ({
? `Blacklist ${R.toLower(selectedCoin.display)} address`
: ''}
</H3>
<span className={classes.error}>{errorMsg}</span>
<Field
name="address"
fullWidth
@ -72,6 +74,9 @@ const BlackListModal = ({
placeholder={`ex: ${placeholderAddress[selectedCoin.code]}`}
component={TextInput}
/>
{!R.isNil(errorMsg) && (
<ErrorMessage className={classes.error}>{errorMsg}</ErrorMessage>
)}
</Form>
</Formik>
<div className={classes.footer}>

View file

@ -162,14 +162,14 @@ const WizardStep = ({
{lastStep && (
<div className={classes.disclaimer}>
<Info2 className={classes.title}>Cash-out Bill Count</Info2>
<Info2 className={classes.title}>Cash Cassette Bill Count</Info2>
<P>
<WarningIcon className={classes.disclaimerIcon} />
When enabling cash-out, your bill count will be automatically set to
zero. Make sure you physically put cash inside the cash cassettes to
allow the machine to dispense it to your users. If you already did,
make sure you set the correct cash-out bill count for this machine
on your Cash Cassettes tab under Maintenance.
make sure you set the correct cash cassette bill count for this
machine on your Cash Boxes & Cassettes tab under Maintenance.
</P>
<Info2 className={classes.title}>Default Commissions</Info2>

View file

@ -0,0 +1,81 @@
import { makeStyles, Paper } from '@material-ui/core'
import { format } from 'date-fns/fp'
import * as R from 'ramda'
import { React, useState } from 'react'
import { InformativeDialog } from 'src/components/InformativeDialog'
import { Label2, H3 } from 'src/components/typography'
import { ReactComponent as CameraIcon } from 'src/styling/icons/ID/photo/comet.svg'
import { URI } from 'src/utils/apollo'
import styles from './CustomerPhotos.styles'
import PhotosCarousel from './components/PhotosCarousel'
const useStyles = makeStyles(styles)
const CustomerPhotos = ({ photosData }) => {
const classes = useStyles()
const [photosDialog, setPhotosDialog] = useState(false)
const [photoClickedIndex, setPhotoClickIndex] = useState(null)
const orderedPhotosData = !R.isNil(photoClickedIndex)
? R.compose(R.flatten, R.reverse, R.splitAt(photoClickedIndex))(photosData)
: photosData
return (
<div>
<div className={classes.header}>
<H3 className={classes.title}>{'Photos & files'}</H3>
</div>
<div className={classes.photosChipList}>
{photosData.map((elem, idx) => (
<PhotoCard
idx={idx}
date={elem.date}
src={`${URI}/${elem.photoDir}/${elem.path}`}
setPhotosDialog={setPhotosDialog}
setPhotoClickIndex={setPhotoClickIndex}
/>
))}
</div>
<InformativeDialog
open={photosDialog}
title={`Photo roll`}
data={<PhotosCarousel photosData={orderedPhotosData} />}
onDissmised={() => {
setPhotosDialog(false)
setPhotoClickIndex(null)
}}
/>
</div>
)
}
export const PhotoCard = ({
idx,
date,
src,
setPhotosDialog,
setPhotoClickIndex
}) => {
const classes = useStyles()
return (
<Paper
className={classes.photoCardChip}
onClick={() => {
setPhotoClickIndex(idx)
setPhotosDialog(true)
}}>
<img className={classes.image} src={src} alt="" />
<div className={classes.footer}>
<CameraIcon />
<Label2 className={classes.date}>
{format('yyyy-MM-dd', new Date(date))}
</Label2>
</div>
</Paper>
)
}
export default CustomerPhotos

View file

@ -0,0 +1,37 @@
const styles = {
header: {
display: 'flex',
flexDirection: 'row'
},
title: {
marginTop: 7,
marginRight: 24,
marginBottom: 32
},
photosChipList: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap'
},
image: {
objectFit: 'cover',
objectPosition: 'center',
width: 224,
height: 200,
borderTopLeftRadius: 4,
borderTopRightRadius: 4
},
photoCardChip: {
margin: [[0, 16, 0, 0]]
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [[8, 0, 0, 8]]
},
date: {
margin: [[0, 0, 8, 12]]
}
}
export default styles

View file

@ -24,6 +24,7 @@ import { fromNamespace, namespaces } from 'src/utils/config'
import CustomerData from './CustomerData'
import CustomerNotes from './CustomerNotes'
import CustomerPhotos from './CustomerPhotos'
import styles from './CustomerProfile.styles'
import {
CustomerDetails,
@ -31,7 +32,7 @@ import {
CustomerSidebar,
Wizard
} from './components'
import { getFormattedPhone, getName } from './helper'
import { getFormattedPhone, getName, formatPhotosData } from './helper'
const useStyles = makeStyles(styles)
@ -367,12 +368,24 @@ const CustomerProfile = memo(() => {
const isCustomerData = clickedItem === 'customerData'
const isOverview = clickedItem === 'overview'
const isNotes = clickedItem === 'notes'
const isPhotos = clickedItem === 'photos'
const loading = customerLoading && configLoading
const frontCameraData = R.pick(['frontCameraPath', 'frontCameraAt'])(
customerData
)
const txPhotosData =
sortedTransactions &&
R.map(R.pick(['id', 'txCustomerPhotoPath', 'txCustomerPhotoAt']))(
sortedTransactions
)
const photosData = formatPhotosData(R.append(frontCameraData, txPhotosData))
const loading = customerLoading || configLoading
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const classes = useStyles({ blocked })
const classes = useStyles()
return (
<>
@ -406,29 +419,26 @@ const CustomerProfile = memo(() => {
/>
</div>
<Label1 className={classes.actionLabel}>Actions</Label1>
<div>
<div className={classes.actionBar}>
<ActionButton
className={classes.customerManualDataEntry}
className={classes.actionButton}
color="primary"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => setWizard(true)}>
{`Manual data entry`}
</ActionButton>
</div>
<div>
<ActionButton
className={classes.customerDiscount}
className={classes.actionButton}
color="primary"
Icon={Discount}
InverseIcon={DiscountReversedIcon}
onClick={() => {}}>
{`Add individual discount`}
</ActionButton>
</div>
<div>
{isSuspended && (
<ActionButton
className={classes.actionButton}
color="primary"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeReversedIcon}
@ -442,7 +452,7 @@ const CustomerProfile = memo(() => {
)}
<ActionButton
color="primary"
className={classes.customerBlock}
className={classes.actionButton}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
@ -458,7 +468,7 @@ const CustomerProfile = memo(() => {
</ActionButton>
<ActionButton
color="primary"
className={classes.retrieveInformation}
className={classes.actionButton}
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
@ -488,6 +498,7 @@ const CustomerProfile = memo(() => {
justifyContent="space-between">
<CustomerDetails
customer={customerData}
photosData={photosData}
locale={locale}
setShowCompliance={() => setShowCompliance(!showCompliance)}
/>
@ -524,6 +535,11 @@ const CustomerProfile = memo(() => {
timezone={timezone}></CustomerNotes>
</div>
)}
{isPhotos && (
<div>
<CustomerPhotos photosData={photosData} />
</div>
)}
</div>
{wizard && (
<Wizard

View file

@ -15,29 +15,16 @@ export default {
customerDetails: {
marginBottom: 18
},
customerBlock: props => ({
actionButton: {
margin: [[0, 0, 4, 0]],
display: 'flex',
flexDirection: 'row',
margin: [[0, 0, 4, 0]],
padding: [[0, props.blocked ? 35 : 48, 0]]
}),
customerDiscount: {
display: 'flex',
flexDirection: 'row',
margin: [[0, 0, 4, 0]],
padding: [[0, 23.5, 0]]
justifyContent: 'center'
},
customerManualDataEntry: {
actionBar: {
display: 'flex',
flexDirection: 'row',
margin: [[8, 0, 4, 0]],
padding: [[0, 40.5, 0]]
},
retrieveInformation: {
display: 'flex',
flexDirection: 'row',
margin: [[0, 0, 4, 0]],
padding: [[0, 32.5, 0]]
flexDirection: 'column',
width: 219
},
panels: {
display: 'flex'

View file

@ -12,88 +12,73 @@ import PhotosCard from './PhotosCard'
const useStyles = makeStyles(mainStyles)
const CustomerDetails = memo(
({ txData, customer, locale, setShowCompliance }) => {
const classes = useStyles()
const CustomerDetails = memo(({ customer, photosData, locale }) => {
const classes = useStyles()
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
const usSsn = R.path(['usSsn'])(customer)
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
const usSsn = R.path(['usSsn'])(customer)
const elements = [
{
header: 'Phone number',
size: 172,
value: getFormattedPhone(customer.phone, locale.country)
}
]
const elements = [
{
header: 'Phone number',
size: 172,
value: getFormattedPhone(customer.phone, locale.country)
}
]
if (idNumber)
elements.push({
header: 'ID number',
size: 172,
value: idNumber
})
if (idNumber)
elements.push({
header: 'ID number',
size: 172,
value: idNumber
})
if (usSsn)
elements.push({
header: 'US SSN',
size: 127,
value: usSsn
})
if (usSsn)
elements.push({
header: 'US SSN',
size: 127,
value: usSsn
})
const name = getName(customer)
const name = getName(customer)
return (
<Box display="flex">
<PhotosCard
frontCameraData={R.pick(['frontCameraPath', 'frontCameraAt'])(
customer
)}
txPhotosData={
txData &&
R.map(R.pick(['id', 'txCustomerPhotoPath', 'txCustomerPhotoAt']))(
txData
)
}
/>
<Box display="flex" flexDirection="column">
<div className={classes.name}>
<IdIcon className={classes.idIcon} />
<H2 noMargin>
{name.length
? name
: getFormattedPhone(
R.path(['phone'])(customer),
locale.country
)}
</H2>
</div>
<Box display="flex" mt="auto">
{elements.map(({ size, header }, idx) => (
<Label1
noMargin
key={idx}
className={classes.label}
style={{ width: size }}>
{header}
</Label1>
))}
</Box>
<Box display="flex">
{elements.map(({ size, value }, idx) => (
<P
noMargin
key={idx}
className={classes.value}
style={{ width: size }}>
{value}
</P>
))}
</Box>
return (
<Box display="flex">
<PhotosCard photosData={photosData} />
<Box display="flex" flexDirection="column">
<div className={classes.name}>
<IdIcon className={classes.idIcon} />
<H2 noMargin>
{name.length
? name
: getFormattedPhone(R.path(['phone'])(customer), locale.country)}
</H2>
</div>
<Box display="flex" mt="auto">
{elements.map(({ size, header }, idx) => (
<Label1
noMargin
key={idx}
className={classes.label}
style={{ width: size }}>
{header}
</Label1>
))}
</Box>
<Box display="flex">
{elements.map(({ size, value }, idx) => (
<P
noMargin
key={idx}
className={classes.value}
style={{ width: size }}>
{value}
</P>
))}
</Box>
</Box>
)
}
)
</Box>
)
})
export default CustomerDetails

View file

@ -8,6 +8,8 @@ import { ReactComponent as NoteReversedIcon } from 'src/styling/icons/customer-n
import { ReactComponent as NoteIcon } from 'src/styling/icons/customer-nav/note/white.svg'
import { ReactComponent as OverviewReversedIcon } from 'src/styling/icons/customer-nav/overview/comet.svg'
import { ReactComponent as OverviewIcon } from 'src/styling/icons/customer-nav/overview/white.svg'
import { ReactComponent as PhotosReversedIcon } from 'src/styling/icons/customer-nav/photos/comet.svg'
import { ReactComponent as Photos } from 'src/styling/icons/customer-nav/photos/white.svg'
import styles from './CustomerSidebar.styles.js'
@ -33,6 +35,12 @@ const CustomerSidebar = ({ isSelected, onClick }) => {
display: 'Notes',
Icon: NoteIcon,
InverseIcon: NoteReversedIcon
},
{
code: 'photos',
display: 'Photos & files',
Icon: Photos,
InverseIcon: PhotosReversedIcon
}
]

View file

@ -4,58 +4,21 @@ import { makeStyles } from '@material-ui/core/styles'
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import { Carousel } from 'src/components/Carousel'
import { InformativeDialog } from 'src/components/InformativeDialog'
import { Info2, Label1 } from 'src/components/typography'
import { Info2 } from 'src/components/typography'
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
import { URI } from 'src/utils/apollo'
import CopyToClipboard from '../../Transactions/CopyToClipboard'
import styles from './PhotosCard.styles'
import PhotosCarousel from './PhotosCarousel'
const useStyles = makeStyles(styles)
const Label = ({ children }) => {
const classes = useStyles()
return <Label1 className={classes.label}>{children}</Label1>
}
const PhotosCard = memo(({ frontCameraData, txPhotosData }) => {
const PhotosCard = memo(({ photosData }) => {
const classes = useStyles()
const [photosDialog, setPhotosDialog] = useState(false)
const mapKeys = pair => {
const [key, value] = pair
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
return ['path', value]
}
if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') {
return ['date', value]
}
return pair
}
const addPhotoDir = R.map(it => {
const hasFrontCameraData = R.has('id')(it)
return hasFrontCameraData
? { ...it, photoDir: 'operator-data/customersphotos' }
: { ...it, photoDir: 'front-camera-photo' }
})
const standardizeKeys = R.map(
R.compose(R.fromPairs, R.map(mapKeys), R.toPairs)
)
const filterByPhotoAvailable = R.filter(
tx => !R.isNil(tx.date) && !R.isNil(tx.path)
)
const photosData = filterByPhotoAvailable(
addPhotoDir(standardizeKeys(R.append(frontCameraData, txPhotosData)))
)
const singlePhoto = R.head(photosData)
return (
@ -97,41 +60,4 @@ const PhotosCard = memo(({ frontCameraData, txPhotosData }) => {
)
})
export const PhotosCarousel = memo(({ photosData }) => {
const classes = useStyles()
const [currentIndex, setCurrentIndex] = useState(0)
const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex])
const slidePhoto = index => setCurrentIndex(index)
return (
<>
<Carousel photosData={photosData} slidePhoto={slidePhoto} />
{!isFaceCustomerPhoto && (
<div className={classes.firstRow}>
<Label>Session ID</Label>
<CopyToClipboard>
{photosData && photosData[currentIndex]?.id}
</CopyToClipboard>
</div>
)}
<div className={classes.secondRow}>
<div>
<div>
<Label>Date</Label>
<div>{photosData && photosData[currentIndex]?.date}</div>
</div>
</div>
<div>
<Label>Taken by</Label>
<div>
{!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'}
</div>
</div>
</div>
</>
)
})
export default PhotosCard

View file

@ -1,7 +1,4 @@
import typographyStyles from 'src/components/typography/styles'
import { zircon, backgroundColor, offColor } from 'src/styling/variables'
const { p } = typographyStyles
import { zircon, backgroundColor } from 'src/styling/variables'
export default {
photo: {
@ -41,43 +38,5 @@ export default {
alignItems: 'center',
justifyContent: 'center',
display: 'flex'
},
label: {
color: offColor,
margin: [[0, 0, 6, 0]]
},
firstRow: {
padding: [[8]],
display: 'flex',
flexDirection: 'column'
},
secondRow: {
extend: p,
display: 'flex',
padding: [[8]],
'& > div': {
display: 'flex',
flexDirection: 'column',
'& > div': {
width: 144,
height: 37,
marginBottom: 15,
marginRight: 55
}
}
},
imgWrapper: {
alignItems: 'center',
justifyContent: 'center',
display: 'flex',
width: 550,
height: 550
},
imgInner: {
objectFit: 'cover',
objectPosition: 'center',
width: 550,
height: 550,
marginBottom: 40
}
}

View file

@ -0,0 +1,56 @@
import { makeStyles } from '@material-ui/core/styles'
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import { Carousel } from 'src/components/Carousel'
import { Label1 } from 'src/components/typography'
import CopyToClipboard from '../../Transactions/CopyToClipboard'
import styles from './PhotosCarousel.styles'
const useStyles = makeStyles(styles)
const PhotosCarousel = memo(({ photosData }) => {
const classes = useStyles()
const [currentIndex, setCurrentIndex] = useState(0)
const Label = ({ children }) => {
const classes = useStyles()
return <Label1 className={classes.label}>{children}</Label1>
}
const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex])
const slidePhoto = index => setCurrentIndex(index)
return (
<>
<Carousel photosData={photosData} slidePhoto={slidePhoto} />
{!isFaceCustomerPhoto && (
<div className={classes.firstRow}>
<Label>Session ID</Label>
<CopyToClipboard>
{photosData && photosData[currentIndex]?.id}
</CopyToClipboard>
</div>
)}
<div className={classes.secondRow}>
<div>
<div>
<Label>Date</Label>
<div>{photosData && photosData[currentIndex]?.date}</div>
</div>
</div>
<div>
<Label>Taken by</Label>
<div>
{!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'}
</div>
</div>
</div>
</>
)
})
export default PhotosCarousel

View file

@ -0,0 +1,31 @@
import typographyStyles from 'src/components/typography/styles'
import { offColor } from 'src/styling/variables'
const { p } = typographyStyles
export default {
label: {
color: offColor,
margin: [[0, 0, 6, 0]]
},
firstRow: {
padding: [[8]],
display: 'flex',
flexDirection: 'column'
},
secondRow: {
extend: p,
display: 'flex',
padding: [[8]],
'& > div': {
display: 'flex',
flexDirection: 'column',
'& > div': {
width: 144,
height: 37,
marginBottom: 15,
marginRight: 55
}
}
}
}

View file

@ -70,12 +70,7 @@ const TransactionsList = ({ customer, data, loading, locale }) => {
const tableElements = [
{
header: 'Machine',
width: 160,
view: R.path(['machineName'])
},
{
width: 125,
width: 40,
view: it => (
<>
{it.txClass === 'cashOut' ? (
@ -86,6 +81,11 @@ const TransactionsList = ({ customer, data, loading, locale }) => {
</>
)
},
{
header: 'Machine',
width: 160,
view: R.path(['machineName'])
},
{
header: 'Transaction ID',
width: 145,

View file

@ -5,10 +5,12 @@ import CustomerSidebar from './CustomerSidebar'
import EditableCard from './EditableCard'
import Field from './Field'
import IdDataCard from './IdDataCard'
import PhotosCarousel from './PhotosCarousel'
import TransactionsList from './TransactionsList'
import Upload from './Upload'
export {
PhotosCarousel,
CustomerDetails,
IdDataCard,
TransactionsList,

View file

@ -209,10 +209,41 @@ const entryType = {
initialValues: { entryType: '' }
}
const mapKeys = pair => {
const [key, value] = pair
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
return ['path', value]
}
if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') {
return ['date', value]
}
return pair
}
const addPhotoDir = R.map(it => {
const hasFrontCameraData = R.has('id')(it)
return hasFrontCameraData
? { ...it, photoDir: 'operator-data/customersphotos' }
: { ...it, photoDir: 'front-camera-photo' }
})
const standardizeKeys = R.map(R.compose(R.fromPairs, R.map(mapKeys), R.toPairs))
const filterByPhotoAvailable = R.filter(
tx => !R.isNil(tx.date) && !R.isNil(tx.path)
)
const formatPhotosData = R.compose(
filterByPhotoAvailable,
addPhotoDir,
standardizeKeys
)
export {
getAuthorizedStatus,
getFormattedPhone,
getName,
entryType,
customElements
customElements,
formatPhotosData
}

View file

@ -7,8 +7,8 @@ import { useHistory } from 'react-router-dom'
import { P } from 'src/components/typography/index'
import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg'
import { ReactComponent as LinkIcon } from 'src/styling/icons/button/link/zodiac.svg'
import { ReactComponent as CashBoxEmpty } from 'src/styling/icons/cassettes/cashbox-empty.svg'
import { ReactComponent as AlertLinkIcon } from 'src/styling/icons/month arrows/right.svg'
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg'
import styles from './Alerts.styles'
@ -49,7 +49,7 @@ const AlertsTable = ({ numToRender, alerts, machines }) => {
<Wrench style={{ height: 23, width: 23, marginRight: 8 }} />
)}
<P className={classes.listItemText}>{alertMessage(alert)}</P>
<LinkIcon
<AlertLinkIcon
className={classes.linkIcon}
onClick={() => history.push(links[alert.type] || '/dashboard')}
/>

View file

@ -1,7 +1,6 @@
import { useQuery } from '@apollo/react-hooks'
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
@ -13,6 +12,7 @@ import { H1, Info2, TL2, Label1 } from 'src/components/typography'
import AddMachine from 'src/pages/AddMachine'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { errorColor } from 'src/styling/variables'
import styles from './Dashboard.styles'
import Footer from './Footer'
@ -46,20 +46,20 @@ const Dashboard = () => {
<>
<TitleSection title="Dashboard">
<div className={classes.headerLabels}>
<>
<div
className={classnames(
classes.headerLabelContainer,
classes.headerLabelContainerMargin
)}>
<TxOutIcon />
<span className={classes.headerLabelSpan}>Cash-out</span>
</div>
<div className={classes.headerLabelContainer}>
<TxInIcon />
<span className={classes.headerLabelSpan}>Cash-in</span>
</div>
</>
<div>
<TxInIcon />
<span>Cash-in</span>
</div>
<div>
<TxOutIcon />
<span>Cash-out</span>
</div>
<div>
<svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} />
</svg>
<span>Action Required</span>
</div>
</div>
</TitleSection>
<div className={classes.root}>

View file

@ -12,18 +12,26 @@ const { label1 } = typographyStyles
const styles = {
headerLabels: {
display: 'flex',
flexDirection: 'row'
},
headerLabelContainerMargin: {
marginRight: 24
},
headerLabelContainer: {
display: 'flex',
alignItems: 'center'
},
headerLabelSpan: {
extend: label1,
marginLeft: 6
flexDirection: 'row',
'& > div:first-child': {
display: 'flex',
alignItems: 'center',
marginLeft: 0
},
'& > div': {
display: 'flex',
alignItems: 'center',
marginLeft: 25
},
'& > div:last-child': {
display: 'flex',
alignItems: 'center',
marginLeft: 64
},
'& > div > span': {
extend: label1,
marginLeft: 7
}
},
root: {
flexGrow: 1,

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
import { useQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core'
import Grid from '@material-ui/core/Grid'
@ -27,19 +28,15 @@ const GET_DATA = gql`
}
}
`
BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
const useStyles = makeStyles(styles)
const Footer = () => {
const { data } = useQuery(GET_DATA)
const [expanded, setExpanded] = useState(false)
const [delayedExpand, setDelayedExpand] = useState(null)
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
const classes = useStyles({
bigFooter: R.keys(withCommissions).length > 8,
expanded
})
const classes = useStyles()
const config = R.path(['config'])(data) ?? {}
const canExpand = R.keys(withCommissions).length > 4
@ -99,31 +96,16 @@ const Footer = () => {
)
}
const handleMouseEnter = () => {
setDelayedExpand(setTimeout(() => canExpand && setExpanded(true), 300))
}
const handleMouseLeave = () => {
clearTimeout(delayedExpand)
setExpanded(false)
}
return (
<>
<div
className={classes.mouseWatcher}
onMouseLeave={handleMouseLeave}
onMouseEnter={handleMouseEnter}
/>
<div className={classes.content}>
<Grid container spacing={1}>
<Grid container className={classes.footerContainer}>
<div className={classes.footer1}>
<div className={classes.content1}>
<Grid container>
<Grid container className={classes.footerContainer1}>
{R.keys(withCommissions).map(key => renderFooterItem(key))}
</Grid>
</Grid>
</div>
<div className={classes.footer} />
</>
</div>
)
}

View file

@ -17,52 +17,34 @@ const styles = {
txOutMargin: {
marginLeft: spacer * 3
},
footer: ({ expanded, bigFooter }) => ({
height:
expanded && bigFooter
? spacer * 12 * 3 + spacer * 3
: expanded
? spacer * 12 * 2 + spacer * 2
: spacer * 12,
tickerLabel: {
color: offColor,
marginTop: -5
},
footer1: {
left: 0,
bottom: 0,
position: 'fixed',
width: '100vw',
backgroundColor: white,
textAlign: 'left',
boxShadow: '0px -1px 10px 0px rgba(50, 50, 50, 0.1)'
}),
tickerLabel: {
color: offColor,
marginTop: -5
},
content: {
width: 1200,
backgroundColor: white,
zIndex: 1,
position: 'fixed',
bottom: -spacer,
transform: 'translateY(-100%)'
boxShadow: '0px -1px 10px 0px rgba(50, 50, 50, 0.1)',
minHeight: spacer * 12,
transition: 'min-height 0.5s ease-out',
'&:hover': {
transition: 'min-height 0.5s ease-in',
minHeight: 200
}
},
footerContainer: ({ expanded, bigFooter }) => ({
marginLeft: spacer * 5,
height: 100,
marginTop: expanded && bigFooter ? -300 : expanded ? -200 : -100,
overflow: !expanded && 'hidden'
}),
mouseWatcher: ({ expanded, bigFooter }) => ({
position: 'fixed',
bottom: 0,
left: 0,
width: '100vw',
height:
expanded && bigFooter
? spacer * 12 * 3 + spacer * 3
: expanded
? spacer * 12 * 2 + spacer * 2
: spacer * 12,
zIndex: 2
})
content1: {
width: 1200,
maxHeight: 100,
backgroundColor: white,
zIndex: 2,
bottom: -spacer,
margin: '0 auto'
}
}
export default styles

View file

@ -8,11 +8,10 @@ import { java, neon, white } from 'src/styling/variables'
const styles = {
wrapper: {
display: 'flex',
height: 130,
marginTop: -8
height: 142
},
percentageBox: {
height: 130,
height: 142,
borderRadius: 4,
display: 'flex',
alignItems: 'center',
@ -33,11 +32,11 @@ const styles = {
borderRadius: 2
},
inWidth: {
width: value => `${value}%`
width: value => `${value}%`,
marginRight: value => (value === 100 ? 0 : 4)
},
outWidth: {
width: value => `${100 - value}%`,
marginRight: 4
width: value => `${100 - value}%`
}
}
@ -59,14 +58,6 @@ const PercentageChart = ({ cashIn, cashOut }) => {
return (
<div className={classes.wrapper}>
<div
className={classnames(
percentageClasses,
classes.outColor,
classes.outWidth
)}>
{buildPercentageView(100 - value, 'cashOut')}
</div>
<div
className={classnames(
percentageClasses,
@ -75,6 +66,14 @@ const PercentageChart = ({ cashIn, cashOut }) => {
)}>
{buildPercentageView(value, 'cashIn')}
</div>
<div
className={classnames(
percentageClasses,
classes.outColor,
classes.outWidth
)}>
{buildPercentageView(100 - value, 'cashOut')}
</div>
</div>
)
}

View file

@ -45,7 +45,7 @@ const RefLineChart = ({
const svg = d3.select(svgRef.current)
const margin = { top: 0, right: 0, bottom: 0, left: 0 }
const width = 336 - margin.left - margin.right
const height = 128 - margin.top - margin.bottom
const height = 140 - margin.top - margin.bottom
const massageData = () => {
// if we're viewing transactions for the past day, then we group by hour. If not, we group by day
@ -148,7 +148,7 @@ const RefLineChart = ({
const y = d3
.scaleLinear()
// 30 is a margin so that the labels and the percentage change label can fit and not overlay the line path
.range([height, 30])
.range([height, 40])
.domain([0, yDomain[1]])
const x = d3
.scaleTime()

View file

@ -1,197 +1,357 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { add } from 'date-fns/fp'
import React, { useEffect, useRef, useCallback } from 'react'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, format, startOfWeek, startOfYear } from 'date-fns/fp'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { backgroundColor, java, neon } from 'src/styling/variables'
import { formatDate, toUtc } from 'src/utils/timezones'
import {
java,
neon,
subheaderDarkColor,
offColor,
fontSecondary,
backgroundColor
} from 'src/styling/variables'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const RefScatterplot = ({ data: realData, timeFrame, timezone }) => {
const svgRef = useRef()
const drawGraph = useCallback(() => {
const svg = d3.select(svgRef.current)
const margin = { top: 25, right: 0, bottom: 25, left: 15 }
const width = 555 - margin.left - margin.right
const height = 150 - margin.top - margin.bottom
// finds maximum value for the Y axis. Minimum value is 100. If value is multiple of 1000, add 100
// (this is because the Y axis looks best with multiples of 100)
const findMaxY = () => {
if (realData.length === 0) return 100
const maxvalueTx =
100 * Math.ceil(d3.max(realData, t => parseFloat(t.fiat)) / 100)
const maxY = Math.max(100, maxvalueTx)
if (maxY % 1000 === 0) return maxY + 100
return maxY
}
const Graph = ({ data, timeFrame, timezone }) => {
const ref = useRef(null)
const timeFormat = v => {
switch (timeFrame) {
case 'Week':
return d3.timeFormat('%a %d')(v)
case 'Month':
return d3.timeFormat('%b %d')(v)
default:
return formatDate(v, timezone, 'HH:mm')
const GRAPH_HEIGHT = 250
const GRAPH_WIDTH = 555
const GRAPH_MARGIN = useMemo(
() => ({
top: 20,
right: 0.5,
bottom: 27,
left: 43.5
}),
[]
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
Day: [NOW - DAY, NOW],
Week: [NOW - WEEK, NOW],
Month: [NOW - MONTH, NOW]
}
const dataPoints = useMemo(
() => ({
Day: {
freq: 24,
step: 60 * 60 * 1000,
tick: d3.utcHour.every(4),
labelFormat: '%H:%M'
},
Week: {
freq: 7,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
Month: {
freq: 30,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(2),
labelFormat: '%d'
}
}
}),
[]
)
// changes values of arguments in some d3 function calls to make the graph labels look good according to the selected time frame
const findXAxisSettings = () => {
switch (timeFrame) {
case 'Week':
return {
nice: 7,
ticks: 7,
subtractDays: 7,
timeRange: [50, 500]
}
case 'Month':
return {
nice: 6,
ticks: 6,
subtractDays: 30,
timeRange: [50, 500]
}
default:
return {
nice: null,
ticks: 4,
subtractDays: 1,
timeRange: [50, 500]
}
const filterDay = useMemo(
x => (timeFrame === 'day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
[timeFrame]
)
const getPastAndCurrentDayLabels = useCallback(d => {
const currentDate = new Date(d)
const currentDateDay = currentDate.getUTCDate()
const currentDateWeekday = currentDate.getUTCDay()
const currentDateMonth = currentDate.getUTCMonth()
const previousDate = new Date(currentDate.getTime())
previousDate.setUTCDate(currentDateDay - 1)
const previousDateDay = previousDate.getUTCDate()
const previousDateWeekday = previousDate.getUTCDay()
const previousDateMonth = previousDate.getUTCMonth()
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
format('EEE', add({ days: i }, startOfWeek(new Date())))
)
const months = Array.from(Array(12)).map((_, i) =>
format('LLL', add({ months: i }, startOfYear(new Date())))
)
return {
previous:
currentDateMonth !== previousDateMonth
? months[previousDateMonth]
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
current:
currentDateMonth !== previousDateMonth
? months[currentDateMonth]
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
}
}, [])
const buildTicks = useCallback(
domain => {
const points = []
const roundDate = d => {
const step = dataPoints[timeFrame].step
return new Date(Math.ceil(d.valueOf() / step) * step)
}
}
// sets width of the graph
svg.attr('width', width)
for (let i = 0; i <= dataPoints[timeFrame].freq; i++) {
const stepDate = new Date(NOW - i * dataPoints[timeFrame].step)
if (roundDate(stepDate) > domain[1]) continue
if (stepDate < domain[0]) continue
points.push(roundDate(stepDate))
}
// background color for the graph
svg
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', width)
.attr('height', height + margin.top)
.attr('fill', backgroundColor)
return points
},
[NOW, dataPoints, timeFrame]
)
// declare g variable where more svg components will be attached
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const x = d3
.scaleUtc()
.domain(periodDomains[timeFrame])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
// y axis range: round up to 100 highest data value, if rounds up to 1000, add 100.
// this keeps the vertical axis nice looking
const maxY = findMaxY()
const xAxisSettings = findXAxisSettings()
const y = d3
.scaleLinear()
.domain([
0,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.05
])
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
// y and x scales
const y = d3
.scaleLinear()
.range([height, 0])
.domain([0, maxY])
.nice(3)
const x = d3
.scaleTime()
.domain([
add({ days: -xAxisSettings.subtractDays }, new Date()).valueOf(),
new Date().valueOf()
])
.range(xAxisSettings.timeRange)
.nice(xAxisSettings.nice)
const buildBackground = useCallback(
g => {
g.append('rect')
.attr('x', 0)
.attr('y', GRAPH_MARGIN.top)
.attr('width', GRAPH_WIDTH)
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.top - GRAPH_MARGIN.bottom)
.attr('fill', backgroundColor)
},
[GRAPH_MARGIN]
)
const timeValue = s => {
const date = toUtc(s)
return x(date.valueOf())
}
const buildXAxis = useCallback(
g =>
g
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.ticks(dataPoints[timeFrame].tick)
.tickFormat(d => {
return d3.timeFormat(dataPoints[timeFrame].labelFormat)(
d.getTime() + d.getTimezoneOffset() * MINUTE
)
})
)
.call(g => g.select('.domain').remove()),
[GRAPH_MARGIN, dataPoints, timeFrame, x]
)
// horizontal gridlines
const makeYGridlines = () => {
return d3.axisLeft(y).ticks(4)
}
g.append('g')
.style('color', '#eef1ff')
.call(
makeYGridlines()
.tickSize(-width)
.tickFormat('')
)
.call(g => g.select('.domain').remove())
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(d3.axisLeft(y).ticks(5))
.call(g => g.select('.domain').remove())
.selectAll('text')
.attr('dy', '-0.25rem'),
[GRAPH_MARGIN, y]
)
/* X AXIS */
// this one is for the labels at the bottom
g.append('g')
.attr('transform', 'translate(0,' + height + ')')
.style('font-size', '13px')
.style('color', '#5f668a')
.style('font-family', 'MuseoSans')
.style('margin-top', '11px')
.call(
d3
.axisBottom(x)
.ticks(xAxisSettings.ticks)
.tickSize(0)
.tickFormat(timeFormat)
)
.selectAll('text')
.attr('dy', '1.5em')
// this is for the x axis line. It is the same color as the horizontal grid lines
g.append('g')
.attr('transform', 'translate(0,' + height + ')')
.style('color', '#eef1ff')
.call(
d3
.axisBottom(x)
.ticks(6)
.tickSize(0)
.tickFormat('')
)
.selectAll('text')
.attr('dy', '1.5em')
const buildGrid = useCallback(
g => {
g.attr('stroke', subheaderDarkColor)
.attr('fill', subheaderDarkColor)
// Vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()))
.join('line')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 1)
)
// Horizontal lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(
d3
.axisLeft(y)
.scale()
.ticks(5)
)
.join('line')
.attr('y1', d => 0.5 + y(d))
.attr('y2', d => 0.5 + y(d))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.right)
)
// Thick vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()).filter(filterDay))
.join('line')
.attr('class', 'dateSeparator')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top - 10)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 2)
.join('text')
)
// Left side breakpoint label
.call(g => {
const separator = d3
?.select('.dateSeparator')
?.node()
?.getBBox()
// Y axis
g.append('g')
.style('font-size', '13px')
.style('color', '#5f668a')
.style('font-family', 'MuseoSans')
.style('margin-top', '11px')
.call(
d3
.axisLeft(y)
.ticks(4)
.tickSize(0)
)
.call(g => g.select('.domain').remove())
.selectAll('text')
.attr('dy', '-0.40em')
.attr('dx', '3em')
if (!separator) return
// Append dots
const dots = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const breakpoint = buildTicks(x.domain()).filter(filterDay)
dots
.selectAll('circle')
.data(realData)
.enter()
.append('circle')
.attr('cx', d => timeValue(d.created))
.attr('cy', d => y(d.fiat))
.attr('r', 4)
.style('fill', d => (d.txClass === 'cashIn' ? java : neon))
}, [realData, timeFrame, timezone])
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x - 7)
.attr('y', separator.y)
.attr('text-anchor', 'end')
.attr('dy', '.25em')
.text(labels.previous)
})
// Right side breakpoint label
.call(g => {
const separator = d3
?.select('.dateSeparator')
?.node()
?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(filterDay)
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x + 7)
.attr('y', separator.y)
.attr('text-anchor', 'start')
.attr('dy', '.25em')
.text(labels.current)
})
},
[GRAPH_MARGIN, buildTicks, getPastAndCurrentDayLabels, x, y, filterDay]
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', offColor)
.style('fill', offColor)
.style('stroke-width', 0)
.style('font-family', fontSecondary),
[]
)
const formatText = useCallback(
() =>
d3
.selectAll('text')
.style('stroke', offColor)
.style('fill', offColor)
.style('stroke-width', 0)
.style('font-family', fontSecondary),
[]
)
const formatTicks = useCallback(() => {
d3.selectAll('.tick line')
.style('stroke', 'transparent')
.style('fill', 'transparent')
}, [])
const drawData = useCallback(
g => {
g.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => {
const created = new Date(d.created)
return x(created.setTime(created.getTime() + offset))
})
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
.attr('fill', d => (d.txClass === 'cashIn' ? java : neon))
.attr('r', 3.5)
},
[data, offset, x, y]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildBackground)
svg.append('g').call(buildGrid)
svg.append('g').call(buildXAxis)
svg.append('g').call(buildYAxis)
svg.append('g').call(formatTicksText)
svg.append('g').call(formatText)
svg.append('g').call(formatTicks)
svg.append('g').call(drawData)
return svg.node()
}, [
buildBackground,
buildGrid,
buildXAxis,
buildYAxis,
drawData,
formatText,
formatTicks,
formatTicksText
])
useEffect(() => {
// first we clear old chart DOM elements on component update
d3.select(svgRef.current)
d3.select(ref.current)
.selectAll('*')
.remove()
drawGraph()
}, [drawGraph])
drawChart()
}, [drawChart])
return (
<>
<svg ref={svgRef} />
</>
)
return <svg ref={ref} />
}
export default RefScatterplot
export default Graph

View file

@ -9,11 +9,13 @@ import * as R from 'ramda'
import React, { useState } from 'react'
import { EmptyTable } from 'src/components/table'
import { Label1, Label2 } from 'src/components/typography/index'
import { Label1, Label2, P } from 'src/components/typography/index'
import { ReactComponent as PercentDownIcon } from 'src/styling/icons/dashboard/down.svg'
import { ReactComponent as PercentNeutralIcon } from 'src/styling/icons/dashboard/equal.svg'
import { ReactComponent as PercentUpIcon } from 'src/styling/icons/dashboard/up.svg'
import { java, neon } from 'src/styling/variables'
import { fromNamespace } from 'src/utils/config'
import { timezones } from 'src/utils/timezone-list'
import { toTimezone } from 'src/utils/timezones'
import PercentageChart from './Graphs/PercentageChart'
@ -199,9 +201,30 @@ const SystemPerformance = () => {
</Grid>
{/* todo new customers */}
</Grid>
<Grid container className={classes.gridContainer}>
<Grid container className={classes.txGraphContainer}>
<Grid item xs={12}>
<Label2>Transactions</Label2>
<div className={classes.graphHeader}>
<Label2 noMargin>Transactions</Label2>
<div className={classes.labelWrapper}>
<P noMargin>
{timezones[timezone].short ?? timezones[timezone].long}{' '}
timezone
</P>
<span className={classes.verticalLine} />
<div>
<svg width={8} height={8}>
<rect width={8} height={8} rx={4} fill={java} />
</svg>
<Label1 noMargin>In</Label1>
</div>
<div>
<svg width={8} height={8}>
<rect width={8} height={8} rx={4} fill={neon} />
</svg>
<Label1 noMargin>Out</Label1>
</div>
</div>
</div>
<Scatterplot
timeFrame={selectedRange}
data={transactionsToShow}
@ -209,9 +232,9 @@ const SystemPerformance = () => {
/>
</Grid>
</Grid>
<Grid container className={classes.gridContainer}>
<Grid container className={classes.commissionGraphContainer}>
<Grid item xs={8}>
<Label2 className={classes.labelMargin}>
<Label2 noMargin className={classes.commissionProfitTitle}>
Profit from commissions
</Label2>
<div className={classes.profitContainer}>
@ -233,23 +256,22 @@ const SystemPerformance = () => {
/>
</Grid>
<Grid item xs={4}>
<Grid container>
<Grid item>
<Label2 className={classes.labelMargin}>Direction</Label2>
</Grid>
<Grid
item
className={classnames(
classes.directionLabelContainer,
classes.dirLabContMargin
)}>
<div className={classes.outSquare} />
<Label1 className={classes.directionLabel}>Out</Label1>
</Grid>
<Grid item className={classes.directionLabelContainer}>
<div className={classes.inSquare} />
<Label1 className={classes.directionLabel}>In</Label1>
</Grid>
<Grid container className={classes.graphHeader}>
<Label2 noMargin>Direction</Label2>
<div className={classes.labelWrapper}>
<div>
<svg width={8} height={8}>
<rect width={8} height={8} rx={2} fill={java} />
</svg>
<Label1 noMargin>In</Label1>
</div>
<div>
<svg width={8} height={8}>
<rect width={8} height={8} rx={2} fill={neon} />
</svg>
<Label1 noMargin>Out</Label1>
</div>
</div>
</Grid>
<Grid item xs>
<PercentageChart

View file

@ -1,5 +1,6 @@
import {
offColor,
offDarkColor,
spacer,
primaryColor,
fontSize3,
@ -7,8 +8,6 @@ import {
fontColor,
spring4,
tomato,
java,
neon,
comet
} from 'src/styling/variables'
@ -67,12 +66,6 @@ const styles = {
navContainer: {
display: 'flex'
},
profitLabel: {
fontSize: fontSize3,
fontFamily: fontSecondary,
fontWeight: 700,
color: fontColor
},
percentUp: {
fontSize: fontSize3,
fontFamily: fontSecondary,
@ -96,34 +89,14 @@ const styles = {
profitContainer: {
display: 'flex',
justifyContent: 'space-between',
margin: '0 26px -30px 16px',
margin: '23px 26px -30px 16px',
position: 'relative'
},
gridContainer: {
marginTop: 30,
height: 225
},
inSquare: {
width: 8,
height: 8,
borderRadius: 2,
marginTop: 18,
marginRight: 4,
backgroundColor: java
},
outSquare: {
width: 8,
height: 8,
borderRadius: 2,
marginTop: 18,
marginRight: 4,
backgroundColor: neon
},
directionLabelContainer: {
display: 'flex'
},
dirLabContMargin: {
marginRight: 20
profitLabel: {
fontSize: fontSize3,
fontFamily: fontSecondary,
fontWeight: 700,
color: fontColor
},
directionIcon: {
width: 16,
@ -131,12 +104,50 @@ const styles = {
marginBottom: -2,
marginRight: 4
},
labelMargin: {
marginBottom: 20,
marginRight: 32
},
emptyTransactions: {
paddingTop: 40
},
commissionProfitTitle: {
marginBottom: 16
},
graphHeader: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16
},
labelWrapper: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
'& > div': {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
marginLeft: 15,
'&:first-child': {
marginLeft: 0
},
'& > p': {
marginLeft: 8
}
}
},
txGraphContainer: {
height: 300,
marginTop: 30
},
commissionsGraphContainer: {
height: 250,
marginTop: 30
},
verticalLine: {
height: 15,
width: 1,
backgroundColor: offDarkColor,
marginLeft: 31,
marginRight: 16
}
}

View file

@ -212,7 +212,9 @@ const Funding = () => {
<div className={classes.addressWrapper}>
<div className={classes.mono}>
<strong>
<CopyToClipboard buttonClassname={classes.copyToClipboard}>
<CopyToClipboard
buttonClassname={classes.copyToClipboard}
key={selected.cryptoCode}>
{formatAddress(
selected.cryptoCode,
selected.fundingAddress

View file

@ -2,7 +2,7 @@ import * as R from 'ramda'
import * as Yup from 'yup'
import Autocomplete from 'src/components/inputs/formik/Autocomplete.js'
import timezoneList from 'src/utils/timezone-list'
import { labels as timezoneList } from 'src/utils/timezone-list'
const getFields = (getData, names, onChange, auxElements = []) => {
return R.filter(

View file

@ -8,7 +8,6 @@ import { DeleteDialog } from 'src/components/DeleteDialog'
import { Link, Button, IconButton } from 'src/components/buttons'
import DataTable from 'src/components/tables/DataTable'
import { Label3, TL1 } from 'src/components/typography'
import { ReactComponent as CardIdIcon } from 'src/styling/icons/ID/card/zodiac.svg'
import { ReactComponent as PhoneIdIcon } from 'src/styling/icons/ID/phone/zodiac.svg'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
@ -49,7 +48,6 @@ const GET_CUSTOMERS = gql`
id
phone
idCardData
phone
}
}
`
@ -64,7 +62,9 @@ const IndividualDiscounts = () => {
const [showModal, setShowModal] = useState(false)
const toggleModal = () => setShowModal(!showModal)
const { data: discountResponse, loading } = useQuery(GET_INDIVIDUAL_DISCOUNTS)
const { data: discountResponse, loading: discountLoading } = useQuery(
GET_INDIVIDUAL_DISCOUNTS
)
const { data: customerData, loading: customerLoading } = useQuery(
GET_CUSTOMERS
)
@ -102,12 +102,6 @@ const IndividualDiscounts = () => {
<div className={classes.identification}>
<PhoneIdIcon />
<span>{customer.phone}</span>
{customer?.idCardData?.documentNumber && (
<>
<CardIdIcon />
<span>{customer?.idCardData?.documentNumber}</span>
</>
)}
</div>
)
}
@ -160,24 +154,22 @@ const IndividualDiscounts = () => {
}
]
const isLoading = loading || customerLoading
const loading = discountLoading || customerLoading
return (
<>
{!isLoading && !R.isEmpty(discountResponse.individualDiscounts) && (
<Box
marginBottom={4}
marginTop={-7}
className={classes.tableWidth}
display="flex"
justifyContent="flex-end">
<Link color="primary" onClick={toggleModal}>
Add new code
</Link>
</Box>
)}
{!isLoading && !R.isEmpty(discountResponse.individualDiscounts) && (
{!loading && !R.isEmpty(discountResponse.individualDiscounts) && (
<>
<Box
marginBottom={4}
marginTop={-7}
className={classes.tableWidth}
display="flex"
justifyContent="flex-end">
<Link color="primary" onClick={toggleModal}>
Add new code
</Link>
</Box>
<DataTable
elements={elements}
data={R.path(['individualDiscounts'])(discountResponse)}
@ -196,7 +188,7 @@ const IndividualDiscounts = () => {
/>
</>
)}
{!isLoading && R.isEmpty(discountResponse.individualDiscounts) && (
{!loading && R.isEmpty(discountResponse.individualDiscounts) && (
<Box display="flex" alignItems="left" flexDirection="column">
<Label3>
It seems there are no active individual customer discounts on your

View file

@ -81,16 +81,21 @@ const Logs = () => {
const deviceId = selected?.deviceId
const { data: machineResponse } = useQuery(GET_MACHINES)
const { data: machineResponse, loading: machinesLoading } = useQuery(
GET_MACHINES
)
const { data: configResponse } = useQuery(GET_DATA)
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const { data: logsResponse, loading } = useQuery(GET_MACHINE_LOGS, {
variables: { deviceId, limit: NUM_LOG_RESULTS },
skip: !selected,
onCompleted: () => setSaveMessage('')
})
const { data: logsResponse, loading: logsLoading } = useQuery(
GET_MACHINE_LOGS,
{
variables: { deviceId, limit: NUM_LOG_RESULTS },
skip: !selected,
onCompleted: () => setSaveMessage('')
}
)
if (machineResponse?.machines?.length && !selected) {
setSelected(machineResponse?.machines[0])
@ -100,6 +105,8 @@ const Logs = () => {
return R.path(['deviceId'])(selected) === it.deviceId
}
const loading = machinesLoading || configLoading || logsLoading
return (
<>
<div className={classes.titleWrapper}>

View file

@ -26,7 +26,7 @@ const widthsByNumberOfCassettes = {
const ValidationSchema = Yup.object().shape({
name: Yup.string().required('Required'),
cashbox: Yup.number()
.label('Cashbox')
.label('Cash box')
.required()
.integer()
.min(0)
@ -82,7 +82,7 @@ const SET_CASSETTE_BILLS = gql`
}
`
const CashCassettes = ({ machine, config, refetchData }) => {
const CashCassettes = ({ machine, config, refetchData, bills }) => {
const classes = useStyles()
const [wizard, setWizard] = useState(false)
@ -101,11 +101,15 @@ const CashCassettes = ({ machine, config, refetchData }) => {
const elements = [
{
name: 'cashbox',
header: 'Cashbox',
header: 'Cash box',
width: widthsByNumberOfCassettes[numberOfCassettes].cashbox,
stripe: false,
view: value => (
<CashIn currency={{ code: fiatCurrency }} notes={value} total={0} />
<CashIn
currency={{ code: fiatCurrency }}
notes={value}
total={R.sum(R.map(it => it.fiat)(bills))}
/>
),
input: NumberInput,
inputProps: {
@ -119,7 +123,7 @@ const CashCassettes = ({ machine, config, refetchData }) => {
it => {
elements.push({
name: `cassette${it}`,
header: `Cash-out ${it}`,
header: `Cash cassette ${it}`,
width: widthsByNumberOfCassettes[numberOfCassettes].cassette,
stripe: true,
doubleHeader: 'Cash-out',

View file

@ -82,7 +82,7 @@ const Transactions = ({ id }) => {
const { data: configData, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configData)
const loading = txLoading && configLoading
const loading = txLoading || configLoading
if (!loading && txResponse) {
txResponse.transactions = txResponse.transactions.splice(0, 5)

View file

@ -93,13 +93,14 @@ const MachineRoute = () => {
)
}
const Machines = ({ data, refetch, reload, bills }) => {
const Machines = ({ data, refetch, reload }) => {
const classes = useStyles()
const timezone = R.path(['config', 'locale_timezone'], data) ?? {}
const machine = R.path(['machine'])(data) ?? {}
const config = R.path(['config'])(data) ?? {}
const bills = R.path(['bills'])(data) ?? []
const machineName = R.path(['name'])(machine) ?? null
const machineID = R.path(['deviceId'])(machine) ?? null

View file

@ -31,7 +31,7 @@ const useStyles = makeStyles(styles)
const ValidationSchema = Yup.object().shape({
name: Yup.string().required(),
cashbox: Yup.number()
.label('Cashbox')
.label('Cash box')
.required()
.integer()
.min(0)
@ -63,7 +63,7 @@ const ValidationSchema = Yup.object().shape({
})
const GET_MACHINES_AND_CONFIG = gql`
query getData {
query getData($billFilters: JSONObject) {
machines {
name
id: deviceId
@ -75,24 +75,21 @@ const GET_MACHINES_AND_CONFIG = gql`
numberOfCassettes
}
config
bills(filters: $billFilters) {
id
fiat
created
deviceId
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
/*
// for cash in total calculation
bills {
fiat
deviceId
created
cashbox
}
*/
const SET_CASSETTE_BILLS = gql`
mutation MachineAction(
$deviceId: ID!
@ -128,7 +125,13 @@ const CashCassettes = () => {
const [editingSchema, setEditingSchema] = useState(null)
const [selectedRadio, setSelectedRadio] = useState(null)
const { data } = useQuery(GET_MACHINES_AND_CONFIG)
const { data, loading: dataLoading } = useQuery(GET_MACHINES_AND_CONFIG, {
variables: {
billFilters: {
batch: 'none'
}
}
})
const [wizard, setWizard] = useState(false)
const [machineId, setMachineId] = useState('')
@ -204,10 +207,14 @@ const CashCassettes = () => {
},
{
name: 'cashbox',
header: 'Cash-in',
header: 'Cash box',
width: maxNumberOfCassettes > 2 ? 140 : 280,
view: value => (
<CashIn currency={{ code: fiatCurrency }} notes={value} total={0} />
view: (value, { id }) => (
<CashIn
currency={{ code: fiatCurrency }}
notes={value}
total={R.sum(R.map(it => it.fiat, bills[id] ?? []))}
/>
),
input: NumberInput,
inputProps: {
@ -222,7 +229,7 @@ const CashCassettes = () => {
elements.push({
name: `cassette${it}`,
header: `Cassette ${it}`,
width: (maxNumberOfCassettes > 2 ? 700 : 560) / maxNumberOfCassettes,
width: (maxNumberOfCassettes > 2 ? 560 : 650) / maxNumberOfCassettes,
stripe: true,
doubleHeader: 'Cash-out',
view: (value, { id }) => (
@ -268,121 +275,124 @@ const CashCassettes = () => {
})
return (
<>
<TitleSection
title="Cash Cassettes"
button={{
text: 'Cashbox history',
icon: HistoryIcon,
inverseIcon: ReverseHistoryIcon,
toggle: setShowHistory
}}
iconClassName={classes.listViewButton}
className={classes.tableWidth}>
{!showHistory && (
<Box alignItems="center" justifyContent="flex-end">
<Label1 className={classes.cashboxReset}>Cashbox reset</Label1>
<Box
display="flex"
alignItems="center"
justifyContent="flex-end"
mr="-4px">
{cashboxReset && (
<P className={classes.selection}>
{onlyFirstToUpper(cashboxReset)}
</P>
)}
<IconButton
onClick={() => setEditingSchema(true)}
className={classes.button}>
<EditIcon />
</IconButton>
</Box>
</Box>
)}
</TitleSection>
<div className={classes.tableContainer}>
{!showHistory && (
<>
<EditableTable
error={error?.message}
name="cashboxes"
stripeWhen={isCashOutDisabled}
elements={elements}
data={machines}
validationSchema={ValidationSchema}
tbodyWrapperClass={classes.tBody}
/>
{data && R.isEmpty(machines) && (
<EmptyTable message="No machines so far" />
)}
</>
)}
{showHistory && (
<CashboxHistory machines={machines} currency={fiatCurrency} />
)}
</div>
<CashCassettesFooter
currencyCode={fiatCurrency}
machines={machines}
config={config}
bills={bills}
deviceIds={deviceIds}
/>
{wizard && (
<Wizard
machine={R.find(R.propEq('id', machineId))(machines)}
cashoutSettings={getCashoutSettings(machineId)}
onClose={() => {
setWizard(false)
!dataLoading && (
<>
<TitleSection
title="Cash Boxes & Cassettes"
button={{
text: 'Cash box history',
icon: HistoryIcon,
inverseIcon: ReverseHistoryIcon,
toggle: setShowHistory
}}
error={error?.message}
save={onSave}
locale={locale}
iconClassName={classes.listViewButton}
className={classes.tableWidth}>
{!showHistory && (
<Box alignItems="center" justifyContent="flex-end">
<Label1 className={classes.cashboxReset}>Cash box resets</Label1>
<Box
display="flex"
alignItems="center"
justifyContent="end"
mr="-4px">
{cashboxReset && (
<P className={classes.selection}>
{onlyFirstToUpper(cashboxReset)}
</P>
)}
<IconButton
onClick={() => setEditingSchema(true)}
className={classes.button}>
<EditIcon />
</IconButton>
</Box>
</Box>
)}
</TitleSection>
<div className={classes.tableContainer}>
{!showHistory && (
<>
<EditableTable
error={error?.message}
name="cashboxes"
stripeWhen={isCashOutDisabled}
elements={elements}
data={machines}
validationSchema={ValidationSchema}
tbodyWrapperClass={classes.tBody}
/>
{data && R.isEmpty(machines) && (
<EmptyTable message="No machines so far" />
)}
</>
)}
{showHistory && (
<CashboxHistory machines={machines} currency={fiatCurrency} />
)}
</div>
<CashCassettesFooter
currencyCode={fiatCurrency}
machines={machines}
config={config}
bills={R.path(['bills'])(data)}
deviceIds={deviceIds}
/>
)}
{editingSchema && (
<Modal
title={'Cashbox reset'}
width={478}
handleClose={() => setEditingSchema(null)}
open={true}>
<P className={classes.descriptions}>
Specify if you want your cash-in counts to be reset automatically or
manually.
</P>
<RadioGroup
name="set-automatic-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[0]]}
onChange={handleRadioButtons}
className={classes.radioButtons}
{wizard && (
<Wizard
machine={R.find(R.propEq('id', machineId), machines)}
cashoutSettings={getCashoutSettings(machineId)}
onClose={() => {
setWizard(false)
}}
error={error?.message}
save={onSave}
locale={locale}
/>
<P className={classes.descriptions}>
Choose this option if you want your cash-in cashbox count to be
reset automatically when it is physically removed from the machine.
</P>
<RadioGroup
name="set-manual-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[1]]}
onChange={handleRadioButtons}
className={classes.radioButtons}
/>
<P className={classes.descriptions}>
Choose this option if you want to edit your cash-in counts manually
on Lamassu Admin, after you physically remove the bills from the
cashbox.
</P>
<DialogActions className={classes.actions}>
<Button onClick={() => saveCashboxOption(selectedRadio)}>
Confirm
</Button>
</DialogActions>
</Modal>
)}
</>
)}
{editingSchema && (
<Modal
title={'Cash box resets'}
width={478}
handleClose={() => setEditingSchema(null)}
open={true}>
<P className={classes.descriptions}>
We can automatically assume you emptied a bill validator's cash
box when the machine detects that it has been removed.
</P>
<RadioGroup
name="set-automatic-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[0]]}
onChange={handleRadioButtons}
className={classes.radioButtons}
/>
<P className={classes.descriptions}>
Assume the cash box is emptied whenever it's removed, creating a
new batch on the history screen and setting its current balance to
zero.
</P>
<RadioGroup
name="set-manual-reset"
value={selectedRadio ?? cashboxReset}
options={[radioButtonOptions[1]]}
onChange={handleRadioButtons}
className={classes.radioButtons}
/>
<P className={classes.descriptions}>
Cash boxes won't be assumed emptied when removed, nor their counts
modified. Instead, to update the count and create a new batch,
you'll click the 'Edit' button on this panel.
</P>
<DialogActions className={classes.actions}>
<Button onClick={() => saveCashboxOption(selectedRadio)}>
Confirm
</Button>
</DialogActions>
</Modal>
)}
</>
)
)
}

View file

@ -1,20 +1,17 @@
import { makeStyles } from '@material-ui/core'
// import BigNumber from 'bignumber.js'
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React from 'react'
import { Info1, Info2, Info3 } from 'src/components/typography/index'
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { fromNamespace } from 'src/utils/config'
import { numberToFiatAmount } from 'src/utils/number.js'
import styles from './CashCassettesFooter.styles.js'
const useStyles = makeStyles(styles)
/* const sortDate = function(a, b) {
return new Date(b.created).getTime() - new Date(a.created).getTime()
} */
const CashCassettesFooter = ({
machines,
config,
@ -43,44 +40,34 @@ const CashCassettesFooter = ({
const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0, 0, 0], machines))
/* const totalInCashBox = R.sum(
R.flatten(
R.map(id => {
const sliceIdx = R.path([id, 0, 'cashbox'])(bills) ?? 0
return R.map(
R.prop('fiat'),
R.slice(0, sliceIdx, R.sort(sortDate, bills[id] ?? []))
)
}, deviceIds)
)
) */
const totalInCashBox = R.sum(R.map(it => it.fiat)(bills))
// const total = new BigNumber(totalInCassettes + totalInCashBox).toFormat(0)
const total = new BigNumber(totalInCassettes + totalInCashBox).toFormat(0)
return (
<div className={classes.footerContainer}>
<div className={classes.footerContent}>
<Info3 className={classes.footerLabel}>Cash value in System</Info3>
{/* <div className={classes.flex}>
<div className={classes.flex}>
<TxInIcon className={classes.icon} />
<Info2 className={classes.iconLabel}>Cash-in:</Info2>
<Info1 className={classes.valueDisplay}>
{totalInCashBox} {currencyCode}
{numberToFiatAmount(totalInCashBox)} {currencyCode}
</Info1>
</div> */}
</div>
<div className={classes.flex}>
<TxOutIcon className={classes.icon} />
<Info2 className={classes.iconLabel}>Cash-out:</Info2>
<Info1 className={classes.valueDisplay}>
{totalInCassettes} {currencyCode}
{numberToFiatAmount(totalInCassettes)} {currencyCode}
</Info1>
</div>
{/* <div className={classes.flex}>
<div className={classes.flex}>
<Info2 className={classes.iconLabel}>Total:</Info2>
<Info1 className={classes.valueDisplay}>
{total} {currencyCode}
{numberToFiatAmount(total)} {currencyCode}
</Info1>
</div> */}
</div>
</div>
</div>
)

View file

@ -24,9 +24,7 @@ export default {
boxShadow: [[0, -1, 10, 0, 'rgba(50, 50, 50, 0.1)']]
},
flex: {
display: 'flex',
// temp marginLeft until cashIn square is enabled
marginLeft: -640
display: 'flex'
},
icon: {
alignSelf: 'center',

View file

@ -1,15 +1,16 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { useQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import React from 'react'
// import * as Yup from 'yup'
import { Link, IconButton } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs'
// import { Link, IconButton } from 'src/components/buttons'
// import { TextInput } from 'src/components/inputs'
import { NumberInput } from 'src/components/inputs/formik'
import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
// import { ReactComponent as EditIconDisabled } from 'src/styling/icons/action/edit/disabled.svg'
// import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { formatDate } from 'src/utils/timezones'
@ -33,13 +34,13 @@ const GET_BATCHES = gql`
}
`
const EDIT_BATCH = gql`
/* const EDIT_BATCH = gql`
mutation editBatch($id: ID, $performedBy: String) {
editBatch(id: $id, performedBy: $performedBy) {
id
}
}
`
` */
const GET_DATA = gql`
query getData {
@ -63,27 +64,29 @@ const styles = {
}
}
const schema = Yup.object().shape({
/* const schema = Yup.object().shape({
performedBy: Yup.string().nullable()
})
}) */
const useStyles = makeStyles(styles)
const CashboxHistory = ({ machines, currency }) => {
const classes = useStyles()
const [error, setError] = useState(false)
const [fields, setFields] = useState([])
/* const [error, setError] = useState(false)
const [field, setField] = useState(null)
const [editing, setEditing] = useState(false) */
const { data: batchesData, loading: batchesLoading } = useQuery(GET_BATCHES)
const [editBatch] = useMutation(EDIT_BATCH, {
/* const [editBatch] = useMutation(EDIT_BATCH, {
refetchQueries: () => ['cashboxBatches']
})
}) */
const { data: configData, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configData)
const loading = batchesLoading && configLoading
const loading = batchesLoading || configLoading
const batches = R.path(['cashboxBatches'])(batchesData)
@ -91,33 +94,36 @@ const CashboxHistory = ({ machines, currency }) => {
(ret, i) =>
R.pipe(
R.assoc(
`cash-out-${i}-refill`,
`cash-cassette-${i}-refill`,
<>
<TxOutIcon />
<span className={classes.operationType}>Cash-out {i} refill</span>
<span className={classes.operationType}>
Cash cassette {i} refill
</span>
</>
),
R.assoc(
`cash-out-${i}-empty`,
`cash-cassette-${i}-empty`,
<>
<TxOutIcon />
<span className={classes.operationType}>Cash-out {i} emptied</span>
<span className={classes.operationType}>
Cash cassette {i} emptied
</span>
</>
)
)(ret),
{
'cash-in-empty': (
'cash-box-empty': (
<>
<TxInIcon />
<span className={classes.operationType}>Cash-in emptied</span>
<span className={classes.operationType}>Cash box emptied</span>
</>
)
},
R.range(1, 5)
)
const save = row => {
const field = R.find(f => f.id === row.id, fields)
/* const save = row => {
const performedBy = field.performedBy === '' ? null : field.performedBy
schema
@ -129,14 +135,15 @@ const CashboxHistory = ({ machines, currency }) => {
})
})
.catch(setError(true))
return close(row.id)
return close()
}
const close = id => {
setFields(R.filter(f => f.id !== id, fields))
const close = () => {
setEditing(false)
setField(null)
}
const notEditing = id => !R.any(R.propEq('id', id), fields)
const notEditing = id => field?.id !== id */
const elements = [
{
@ -174,7 +181,7 @@ const CashboxHistory = ({ machines, currency }) => {
{
name: 'total',
header: 'Total',
width: 100,
width: 180,
textAlign: 'right',
view: it => (
<span>
@ -195,8 +202,8 @@ const CashboxHistory = ({ machines, currency }) => {
width: 125,
textAlign: 'right',
view: it => formatDate(it.created, timezone, 'HH:mm')
},
{
}
/* {
name: 'performedBy',
header: 'Performed by',
width: 180,
@ -206,21 +213,10 @@ const CashboxHistory = ({ machines, currency }) => {
return R.isNil(it.performedBy) ? 'Unknown entity' : it.performedBy
return (
<TextInput
onChange={e =>
setFields(
R.map(
f =>
f.id === it.id ? { ...f, performedBy: e.target.value } : f,
fields
)
)
}
onChange={e => setField({ ...field, performedBy: e.target.value })}
error={error}
width={190 * 0.85}
value={R.prop(
'performedBy',
R.find(f => f.id === it.id, fields)
)}
value={field?.performedBy}
/>
)
}
@ -228,19 +224,18 @@ const CashboxHistory = ({ machines, currency }) => {
{
name: '',
header: 'Edit',
width: 150,
width: 80,
textAlign: 'right',
view: it => {
if (notEditing(it.id))
return (
<IconButton
disabled={editing}
onClick={() => {
setFields([
...fields,
{ id: it.id, performedBy: it.performedBy }
])
setField({ id: it.id, performedBy: it.performedBy })
setEditing(true)
}}>
<EditIcon />
{editing ? <EditIconDisabled /> : <EditIcon />}
</IconButton>
)
return (
@ -248,26 +243,23 @@ const CashboxHistory = ({ machines, currency }) => {
<Link type="submit" color="primary" onClick={() => save(it)}>
Save
</Link>
<Link color="secondary" onClick={() => close(it.id)}>
<Link color="secondary" onClick={close}>
Cancel
</Link>
</div>
)
}
}
} */
]
return (
<>
{!loading && (
<DataTable
name="cashboxHistory"
elements={elements}
data={batches}
emptyText="No cashbox batches so far"
/>
)}
</>
<DataTable
loading={loading}
name="cashboxHistory"
elements={elements}
data={batches}
emptyText="No cashbox batches so far"
/>
)
}

View file

@ -54,7 +54,11 @@ const MachineStatus = () => {
const history = useHistory()
const { state } = useLocation()
const addedMachineId = state?.id
const { data: machinesResponse, refetch, loading } = useQuery(GET_MACHINES)
const {
data: machinesResponse,
refetch,
loading: machinesLoading
} = useQuery(GET_MACHINES)
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
@ -114,6 +118,8 @@ const MachineStatus = () => {
<MachineDetailsRow it={it} onActionSuccess={refetch} timezone={timezone} />
)
const loading = machinesLoading || configLoading
return (
<>
<div className={classes.titleWrapper}>
@ -132,7 +138,7 @@ const MachineStatus = () => {
</div>
</div>
<DataTable
loading={loading && configLoading}
loading={loading}
elements={elements}
data={machines}
Details={InnerMachineDetailsRow}

View file

@ -47,7 +47,6 @@ const Wizard = ({ machine, cashoutSettings, locale, onClose, save, error }) => {
const onContinue = it => {
const newConfig = R.merge(config, it)
if (isLastStep) {
const wasCashboxEmptied = [
config?.wasCashboxEmptied,

View file

@ -67,7 +67,7 @@ const WizardSplash = ({ name, onContinue }) => {
<div className={classes.warningInfo}>
<WarningIcon className={classes.warningIcon} />
<P noMargin className={classes.warningText}>
For cash-out cassettes, please make sure you've removed the remaining
For cash cassettes, please make sure you've removed the remaining
bills before adding the new ones.
</P>
</div>

View file

@ -22,6 +22,7 @@ import tejo4CassetteThree from 'src/styling/icons/cassettes/tejo/4-cassettes/4-c
import tejo4CassetteFour from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-4-left.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { comet, errorColor } from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
const styles = {
content: {
@ -115,7 +116,8 @@ const WizardStep = ({
lastStep,
steps,
fiatCurrency,
onContinue
onContinue,
initialValues
}) => {
const classes = useStyles()
@ -168,7 +170,7 @@ const WizardStep = ({
classes.verticalAlign,
classes.fullWidth
)}>
<H4 noMargin>Did you empty the cash-in box?</H4>
<H4 noMargin>Did you empty the cash box?</H4>
<Field
component={RadioGroup}
name="wasCashboxEmptied"
@ -188,8 +190,8 @@ const WizardStep = ({
<P>Since previous update</P>
<Tooltip width={215}>
<P>
Number of bills inside the cashbox, since the last
cashbox changes.
Number of bills inside the cash box, since the last
cash box changes.
</P>
</Tooltip>
</div>
@ -219,12 +221,7 @@ const WizardStep = ({
validateOnBlur={false}
validateOnChange={false}
onSubmit={onContinue}
initialValues={{
cassette1: '',
cassette2: '',
cassette3: '',
cassette4: ''
}}
initialValues={initialValues}
enableReinitialize
validationSchema={steps[step - 1].schema}>
{({ values, errors }) => (
@ -255,7 +252,7 @@ const WizardStep = ({
<H4
className={classes.cassetteFormTitleContent}
noMargin>
Cash-out {step - 1} (dispenser)
Cash cassette {step - 1} (dispenser)
</H4>
</div>
<Cashbox
@ -283,7 +280,8 @@ const WizardStep = ({
</P>
</div>
<P noMargin className={classes.fiatTotal}>
= {cassetteTotal(values)} {fiatCurrency}
= {numberToFiatAmount(cassetteTotal(values))}{' '}
{fiatCurrency}
</P>
</div>
</div>

View file

@ -131,10 +131,10 @@ const FiatBalanceOverrides = ({ section }) => {
it => {
elements.push({
name: `fillingPercentageCassette${it}`,
display: `Cash-out ${it}`,
display: `Cash cassette ${it}`,
width: 155,
textAlign: 'right',
doubleHeader: 'Cash-out (Cassette Empty)',
doubleHeader: 'Cash Cassette Empty',
bold: true,
input: NumberInput,
suffix: '%',

View file

@ -98,13 +98,13 @@ const Logs = () => {
const [saveMessage, setSaveMessage] = useState(null)
const [logLevel, setLogLevel] = useState(SHOW_ALL)
const { data, loading } = useQuery(GET_SERVER_DATA, {
const { data, loading: dataLoading } = useQuery(GET_SERVER_DATA, {
onCompleted: () => setSaveMessage(''),
variables: {
limit: NUM_LOG_RESULTS
}
})
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const defaultLogLevels = [
@ -132,6 +132,8 @@ const Logs = () => {
setLogLevel(logLevel)
}
const loading = dataLoading || configLoading
return (
<>
<div className={classes.titleWrapper}>
@ -206,8 +208,8 @@ const Logs = () => {
))}
</TableBody>
</Table>
{loading && configLoading && <H4>{'Loading...'}</H4>}
{!loading && !configLoading && !data?.serverLogs?.length && (
{loading && <H4>{'Loading...'}</H4>}
{!loading && !data?.serverLogs?.length && (
<H4>{'No activity so far'}</H4>
)}
</div>

View file

@ -27,7 +27,7 @@ export default {
settings: {
enabled: true,
disabledMessage: 'RBF verification not available',
label: 'Enable RBF verification',
label: 'Lower the confidence of RBF transactions',
requirement: 'bitcoind'
},
face: true

View file

@ -48,7 +48,7 @@ const SessionManagement = () => {
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const loading = sessionsLoading && configLoading
const loading = sessionsLoading || configLoading
const elements = [
{
@ -61,7 +61,7 @@ const SessionManagement = () => {
{
header: 'Last known use',
width: 305,
textAlign: 'center',
textAlign: 'left',
size: 'sm',
view: s => {
if (R.isNil(s.sess.ua)) return 'No Record'
@ -72,7 +72,7 @@ const SessionManagement = () => {
{
header: 'Last known location',
width: 250,
textAlign: 'center',
textAlign: 'left',
size: 'sm',
view: s => {
return isLocalhost(s.sess.ipAddress) ? 'This device' : s.sess.ipAddress
@ -107,15 +107,14 @@ const SessionManagement = () => {
]
return (
!loading && (
<>
<TitleSection title="Session Management" />
<DataTable
elements={elements}
data={R.path(['sessions'])(tknResponse)}
/>
</>
)
<>
<TitleSection title="Session Management" />
<DataTable
loading={loading}
elements={elements}
data={R.path(['sessions'])(tknResponse)}
/>
</>
)
}

View file

@ -15,6 +15,8 @@ import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { ReactComponent as CustomerLinkIcon } from 'src/styling/icons/month arrows/right.svg'
import { ReactComponent as CustomerLinkWhiteIcon } from 'src/styling/icons/month arrows/right_white.svg'
import { errorColor } from 'src/styling/variables'
import { formatDate } from 'src/utils/timezones'
import DetailsRow from './DetailsCard'
@ -124,13 +126,13 @@ const Transactions = () => {
const history = useHistory()
const [filters, setFilters] = useState([])
const { data: filtersResponse, loading: loadingFilters } = useQuery(
const { data: filtersResponse, loading: filtersLoading } = useQuery(
GET_TRANSACTION_FILTERS
)
const [variables, setVariables] = useState({ limit: NUM_LOG_RESULTS })
const {
data: txData,
loading: loadingTransactions,
loading: transactionsLoading,
refetch,
startPolling,
stopPolling
@ -185,7 +187,11 @@ const Transactions = () => {
<div className={classes.overflowTd}>{getCustomerDisplayName(it)}</div>
{!it.isAnonymous && (
<div onClick={() => redirect(it.customerId)}>
<CustomerLinkIcon className={classes.customerLinkIcon} />
{it.hasError ? (
<CustomerLinkWhiteIcon className={classes.customerLinkIcon} />
) : (
<CustomerLinkIcon className={classes.customerLinkIcon} />
)}
</div>
)}
</div>
@ -294,6 +300,14 @@ const Transactions = () => {
const filterOptions = R.path(['transactionFilters'])(filtersResponse)
const loading = transactionsLoading || filtersLoading || configLoading
const errorLabel = (
<svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} />
</svg>
)
return (
<>
<div className={classes.titleWrapper}>
@ -301,7 +315,7 @@ const Transactions = () => {
<Title>Transactions</Title>
<div className={classes.buttonsWrapper}>
<SearchBox
loading={loadingFilters}
loading={filtersLoading}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search Transactions'}
@ -331,6 +345,10 @@ const Transactions = () => {
<TxOutIcon />
<span>Cash-out</span>
</div>
<div>
{errorLabel}
<span>Transaction error</span>
</div>
</div>
</div>
{filters.length > 0 && (
@ -342,7 +360,7 @@ const Transactions = () => {
/>
)}
<DataTable
loading={loadingTransactions && configLoading}
loading={loading}
emptyText="No transactions so far"
elements={elements}
data={txList}

View file

@ -80,8 +80,11 @@ const mainStyles = {
display: 'flex',
alignItems: 'center'
},
'& > div': {
marginLeft: 24
},
'& > div:first-child': {
marginRight: 24
marginLeft: 0
},
'& span': {
extend: label1,

View file

@ -183,10 +183,10 @@ const InfoPanel = ({ step, config = {}, liveValues = {}, currency }) => {
return (
<>
<H5 className={classes.infoTitle}>Trigger overview so far</H5>
<Info3 noMargin className={classes.infoText}>
<Info3 noMargin>
{oldText}
{step !== 1 && ', '}
{newText}
<span className={classes.infoCurrentText}>{newText}</span>
{!isLastStep && '...'}
</Info3>
</>

View file

@ -61,7 +61,7 @@ const getDefaultSettings = () => {
return [
{
name: 'expirationTime',
header: 'Expiration Time',
header: 'Expiration time',
width: 196,
size: 'sm',
editable: false
@ -101,7 +101,7 @@ const getOverrides = () => {
},
{
name: 'expirationTime',
header: 'Expiration Time',
header: 'Expiration time',
width: 196,
size: 'sm',
editable: false

View file

@ -6,10 +6,16 @@ import * as R from 'ramda'
import React, { useReducer, useState, useContext } from 'react'
import AppContext from 'src/AppContext'
import { Link } from 'src/components/buttons'
import { ActionButton, Link } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable'
import { ReactComponent as WhiteKeyIcon } from 'src/styling/icons/button/key/white.svg'
import { ReactComponent as KeyIcon } from 'src/styling/icons/button/key/zodiac.svg'
import { ReactComponent as WhiteLockIcon } from 'src/styling/icons/button/lock/white.svg'
import { ReactComponent as LockIcon } from 'src/styling/icons/button/lock/zodiac.svg'
import { ReactComponent as WhiteUserRoleIcon } from 'src/styling/icons/button/user-role/white.svg'
import { ReactComponent as UserRoleIcon } from 'src/styling/icons/button/user-role/zodiac.svg'
import styles from './UserManagement.styles'
import ChangeRoleModal from './modals/ChangeRoleModal'
@ -153,35 +159,37 @@ const Users = () => {
size: 'sm',
view: u => {
return (
<>
<Chip
size="small"
label="Reset password"
className={classes.actionChip}
<div className={classes.actionButtonWrapper}>
<ActionButton
Icon={KeyIcon}
InverseIcon={WhiteKeyIcon}
color="primary"
onClick={() => {
setUserInfo(u)
dispatch({
type: 'open',
payload: 'showResetPasswordModal'
})
}}
/>
<Chip
size="small"
label="Reset 2FA"
className={classes.actionChip}
}}>
Reset password
</ActionButton>
<ActionButton
Icon={LockIcon}
InverseIcon={WhiteLockIcon}
color="primary"
onClick={() => {
setUserInfo(u)
dispatch({
type: 'open',
payload: 'showReset2FAModal'
})
}}
/>
<Chip
size="small"
label="Add FIDO"
className={classes.actionChip}
}}>
Reset 2FA
</ActionButton>
<ActionButton
Icon={UserRoleIcon}
InverseIcon={WhiteUserRoleIcon}
color="primary"
onClick={() => {
setUserInfo(u)
generateAttestationOptions({
@ -189,9 +197,10 @@ const Users = () => {
userID: u.id
}
})
}}
/>
</>
}}>
Add FIDO
</ActionButton>
</div>
)
}
},

View file

@ -52,10 +52,6 @@ const styles = {
fontFamily: fontPrimary,
marginLeft: 10
},
actionChip: {
backgroundColor: subheaderColor,
marginRight: 15
},
info: {
fontFamily: fontSecondary,
textAlign: 'justify'
@ -118,6 +114,10 @@ const styles = {
},
roleSwitch: {
marginLeft: 15
},
actionButtonWrapper: {
display: 'flex',
gap: 12
}
}

View file

@ -1,5 +1,6 @@
import { useLazyQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles'
import { Form, Formik } from 'formik'
import gql from 'graphql-tag'
import React, { useState } from 'react'
@ -48,6 +49,14 @@ const Input2FAModal = ({ showModal, handleClose, setConfirmation }) => {
return null
}
const handleSubmit = () => {
if (twoFACode.length !== 6) {
setInvalidCode(true)
return
}
confirm2FA({ variables: { code: twoFACode } })
}
return (
showModal && (
<Modal
@ -61,28 +70,26 @@ const Input2FAModal = ({ showModal, handleClose, setConfirmation }) => {
To make changes on this user, please confirm this action by entering
your two-factor authentication code below.
</P>
<CodeInput
name="2fa"
value={twoFACode}
onChange={handleCodeChange}
numInputs={6}
error={invalidCode}
containerStyle={classes.codeContainer}
shouldAutoFocus
/>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={twoFACode}
onChange={handleCodeChange}
numInputs={6}
error={invalidCode}
containerStyle={classes.codeContainer}
shouldAutoFocus
/>
<button onClick={handleSubmit} className={classes.enterButton} />
</Form>
</Formik>
{getErrorMsg() && (
<P className={classes.errorMessage}>{getErrorMsg()}</P>
)}
<div className={classes.footer}>
<Button
className={classes.submit}
onClick={() => {
if (twoFACode.length !== 6) {
setInvalidCode(true)
return
}
confirm2FA({ variables: { code: twoFACode } })
}}>
<Button className={classes.submit} onClick={handleSubmit}>
Confirm
</Button>
</div>

View file

@ -38,7 +38,8 @@ const SAVE_ACCOUNTS = gql`
}
`
const isConfigurable = it => !R.isNil(it) && !R.contains(it)(['mock-exchange'])
const isConfigurable = it =>
!R.isNil(it) && !R.contains(it)(['mock-exchange', 'no-exchange'])
const ChooseExchange = ({ data: currentData, addData }) => {
const classes = useStyles()

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/key/white</title>
<g id="icon/button/key/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" transform="translate(0.500000, 0.500000)" stroke="#FFFFFF">
<circle id="Oval" cx="2.75" cy="8.25" r="2.75"></circle>
<line x1="5.04166667" y1="5.95833333" x2="11" y2="0" id="Path-13" stroke-linecap="round" stroke-linejoin="round"></line>
<line x1="8.25" y1="3.66666667" x2="10.5416667" y2="1.375" id="Path-13-Copy" stroke-width="2" stroke-linejoin="round"></line>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 773 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/key/zodiac</title>
<g id="icon/button/key/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Group" transform="translate(0.500000, 0.500000)" stroke="#1B2559">
<circle id="Oval" cx="2.75" cy="8.25" r="2.75"></circle>
<line x1="5.04166667" y1="5.95833333" x2="11" y2="0" id="Path-13" stroke-linecap="round" stroke-linejoin="round"></line>
<line x1="8.25" y1="3.66666667" x2="10.5416667" y2="1.375" id="Path-13-Copy" stroke-width="2" stroke-linejoin="round"></line>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 775 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/lock/white</title>
<g id="icon/button/lock/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Lock-Icon-White" transform="translate(0.500000, 0.500000)">
<path d="M7.98058644,2.48058644 C7.98058644,1.11059638 6.86999006,0 5.5,0 C4.13000994,0 3.01941356,1.11059638 3.01941356,2.48058644 C3.01941356,3.39391315 3.01941356,4.09482878 3.01941356,4.58333333 L7.98058644,4.58333333 C7.98058644,4.09482878 7.98058644,3.39391315 7.98058644,2.48058644 Z" id="Lock" stroke="#FFFFFF" stroke-linejoin="round"></path>
<rect id="Body" stroke="#FFFFFF" stroke-linejoin="round" x="0" y="4.58333333" width="11" height="6.41666667"></rect>
<circle id="Key-Hole" fill="#FFFFFF" cx="5.5" cy="7.33333333" r="1"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1,010 B

View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/lock/zodiac</title>
<g id="icon/button/lock/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="Lock-Icon-Zodiac" transform="translate(0.500000, 0.500000)">
<path d="M7.98058644,2.48058644 C7.98058644,1.11059638 6.86999006,0 5.5,0 C4.13000994,0 3.01941356,1.11059638 3.01941356,2.48058644 C3.01941356,3.39391315 3.01941356,4.09482878 3.01941356,4.58333333 L7.98058644,4.58333333 C7.98058644,4.09482878 7.98058644,3.39391315 7.98058644,2.48058644 Z" id="Lock" stroke="#1B2559" stroke-linejoin="round"></path>
<rect id="Body" stroke="#1B2559" stroke-linejoin="round" x="0" y="4.58333333" width="11" height="6.41666667"></rect>
<circle id="Key-Hole" fill="#1B2559" cx="5.5" cy="7.33333333" r="1"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1,013 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/user-role/white</title>
<g id="icon/button/user-role/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="User-Role-Icon-White" transform="translate(2.500000, 0.500000)" stroke="#FFFFFF">
<path d="M5.50008791,6.84274776 L5.5,11 L3.66666667,9.35927189 L1.83333333,11 L1.83223109,6.84216075 C2.37179795,7.15453375 2.99835187,7.33333333 3.66666667,7.33333333 C4.33456272,7.33333333 4.96075021,7.15475774 5.50008791,6.84274776 Z" id="Bottom"></path>
<circle id="Top" cx="3.66666667" cy="3.66666667" r="3.66666667"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 840 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="12px" height="12px" viewBox="0 0 12 12" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/button/user-role/zodiac</title>
<g id="icon/button/user-role/zodiac" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linejoin="round">
<g id="User-Role-Icon-Zodiac" transform="translate(2.500000, 0.500000)" stroke="#1B2559">
<path d="M5.50008791,6.84274776 L5.5,11 L3.66666667,9.35927189 L1.83333333,11 L1.83223109,6.84216075 C2.37179795,7.15453375 2.99835187,7.33333333 3.66666667,7.33333333 C4.33456272,7.33333333 4.96075021,7.15475774 5.50008791,6.84274776 Z" id="Bottom"></path>
<circle id="Top" cx="3.66666667" cy="3.66666667" r="3.66666667"></circle>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 843 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/customer-nav/photos/comet</title>
<g id="icon/customer-nav/photos/comet" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" stroke="#5F668A" stroke-width="2" x="1" y="1" width="18" height="18" rx="1"></rect>
<circle id="Oval" stroke="#5F668A" stroke-width="2" cx="15" cy="5" r="1"></circle>
<polyline id="Path" stroke="#5F668A" stroke-width="2" stroke-linejoin="round" points="1 19 7 13 13 19"></polyline>
<path d="M13.3333333,14 L18,19 L13.3333333,19 L11,16.5 L13.3333333,14 Z" id="Combined-Shape" stroke="#5F668A" stroke-width="2" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 850 B

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<title>icon/customer-nav/photos/white</title>
<g id="icon/customer-nav/photos/white" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<rect id="Rectangle" stroke="#FFFFFF" stroke-width="2" x="1" y="1" width="18" height="18" rx="1"></rect>
<circle id="Oval" stroke="#FFFFFF" stroke-width="2" cx="15" cy="5" r="1"></circle>
<polyline id="Path" stroke="#FFFFFF" stroke-width="2" stroke-linejoin="round" points="1 19 7 13 13 19"></polyline>
<path d="M13.3333333,14 L18,19 L13.3333333,19 L11,16.5 L13.3333333,14 Z" id="Combined-Shape" stroke="#FFFFFF" stroke-width="2" stroke-linejoin="round"></path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 850 B

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<circle id="path-1-right" cx="10" cy="10" r="10"></circle>
</defs>
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="pop-up/action/download-logs/date-range-copy-2" transform="translate(-232.000000, -187.000000)">
<g id="icon/sf-contain-b-copy-4" transform="translate(242.000000, 197.000000) scale(-1, 1) rotate(-270.000000) translate(-242.000000, -197.000000) translate(232.000000, 187.000000)">
<mask id="mask-2" fill="white">
<use xlink:href="#path-1-right"></use>
</mask>
<use id="Mask" fill="#FFFFFF" fill-rule="nonzero" xlink:href="#path-1-right"></use>
<g id="icon/sf-small/wizzard" mask="url(#mask-2)" stroke-linecap="round" stroke-linejoin="round">
<g transform="translate(6.666667, 6.000000)" id="Group">
<g>
<polyline id="Path-3" stroke="#1B2559" stroke-width="2" points="0 4.83333333 3.33333333 8.16666667 6.66666667 4.83333333"></polyline>
<line x1="3.33333333" y1="0.25" x2="3.33333333" y2="6.5" id="Path-4" stroke="#1B2559" stroke-width="2"></line>
</g>
</g>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -7,4 +7,7 @@ const transformNumber = value => (isValidNumber(value) ? value : null)
const defaultToZero = value =>
isValidNumber(parseInt(value)) ? parseInt(value) : 0
export { defaultToZero, transformNumber }
const numberToFiatAmount = value =>
value.toLocaleString('en-US', { maximumFractionDigits: 2 })
export { defaultToZero, transformNumber, numberToFiatAmount }

Some files were not shown because too many files have changed in this diff Show more