Merge branch 'dev' into backport/commission-rounding

This commit is contained in:
Rafael Taranto 2024-11-29 13:45:55 +00:00 committed by GitHub
commit 1b9c6f7df7
100 changed files with 1718 additions and 348 deletions

View file

@ -55,6 +55,20 @@ function updateCore (coinRec, isCurrentlyRunning) {
common.es(`echo "\nlistenonion=0" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`) common.es(`echo "\nlistenonion=0" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
} }
if (common.es(`grep "fallbackfee=" ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf || true`)) {
common.logger.info(`fallbackfee already defined, skipping...`)
} else {
common.logger.info(`Setting 'fallbackfee=0.00005' in config file...`)
common.es(`echo "\nfallbackfee=0.00005" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
}
if (common.es(`grep "rpcworkqueue=" ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf || true`)) {
common.logger.info(`rpcworkqueue already defined, skipping...`)
} else {
common.logger.info(`Setting 'rpcworkqueue=2000' in config file...`)
common.es(`echo "\nrpcworkqueue=2000" >> ${BLOCKCHAIN_DIR}/bitcoin/bitcoin.conf`)
}
if (isCurrentlyRunning && !isDevMode()) { if (isCurrentlyRunning && !isDevMode()) {
common.logger.info('Starting wallet...') common.logger.info('Starting wallet...')
common.es(`sudo supervisorctl start bitcoin`) common.es(`sudo supervisorctl start bitcoin`)

View file

@ -148,18 +148,17 @@ function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
} }
}) })
.catch(err => { .catch(err => {
// Important: We don't know what kind of error this is // Important: We don't know what kind of error this is
// so not safe to assume that funds weren't sent. // so not safe to assume that funds weren't sent.
// Therefore, don't set sendPending to false except for
// errors (like InsufficientFundsError) that are guaranteed // Setting sendPending to true ensures that the transaction gets
// not to send. // silently terminated and no retries are done
const sendPending = err.name !== 'InsufficientFundsError'
return { return {
sendTime: 'now()^', sendTime: 'now()^',
error: err.message, error: err.message,
errorCode: err.name, errorCode: err.name,
sendPending sendPending: true
} }
}) })
.then(sendRec => { .then(sendRec => {

View file

@ -51,7 +51,7 @@ const mapValuesWithKey = _.mapValues.convert({cap: false})
function convertBigNumFields (obj) { function convertBigNumFields (obj) {
const convert = (value, key) => { const convert = (value, key) => {
if (_.includes(key, [ 'cryptoAtoms', 'receivedCryptoAtoms', 'fiat' ])) { if (_.includes(key, [ 'cryptoAtoms', 'receivedCryptoAtoms', 'fiat', 'fixedFee', 'fixedFeeCrypto' ])) {
return value.toString() return value.toString()
} }

View file

@ -29,6 +29,7 @@ function mapCoin (rates, deviceId, settings, cryptoCode) {
const cashInFee = showCommissions ? commissions.cashIn / 100 : null const cashInFee = showCommissions ? commissions.cashIn / 100 : null
const cashOutFee = showCommissions ? commissions.cashOut / 100 : null const cashOutFee = showCommissions ? commissions.cashOut / 100 : null
const cashInFixedFee = showCommissions ? commissions.fixedFee : null const cashInFixedFee = showCommissions ? commissions.fixedFee : null
const cashOutFixedFee = showCommissions ? commissions.cashOutFixedFee : null
const cashInRate = showCommissions ? _.invoke('cashIn.toNumber', buildedRates) : null const cashInRate = showCommissions ? _.invoke('cashIn.toNumber', buildedRates) : null
const cashOutRate = showCommissions ? _.invoke('cashOut.toNumber', buildedRates) : null const cashOutRate = showCommissions ? _.invoke('cashOut.toNumber', buildedRates) : null
@ -37,6 +38,7 @@ function mapCoin (rates, deviceId, settings, cryptoCode) {
cashInFee, cashInFee,
cashOutFee, cashOutFee,
cashInFixedFee, cashInFixedFee,
cashOutFixedFee,
cashInRate, cashInRate,
cashOutRate cashOutRate
} }

View file

@ -80,7 +80,7 @@ function getWithEmail (email) {
* *
* @param {string} id Customer's id * @param {string} id Customer's id
* @param {object} data Fields to update * @param {object} data Fields to update
* @param {string} Acting user's token * @param {string} userToken Acting user's token
* *
* @returns {Promise} Newly updated Customer * @returns {Promise} Newly updated Customer
*/ */
@ -114,6 +114,7 @@ function update (id, data, userToken) {
async function updateCustomer (id, data, userToken) { async function updateCustomer (id, data, userToken) {
const formattedData = _.pick( const formattedData = _.pick(
[ [
'sanctions',
'authorized_override', 'authorized_override',
'id_card_photo_override', 'id_card_photo_override',
'id_card_data_override', 'id_card_data_override',
@ -229,7 +230,7 @@ function enhanceEditedPhotos (fields) {
/** /**
* Remove the edited data from the db record * Remove the edited data from the db record
* *
* @name enhanceOverrideFields * @name deleteEditedData
* @function * @function
* *
* @param {string} id Customer's id * @param {string} id Customer's id

View file

@ -89,6 +89,7 @@ const staticConfig = ({ currentConfigVersion, deviceId, deviceName, pq, settings
'cashInCommission', 'cashInCommission',
'cashInFee', 'cashInFee',
'cashOutCommission', 'cashOutCommission',
'cashOutFee',
'cryptoCode', 'cryptoCode',
'cryptoCodeDisplay', 'cryptoCodeDisplay',
'cryptoNetwork', 'cryptoNetwork',

View file

@ -6,6 +6,7 @@ type Coin {
display: String! display: String!
minimumTx: String! minimumTx: String!
cashInFee: String! cashInFee: String!
cashOutFee: String!
cashInCommission: String! cashInCommission: String!
cashOutCommission: String! cashOutCommission: String!
cryptoNetwork: String! cryptoNetwork: String!

View file

@ -0,0 +1,15 @@
const addRWBytes = () => (req, res, next) => {
const handle = () => {
res.removeListener('finish', handle)
res.removeListener('close', handle)
res.bytesRead = req.connection.bytesRead
res.bytesWritten = req.connection.bytesWritten
}
res.on('finish', handle)
res.on('close', handle)
next()
}
module.exports = addRWBytes

View file

@ -14,6 +14,7 @@ const machine = require('./machine.resolver')
const notification = require('./notification.resolver') const notification = require('./notification.resolver')
const pairing = require('./pairing.resolver') const pairing = require('./pairing.resolver')
const rates = require('./rates.resolver') const rates = require('./rates.resolver')
const sanctions = require('./sanctions.resolver')
const scalar = require('./scalar.resolver') const scalar = require('./scalar.resolver')
const settings = require('./settings.resolver') const settings = require('./settings.resolver')
const sms = require('./sms.resolver') const sms = require('./sms.resolver')
@ -37,6 +38,7 @@ const resolvers = [
notification, notification,
pairing, pairing,
rates, rates,
sanctions,
scalar, scalar,
settings, settings,
sms, sms,

View file

@ -0,0 +1,13 @@
const sanctions = require('../../../sanctions')
const authentication = require('../modules/userManagement')
const resolvers = {
Query: {
checkAgainstSanctions: (...[, { customerId }, context]) => {
const token = authentication.getToken(context)
return sanctions.checkByUser(customerId, token)
}
}
}
module.exports = resolvers

View file

@ -14,6 +14,7 @@ const machine = require('./machine.type')
const notification = require('./notification.type') const notification = require('./notification.type')
const pairing = require('./pairing.type') const pairing = require('./pairing.type')
const rates = require('./rates.type') const rates = require('./rates.type')
const sanctions = require('./sanctions.type')
const scalar = require('./scalar.type') const scalar = require('./scalar.type')
const settings = require('./settings.type') const settings = require('./settings.type')
const sms = require('./sms.type') const sms = require('./sms.type')
@ -37,6 +38,7 @@ const types = [
notification, notification,
pairing, pairing,
rates, rates,
sanctions,
scalar, scalar,
settings, settings,
sms, sms,

View file

@ -0,0 +1,13 @@
const { gql } = require('apollo-server-express')
const typeDef = gql`
type SanctionMatches {
ofacSanctioned: Boolean
}
type Query {
checkAgainstSanctions(customerId: ID): SanctionMatches @auth
}
`
module.exports = typeDef

View file

@ -23,7 +23,7 @@ const typeDef = gql`
errorCode: String errorCode: String
operatorCompleted: Boolean operatorCompleted: Boolean
sendPending: Boolean sendPending: Boolean
cashInFee: String fixedFee: String
minimumTx: Float minimumTx: Float
customerId: ID customerId: ID
isAnonymous: Boolean isAnonymous: Boolean

View file

@ -50,7 +50,19 @@ function batch (
excludeTestingCustomers = false, excludeTestingCustomers = false,
simplified simplified
) { ) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addProfits, addNames) const packager = _.flow(
_.flatten,
_.orderBy(_.property('created'), ['desc']),
_.map(_.flow(
camelize,
_.mapKeys(k =>
k == 'cashInFee' ? 'fixedFee' :
k
)
)),
addProfits,
addNames
)
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*, const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
c.phone AS customer_phone, c.phone AS customer_phone,
@ -153,7 +165,7 @@ function batch (
function advancedBatch (data) { function advancedBatch (data) {
const fields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms', const fields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms',
'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount', 'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount',
'dispense', 'notified', 'redeem', 'phone', 'error', 'dispense', 'notified', 'redeem', 'phone', 'error', 'fixedFee',
'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout', 'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout',
'dispenseConfirmed', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4', 'dispenseConfirmed', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4',
'provisionedRecycler1', 'provisionedRecycler2', 'provisionedRecycler3', 'provisionedRecycler4', 'provisionedRecycler5', 'provisionedRecycler6', 'provisionedRecycler1', 'provisionedRecycler2', 'provisionedRecycler3', 'provisionedRecycler4', 'provisionedRecycler5', 'provisionedRecycler6',
@ -169,7 +181,9 @@ function advancedBatch (data) {
...it, ...it,
status: getStatus(it), status: getStatus(it),
fiatProfit: getProfit(it).toString(), fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString() cryptoAmount: getCryptoAmount(it).toString(),
fixedFee: it.fixedFee ?? null,
fee: it.fee ?? null,
})) }))
return _.compose(_.map(_.pick(fields)), addAdvancedFields)(data) return _.compose(_.map(_.pick(fields)), addAdvancedFields)(data)

View file

@ -249,6 +249,7 @@ function plugins (settings, deviceId) {
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config) const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
const minimumTx = new BN(commissions.minimumTx) const minimumTx = new BN(commissions.minimumTx)
const cashInFee = new BN(commissions.fixedFee) const cashInFee = new BN(commissions.fixedFee)
const cashOutFee = new BN(commissions.cashOutFixedFee)
const cashInCommission = new BN(commissions.cashIn) const cashInCommission = new BN(commissions.cashIn)
const cashOutCommission = _.isNumber(commissions.cashOut) ? new BN(commissions.cashOut) : null const cashOutCommission = _.isNumber(commissions.cashOut) ? new BN(commissions.cashOut) : null
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
@ -261,6 +262,7 @@ function plugins (settings, deviceId) {
isCashInOnly: Boolean(cryptoRec.isCashinOnly), isCashInOnly: Boolean(cryptoRec.isCashinOnly),
minimumTx: BN.max(minimumTx, cashInFee), minimumTx: BN.max(minimumTx, cashInFee),
cashInFee, cashInFee,
cashOutFee,
cashInCommission, cashInCommission,
cashOutCommission, cashOutCommission,
cryptoNetwork, cryptoNetwork,

View file

@ -7,10 +7,11 @@ const nocache = require('nocache')
const logger = require('./logger') const logger = require('./logger')
const addRWBytes = require('./middlewares/addRWBytes')
const authorize = require('./middlewares/authorize') const authorize = require('./middlewares/authorize')
const computeSchema = require('./middlewares/compute-schema')
const errorHandler = require('./middlewares/errorHandler') const errorHandler = require('./middlewares/errorHandler')
const filterOldRequests = require('./middlewares/filterOldRequests') const filterOldRequests = require('./middlewares/filterOldRequests')
const computeSchema = require('./middlewares/compute-schema')
const findOperatorId = require('./middlewares/operatorId') const findOperatorId = require('./middlewares/operatorId')
const populateDeviceId = require('./middlewares/populateDeviceId') const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings') const populateSettings = require('./middlewares/populateSettings')
@ -50,11 +51,24 @@ const configRequiredRoutes = [
] ]
// middleware setup // middleware setup
app.use(addRWBytes())
app.use(compression({ threshold: 500 })) app.use(compression({ threshold: 500 }))
app.use(helmet()) app.use(helmet())
app.use(nocache()) app.use(nocache())
app.use(express.json({ limit: '2mb' })) app.use(express.json({ limit: '2mb' }))
app.use(morgan(':method :url :status :response-time ms -- :req[content-length]/:res[content-length] b', { stream: logger.stream }))
morgan.token('bytesRead', (_req, res) => res.bytesRead)
morgan.token('bytesWritten', (_req, res) => res.bytesWritten)
app.use(morgan(':method :url :status :response-time ms -- :bytesRead/:bytesWritten B', { stream: logger.stream }))
app.use('/robots.txt', (req, res) => {
res.type('text/plain')
res.send("User-agent: *\nDisallow: /")
})
app.get('/', (req, res) => {
res.sendStatus(404)
})
// app /pair and /ca routes // app /pair and /ca routes
app.use('/', pairingRoutes) app.use('/', pairingRoutes)

View file

@ -108,7 +108,7 @@ function updateCustomer (req, res, next) {
.then(_.merge(patch)) .then(_.merge(patch))
.then(newPatch => customers.updatePhotoCard(id, newPatch)) .then(newPatch => customers.updatePhotoCard(id, newPatch))
.then(newPatch => customers.updateFrontCamera(id, newPatch)) .then(newPatch => customers.updateFrontCamera(id, newPatch))
.then(newPatch => customers.update(id, newPatch, null, txId)) .then(newPatch => customers.update(id, newPatch, null))
.then(customer => { .then(customer => {
createPendingManualComplianceNotifs(settings, customer, deviceId) createPendingManualComplianceNotifs(settings, customer, deviceId)
respond(req, res, { customer }) respond(req, res, { customer })

44
lib/sanctions.js Normal file
View file

@ -0,0 +1,44 @@
const _ = require('lodash/fp')
const ofac = require('./ofac')
const T = require('./time')
const logger = require('./logger')
const customers = require('./customers')
const sanctionStatus = {
loaded: false,
timestamp: null
}
const loadOrUpdateSanctions = () => {
if (!sanctionStatus.loaded || (sanctionStatus.timestamp && Date.now() > sanctionStatus.timestamp + T.day)) {
logger.info('No sanction lists loaded. Loading sanctions...')
return ofac.load()
.then(() => {
logger.info('OFAC sanction list loaded!')
sanctionStatus.loaded = true
sanctionStatus.timestamp = Date.now()
})
.catch(e => {
logger.error('Couldn\'t load OFAC sanction list!')
})
}
return Promise.resolve()
}
const checkByUser = (customerId, userToken) => {
return Promise.all([loadOrUpdateSanctions(), customers.getCustomerById(customerId)])
.then(([, customer]) => {
const { firstName, lastName, dateOfBirth } = customer?.idCardData
const birthdate = _.replace(/-/g, '')(dateOfBirth)
const ofacMatches = ofac.match({ firstName, lastName }, birthdate, { threshold: 0.85, fullNameThreshold: 0.95, debug: false })
const isOfacSanctioned = _.size(ofacMatches) > 0
customers.updateCustomer(customerId, { sanctions: !isOfacSanctioned }, userToken)
return { ofacSanctioned: isOfacSanctioned }
})
}
module.exports = {
checkByUser
}

View file

@ -40,6 +40,7 @@ function massage (tx, pi) {
: { : {
cryptoAtoms: new BN(r.cryptoAtoms), cryptoAtoms: new BN(r.cryptoAtoms),
fiat: new BN(r.fiat), fiat: new BN(r.fiat),
fixedFee: new BN(r.fixedFee),
rawTickerPrice: r.rawTickerPrice ? new BN(r.rawTickerPrice) : null, rawTickerPrice: r.rawTickerPrice ? new BN(r.rawTickerPrice) : null,
commissionPercentage: new BN(r.commissionPercentage) commissionPercentage: new BN(r.commissionPercentage)
} }
@ -69,7 +70,7 @@ function cancel (txId) {
} }
function customerHistory (customerId, thresholdDays) { function customerHistory (customerId, thresholdDays) {
const sql = `SELECT * FROM ( const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM (
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction, SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction,
((NOT txIn.send_confirmed) AND (txIn.created <= now() - interval $3)) AS expired ((NOT txIn.send_confirmed) AND (txIn.created <= now() - interval $3)) AS expired
FROM cash_in_txs txIn FROM cash_in_txs txIn

View file

@ -0,0 +1,7 @@
const db = require('./db')
exports.up = next => db.multi([
'ALTER TABLE cash_out_txs ADD COLUMN fixed_fee numeric(14, 5) NOT NULL DEFAULT 0;'
], next)
exports.down = next => next()

View file

@ -0,0 +1,7 @@
const { saveConfig } = require('../lib/new-settings-loader')
exports.up = next => saveConfig({ 'commissions_cashOutFixedFee': 0 })
.then(next)
.catch(next)
exports.down = next => next()

View file

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View file

@ -13,9 +13,10 @@ const useStyles = makeStyles({
display: 'flex' display: 'flex'
}, },
imgInner: { imgInner: {
objectFit: 'cover', objectFit: 'contain',
objectPosition: 'center', objectPosition: 'center',
width: 500, width: 500,
height: 400,
marginBottom: 40 marginBottom: 40
} }
}) })

View file

@ -13,6 +13,16 @@ const useStyles = makeStyles({
cursor: 'pointer', cursor: 'pointer',
marginTop: 4 marginTop: 4
}, },
relativelyPositioned: {
position: 'relative'
},
safeSpace: {
position: 'absolute',
backgroundColor: '#0000',
height: 40,
left: '-50%',
width: '200%'
},
popoverContent: ({ width }) => ({ popoverContent: ({ width }) => ({
width, width,
padding: [[10, 15]] padding: [[10, 15]]
@ -27,6 +37,10 @@ const usePopperHandler = width => {
setHelpPopperAnchorEl(helpPopperAnchorEl ? null : event.currentTarget) setHelpPopperAnchorEl(helpPopperAnchorEl ? null : event.currentTarget)
} }
const openHelpPopper = event => {
setHelpPopperAnchorEl(event.currentTarget)
}
const handleCloseHelpPopper = () => { const handleCloseHelpPopper = () => {
setHelpPopperAnchorEl(null) setHelpPopperAnchorEl(null)
} }
@ -38,25 +52,32 @@ const usePopperHandler = width => {
helpPopperAnchorEl, helpPopperAnchorEl,
helpPopperOpen, helpPopperOpen,
handleOpenHelpPopper, handleOpenHelpPopper,
openHelpPopper,
handleCloseHelpPopper handleCloseHelpPopper
} }
} }
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => { const HelpTooltip = memo(({ children, width }) => {
const handler = usePopperHandler(width) const handler = usePopperHandler(width)
return ( return (
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}> <ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
<div> <div
className={handler.classes.relativelyPositioned}
onMouseLeave={handler.handleCloseHelpPopper}>
{handler.helpPopperOpen && (
<div className={handler.classes.safeSpace}></div>
)}
<button <button
type="button" type="button"
className={handler.classes.transparentButton} className={handler.classes.transparentButton}
onClick={handler.handleOpenHelpPopper}> onMouseEnter={handler.openHelpPopper}>
<Icon /> <HelpIcon />
</button> </button>
<Popper <Popper
open={handler.helpPopperOpen} open={handler.helpPopperOpen}
anchorEl={handler.helpPopperAnchorEl} anchorEl={handler.helpPopperAnchorEl}
arrowEnabled={true}
placement="bottom"> placement="bottom">
<div className={handler.classes.popoverContent}>{children}</div> <div className={handler.classes.popoverContent}>{children}</div>
</Popper> </Popper>
@ -69,31 +90,30 @@ const HoverableTooltip = memo(({ parentElements, children, width }) => {
const handler = usePopperHandler(width) const handler = usePopperHandler(width)
return ( return (
<div> <ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
{!R.isNil(parentElements) && ( <div>
<div {!R.isNil(parentElements) && (
onMouseEnter={handler.handleOpenHelpPopper} <div onMouseEnter={handler.handleOpenHelpPopper}>
onMouseLeave={handler.handleCloseHelpPopper}> {parentElements}
{parentElements} </div>
</div> )}
)} {R.isNil(parentElements) && (
{R.isNil(parentElements) && ( <button
<button type="button"
type="button" onMouseEnter={handler.handleOpenHelpPopper}
onMouseEnter={handler.handleOpenHelpPopper} className={handler.classes.transparentButton}>
onMouseLeave={handler.handleCloseHelpPopper} <HelpIcon />
className={handler.classes.transparentButton}> </button>
<HelpIcon /> )}
</button> <Popper
)} open={handler.helpPopperOpen}
<Popper anchorEl={handler.helpPopperAnchorEl}
open={handler.helpPopperOpen} placement="bottom">
anchorEl={handler.helpPopperAnchorEl} <div className={handler.classes.popoverContent}>{children}</div>
placement="bottom"> </Popper>
<div className={handler.classes.popoverContent}>{children}</div> </div>
</Popper> </ClickAwayListener>
</div>
) )
}) })
export { Tooltip, HoverableTooltip } export { HoverableTooltip, HelpTooltip }

View file

@ -9,7 +9,7 @@ import {
TDoubleLevelHead, TDoubleLevelHead,
ThDoubleLevel ThDoubleLevel
} from 'src/components/fake-table/Table' } from 'src/components/fake-table/Table'
import { startCase } from 'src/utils/string' import { sentenceCase } from 'src/utils/string'
import TableCtx from './Context' import TableCtx from './Context'
@ -22,22 +22,27 @@ const styles = {
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const groupSecondHeader = elements => { const groupSecondHeader = elements => {
const [toSHeader, noSHeader] = R.partition(R.has('doubleHeader'))(elements) const doubleHeader = R.prop('doubleHeader')
const sameDoubleHeader = (a, b) => doubleHeader(a) === doubleHeader(b)
if (!toSHeader.length) { const group = R.pipe(
return [elements, THead] R.groupWith(sameDoubleHeader),
} R.map(group =>
R.isNil(doubleHeader(group[0])) // No doubleHeader
const index = R.indexOf(toSHeader[0], elements) ? group
const width = R.compose(R.sum, R.map(R.path(['width'])))(toSHeader) : [
{
const innerElements = R.insert( width: R.sum(R.map(R.prop('width'), group)),
index, elements: group,
{ width, elements: toSHeader, name: toSHeader[0].doubleHeader }, name: doubleHeader(group[0])
noSHeader }
]
),
R.reduce(R.concat, [])
) )
return [innerElements, TDoubleLevelHead] return R.all(R.pipe(doubleHeader, R.isNil), elements)
? [elements, THead]
: [group(elements), TDoubleLevelHead]
} }
const Header = () => { const Header = () => {
@ -99,7 +104,7 @@ const Header = () => {
<>{attachOrderedByToComplexHeader(header) ?? header}</> <>{attachOrderedByToComplexHeader(header) ?? header}</>
) : ( ) : (
<span className={orderClasses}> <span className={orderClasses}>
{!R.isNil(display) ? display : startCase(name)}{' '} {!R.isNil(display) ? display : sentenceCase(name)}{' '}
{!R.isNil(orderedBy) && R.equals(name, orderedBy.code) && '-'} {!R.isNil(orderedBy) && R.equals(name, orderedBy.code) && '-'}
</span> </span>
)} )}

View file

@ -187,7 +187,7 @@ const MachineActions = memo(({ machine, onActionSuccess }) => {
display: 'Restart services for' display: 'Restart services for'
}) })
}}> }}>
Restart Services Restart services
</ActionButton> </ActionButton>
{machine.model === 'aveiro' && ( {machine.model === 'aveiro' && (
<ActionButton <ActionButton

View file

@ -6,7 +6,7 @@ import * as R from 'ramda'
import React, { useContext } from 'react' import React, { useContext } from 'react'
import AppContext from 'src/AppContext' import AppContext from 'src/AppContext'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import DataTable from 'src/components/tables/DataTable' import DataTable from 'src/components/tables/DataTable'
import { H4, Info2, P } from 'src/components/typography' import { H4, Info2, P } from 'src/components/typography'
@ -128,9 +128,9 @@ const Accounting = () => {
<span className={classes.operation}> <span className={classes.operation}>
{it.description} {it.description}
{!!it.extraInfo && ( {!!it.extraInfo && (
<HoverableTooltip width={175}> <HelpTooltip width={175}>
<P>{it.extraInfo}</P> <P>{it.extraInfo}</P>
</HoverableTooltip> </HelpTooltip>
)} )}
</span> </span>
) )

View file

@ -23,22 +23,26 @@ import LegendEntry from './components/LegendEntry'
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper' import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
import OverTimeWrapper from './components/wrappers/OverTimeWrapper' import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper' import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }] const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
const REPRESENTING_OPTIONS = [ const REPRESENTING_OPTIONS = [
{ code: 'overTime', display: 'Over time' }, { code: 'overTime', display: 'Over time' },
{ code: 'topMachines', display: 'Top Machines' }, { code: 'volumeOverTime', display: 'Volume' },
{ code: 'topMachines', display: 'Top machines' },
{ code: 'hourOfTheDay', display: 'Hour of the day' } { code: 'hourOfTheDay', display: 'Hour of the day' }
] ]
const PERIOD_OPTIONS = [ const PERIOD_OPTIONS = [
{ code: 'day', display: 'Last 24 hours' }, { code: 'day', display: 'Last 24 hours' },
{ code: 'threeDays', display: 'Last 3 days' },
{ code: 'week', display: 'Last 7 days' }, { code: 'week', display: 'Last 7 days' },
{ code: 'month', display: 'Last 30 days' } { code: 'month', display: 'Last 30 days' }
] ]
const TIME_OPTIONS = { const TIME_OPTIONS = {
day: DAY, day: DAY,
threeDays: 3 * DAY,
week: WEEK, week: WEEK,
month: MONTH month: MONTH
} }
@ -77,7 +81,7 @@ const GET_TRANSACTIONS = gql`
hasError: error hasError: error
deviceId deviceId
fiat fiat
cashInFee fixedFee
fiatCode fiatCode
cryptoAtoms cryptoAtoms
cryptoCode cryptoCode
@ -223,13 +227,11 @@ const Analytics = () => {
previous: filteredData(period.code).previous.length previous: filteredData(period.code).previous.length
} }
const avgAmount = { const median = values => (values.length === 0 ? 0 : R.median(values))
current:
R.sum(R.map(d => d.fiat, filteredData(period.code).current)) / const medianAmount = {
(txs.current === 0 ? 1 : txs.current), current: median(R.map(d => d.fiat, filteredData(period.code).current)),
previous: previous: median(R.map(d => d.fiat, filteredData(period.code).previous))
R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) /
(txs.previous === 0 ? 1 : txs.previous)
} }
const txVolume = { const txVolume = {
@ -265,6 +267,20 @@ const Analytics = () => {
currency={fiatLocale} currency={fiatLocale}
/> />
) )
case 'volumeOverTime':
return (
<VolumeOverTimeWrapper
title="Transactions volume over time"
representing={representing}
period={period}
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
machines={machineOptions}
selectedMachine={machine}
handleMachineChange={setMachine}
timezone={timezone}
currency={fiatLocale}
/>
)
case 'topMachines': case 'topMachines':
return ( return (
<TopMachinesWrapper <TopMachinesWrapper
@ -347,9 +363,9 @@ const Analytics = () => {
/> />
<div className={classes.verticalLine} /> <div className={classes.verticalLine} />
<OverviewEntry <OverviewEntry
label="Avg. txn amount" label="Median amount"
value={avgAmount.current} value={medianAmount.current}
oldValue={avgAmount.previous} oldValue={medianAmount.previous}
currency={fiatLocale} currency={fiatLocale}
/> />
<div className={classes.verticalLine} /> <div className={classes.verticalLine} />

View file

@ -1,4 +1,14 @@
import { offDarkColor, tomato, neon, java } from 'src/styling/variables' import {
offColor,
offDarkColor,
tomato,
neon,
java
} from 'src/styling/variables'
import typographyStyles from '../../components/typography/styles'
const { label1 } = typographyStyles
const styles = { const styles = {
overviewLegend: { overviewLegend: {
@ -135,6 +145,18 @@ const styles = {
topMachinesRadio: { topMachinesRadio: {
display: 'flex', display: 'flex',
flexDirection: 'row' flexDirection: 'row'
},
graphHeaderSwitchBox: {
display: 'flex',
flexDirection: 'column',
'& > *': {
margin: 0
},
'& > :first-child': {
marginBottom: 2,
extend: label1,
color: offColor
}
} }
} }

View file

@ -26,18 +26,12 @@ const GraphTooltip = ({
const formattedDateInterval = !R.includes('hourOfDay', representing.code) const formattedDateInterval = !R.includes('hourOfDay', representing.code)
? [ ? [
formatDate( formatDate(dateInterval[1], null, 'MMM d'),
dateInterval[1], formatDate(dateInterval[1], null, 'HH:mm'),
null, formatDate(dateInterval[0], null, 'HH:mm')
period.code === 'day' ? 'MMM d, HH:mm' : 'MMM d'
),
formatDate(
dateInterval[0],
null,
period.code === 'day' ? 'HH:mm' : 'MMM d'
)
] ]
: [ : [
formatDate(dateInterval[1], null, 'MMM d'),
formatDateNonUtc(dateInterval[1], 'HH:mm'), formatDateNonUtc(dateInterval[1], 'HH:mm'),
formatDateNonUtc(dateInterval[0], 'HH:mm') formatDateNonUtc(dateInterval[0], 'HH:mm')
] ]
@ -55,10 +49,11 @@ const GraphTooltip = ({
return ( return (
<Paper className={classes.dotOtWrapper}> <Paper className={classes.dotOtWrapper}>
{!R.includes('hourOfDay', representing.code) && (
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
)}
<Info2 noMargin> <Info2 noMargin>
{period.code === 'day' || R.includes('hourOfDay', representing.code) {`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
? `${formattedDateInterval[0]} - ${formattedDateInterval[1]}`
: `${formattedDateInterval[0]}`}
</Info2> </Info2>
<P noMargin className={classes.dotOtTransactionAmount}> <P noMargin className={classes.dotOtTransactionAmount}>
{R.length(data)}{' '} {R.length(data)}{' '}

View file

@ -1,8 +1,8 @@
import { Box } from '@material-ui/core' import { Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import React from 'react' import React, { useState } from 'react'
import { Select } from 'src/components/inputs' import { Select, Switch } from 'src/components/inputs'
import { H2 } from 'src/components/typography' import { H2 } from 'src/components/typography'
import { primaryColor } from 'src/styling/variables' import { primaryColor } from 'src/styling/variables'
@ -25,11 +25,13 @@ const OverTimeDotGraphHeader = ({
}) => { }) => {
const classes = useStyles() const classes = useStyles()
const [logarithmic, setLogarithmic] = useState()
const legend = { const legend = {
cashIn: <div className={classes.cashInIcon}></div>, cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>, cashOut: <div className={classes.cashOutIcon}></div>,
transaction: <div className={classes.txIcon}></div>, transaction: <div className={classes.txIcon}></div>,
average: ( median: (
<svg height="12" width="18"> <svg height="12" width="18">
<path <path
stroke={primaryColor} stroke={primaryColor}
@ -53,10 +55,14 @@ const OverTimeDotGraphHeader = ({
IconElement={legend.transaction} IconElement={legend.transaction}
label={'One transaction'} label={'One transaction'}
/> />
<LegendEntry IconElement={legend.average} label={'Average'} /> <LegendEntry IconElement={legend.median} label={'Median'} />
</Box> </Box>
</div> </div>
<div className={classes.graphHeaderRight}> <div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<span>Log. scale</span>
<Switch onChange={event => setLogarithmic(event.target.checked)} />
</div>
<Select <Select
label="Machines" label="Machines"
onSelectedItemChange={handleMachineChange} onSelectedItemChange={handleMachineChange}
@ -74,6 +80,7 @@ const OverTimeDotGraphHeader = ({
currency={currency} currency={currency}
selectedMachine={selectedMachine} selectedMachine={selectedMachine}
machines={machines} machines={machines}
log={logarithmic}
/> />
</> </>
) )

View file

@ -0,0 +1,91 @@
import { Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import React, { useState } from 'react'
import { Select, Switch } from 'src/components/inputs'
import { H2 } from 'src/components/typography'
import { neon, java } from 'src/styling/variables'
import styles from '../../Analytics.styles'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
const useStyles = makeStyles(styles)
const VolumeOverTimeGraphHeader = ({
title,
representing,
period,
data,
machines,
selectedMachine,
handleMachineChange,
timezone,
currency
}) => {
const classes = useStyles()
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: (
<svg height="12" width="18">
<path
stroke={java}
strokeWidth="3"
d="M 3 6 l 12 0"
stroke-linecap="round"
/>
</svg>
),
cashOut: (
<svg height="12" width="18">
<path
stroke={neon}
strokeWidth="3"
d="M 3 6 l 12 0"
stroke-linecap="round"
/>
</svg>
)
}
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<Box className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</Box>
</div>
<div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<span>Log. scale</span>
<Switch onChange={event => setLogarithmic(event.target.checked)} />
</div>
<Select
label="Machines"
onSelectedItemChange={handleMachineChange}
items={machines}
default={machines[0]}
selectedItem={selectedMachine}
/>
</div>
</div>
<Graph
representing={representing}
period={period}
data={data}
timezone={timezone}
currency={currency}
selectedMachine={selectedMachine}
machines={machines}
log={logarithmic}
/>
</>
)
}
export default VolumeOverTimeGraphHeader

View file

@ -5,6 +5,7 @@ import GraphTooltip from '../components/tooltips/GraphTooltip'
import HourOfDayBarGraph from './HourOfDayBarGraph' import HourOfDayBarGraph from './HourOfDayBarGraph'
import OverTimeDotGraph from './OverTimeDotGraph' import OverTimeDotGraph from './OverTimeDotGraph'
import OverTimeLineGraph from './OverTimeLineGraph'
import TopMachinesBarGraph from './TopMachinesBarGraph' import TopMachinesBarGraph from './TopMachinesBarGraph'
const GraphWrapper = ({ const GraphWrapper = ({
@ -15,7 +16,8 @@ const GraphWrapper = ({
currency, currency,
selectedMachine, selectedMachine,
machines, machines,
selectedDay selectedDay,
log
}) => { }) => {
const [selectionCoords, setSelectionCoords] = useState(null) const [selectionCoords, setSelectionCoords] = useState(null)
const [selectionDateInterval, setSelectionDateInterval] = useState(null) const [selectionDateInterval, setSelectionDateInterval] = useState(null)
@ -33,6 +35,20 @@ const GraphWrapper = ({
setSelectionDateInterval={setSelectionDateInterval} setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData} setSelectionData={setSelectionData}
selectedMachine={selectedMachine} selectedMachine={selectedMachine}
log={log}
/>
)
case 'volumeOverTime':
return (
<OverTimeLineGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
log={log}
/> />
) )
case 'topMachinesVolume': case 'topMachinesVolume':

View file

@ -15,6 +15,7 @@ import {
fontSecondary, fontSecondary,
subheaderColor subheaderColor
} from 'src/styling/variables' } from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time' import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({ const Graph = ({
@ -23,7 +24,8 @@ const Graph = ({
timezone, timezone,
setSelectionCoords, setSelectionCoords,
setSelectionData, setSelectionData,
setSelectionDateInterval setSelectionDateInterval,
log = false
}) => { }) => {
const ref = useRef(null) const ref = useRef(null)
@ -36,7 +38,7 @@ const Graph = ({
top: 25, top: 25,
right: 3.5, right: 3.5,
bottom: 27, bottom: 27,
left: 36.5 left: 38
}), }),
[] []
) )
@ -46,6 +48,7 @@ const Graph = ({
const periodDomains = { const periodDomains = {
day: [NOW - DAY, NOW], day: [NOW - DAY, NOW],
threeDays: [NOW - 3 * DAY, NOW],
week: [NOW - WEEK, NOW], week: [NOW - WEEK, NOW],
month: [NOW - MONTH, NOW] month: [NOW - MONTH, NOW]
} }
@ -58,6 +61,12 @@ const Graph = ({
tick: d3.utcHour.every(1), tick: d3.utcHour.every(1),
labelFormat: '%H:%M' labelFormat: '%H:%M'
}, },
threeDays: {
freq: 12,
step: 6 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
week: { week: {
freq: 7, freq: 7,
step: 24 * 60 * 60 * 1000, step: 24 * 60 * 60 * 1000,
@ -164,7 +173,7 @@ const Graph = ({
.domain(periodDomains[period.code]) .domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH]) .range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const y = d3 const yLin = d3
.scaleLinear() .scaleLinear()
.domain([ .domain([
0, 0,
@ -173,6 +182,16 @@ const Graph = ({
.nice() .nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top]) .range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const yLog = d3
.scaleLog()
.domain([
(d3.min(data, d => new BigNumber(d.fiat).toNumber()) ?? 1) * 0.9,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.1
])
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const y = log ? yLog : yLin
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => { const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
const fullBreakpoints = [ const fullBreakpoints = [
graphLimits[1], graphLimits[1],
@ -219,14 +238,11 @@ const Graph = ({
d.getTime() + d.getTimezoneOffset() * MINUTE d.getTime() + d.getTimezoneOffset() * MINUTE
) )
}) })
.tickSizeOuter(0)
) )
.call(g => g.select('.domain').remove())
.call(g => .call(g =>
g g
.append('line') .select('.domain')
.attr('x1', GRAPH_MARGIN.left)
.attr('y1', -GRAPH_HEIGHT + GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
.attr('x2', GRAPH_MARGIN.left)
.attr('stroke', primaryColor) .attr('stroke', primaryColor)
.attr('stroke-width', 1) .attr('stroke-width', 1)
), ),
@ -237,18 +253,23 @@ const Graph = ({
g => g =>
g g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(d3.axisLeft(y).ticks(GRAPH_HEIGHT / 100)) .call(
.call(g => g.select('.domain').remove()) d3
.call(g => .axisLeft(y)
g .ticks(GRAPH_HEIGHT / 100)
.selectAll('.tick line') .tickSizeOuter(0)
.filter(d => d === 0) .tickFormat(d => {
.clone() if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.left)
.attr('stroke-width', 1) if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
.attr('stroke', primaryColor)
), return numberToFiatAmount(d)
[GRAPH_MARGIN, y] })
)
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1),
[GRAPH_MARGIN, y, log]
) )
const buildGrid = useCallback( const buildGrid = useCallback(
@ -477,25 +498,23 @@ const Graph = ({
const buildAvg = useCallback( const buildAvg = useCallback(
g => { g => {
const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0
if (log && median === 0) return
g.attr('stroke', primaryColor) g.attr('stroke', primaryColor)
.attr('stroke-width', 3) .attr('stroke-width', 3)
.attr('stroke-dasharray', '10, 5') .attr('stroke-dasharray', '10, 5')
.call(g => .call(g =>
g g
.append('line') .append('line')
.attr( .attr('y1', 0.5 + y(median))
'y1', .attr('y2', 0.5 + y(median))
0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
)
.attr(
'y2',
0.5 + y(d3.mean(data, d => new BigNumber(d.fiat).toNumber()) ?? 0)
)
.attr('x1', GRAPH_MARGIN.left) .attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH) .attr('x2', GRAPH_WIDTH)
) )
}, },
[GRAPH_MARGIN, y, data] [GRAPH_MARGIN, y, data, log]
) )
const drawData = useCallback( const drawData = useCallback(
@ -554,5 +573,6 @@ export default memo(
Graph, Graph,
(prev, next) => (prev, next) =>
R.equals(prev.period, next.period) && R.equals(prev.period, next.period) &&
R.equals(prev.selectedMachine, next.selectedMachine) R.equals(prev.selectedMachine, next.selectedMachine) &&
R.equals(prev.log, next.log)
) )

View file

@ -0,0 +1,654 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import {
add,
addMilliseconds,
compareDesc,
differenceInMilliseconds,
format,
startOfWeek,
startOfYear
} from 'date-fns/fp'
import * as R from 'ramda'
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import {
java,
neon,
subheaderDarkColor,
offColor,
fontColor,
primaryColor,
fontSecondary,
subheaderColor
} from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({
data,
period,
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
log = false
}) => {
const ref = useRef(null)
const GRAPH_POPOVER_WIDTH = 150
const GRAPH_POPOVER_MARGIN = 25
const GRAPH_HEIGHT = 401
const GRAPH_WIDTH = 1163
const GRAPH_MARGIN = useMemo(
() => ({
top: 25,
right: 3.5,
bottom: 27,
left: 38
}),
[]
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
day: [NOW - DAY, NOW],
threeDays: [NOW - 3 * DAY, NOW],
week: [NOW - WEEK, NOW],
month: [NOW - MONTH, NOW]
}
const dataPoints = useMemo(
() => ({
day: {
freq: 24,
step: 60 * 60 * 1000,
tick: d3.utcHour.every(1),
labelFormat: '%H:%M'
},
threeDays: {
freq: 12,
step: 6 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
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(1),
labelFormat: '%d'
}
}),
[]
)
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[period.code].step
return new Date(Math.ceil(d.valueOf() / step) * step)
}
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
if (roundDate(stepDate) > domain[1]) continue
if (stepDate < domain[0]) continue
points.push(roundDate(stepDate))
}
return points
},
[NOW, dataPoints, period.code]
)
const buildAreas = useCallback(
domain => {
const points = []
points.push(domain[1])
const roundDate = d => {
const step = dataPoints[period.code].step
return new Date(Math.ceil(d.valueOf() / step) * step)
}
for (let i = 0; i <= dataPoints[period.code].freq; i++) {
const stepDate = new Date(NOW - i * dataPoints[period.code].step)
if (roundDate(stepDate) > new Date(domain[1])) continue
if (stepDate < new Date(domain[0])) continue
points.push(roundDate(stepDate))
}
points.push(domain[0])
return points
},
[NOW, dataPoints, period.code]
)
const x = d3
.scaleUtc()
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
// Create a second X axis for mouseover events to be placed correctly across the entire graph width and not limited by X's domain
const x2 = d3
.scaleUtc()
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const bins = buildAreas(x.domain())
.sort((a, b) => compareDesc(a.date, b.date))
.map(addMilliseconds(-dataPoints[period.code].step))
.map((date, i, dates) => {
// move first and last bin in such way
// that all bin have uniform width
if (i === 0)
return addMilliseconds(dataPoints[period.code].step, dates[1])
else if (i === dates.length - 1)
return addMilliseconds(
-dataPoints[period.code].step,
dates[dates.length - 2]
)
else return date
})
.map(date => {
const middleOfBin = addMilliseconds(
dataPoints[period.code].step / 2,
date
)
const txs = data.filter(tx => {
const txCreated = new Date(tx.created)
const shift = new Date(txCreated.getTime() + offset)
return (
Math.abs(differenceInMilliseconds(shift, middleOfBin)) <
dataPoints[period.code].step / 2
)
})
const cashIn = txs
.filter(tx => tx.txClass === 'cashIn')
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
const cashOut = txs
.filter(tx => tx.txClass === 'cashOut')
.reduce((sum, tx) => sum + new BigNumber(tx.fiat).toNumber(), 0)
return { date: middleOfBin, cashIn, cashOut }
})
const min = d3.min(bins, d => Math.min(d.cashIn, d.cashOut)) ?? 0
const max = d3.max(bins, d => Math.max(d.cashIn, d.cashOut)) ?? 1000
const yLin = d3
.scaleLinear()
.domain([0, (max === min ? min + 1000 : max) * 1.03])
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const yLog = d3
.scaleLog()
.domain([
min === 0 ? 0.9 : min * 0.9,
(max === min ? min + Math.pow(10, 2 * min + 1) : max) * 2
])
.clamp(true)
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const y = log ? yLog : yLin
const getAreaInterval = (breakpoints, dataLimits, graphLimits) => {
const fullBreakpoints = [
graphLimits[1],
...R.filter(it => it > dataLimits[0] && it < dataLimits[1], breakpoints),
dataLimits[0]
]
const intervals = []
for (let i = 0; i < fullBreakpoints.length - 1; i++) {
intervals.push([fullBreakpoints[i], fullBreakpoints[i + 1]])
}
return intervals
}
const getAreaIntervalByX = (intervals, xValue) => {
return R.find(it => xValue <= it[0] && xValue >= it[1], intervals) ?? [0, 0]
}
const getDateIntervalByX = (areas, intervals, xValue) => {
const flattenIntervals = R.uniq(R.flatten(intervals))
// flattenIntervals and areas should have the same number of elements
for (let i = intervals.length - 1; i >= 0; i--) {
if (xValue < flattenIntervals[i]) {
return [areas[i], areas[i + 1]]
}
}
}
const buildXAxis = useCallback(
g =>
g
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.ticks(dataPoints[period.code].tick)
.tickFormat(d => {
return d3.timeFormat(dataPoints[period.code].labelFormat)(
d.getTime() + d.getTimezoneOffset() * MINUTE
)
})
.tickSizeOuter(0)
)
.call(g =>
g
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1)
),
[GRAPH_MARGIN, dataPoints, period.code, x]
)
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(
d3
.axisLeft(y)
.ticks(GRAPH_HEIGHT / 100)
.tickSizeOuter(0)
.tickFormat(d => {
if (log && !['1', '2', '5'].includes(d.toString()[0])) return ''
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
return numberToFiatAmount(d)
})
)
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1),
[GRAPH_MARGIN, y, log]
)
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)
)
// Horizontal lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(
d3
.axisLeft(y)
.scale()
.ticks(GRAPH_HEIGHT / 100)
)
.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)
)
// Vertical transparent rectangles for events
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildAreas(x.domain()))
.join('rect')
.attr('x', d => x(d))
.attr('y', GRAPH_MARGIN.top)
.attr('width', d => {
const xValue = Math.round(x(d) * 100) / 100
const intervals = getAreaInterval(
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
x.range(),
x2.range()
)
const interval = getAreaIntervalByX(intervals, xValue)
return Math.round((interval[0] - interval[1]) * 100) / 100
})
.attr(
'height',
GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top
)
.attr('stroke', 'transparent')
.attr('fill', 'transparent')
.on('mouseover', d => {
const xValue = Math.round(d.target.x.baseVal.value * 100) / 100
const areas = buildAreas(x.domain())
const intervals = getAreaInterval(
buildAreas(x.domain()).map(it => Math.round(x(it) * 100) / 100),
x.range(),
x2.range()
)
const dateInterval = getDateIntervalByX(areas, intervals, xValue)
if (!dateInterval) return
const filteredData = data.filter(it => {
const created = new Date(it.created)
const tzCreated = created.setTime(created.getTime() + offset)
return (
tzCreated > new Date(dateInterval[1]) &&
tzCreated <= new Date(dateInterval[0])
)
})
const rectXCoords = {
left: R.clone(d.target.getBoundingClientRect().x),
right: R.clone(
d.target.getBoundingClientRect().x +
d.target.getBoundingClientRect().width
)
}
const xCoord =
d.target.x.baseVal.value < 0.75 * GRAPH_WIDTH
? rectXCoords.right + GRAPH_POPOVER_MARGIN
: rectXCoords.left -
GRAPH_POPOVER_WIDTH -
GRAPH_POPOVER_MARGIN
const yCoord = R.clone(d.target.getBoundingClientRect().y)
setSelectionDateInterval(dateInterval)
setSelectionData(filteredData)
setSelectionCoords({
x: Math.round(xCoord),
y: Math.round(yCoord)
})
d3.select(d.target).attr('fill', subheaderColor)
})
.on('mouseleave', d => {
d3.select(d.target).attr('fill', 'transparent')
setSelectionDateInterval(null)
setSelectionData(null)
setSelectionCoords(null)
})
)
// Thick vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(
buildTicks(x.domain()).filter(x => {
if (period.code === 'day') return x.getUTCHours() === 0
return x.getUTCDate() === 1
})
)
.join('line')
.attr('class', 'dateSeparator')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top - 50)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 5)
.join('text')
)
// Left side breakpoint label
.call(g => {
const separator = d3
?.select('.dateSeparator')
?.node()
?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(x => {
if (period.code === 'day') return x.getUTCHours() === 0
return x.getUTCDate() === 1
})
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x - 10)
.attr('y', separator.y + 33)
.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(x => {
if (period.code === 'day') return x.getUTCHours() === 0
return x.getUTCDate() === 1
})
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x + 10)
.attr('y', separator.y + 33)
.attr('text-anchor', 'start')
.attr('dy', '.25em')
.text(labels.current)
})
},
[
GRAPH_MARGIN,
buildTicks,
getPastAndCurrentDayLabels,
x,
x2,
y,
period,
buildAreas,
data,
offset,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval
]
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', fontColor)
.style('fill', fontColor)
.style('stroke-width', 0.5)
.style('font-family', fontSecondary),
[]
)
const formatText = useCallback(
() =>
d3
.selectAll('text')
.style('stroke', offColor)
.style('fill', offColor)
.style('stroke-width', 0.5)
.style('font-family', fontSecondary),
[]
)
const formatTicks = useCallback(() => {
d3.selectAll('.tick line')
.style('stroke', primaryColor)
.style('fill', primaryColor)
}, [])
const drawData = useCallback(
g => {
g.append('clipPath')
.attr('id', 'clip-path')
.append('rect')
.attr('x', GRAPH_MARGIN.left)
.attr('y', GRAPH_MARGIN.top)
.attr('width', GRAPH_WIDTH)
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
.attr('fill', java)
g.append('g')
.attr('clip-path', 'url(#clip-path)')
.selectAll('circle .cashIn')
.data(bins)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.cashIn))
.attr('fill', java)
.attr('r', d => (d.cashIn === 0 ? 0 : 3.5))
g.append('path')
.datum(bins)
.attr('fill', 'none')
.attr('stroke', java)
.attr('stroke-width', 3)
.attr('clip-path', 'url(#clip-path)')
.attr(
'd',
d3
.line()
.curve(d3.curveMonotoneX)
.x(d => x(d.date))
.y(d => y(d.cashIn))
)
g.append('g')
.attr('clip-path', 'url(#clip-path)')
.selectAll('circle .cashIn')
.data(bins)
.join('circle')
.attr('cx', d => x(d.date))
.attr('cy', d => y(d.cashOut))
.attr('fill', neon)
.attr('r', d => (d.cashOut === 0 ? 0 : 3.5))
g.append('path')
.datum(bins)
.attr('fill', 'none')
.attr('stroke', neon)
.attr('stroke-width', 3)
.attr('clip-path', 'url(#clip-path)')
.attr(
'd',
d3
.line()
.curve(d3.curveMonotoneX)
.x(d => x(d.date))
.y(d => y(d.cashOut))
)
},
[x, y, bins, GRAPH_MARGIN]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildGrid)
svg.append('g').call(drawData)
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)
return svg.node()
}, [
buildGrid,
buildXAxis,
buildYAxis,
drawData,
formatText,
formatTicks,
formatTicksText
])
useEffect(() => {
d3.select(ref.current)
.selectAll('*')
.remove()
drawChart()
}, [drawChart])
return <svg ref={ref} />
}
export default memo(
Graph,
(prev, next) =>
R.equals(prev.period, next.period) &&
R.equals(prev.selectedMachine, next.selectedMachine) &&
R.equals(prev.log, next.log)
)

View file

@ -7,8 +7,13 @@ import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Link, Button, IconButton } from 'src/components/buttons' import {
Link,
Button,
IconButton,
SupportLinkButton
} from 'src/components/buttons'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import Sidebar from 'src/components/layout/Sidebar' import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
@ -251,13 +256,13 @@ const Blacklist = () => {
value={enablePaperWalletOnly} value={enablePaperWalletOnly}
/> />
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2> <Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
The "Enable paper wallet (only)" option means that only paper The "Enable paper wallet (only)" option means that only paper
wallets will be printed for users, and they won't be permitted wallets will be printed for users, and they won't be permitted
to scan an address from their own wallet. to scan an address from their own wallet.
</P> </P>
</HoverableTooltip> </HelpTooltip>
</Box> </Box>
<Box <Box
display="flex" display="flex"
@ -273,13 +278,21 @@ const Blacklist = () => {
value={rejectAddressReuse} value={rejectAddressReuse}
/> />
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2> <Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
The "Reject reused addresses" option means that all addresses The "Reject reused addresses" option means that all addresses
that are used once will be automatically rejected if there's that are used once will be automatically rejected if there's
an attempt to use them again on a new transaction. an attempt to use them again on a new transaction.
</P> </P>
</HoverableTooltip> <P>
For details please read the relevant knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360033622211-Reject-Address-Reuse"
label="Reject Address Reuse"
bottomSpace="1"
/>
</HelpTooltip>
</Box> </Box>
</Box> </Box>
<BlacklistTable <BlacklistTable

View file

@ -4,7 +4,8 @@ import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { SupportLinkButton } from 'src/components/buttons'
import { NamespacedTable as EditableTable } from 'src/components/editableTable' import { NamespacedTable as EditableTable } from 'src/components/editableTable'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
@ -92,7 +93,21 @@ const CashOut = ({ name: SCREEN_KEY }) => {
return ( return (
!loading && ( !loading && (
<> <>
<TitleSection title="Cash-out"> <TitleSection
title="Cash-out"
appendix={
<HelpTooltip width={320}>
<P>
For details on configuring cash-out, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115003720192-Enabling-cash-out-on-the-admin"
label="Enabling cash-out on the admin"
bottomSpace="1"
/>
</HelpTooltip>
}>
<div className={classes.fudgeFactor}> <div className={classes.fudgeFactor}>
<P>Transaction fudge factor</P> <P>Transaction fudge factor</P>
<Switch <Switch
@ -105,7 +120,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
<Label2 className={classes.switchLabel}> <Label2 className={classes.switchLabel}>
{fudgeFactorActive ? 'On' : 'Off'} {fudgeFactorActive ? 'On' : 'Off'}
</Label2> </Label2>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
Automatically accept customer deposits as complete if their Automatically accept customer deposits as complete if their
received amount is 100 crypto atoms or less. received amount is 100 crypto atoms or less.
@ -114,7 +129,13 @@ const CashOut = ({ name: SCREEN_KEY }) => {
(Crypto atoms are the smallest unit in each cryptocurrency. (Crypto atoms are the smallest unit in each cryptocurrency.
E.g., satoshis in Bitcoin, or wei in Ethereum.) E.g., satoshis in Bitcoin, or wei in Ethereum.)
</P> </P>
</HoverableTooltip> <P>For details please read the relevant knowledgebase article:</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360050838011-Automatically-accepting-undersent-deposits-with-Fudge-Factor-"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
</TitleSection> </TitleSection>
<EditableTable <EditableTable

View file

@ -134,7 +134,7 @@ const WizardStep = ({
to zero. Make sure you physically put cash inside the cash to zero. Make sure you physically put cash inside the cash
cassettes to allow the machine to dispense it to your users. If cassettes to allow the machine to dispense it to your users. If
you already did, make sure you set the correct cash cassette bill you already did, make sure you set the correct cash cassette bill
count for this machine on your Cash Boxes & Cassettes tab under count for this machine on your Cash boxes & cassettes tab under
Maintenance. Maintenance.
</P> </P>
<Info2 className={classes.title}>Default Commissions</Info2> <Info2 className={classes.title}>Default Commissions</Info2>

View file

@ -4,12 +4,16 @@ import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import { SupportLinkButton } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import { ReactComponent as ReverseListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/white.svg' import { ReactComponent as ReverseListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/white.svg'
import { ReactComponent as ListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/zodiac.svg' import { ReactComponent as ListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/zodiac.svg'
import { ReactComponent as OverrideLabelIcon } from 'src/styling/icons/status/spring2.svg' import { ReactComponent as OverrideLabelIcon } from 'src/styling/icons/status/spring2.svg'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config' import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { P } from '../../components/typography'
import CommissionsDetails from './components/CommissionsDetails' import CommissionsDetails from './components/CommissionsDetails'
import CommissionsList from './components/CommissionsList' import CommissionsList from './components/CommissionsList'
@ -118,6 +122,24 @@ const Commissions = ({ name: SCREEN_KEY }) => {
} }
]} ]}
iconClassName={classes.listViewButton} iconClassName={classes.listViewButton}
appendix={
<HelpTooltip width={320}>
<P>
For details about commissions, please read the relevant
knowledgebase articles:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001211752-Fixed-fees-Minimum-transaction"
label="Fixed fees & Minimum transaction"
bottomSpace="1"
/>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360061558352-Commissions-and-Profit-Calculations"
label="SCommissions and Profit Calculations"
bottomSpace="1"
/>
</HelpTooltip>
}
/> />
{!showMachines && !loading && ( {!showMachines && !loading && (

View file

@ -37,7 +37,7 @@ const SHOW_ALL = {
const ORDER_OPTIONS = [ const ORDER_OPTIONS = [
{ {
code: 'machine', code: 'machine',
display: 'Machine Name' display: 'Machine name'
}, },
{ {
code: 'cryptoCurrencies', code: 'cryptoCurrencies',
@ -53,7 +53,7 @@ const ORDER_OPTIONS = [
}, },
{ {
code: 'fixedFee', code: 'fixedFee',
display: 'Fixed Fee' display: 'Fixed fee'
}, },
{ {
code: 'minimumTx', code: 'minimumTx',

View file

@ -91,7 +91,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
}, },
{ {
name: 'cryptoCurrencies', name: 'cryptoCurrencies',
width: 280, width: 145,
size: 'sm', size: 'sm',
view: displayCodeArray(cryptoData), view: displayCodeArray(cryptoData),
input: Autocomplete, input: Autocomplete,
@ -108,7 +108,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
header: cashInHeader, header: cashInHeader,
name: 'cashIn', name: 'cashIn',
display: 'Cash-in', display: 'Cash-in',
width: 130, width: 123,
input: NumberInput, input: NumberInput,
textAlign: 'right', textAlign: 'right',
suffix: '%', suffix: '%',
@ -121,7 +121,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
header: cashOutHeader, header: cashOutHeader,
name: 'cashOut', name: 'cashOut',
display: 'Cash-out', display: 'Cash-out',
width: 130, width: 127,
input: NumberInput, input: NumberInput,
textAlign: 'right', textAlign: 'right',
suffix: '%', suffix: '%',
@ -133,7 +133,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
{ {
name: 'fixedFee', name: 'fixedFee',
display: 'Fixed fee', display: 'Fixed fee',
width: 144, width: 126,
input: NumberInput, input: NumberInput,
doubleHeader: 'Cash-in only', doubleHeader: 'Cash-in only',
textAlign: 'right', textAlign: 'right',
@ -146,7 +146,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
{ {
name: 'minimumTx', name: 'minimumTx',
display: 'Minimum Tx', display: 'Minimum Tx',
width: 169, width: 140,
doubleHeader: 'Cash-in only', doubleHeader: 'Cash-in only',
textAlign: 'center', textAlign: 'center',
editingAlign: 'right', editingAlign: 'right',
@ -156,6 +156,20 @@ const getOverridesFields = (getData, currency, auxElements) => {
inputProps: { inputProps: {
decimalPlaces: 2 decimalPlaces: 2
} }
},
{
name: 'cashOutFixedFee',
display: 'Fixed fee',
width: 134,
doubleHeader: 'Cash-out only',
textAlign: 'center',
editingAlign: 'right',
input: NumberInput,
suffix: currency,
bold: bold,
inputProps: {
decimalPlaces: 2
}
} }
] ]
} }
@ -218,6 +232,21 @@ const mainFields = currency => [
inputProps: { inputProps: {
decimalPlaces: 2 decimalPlaces: 2
} }
},
{
name: 'cashOutFixedFee',
display: 'Fixed fee',
width: 169,
size: 'lg',
doubleHeader: 'Cash-out only',
textAlign: 'center',
editingAlign: 'right',
input: NumberInput,
suffix: currency,
bold: bold,
inputProps: {
decimalPlaces: 2
}
} }
] ]
@ -245,7 +274,7 @@ const getSchema = locale => {
.max(percentMax) .max(percentMax)
.required(), .required(),
fixedFee: Yup.number() fixedFee: Yup.number()
.label('Fixed Fee') .label('Cash-in fixed fee')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(), .required(),
@ -253,6 +282,11 @@ const getSchema = locale => {
.label('Minimum Tx') .label('Minimum Tx')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required() .required()
}) })
} }
@ -326,7 +360,7 @@ const getOverridesSchema = (values, rawData, locale) => {
return true return true
} }
}) })
.label('Crypto Currencies') .label('Crypto currencies')
.required() .required()
.min(1), .min(1),
cashIn: Yup.number() cashIn: Yup.number()
@ -340,7 +374,7 @@ const getOverridesSchema = (values, rawData, locale) => {
.max(percentMax) .max(percentMax)
.required(), .required(),
fixedFee: Yup.number() fixedFee: Yup.number()
.label('Fixed Fee') .label('Cash-in fixed fee')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(), .required(),
@ -348,6 +382,11 @@ const getOverridesSchema = (values, rawData, locale) => {
.label('Minimum Tx') .label('Minimum Tx')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required() .required()
}) })
} }
@ -356,7 +395,8 @@ const defaults = {
cashIn: '', cashIn: '',
cashOut: '', cashOut: '',
fixedFee: '', fixedFee: '',
minimumTx: '' minimumTx: '',
cashOutFixedFee: ''
} }
const overridesDefaults = { const overridesDefaults = {
@ -365,7 +405,8 @@ const overridesDefaults = {
cashIn: '', cashIn: '',
cashOut: '', cashOut: '',
fixedFee: '', fixedFee: '',
minimumTx: '' minimumTx: '',
cashOutFixedFee: ''
} }
const getOrder = ({ machine, cryptoCurrencies }) => { const getOrder = ({ machine, cryptoCurrencies }) => {
@ -385,6 +426,7 @@ const createCommissions = (cryptoCode, deviceId, isDefault, config) => {
fixedFee: config.fixedFee, fixedFee: config.fixedFee,
cashOut: config.cashOut, cashOut: config.cashOut,
cashIn: config.cashIn, cashIn: config.cashIn,
cashOutFixedFee: config.cashOutFixedFee,
machine: deviceId, machine: deviceId,
cryptoCurrencies: [cryptoCode], cryptoCurrencies: [cryptoCode],
default: isDefault, default: isDefault,
@ -437,7 +479,7 @@ const getListCommissionsSchema = locale => {
.label('Machine') .label('Machine')
.required(), .required(),
cryptoCurrencies: Yup.array() cryptoCurrencies: Yup.array()
.label('Crypto Currency') .label('Crypto currency')
.required() .required()
.min(1), .min(1),
cashIn: Yup.number() cashIn: Yup.number()
@ -451,7 +493,7 @@ const getListCommissionsSchema = locale => {
.max(percentMax) .max(percentMax)
.required(), .required(),
fixedFee: Yup.number() fixedFee: Yup.number()
.label('Fixed Fee') .label('Cash-in fixed fee')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(), .required(),
@ -459,6 +501,11 @@ const getListCommissionsSchema = locale => {
.label('Minimum Tx') .label('Minimum Tx')
.min(0) .min(0)
.max(highestBill) .max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required() .required()
}) })
} }
@ -487,7 +534,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{ {
name: 'cryptoCurrencies', name: 'cryptoCurrencies',
display: 'Crypto Currency', display: 'Crypto Currency',
width: 255, width: 150,
view: R.prop(0), view: R.prop(0),
size: 'sm', size: 'sm',
editable: false editable: false
@ -496,7 +543,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
header: cashInHeader, header: cashInHeader,
name: 'cashIn', name: 'cashIn',
display: 'Cash-in', display: 'Cash-in',
width: 130, width: 120,
input: NumberInput, input: NumberInput,
textAlign: 'right', textAlign: 'right',
suffix: '%', suffix: '%',
@ -509,7 +556,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
header: cashOutHeader, header: cashOutHeader,
name: 'cashOut', name: 'cashOut',
display: 'Cash-out', display: 'Cash-out',
width: 140, width: 126,
input: NumberInput, input: NumberInput,
textAlign: 'right', textAlign: 'right',
greenText: true, greenText: true,
@ -522,7 +569,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{ {
name: 'fixedFee', name: 'fixedFee',
display: 'Fixed fee', display: 'Fixed fee',
width: 144, width: 140,
input: NumberInput, input: NumberInput,
doubleHeader: 'Cash-in only', doubleHeader: 'Cash-in only',
textAlign: 'right', textAlign: 'right',
@ -535,7 +582,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{ {
name: 'minimumTx', name: 'minimumTx',
display: 'Minimum Tx', display: 'Minimum Tx',
width: 144, width: 140,
input: NumberInput, input: NumberInput,
doubleHeader: 'Cash-in only', doubleHeader: 'Cash-in only',
textAlign: 'right', textAlign: 'right',
@ -544,6 +591,20 @@ const getListCommissionsFields = (getData, currency, defaults) => {
inputProps: { inputProps: {
decimalPlaces: 2 decimalPlaces: 2
} }
},
{
name: 'cashOutFixedFee',
display: 'Fixed fee',
width: 140,
input: NumberInput,
doubleHeader: 'Cash-out only',
textAlign: 'center',
editingAlign: 'right',
suffix: currency,
textStyle: obj => getTextStyle(obj),
inputProps: {
decimalPlaces: 2
}
} }
] ]
} }

View file

@ -73,10 +73,13 @@ const CustomerData = ({
authorizeCustomRequest, authorizeCustomRequest,
updateCustomEntry, updateCustomEntry,
retrieveAdditionalDataDialog, retrieveAdditionalDataDialog,
setRetrieve setRetrieve,
checkAgainstSanctions
}) => { }) => {
const classes = useStyles() const classes = useStyles()
const [listView, setListView] = useState(false) const [listView, setListView] = useState(false)
const [previewPhoto, setPreviewPhoto] = useState(null)
const [previewCard, setPreviewCard] = useState(null)
const idData = R.path(['idCardData'])(customer) const idData = R.path(['idCardData'])(customer)
const rawExpirationDate = R.path(['expirationDate'])(idData) const rawExpirationDate = R.path(['expirationDate'])(idData)
@ -172,6 +175,12 @@ const CustomerData = ({
idCardData: R.merge(idData, formatDates(values)) idCardData: R.merge(idData, formatDates(values))
}), }),
validationSchema: customerDataSchemas.idCardData, validationSchema: customerDataSchemas.idCardData,
checkAgainstSanctions: () =>
checkAgainstSanctions({
variables: {
customerId: R.path(['id'])(customer)
}
}),
initialValues: initialValues.idCardData, initialValues: initialValues.idCardData,
isAvailable: !R.isNil(idData), isAvailable: !R.isNil(idData),
editable: true editable: true
@ -213,9 +222,6 @@ const CustomerData = ({
{ {
title: 'Name', title: 'Name',
titleIcon: <EditIcon className={classes.editIcon} />, titleIcon: <EditIcon className={classes.editIcon} />,
authorize: () => {},
reject: () => {},
save: () => {},
isAvailable: false, isAvailable: false,
editable: true editable: true
}, },
@ -226,7 +232,7 @@ const CustomerData = ({
authorize: () => authorize: () =>
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }), updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }), reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
children: <Info3>{sanctionsDisplay}</Info3>, children: () => <Info3>{sanctionsDisplay}</Info3>,
isAvailable: !R.isNil(sanctions), isAvailable: !R.isNil(sanctions),
editable: true editable: true
}, },
@ -238,20 +244,33 @@ const CustomerData = ({
authorize: () => authorize: () =>
updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }), updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }), reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }),
save: values => save: values => {
replacePhoto({ setPreviewPhoto(null)
return replacePhoto({
newPhoto: values.frontCamera, newPhoto: values.frontCamera,
photoType: 'frontCamera' photoType: 'frontCamera'
}), })
},
cancel: () => setPreviewPhoto(null),
deleteEditedData: () => deleteEditedData({ frontCamera: null }), deleteEditedData: () => deleteEditedData({ frontCamera: null }),
children: customer.frontCameraPath ? ( children: values => {
<Photo if (values.frontCamera !== previewPhoto) {
show={customer.frontCameraPath} setPreviewPhoto(values.frontCamera)
src={`${URI}/front-camera-photo/${R.path(['frontCameraPath'])( }
customer
)}`} return customer.frontCameraPath ? (
/> <Photo
) : null, show={customer.frontCameraPath}
src={
!R.isNil(previewPhoto)
? URL.createObjectURL(previewPhoto)
: `${URI}/front-camera-photo/${R.path(['frontCameraPath'])(
customer
)}`
}
/>
) : null
},
hasImage: true, hasImage: true,
validationSchema: customerDataSchemas.frontCamera, validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera, initialValues: initialValues.frontCamera,
@ -266,18 +285,33 @@ const CustomerData = ({
authorize: () => authorize: () =>
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }), updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }), reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }),
save: values => save: values => {
replacePhoto({ setPreviewCard(null)
return replacePhoto({
newPhoto: values.idCardPhoto, newPhoto: values.idCardPhoto,
photoType: 'idCardPhoto' photoType: 'idCardPhoto'
}), })
},
cancel: () => setPreviewCard(null),
deleteEditedData: () => deleteEditedData({ idCardPhoto: null }), deleteEditedData: () => deleteEditedData({ idCardPhoto: null }),
children: customer.idCardPhotoPath ? ( children: values => {
<Photo if (values.idCardPhoto !== previewCard) {
show={customer.idCardPhotoPath} setPreviewCard(values.idCardPhoto)
src={`${URI}/id-card-photo/${R.path(['idCardPhotoPath'])(customer)}`} }
/>
) : null, return customer.idCardPhotoPath ? (
<Photo
show={customer.idCardPhotoPath}
src={
!R.isNil(previewCard)
? URL.createObjectURL(previewCard)
: `${URI}/id-card-photo/${R.path(['idCardPhotoPath'])(
customer
)}`
}
/>
) : null
},
hasImage: true, hasImage: true,
validationSchema: customerDataSchemas.idCardPhoto, validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto, initialValues: initialValues.idCardPhoto,
@ -292,6 +326,7 @@ const CustomerData = ({
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }), authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }), reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
save: values => editCustomer(values), save: values => editCustomer(values),
children: () => {},
deleteEditedData: () => deleteEditedData({ usSsn: null }), deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: customerDataSchemas.usSsn, validationSchema: customerDataSchemas.usSsn,
initialValues: initialValues.usSsn, initialValues: initialValues.usSsn,
@ -427,6 +462,7 @@ const CustomerData = ({
titleIcon, titleIcon,
fields, fields,
save, save,
cancel,
deleteEditedData, deleteEditedData,
retrieveAdditionalData, retrieveAdditionalData,
children, children,
@ -434,7 +470,8 @@ const CustomerData = ({
initialValues, initialValues,
hasImage, hasImage,
hasAdditionalData, hasAdditionalData,
editable editable,
checkAgainstSanctions
}, },
idx idx
) => { ) => {
@ -453,8 +490,10 @@ const CustomerData = ({
validationSchema={validationSchema} validationSchema={validationSchema}
initialValues={initialValues} initialValues={initialValues}
save={save} save={save}
cancel={cancel}
deleteEditedData={deleteEditedData} deleteEditedData={deleteEditedData}
retrieveAdditionalData={retrieveAdditionalData} retrieveAdditionalData={retrieveAdditionalData}
checkAgainstSanctions={checkAgainstSanctions}
editable={editable}></EditableCard> editable={editable}></EditableCard>
) )
} }

View file

@ -1,4 +1,4 @@
import { useQuery, useMutation } from '@apollo/react-hooks' import { useQuery, useMutation, useLazyQuery } from '@apollo/react-hooks'
import { import {
makeStyles, makeStyles,
Breadcrumbs, Breadcrumbs,
@ -292,6 +292,14 @@ const GET_ACTIVE_CUSTOM_REQUESTS = gql`
} }
` `
const CHECK_AGAINST_SANCTIONS = gql`
query checkAgainstSanctions($customerId: ID) {
checkAgainstSanctions(customerId: $customerId) {
ofacSanctioned
}
}
`
const CustomerProfile = memo(() => { const CustomerProfile = memo(() => {
const history = useHistory() const history = useHistory()
@ -400,6 +408,10 @@ const CustomerProfile = memo(() => {
onCompleted: () => getCustomer() onCompleted: () => getCustomer()
}) })
const [checkAgainstSanctions] = useLazyQuery(CHECK_AGAINST_SANCTIONS, {
onCompleted: () => getCustomer()
})
const updateCustomer = it => const updateCustomer = it =>
setCustomer({ setCustomer({
variables: { variables: {
@ -662,6 +674,7 @@ const CustomerProfile = memo(() => {
authorizeCustomRequest={authorizeCustomRequest} authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry} updateCustomEntry={updateCustomEntry}
setRetrieve={setRetrieve} setRetrieve={setRetrieve}
checkAgainstSanctions={checkAgainstSanctions}
retrieveAdditionalDataDialog={ retrieveAdditionalDataDialog={
<RetrieveDataDialog <RetrieveDataDialog
onDismissed={() => { onDismissed={() => {

View file

@ -36,7 +36,7 @@ const CustomersList = ({
view: getName view: getName
}, },
{ {
header: 'Total TXs', header: 'Total Txs',
width: 126, width: 126,
textAlign: 'right', textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}` view: it => `${Number.parseInt(it.totalTxs)}`

View file

@ -26,7 +26,7 @@ const CustomerSidebar = ({ isSelected, onClick }) => {
}, },
{ {
code: 'customerData', code: 'customerData',
display: 'Customer Data', display: 'Customer data',
Icon: CustomerDataIcon, Icon: CustomerDataIcon,
InverseIcon: CustomerDataReversedIcon InverseIcon: CustomerDataReversedIcon
}, },

View file

@ -3,11 +3,12 @@ import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames' import classnames from 'classnames'
import { Form, Formik, Field as FormikField } from 'formik' import { Form, Formik, Field as FormikField } from 'formik'
import * as R from 'ramda' import * as R from 'ramda'
import { useState, React } from 'react' import { useState, React, useRef } from 'react'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import PromptWhenDirty from 'src/components/PromptWhenDirty' import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { MainStatus } from 'src/components/Status' import { MainStatus } from 'src/components/Status'
// import { HelpTooltip } from 'src/components/Tooltip'
import { ActionButton } from 'src/components/buttons' import { ActionButton } from 'src/components/buttons'
import { Label1, P, H3 } from 'src/components/typography' import { Label1, P, H3 } from 'src/components/typography'
import { import {
@ -132,23 +133,27 @@ const ReadOnlyField = ({ field, value, ...props }) => {
const EditableCard = ({ const EditableCard = ({
fields, fields,
save, save = () => {},
authorize, cancel = () => {},
authorize = () => {},
hasImage, hasImage,
reject, reject = () => {},
state, state,
title, title,
titleIcon, titleIcon,
children, children = () => {},
validationSchema, validationSchema,
initialValues, initialValues,
deleteEditedData, deleteEditedData,
retrieveAdditionalData, retrieveAdditionalData,
hasAdditionalData = true, hasAdditionalData = true,
editable editable,
checkAgainstSanctions
}) => { }) => {
const classes = useStyles() const classes = useStyles()
const formRef = useRef()
const [editing, setEditing] = useState(false) const [editing, setEditing] = useState(false)
const [input, setInput] = useState(null) const [input, setInput] = useState(null)
const [error, setError] = useState(null) const [error, setError] = useState(null)
@ -178,7 +183,7 @@ const EditableCard = ({
<H3 className={classes.cardTitle}>{title}</H3> <H3 className={classes.cardTitle}>{title}</H3>
{ {
// TODO: Enable for next release // TODO: Enable for next release
/* <HoverableTooltip width={304}></HoverableTooltip> */ /* <HelpTooltip width={304}></HelpTooltip> */
} }
</div> </div>
{state && authorize && ( {state && authorize && (
@ -187,8 +192,9 @@ const EditableCard = ({
</div> </div>
)} )}
</div> </div>
{children} {children(formRef.current?.values ?? {})}
<Formik <Formik
innerRef={formRef}
validateOnBlur={false} validateOnBlur={false}
validateOnChange={false} validateOnChange={false}
enableReinitialize enableReinitialize
@ -273,6 +279,16 @@ const EditableCard = ({
Retrieve API data Retrieve API data
</ActionButton> </ActionButton>
)} )}
{checkAgainstSanctions && (
<ActionButton
color="primary"
type="button"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => checkAgainstSanctions()}>
Check against OFAC sanction list
</ActionButton>
)}
</div> </div>
{editable && ( {editable && (
<ActionButton <ActionButton
@ -314,7 +330,7 @@ const EditableCard = ({
{editing && ( {editing && (
<div className={classes.editingWrapper}> <div className={classes.editingWrapper}>
<div className={classes.replace}> <div className={classes.replace}>
{hasImage && ( {hasImage && state !== OVERRIDE_PENDING && (
<ActionButton <ActionButton
color="secondary" color="secondary"
type="button" type="button"
@ -359,6 +375,7 @@ const EditableCard = ({
color="secondary" color="secondary"
Icon={CancelReversedIcon} Icon={CancelReversedIcon}
InverseIcon={CancelReversedIcon} InverseIcon={CancelReversedIcon}
onClick={() => cancel()}
type="reset"> type="reset">
Cancel Cancel
</ActionButton> </ActionButton>

View file

@ -32,7 +32,7 @@ const IdDataCard = memo(({ customerData, updateCustomer }) => {
size: 160 size: 160
}, },
{ {
header: 'Birth Date', header: 'Birth date',
display: display:
(rawDob && (rawDob &&
format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDob))) ?? format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDob))) ??
@ -61,7 +61,7 @@ const IdDataCard = memo(({ customerData, updateCustomer }) => {
size: 120 size: 120
}, },
{ {
header: 'Expiration Date', header: 'Expiration date',
display: ifNotNull( display: ifNotNull(
rawExpirationDate, rawExpirationDate,
format('yyyy-MM-dd', rawExpirationDate) format('yyyy-MM-dd', rawExpirationDate)

View file

@ -411,7 +411,7 @@ const customerDataElements = {
}, },
{ {
name: 'expirationDate', name: 'expirationDate',
label: 'Expiration Date', label: 'Expiration date',
component: TextInput, component: TextInput,
editable: true editable: true
}, },

View file

@ -4,12 +4,7 @@ import React, { useEffect, useRef, useCallback } from 'react'
import { backgroundColor, zircon, primaryColor } from 'src/styling/variables' import { backgroundColor, zircon, primaryColor } from 'src/styling/variables'
const transactionProfit = tx => { const transactionProfit = R.prop('profit')
const cashInFee = tx.cashInFee ? Number.parseFloat(tx.cashInFee) : 0
const commission =
Number.parseFloat(tx.commissionPercentage) * Number.parseFloat(tx.fiat)
return commission + cashInFee
}
const mockPoint = (tx, offsetMs, profit) => { const mockPoint = (tx, offsetMs, profit) => {
const date = new Date(new Date(tx.created).getTime() + offsetMs).toISOString() const date = new Date(new Date(tx.created).getTime() + offsetMs).toISOString()

View file

@ -12,6 +12,7 @@ import {
fontSecondary, fontSecondary,
backgroundColor backgroundColor
} from 'src/styling/variables' } from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time' import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({ data, timeFrame, timezone }) => { const Graph = ({ data, timeFrame, timezone }) => {
@ -172,7 +173,16 @@ const Graph = ({ data, timeFrame, timezone }) => {
g => g =>
g g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`) .attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(d3.axisLeft(y).ticks(5)) .call(
d3
.axisLeft(y)
.ticks(5)
.tickFormat(d => {
if (d >= 1000) return numberToFiatAmount(d / 1000) + 'k'
return numberToFiatAmount(d)
})
)
.call(g => g.select('.domain').remove()) .call(g => g.select('.domain').remove())
.selectAll('text') .selectAll('text')
.attr('dy', '-0.25rem'), .attr('dy', '-0.25rem'),

View file

@ -36,7 +36,7 @@ const GET_DATA = gql`
transactions(excludeTestingCustomers: $excludeTestingCustomers) { transactions(excludeTestingCustomers: $excludeTestingCustomers) {
fiatCode fiatCode
fiat fiat
cashInFee fixedFee
commissionPercentage commissionPercentage
created created
txClass txClass

View file

@ -164,7 +164,7 @@ const Funding = () => {
{funding.length && ( {funding.length && (
<div className={classes.total}> <div className={classes.total}>
<Label1 className={classes.totalTitle}> <Label1 className={classes.totalTitle}>
Total Crypto Balance Total crypto balance
</Label1> </Label1>
<Info1 noMargin> <Info1 noMargin>
{getConfirmedTotal(funding)} {getConfirmedTotal(funding)}

View file

@ -5,7 +5,8 @@ import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { Link } from 'src/components/buttons' import { HelpTooltip } from 'src/components/Tooltip'
import { Link, SupportLinkButton } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable' import { Table as EditableTable } from 'src/components/editableTable'
import Section from 'src/components/layout/Section' import Section from 'src/components/layout/Section'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
@ -61,8 +62,9 @@ const GET_DATA = gql`
` `
const SAVE_CONFIG = gql` const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) { mutation Save($config: JSONObject, $accounts: JSONObject) {
saveConfig(config: $config) saveConfig(config: $config)
saveAccounts(accounts: $accounts)
} }
` `
@ -134,9 +136,9 @@ const Locales = ({ name: SCREEN_KEY }) => {
return save(newConfig) return save(newConfig)
} }
const save = config => { const save = (config, accounts) => {
setDataToSave(null) setDataToSave(null)
return saveConfig({ variables: { config } }) return saveConfig({ variables: { config, accounts } })
} }
const saveOverrides = it => { const saveOverrides = it => {
@ -162,8 +164,8 @@ const Locales = ({ name: SCREEN_KEY }) => {
const onEditingDefault = (it, editing) => setEditingDefault(editing) const onEditingDefault = (it, editing) => setEditingDefault(editing)
const onEditingOverrides = (it, editing) => setEditingOverrides(editing) const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
const wizardSave = it => const wizardSave = (config, accounts) =>
save(toNamespace(namespaces.WALLETS)(it)).then(it => { save(toNamespace(namespaces.WALLETS)(config), accounts).then(it => {
onChangeFunction() onChangeFunction()
setOnChangeFunction(null) setOnChangeFunction(null)
return it return it
@ -176,7 +178,22 @@ const Locales = ({ name: SCREEN_KEY }) => {
close={() => setDataToSave(null)} close={() => setDataToSave(null)}
save={() => dataToSave && save(dataToSave)} save={() => dataToSave && save(dataToSave)}
/> />
<TitleSection title="Locales" /> <TitleSection
title="Locales"
appendix={
<HelpTooltip width={320}>
<P>
For details on configuring languages, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360016257471-Setting-multiple-machine-languages"
label="Setting multiple machine languages"
bottomSpace="1"
/>
</HelpTooltip>
}
/>
<Section> <Section>
<EditableTable <EditableTable
title="Default settings" title="Default settings"

View file

@ -157,7 +157,7 @@ const LocaleSchema = Yup.object().shape({
.label('Country') .label('Country')
.required(), .required(),
fiatCurrency: Yup.string() fiatCurrency: Yup.string()
.label('Fiat Currency') .label('Fiat currency')
.required(), .required(),
languages: Yup.array() languages: Yup.array()
.label('Languages') .label('Languages')
@ -165,7 +165,7 @@ const LocaleSchema = Yup.object().shape({
.min(1) .min(1)
.max(4), .max(4),
cryptoCurrencies: Yup.array() cryptoCurrencies: Yup.array()
.label('Crypto Currencies') .label('Crypto currencies')
.required() .required()
.min(1), .min(1),
timezone: Yup.string() timezone: Yup.string()
@ -186,7 +186,7 @@ const OverridesSchema = Yup.object().shape({
.min(1) .min(1)
.max(4), .max(4),
cryptoCurrencies: Yup.array() cryptoCurrencies: Yup.array()
.label('Crypto Currencies') .label('Crypto currencies')
.required() .required()
.min(1) .min(1)
}) })

View file

@ -6,7 +6,7 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { NumberInput, Autocomplete } from 'src/components/inputs/formik' import { NumberInput, Autocomplete } from 'src/components/inputs/formik'
import { H3, TL1, P } from 'src/components/typography' import { H3, TL1, P } from 'src/components/typography'
@ -99,7 +99,7 @@ const IndividualDiscountModal = ({
<div> <div>
<div className={classes.discountRateWrapper}> <div className={classes.discountRateWrapper}>
<H3>Define discount rate</H3> <H3>Define discount rate</H3>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
This is a percentage discount off of your existing This is a percentage discount off of your existing
commission rates for a customer entering this code at commission rates for a customer entering this code at
@ -110,7 +110,7 @@ const IndividualDiscountModal = ({
code is set for 50%, then you'll instead be charging 4% code is set for 50%, then you'll instead be charging 4%
on transactions using the code. on transactions using the code.
</P> </P>
</HoverableTooltip> </HelpTooltip>
</div> </div>
<div className={classes.discountInput}> <div className={classes.discountInput}>
<Field <Field

View file

@ -6,7 +6,7 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { TextInput, NumberInput } from 'src/components/inputs/formik' import { TextInput, NumberInput } from 'src/components/inputs/formik'
import { H3, TL1, P } from 'src/components/typography' import { H3, TL1, P } from 'src/components/typography'
@ -69,7 +69,7 @@ const PromoCodesModal = ({ showModal, onClose, errorMsg, addCode }) => {
/> />
<div className={classes.modalLabel2Wrapper}> <div className={classes.modalLabel2Wrapper}>
<H3 className={classes.modalLabel2}>Define discount rate</H3> <H3 className={classes.modalLabel2}>Define discount rate</H3>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
This is a percentage discount off of your existing This is a percentage discount off of your existing
commission rates for a customer entering this code at the commission rates for a customer entering this code at the
@ -80,7 +80,7 @@ const PromoCodesModal = ({ showModal, onClose, errorMsg, addCode }) => {
set for 50%, then you'll instead be charging 4% on set for 50%, then you'll instead be charging 4% on
transactions using the code. transactions using the code.
</P> </P>
</HoverableTooltip> </HelpTooltip>
</div> </div>
<div className={classes.discountInput}> <div className={classes.discountInput}>
<Field <Field

View file

@ -111,7 +111,7 @@ const Logs = () => {
<> <>
<div className={classes.titleWrapper}> <div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}> <div className={classes.titleAndButtonsContainer}>
<Title>Machine Logs</Title> <Title>Machine logs</Title>
{logsResponse && ( {logsResponse && (
<div className={classes.buttonsWrapper}> <div className={classes.buttonsWrapper}>
<LogsDowloaderPopover <LogsDowloaderPopover

View file

@ -64,10 +64,11 @@ const Commissions = ({ name: SCREEN_KEY, id: deviceId }) => {
cashIn: config.cashIn, cashIn: config.cashIn,
cashOut: config.cashOut, cashOut: config.cashOut,
fixedFee: config.fixedFee, fixedFee: config.fixedFee,
minimumTx: config.minimumTx minimumTx: config.minimumTx,
cashOutFixedFee: config.cashOutFixedFee
}, },
R.project( R.project(
['cashIn', 'cashOut', 'fixedFee', 'minimumTx'], ['cashIn', 'cashOut', 'fixedFee', 'minimumTx', 'cashOutFixedFee'],
R.filter( R.filter(
o => o =>
R.includes(coin.code, o.cryptoCurrencies) || R.includes(coin.code, o.cryptoCurrencies) ||

View file

@ -61,6 +61,14 @@ const getOverridesFields = currency => {
doubleHeader: 'Cash-in only', doubleHeader: 'Cash-in only',
textAlign: 'right', textAlign: 'right',
suffix: currency suffix: currency
},
{
name: 'cashOutFixedFee',
display: 'Fixed fee',
width: 155,
doubleHeader: 'Cash-out only',
textAlign: 'right',
suffix: currency
} }
] ]
} }

View file

@ -40,7 +40,7 @@ const GET_TRANSACTIONS = gql`
hasError: error hasError: error
deviceId deviceId
fiat fiat
cashInFee fixedFee
fiatCode fiatCode
cryptoAtoms cryptoAtoms
cryptoCode cryptoCode

View file

@ -6,7 +6,8 @@ import React, { useState } from 'react'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper' import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { IconButton, Button } from 'src/components/buttons' import { HelpTooltip } from 'src/components/Tooltip.js'
import { IconButton, Button, SupportLinkButton } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs' import { RadioGroup } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import { EmptyTable } from 'src/components/table' import { EmptyTable } from 'src/components/table'
@ -204,7 +205,7 @@ const CashCassettes = () => {
!dataLoading && ( !dataLoading && (
<> <>
<TitleSection <TitleSection
title="Cash Boxes & Cassettes" title="Cash boxes & cassettes"
buttons={[ buttons={[
{ {
text: 'Cash box history', text: 'Cash box history',
@ -229,7 +230,20 @@ const CashCassettes = () => {
} }
]} ]}
iconClassName={classes.listViewButton} iconClassName={classes.listViewButton}
className={classes.tableWidth}> className={classes.tableWidth}
appendix={
<HelpTooltip width={220}>
<P>
For details on configuring cash boxes and cassettes, please read
the relevant knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/4420839641229-Cash-Boxes-Cassettess"
label="Cash Boxes & Cassettes"
bottomSpace="1"
/>
</HelpTooltip>
}>
{!showHistory && ( {!showHistory && (
<Box alignItems="center" justifyContent="flex-end"> <Box alignItems="center" justifyContent="flex-end">
<Label1 className={classes.cashboxReset}>Cash box resets</Label1> <Label1 className={classes.cashboxReset}>Cash box resets</Label1>

View file

@ -158,7 +158,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
}, },
{ {
name: 'billCount', name: 'billCount',
header: 'Bill Count', header: 'Bill count',
width: 115, width: 115,
textAlign: 'left', textAlign: 'left',
input: NumberInput, input: NumberInput,

View file

@ -92,7 +92,7 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
<Item xs> <Item xs>
<Container className={classes.row}> <Container className={classes.row}>
<Item xs={2}> <Item xs={2}>
<Label>Machine Model</Label> <Label>Machine model</Label>
<span>{modelPrettifier[machine.model]}</span> <span>{modelPrettifier[machine.model]}</span>
</Item> </Item>
<Item xs={4}> <Item xs={4}>
@ -126,7 +126,7 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
</span> </span>
</Item> </Item>
<Item xs={2}> <Item xs={2}>
<Label>Packet Loss</Label> <Label>Packet loss</Label>
<span> <span>
{machine.packetLoss {machine.packetLoss
? new BigNumber(machine.packetLoss).toFixed(3).toString() + ? new BigNumber(machine.packetLoss).toFixed(3).toString() +

View file

@ -74,7 +74,7 @@ const MachineStatus = () => {
const elements = [ const elements = [
{ {
header: 'Machine Name', header: 'Machine name',
width: 250, width: 250,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
@ -111,7 +111,7 @@ const MachineStatus = () => {
: 'unknown' : 'unknown'
}, },
{ {
header: 'Software Version', header: 'Software version',
width: 200, width: 200,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
@ -134,7 +134,7 @@ const MachineStatus = () => {
<> <>
<div className={classes.titleWrapper}> <div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}> <div className={classes.titleAndButtonsContainer}>
<Title>Machine Status</Title> <Title>Machine status</Title>
</div> </div>
<div className={classes.headerLabels}> <div className={classes.headerLabels}>
<div> <div>

View file

@ -6,7 +6,7 @@ import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import Stepper from 'src/components/Stepper' import Stepper from 'src/components/Stepper'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { Cashbox } from 'src/components/inputs/cashbox/Cashbox' import { Cashbox } from 'src/components/inputs/cashbox/Cashbox'
import { NumberInput, RadioGroup } from 'src/components/inputs/formik' import { NumberInput, RadioGroup } from 'src/components/inputs/formik'
@ -245,12 +245,12 @@ const WizardStep = ({
classes.centerAlignment classes.centerAlignment
)}> )}>
<P>Since previous update</P> <P>Since previous update</P>
<HoverableTooltip width={215}> <HelpTooltip width={215}>
<P> <P>
Number of bills inside the cash box, since the last Number of bills inside the cash box, since the last
cash box changes. cash box changes.
</P> </P>
</HoverableTooltip> </HelpTooltip>
</div> </div>
<div <div
className={classnames( className={classnames(

View file

@ -4,6 +4,8 @@ import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import { SupportLinkButton } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import { P } from 'src/components/typography' import { P } from 'src/components/typography'
import FormRenderer from 'src/pages/Services/FormRenderer' import FormRenderer from 'src/pages/Services/FormRenderer'
@ -159,7 +161,24 @@ const Notifications = ({
!loading && ( !loading && (
<> <>
<NotificationsCtx.Provider value={contextValue}> <NotificationsCtx.Provider value={contextValue}>
{displayTitle && <TitleSection title="Notifications" />} {displayTitle && (
<TitleSection
title="Notifications"
appendix={
<HelpTooltip width={250}>
<P>
For details on configuring notifications, please read the
relevant knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210592-Enabling-notifications"
label="Enabling notifications"
bottomSpace="1"
/>
</HelpTooltip>
}
/>
)}
{displayThirdPartyProvider && ( {displayThirdPartyProvider && (
<Section <Section
title="Third party providers" title="Third party providers"

View file

@ -32,7 +32,7 @@ const CryptoBalanceAlerts = ({ section, fieldWidth }) => {
section={section} section={section}
decoration={currency} decoration={currency}
className={classes.cryptoBalanceAlertsForm} className={classes.cryptoBalanceAlertsForm}
title="Default (Low Balance)" title="Default (Low balance)"
label="Alert me under" label="Alert me under"
editing={isEditing(LOW_BALANCE_KEY)} editing={isEditing(LOW_BALANCE_KEY)}
disabled={isDisabled(LOW_BALANCE_KEY)} disabled={isDisabled(LOW_BALANCE_KEY)}
@ -49,7 +49,7 @@ const CryptoBalanceAlerts = ({ section, fieldWidth }) => {
save={save} save={save}
decoration={currency} decoration={currency}
className={classes.cryptoBalanceAlertsSecondForm} className={classes.cryptoBalanceAlertsSecondForm}
title="Default (High Balance)" title="Default (High balance)"
label="Alert me over" label="Alert me over"
editing={isEditing(HIGH_BALANCE_KEY)} editing={isEditing(HIGH_BALANCE_KEY)}
disabled={isDisabled(HIGH_BALANCE_KEY)} disabled={isDisabled(HIGH_BALANCE_KEY)}

View file

@ -62,7 +62,7 @@ const CryptoBalanceOverrides = ({ section }) => {
.nullable() .nullable()
.required(), .required(),
[LOW_BALANCE_KEY]: Yup.number() [LOW_BALANCE_KEY]: Yup.number()
.label('Low Balance') .label('Low balance')
.when(HIGH_BALANCE_KEY, { .when(HIGH_BALANCE_KEY, {
is: HIGH_BALANCE_KEY => !HIGH_BALANCE_KEY, is: HIGH_BALANCE_KEY => !HIGH_BALANCE_KEY,
then: Yup.number().required() then: Yup.number().required()
@ -73,7 +73,7 @@ const CryptoBalanceOverrides = ({ section }) => {
.max(CURRENCY_MAX) .max(CURRENCY_MAX)
.nullable(), .nullable(),
[HIGH_BALANCE_KEY]: Yup.number() [HIGH_BALANCE_KEY]: Yup.number()
.label('High Balance') .label('High balance')
.when(LOW_BALANCE_KEY, { .when(LOW_BALANCE_KEY, {
is: LOW_BALANCE_KEY => !LOW_BALANCE_KEY, is: LOW_BALANCE_KEY => !LOW_BALANCE_KEY,
then: Yup.number().required() then: Yup.number().required()

View file

@ -12,7 +12,7 @@ import {
} from 'src/components/fake-table/Table' } from 'src/components/fake-table/Table'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import { fromNamespace, toNamespace } from 'src/utils/config' import { fromNamespace, toNamespace } from 'src/utils/config'
import { startCase } from 'src/utils/string' import { sentenceCase } from 'src/utils/string'
import NotificationsCtx from '../NotificationsContext' import NotificationsCtx from '../NotificationsContext'
@ -62,7 +62,7 @@ const Row = ({
return ( return (
<Tr> <Tr>
<Td width={channelSize}> <Td width={channelSize}>
{shouldUpperCase ? R.toUpper(namespace) : startCase(namespace)} {shouldUpperCase ? R.toUpper(namespace) : sentenceCase(namespace)}
</Td> </Td>
<Cell name="balance" disabled={disabled} /> <Cell name="balance" disabled={disabled} />
<Cell name="transactions" disabled={disabled} /> <Cell name="transactions" disabled={disabled} />
@ -127,7 +127,7 @@ const Setup = ({ wizard, forceDisable }) => {
<Th width={channelSize - widthAdjust}>Channel</Th> <Th width={channelSize - widthAdjust}>Channel</Th>
{Object.keys(sizes).map(it => ( {Object.keys(sizes).map(it => (
<Th key={it} width={sizes[it] - widthAdjust} textAlign="center"> <Th key={it} width={sizes[it] - widthAdjust} textAlign="center">
{startCase(it)} {sentenceCase(it)}
</Th> </Th>
))} ))}
</THead> </THead>

View file

@ -3,12 +3,14 @@ import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import React, { memo } from 'react' import React, { memo } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { BooleanPropertiesTable } from 'src/components/booleanPropertiesTable' import { BooleanPropertiesTable } from 'src/components/booleanPropertiesTable'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import { H4, P, Label2 } from 'src/components/typography' import { H4, P, Label2 } from 'src/components/typography'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config' import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { SupportLinkButton } from '../../components/buttons'
import { global } from './OperatorInfo.styles' import { global } from './OperatorInfo.styles'
const useStyles = makeStyles(global) const useStyles = makeStyles(global)
@ -66,7 +68,7 @@ const CoinATMRadar = memo(({ wizard }) => {
<div> <div>
<div className={classes.header}> <div className={classes.header}>
<H4>Coin ATM Radar share settings</H4> <H4>Coin ATM Radar share settings</H4>
<HoverableTooltip width={304}> <HelpTooltip width={320}>
<P> <P>
For details on configuring this panel, please read the relevant For details on configuring this panel, please read the relevant
knowledgebase article{' '} knowledgebase article{' '}
@ -78,7 +80,12 @@ const CoinATMRadar = memo(({ wizard }) => {
</a> </a>
. .
</P> </P>
</HoverableTooltip> <SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360023720472-Coin-ATM-Radar"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
<Row <Row
title={'Share information?'} title={'Share information?'}

View file

@ -9,7 +9,8 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import PromptWhenDirty from 'src/components/PromptWhenDirty' import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { Link, IconButton } from 'src/components/buttons' import { HelpTooltip } from 'src/components/Tooltip'
import { Link, IconButton, SupportLinkButton } from 'src/components/buttons'
import Switch from 'src/components/inputs/base/Switch' import Switch from 'src/components/inputs/base/Switch'
import { TextInput } from 'src/components/inputs/formik' import { TextInput } from 'src/components/inputs/formik'
import { P, H4, Info3, Label1, Label2, Label3 } from 'src/components/typography' import { P, H4, Info3, Label1, Label2, Label3 } from 'src/components/typography'
@ -136,7 +137,7 @@ const ContactInfo = ({ wizard }) => {
const fields = [ const fields = [
{ {
name: 'name', name: 'name',
label: 'Full name', label: 'Company name',
value: info.name ?? '', value: info.name ?? '',
component: TextInput component: TextInput
}, },
@ -160,7 +161,7 @@ const ContactInfo = ({ wizard }) => {
}, },
{ {
name: 'companyNumber', name: 'companyNumber',
label: 'Company number', label: 'Company registration number',
value: info.companyNumber ?? '', value: info.companyNumber ?? '',
component: TextInput component: TextInput
} }
@ -189,6 +190,17 @@ const ContactInfo = ({ wizard }) => {
<> <>
<div className={classes.header}> <div className={classes.header}>
<H4>Contact information</H4> <H4>Contact information</H4>
<HelpTooltip width={320}>
<P>
For details on configuring this panel, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360033051732-Enabling-Operator-Info"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
<div className={classes.switchRow}> <div className={classes.switchRow}>
<P>Info card enabled?</P> <P>Info card enabled?</P>

View file

@ -4,11 +4,14 @@ import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { memo } from 'react' import React, { memo } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import { BooleanPropertiesTable } from 'src/components/booleanPropertiesTable' import { BooleanPropertiesTable } from 'src/components/booleanPropertiesTable'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import { H4, P, Label2 } from 'src/components/typography' import { H4, P, Label2 } from 'src/components/typography'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config' import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { SupportLinkButton } from '../../components/buttons'
import { global } from './OperatorInfo.styles' import { global } from './OperatorInfo.styles'
const useStyles = makeStyles(global) const useStyles = makeStyles(global)
@ -47,6 +50,17 @@ const ReceiptPrinting = memo(({ wizard }) => {
<> <>
<div className={classes.header}> <div className={classes.header}>
<H4>Receipt options</H4> <H4>Receipt options</H4>
<HelpTooltip width={320}>
<P>
For details on configuring this panel, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360058513951-Receipt-options-printers"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
<div className={classes.switchRow}> <div className={classes.switchRow}>
<P>Enable receipt printing</P> <P>Enable receipt printing</P>
@ -109,7 +123,7 @@ const ReceiptPrinting = memo(({ wizard }) => {
}, },
{ {
name: 'companyNumber', name: 'companyNumber',
display: 'Company number' display: 'Company registration number'
}, },
{ {
name: 'machineLocation', name: 'machineLocation',

View file

@ -4,8 +4,8 @@ import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { IconButton } from 'src/components/buttons' import { IconButton, SupportLinkButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import DataTable from 'src/components/tables/DataTable' import DataTable from 'src/components/tables/DataTable'
import { H4, P, Label3 } from 'src/components/typography' import { H4, P, Label3 } from 'src/components/typography'
@ -162,9 +162,9 @@ const SMSNotices = () => {
!R.isEmpty(TOOLTIPS[it.event]) ? ( !R.isEmpty(TOOLTIPS[it.event]) ? (
<div className={classes.messageWithTooltip}> <div className={classes.messageWithTooltip}>
{R.prop('messageName', it)} {R.prop('messageName', it)}
<HoverableTooltip width={250}> <HelpTooltip width={250}>
<P>{TOOLTIPS[it.event]}</P> <P>{TOOLTIPS[it.event]}</P>
</HoverableTooltip> </HelpTooltip>
</div> </div>
) : ( ) : (
R.prop('messageName', it) R.prop('messageName', it)
@ -237,6 +237,17 @@ const SMSNotices = () => {
<> <>
<div className={classes.header}> <div className={classes.header}>
<H4>SMS notices</H4> <H4>SMS notices</H4>
<HelpTooltip width={320}>
<P>
For details on configuring this panel, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001205591-SMS-Phone-Verification"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
{showModal && ( {showModal && (
<CustomSMSModal <CustomSMSModal

View file

@ -9,7 +9,8 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage' import ErrorMessage from 'src/components/ErrorMessage'
import PromptWhenDirty from 'src/components/PromptWhenDirty' import PromptWhenDirty from 'src/components/PromptWhenDirty'
import { Link, IconButton } from 'src/components/buttons' import { HelpTooltip } from 'src/components/Tooltip'
import { Link, IconButton, SupportLinkButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import { TextInput } from 'src/components/inputs/formik' import { TextInput } from 'src/components/inputs/formik'
import { H4, Info2, Info3, Label2, Label3, P } from 'src/components/typography' import { H4, Info2, Info3, Label2, Label3, P } from 'src/components/typography'
@ -171,6 +172,17 @@ const TermsConditions = () => {
<> <>
<div className={classes.header}> <div className={classes.header}>
<H4>Terms &amp; Conditions</H4> <H4>Terms &amp; Conditions</H4>
<HelpTooltip width={320}>
<P>
For details on configuring this panel, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360015982211-Terms-and-Conditions"
label="Lamassu Support Article"
bottomSpace="1"
/>
</HelpTooltip>
</div> </div>
<div className={classes.switchRow}> <div className={classes.switchRow}>
<P>Show on screen</P> <P>Show on screen</P>

View file

@ -103,7 +103,7 @@ const Services = () => {
return ( return (
<div className={classes.wrapper}> <div className={classes.wrapper}>
<TitleSection title="3rd Party Services" /> <TitleSection title="Third-Party services" />
<Grid container spacing={4}> <Grid container spacing={4}>
{R.values(schemas).map(schema => ( {R.values(schemas).map(schema => (
<Grid item key={schema.code}> <Grid item key={schema.code}>

View file

@ -12,14 +12,14 @@ export default {
elements: [ elements: [
{ {
code: 'apiKey', code: 'apiKey',
display: 'API Key', display: 'API key',
component: TextInputFormik, component: TextInputFormik,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'privateKey', code: 'privateKey',
display: 'Private Key', display: 'Private key',
component: SecretInputFormik component: SecretInputFormik
} }
], ],

View file

@ -12,14 +12,14 @@ export default {
elements: [ elements: [
{ {
code: 'apiKey', code: 'apiKey',
display: 'API Key', display: 'API key',
component: TextInputFormik, component: TextInputFormik,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'privateKey', code: 'privateKey',
display: 'Private Key', display: 'Private key',
component: SecretInputFormik component: SecretInputFormik
} }
], ],

View file

@ -26,7 +26,7 @@ export default {
elements: [ elements: [
{ {
code: 'token', code: 'token',
display: 'API Token', display: 'API token',
component: TextInput, component: TextInput,
face: true, face: true,
long: true long: true
@ -47,52 +47,52 @@ export default {
}, },
{ {
code: 'BTCWalletId', code: 'BTCWalletId',
display: 'BTC Wallet ID', display: 'BTC wallet ID',
component: TextInput component: TextInput
}, },
{ {
code: 'BTCWalletPassphrase', code: 'BTCWalletPassphrase',
display: 'BTC Wallet Passphrase', display: 'BTC wallet passphrase',
component: SecretInput component: SecretInput
}, },
{ {
code: 'LTCWalletId', code: 'LTCWalletId',
display: 'LTC Wallet ID', display: 'LTC wallet ID',
component: TextInput component: TextInput
}, },
{ {
code: 'LTCWalletPassphrase', code: 'LTCWalletPassphrase',
display: 'LTC Wallet Passphrase', display: 'LTC wallet passphrase',
component: SecretInput component: SecretInput
}, },
{ {
code: 'ZECWalletId', code: 'ZECWalletId',
display: 'ZEC Wallet ID', display: 'ZEC wallet ID',
component: TextInput component: TextInput
}, },
{ {
code: 'ZECWalletPassphrase', code: 'ZECWalletPassphrase',
display: 'ZEC Wallet Passphrase', display: 'ZEC wallet passphrase',
component: SecretInput component: SecretInput
}, },
{ {
code: 'BCHWalletId', code: 'BCHWalletId',
display: 'BCH Wallet ID', display: 'BCH wallet ID',
component: TextInput component: TextInput
}, },
{ {
code: 'BCHWalletPassphrase', code: 'BCHWalletPassphrase',
display: 'BCH Wallet Passphrase', display: 'BCH wallet passphrase',
component: SecretInput component: SecretInput
}, },
{ {
code: 'DASHWalletId', code: 'DASHWalletId',
display: 'DASH Wallet ID', display: 'DASH wallet ID',
component: TextInput component: TextInput
}, },
{ {
code: 'DASHWalletPassphrase', code: 'DASHWalletPassphrase',
display: 'DASH Wallet Passphrase', display: 'DASH wallet passphrase',
component: SecretInput component: SecretInput
} }
], ],

View file

@ -19,14 +19,14 @@ export default {
}, },
{ {
code: 'key', code: 'key',
display: 'API Key', display: 'API key',
component: TextInputFormik, component: TextInputFormik,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'secret', code: 'secret',
display: 'API Secret', display: 'API secret',
component: SecretInputFormik component: SecretInputFormik
} }
], ],

View file

@ -9,14 +9,14 @@ export default {
elements: [ elements: [
{ {
code: 'token', code: 'token',
display: 'API Token', display: 'API token',
component: TextInput, component: TextInput,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'confidenceFactor', code: 'confidenceFactor',
display: 'Confidence Factor', display: 'Confidence factor',
component: NumberInput, component: NumberInput,
face: true face: true
}, },

View file

@ -12,14 +12,21 @@ export default {
elements: [ elements: [
{ {
code: 'apiKey', code: 'apiKey',
display: 'API Key', display: 'API key',
component: TextInputFormik,
face: true,
long: true
},
{
code: 'uid',
display: 'User ID',
component: TextInputFormik, component: TextInputFormik,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'privateKey', code: 'privateKey',
display: 'Private Key', display: 'Private key',
component: SecretInputFormik component: SecretInputFormik
} }
], ],
@ -28,6 +35,9 @@ export default {
apiKey: Yup.string('The API key must be a string') apiKey: Yup.string('The API key must be a string')
.max(100, 'The API key is too long') .max(100, 'The API key is too long')
.required('The API key is required'), .required('The API key is required'),
uid: Yup.string('The User ID must be a string')
.max(100, 'The User ID is too long')
.required('The User ID is required'),
privateKey: Yup.string('The private key must be a string') privateKey: Yup.string('The private key must be a string')
.max(100, 'The private key is too long') .max(100, 'The private key is too long')
.test(secretTest(account?.privateKey, 'private key')) .test(secretTest(account?.privateKey, 'private key'))

View file

@ -26,12 +26,12 @@ export default {
}, },
{ {
code: 'clientKey', code: 'clientKey',
display: 'Client Key', display: 'Client key',
component: TextInputFormik component: TextInputFormik
}, },
{ {
code: 'clientSecret', code: 'clientSecret',
display: 'Client Secret', display: 'Client secret',
component: SecretInputFormik component: SecretInputFormik
} }
], ],

View file

@ -12,14 +12,14 @@ export default {
elements: [ elements: [
{ {
code: 'apiKey', code: 'apiKey',
display: 'API Key', display: 'API key',
component: TextInputFormik, component: TextInputFormik,
face: true, face: true,
long: true long: true
}, },
{ {
code: 'privateKey', code: 'privateKey',
display: 'Private Key', display: 'Private key',
component: SecretInputFormik component: SecretInputFormik
} }
], ],

View file

@ -9,7 +9,7 @@ export default {
elements: [ elements: [
{ {
code: 'apiKey', code: 'apiKey',
display: 'API Key', display: 'API key',
component: TextInputFormik component: TextInputFormik
}, },
{ {
@ -19,13 +19,13 @@ export default {
}, },
{ {
code: 'fromEmail', code: 'fromEmail',
display: 'From Email', display: 'From email',
component: TextInputFormik, component: TextInputFormik,
face: true face: true
}, },
{ {
code: 'toEmail', code: 'toEmail',
display: 'To Email', display: 'To email',
component: TextInputFormik, component: TextInputFormik,
face: true face: true
} }

View file

@ -13,7 +13,7 @@ const singleBitgo = code => ({
elements: [ elements: [
{ {
code: 'token', code: 'token',
display: 'API Token', display: 'API token',
component: TextInput, component: TextInput,
face: true, face: true,
long: true long: true
@ -34,12 +34,12 @@ const singleBitgo = code => ({
}, },
{ {
code: `${code}WalletId`, code: `${code}WalletId`,
display: `${code} Wallet ID`, display: `${code} wallet ID`,
component: TextInput component: TextInput
}, },
{ {
code: `${code}WalletPassphrase`, code: `${code}WalletPassphrase`,
display: `${code} Wallet Passphrase`, display: `${code} wallet passphrase`,
component: SecretInput component: SecretInput
} }
], ],

View file

@ -17,18 +17,18 @@ export default {
}, },
{ {
code: 'authToken', code: 'authToken',
display: 'Auth Token', display: 'Auth token',
component: SecretInputFormik component: SecretInputFormik
}, },
{ {
code: 'fromNumber', code: 'fromNumber',
display: 'Twilio Number (international format)', display: 'Twilio number (international format)',
component: TextInputFormik, component: TextInputFormik,
face: true face: true
}, },
{ {
code: 'toNumber', code: 'toNumber',
display: 'Notifications Number (international format)', display: 'Notifications number (international format)',
component: TextInputFormik, component: TextInputFormik,
face: true face: true
} }

View file

@ -108,7 +108,7 @@ const SessionManagement = () => {
return ( return (
<> <>
<TitleSection title="Session Management" /> <TitleSection title="Session management" />
<DataTable <DataTable
loading={loading} loading={loading}
elements={elements} elements={elements}

View file

@ -17,6 +17,7 @@ const CopyToClipboard = ({
buttonClassname, buttonClassname,
children, children,
wrapperClassname, wrapperClassname,
removeSpace = true,
...props ...props
}) => { }) => {
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
@ -46,7 +47,8 @@ const CopyToClipboard = ({
{children} {children}
</div> </div>
<div className={classnames(classes.buttonWrapper, buttonClassname)}> <div className={classnames(classes.buttonWrapper, buttonClassname)}>
<ReactCopyToClipboard text={R.replace(/\s/g, '')(children)}> <ReactCopyToClipboard
text={removeSpace ? R.replace(/\s/g, '')(children) : children}>
<button <button
aria-describedby={id} aria-describedby={id}
onClick={event => handleClick(event)}> onClick={event => handleClick(event)}>

View file

@ -11,7 +11,7 @@ import * as R from 'ramda'
import React, { memo, useState } from 'react' import React, { memo, useState } from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog' import { ConfirmDialog } from 'src/components/ConfirmDialog'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { IDButton, ActionButton } from 'src/components/buttons' import { IDButton, ActionButton } from 'src/components/buttons'
import { P, Label1 } from 'src/components/typography' import { P, Label1 } from 'src/components/typography'
import { ReactComponent as CardIdInverseIcon } from 'src/styling/icons/ID/card/white.svg' import { ReactComponent as CardIdInverseIcon } from 'src/styling/icons/ID/card/white.svg'
@ -134,9 +134,9 @@ const DetailsRow = ({ it: tx, timezone }) => {
const commissionPercentage = BigNumber( const commissionPercentage = BigNumber(
Number.parseFloat(tx.commissionPercentage, 2) * 100 Number.parseFloat(tx.commissionPercentage, 2) * 100
).toFixed(2, 1) // ROUND_DOWN ).toFixed(2, 1) // ROUND_DOWN
const cashInFee = isCashIn ? Number.parseFloat(tx.cashInFee) : 0 const fixedFee = Number.parseFloat(tx.fixedFee) || 0
const fiat = BigNumber(tx.fiat) const fiat = BigNumber(tx.fiat)
.minus(cashInFee) .minus(fixedFee)
.toFixed(2, 1) // ROUND_DOWN .toFixed(2, 1) // ROUND_DOWN
const crypto = getCryptoAmount(tx) const crypto = getCryptoAmount(tx)
const cryptoFee = tx.fee ? `${getCryptoFeeAmount(tx)} ${tx.fiatCode}` : 'N/A' const cryptoFee = tx.fee ? `${getCryptoFeeAmount(tx)} ${tx.fiatCode}` : 'N/A'
@ -192,6 +192,13 @@ const DetailsRow = ({ it: tx, timezone }) => {
<> <>
<Label>Transaction status</Label> <Label>Transaction status</Label>
<span className={classes.bold}>{getStatus(tx)}</span> <span className={classes.bold}>{getStatus(tx)}</span>
{getStatusDetails(tx) ? (
<CopyToClipboard removeSpace={false} className={classes.errorCopy}>
{getStatusDetails(tx)}
</CopyToClipboard>
) : (
<></>
)}
</> </>
) )
@ -351,7 +358,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
</div> </div>
<div> <div>
<Label>Fixed fee</Label> <Label>Fixed fee</Label>
<div>{isCashIn ? `${cashInFee} ${tx.fiatCode}` : 'N/A'}</div> <div>{`${fixedFee} ${tx.fiatCode}`}</div>
</div> </div>
</div> </div>
<div className={classes.secondRow}> <div className={classes.secondRow}>
@ -359,9 +366,9 @@ const DetailsRow = ({ it: tx, timezone }) => {
<div className={classes.addressHeader}> <div className={classes.addressHeader}>
<Label>Address</Label> <Label>Address</Label>
{!R.isNil(tx.walletScore) && ( {!R.isNil(tx.walletScore) && (
<HoverableTooltip parentElements={walletScoreEl}> <HelpTooltip parentElements={walletScoreEl}>
{`Chain analysis score: ${tx.walletScore}/10`} {`Chain analysis score: ${tx.walletScore}/10`}
</HoverableTooltip> </HelpTooltip>
)} )}
</div> </div>
<div> <div>
@ -393,13 +400,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
</div> </div>
<div className={classes.lastRow}> <div className={classes.lastRow}>
<div className={classes.status}> <div className={classes.status}>
{getStatusDetails(tx) ? ( {errorElements}
<HoverableTooltip parentElements={errorElements} width={200}>
<P>{getStatusDetails(tx)}</P>
</HoverableTooltip>
) : (
errorElements
)}
{((tx.txClass === 'cashOut' && getStatus(tx) === 'Pending') || {((tx.txClass === 'cashOut' && getStatus(tx) === 'Pending') ||
(tx.txClass === 'cashIn' && getStatus(tx) === 'Batched')) && ( (tx.txClass === 'cashIn' && getStatus(tx) === 'Batched')) && (
<ActionButton <ActionButton

View file

@ -1,7 +1,7 @@
import typographyStyles from 'src/components/typography/styles' import typographyStyles from 'src/components/typography/styles'
import { offColor, comet, white, tomato } from 'src/styling/variables' import { offColor, comet, white, tomato } from 'src/styling/variables'
const { p } = typographyStyles const { p, label3 } = typographyStyles
export default { export default {
wrapper: { wrapper: {
@ -137,5 +137,10 @@ export default {
}, },
swept: { swept: {
width: 250 width: 250
},
errorCopy: {
extend: label3,
lineBreak: 'normal',
maxWidth: 180
} }
} }

View file

@ -11,6 +11,8 @@ import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
import SearchBox from 'src/components/SearchBox' import SearchBox from 'src/components/SearchBox'
import SearchFilter from 'src/components/SearchFilter' import SearchFilter from 'src/components/SearchFilter'
import Title from 'src/components/Title' import Title from 'src/components/Title'
import { HelpTooltip } from 'src/components/Tooltip'
import { SupportLinkButton } from 'src/components/buttons'
import DataTable from 'src/components/tables/DataTable' import DataTable from 'src/components/tables/DataTable'
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 { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
@ -105,7 +107,7 @@ const GET_TRANSACTIONS = gql`
deviceId deviceId
fiat fiat
fee fee
cashInFee fixedFee
fiatCode fiatCode
cryptoAtoms cryptoAtoms
cryptoCode cryptoCode
@ -233,7 +235,22 @@ const Transactions = () => {
}, },
{ {
header: 'Status', header: 'Status',
view: it => getStatus(it), view: it => {
if (getStatus(it) === 'Pending')
return (
<div className={classes.pendingBox}>
{'Pending'}
<HelpTooltip width={285}>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
label="Cancelling cash-out transactions"
bottomSpace="0"
/>
</HelpTooltip>
</div>
)
else return getStatus(it)
},
textAlign: 'left', textAlign: 'left',
size: 'sm', size: 'sm',
width: 80 width: 80
@ -323,7 +340,7 @@ const Transactions = () => {
loading={filtersLoading} loading={filtersLoading}
filters={filters} filters={filters}
options={filterOptions} options={filterOptions}
inputPlaceholder={'Search Transactions'} inputPlaceholder={'Search transactions'}
onChange={onFilterChange} onChange={onFilterChange}
/> />
</div> </div>

View file

@ -6,7 +6,7 @@ import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Link, SupportLinkButton } from 'src/components/buttons' import { Link, SupportLinkButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs' import { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
@ -187,13 +187,13 @@ const Triggers = () => {
<Label2 className={classes.switchLabel}> <Label2 className={classes.switchLabel}>
{rejectAddressReuse ? 'On' : 'Off'} {rejectAddressReuse ? 'On' : 'Off'}
</Label2> </Label2>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
This option requires a user to scan a different cryptocurrency This option requires a user to scan a different cryptocurrency
address if they attempt to scan one that had been previously address if they attempt to scan one that had been previously
used for a transaction in your network used for a transaction in your network
</P> </P>
</HoverableTooltip> </HelpTooltip>
</Box> </Box>
</Box> </Box>
)} )}

View file

@ -241,7 +241,7 @@ const Users = () => {
return ( return (
<> <>
<TitleSection title="User Management" /> <TitleSection title="User management" />
<Box <Box
marginBottom={3} marginBottom={3}
marginTop={-5} marginTop={-5}

View file

@ -5,6 +5,8 @@ import * as R from 'ramda'
import React, { useState } from 'react' import React, { useState } from 'react'
import Modal from 'src/components/Modal' import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import { SupportLinkButton } from 'src/components/buttons'
import { NamespacedTable as EditableTable } from 'src/components/editableTable' import { NamespacedTable as EditableTable } from 'src/components/editableTable'
import TitleSection from 'src/components/layout/TitleSection' import TitleSection from 'src/components/layout/TitleSection'
import FormRenderer from 'src/pages/Services/FormRenderer' import FormRenderer from 'src/pages/Services/FormRenderer'
@ -13,6 +15,8 @@ import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle
import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg' import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg'
import { fromNamespace, toNamespace } from 'src/utils/config' import { fromNamespace, toNamespace } from 'src/utils/config'
import { P } from '../../components/typography'
import AdvancedWallet from './AdvancedWallet' import AdvancedWallet from './AdvancedWallet'
import styles from './Wallet.styles.js' import styles from './Wallet.styles.js'
import Wizard from './Wizard' import Wizard from './Wizard'
@ -115,7 +119,7 @@ const Wallet = ({ name: SCREEN_KEY }) => {
<> <>
<div className={classes.header}> <div className={classes.header}>
<TitleSection <TitleSection
title="Wallet Settings" title="Wallet settings"
buttons={[ buttons={[
{ {
text: 'Advanced settings', text: 'Advanced settings',
@ -124,6 +128,19 @@ const Wallet = ({ name: SCREEN_KEY }) => {
toggle: setAdvancedSettings toggle: setAdvancedSettings
} }
]} ]}
appendix={
<HelpTooltip width={340}>
<P>
For details on configuring wallets, please read the relevant
knowledgebase article:
</P>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/360000725832-Wallets-Exchange-Linkage-and-Volatility"
label="Wallets, Exchange Linkage, and Volatility"
bottomSpace="1"
/>
</HelpTooltip>
}
/> />
</div> </div>
{!advancedSettings && ( {!advancedSettings && (

View file

@ -108,7 +108,7 @@ const getAdvancedWalletElements = () => {
}, },
{ {
name: 'allowTransactionBatching', name: 'allowTransactionBatching',
header: `Allow BTC Transaction Batching`, header: `Allow BTC transaction batching`,
size: 'sm', size: 'sm',
stripe: true, stripe: true,
width: 260, width: 260,
@ -119,7 +119,7 @@ const getAdvancedWalletElements = () => {
}, },
{ {
name: 'feeMultiplier', name: 'feeMultiplier',
header: `BTC Miner's Fee`, header: `BTC miner's fee`,
size: 'sm', size: 'sm',
stripe: true, stripe: true,
width: 250, width: 250,
@ -179,7 +179,7 @@ const getAdvancedWalletElementsOverrides = (
}, },
{ {
name: 'feeMultiplier', name: 'feeMultiplier',
header: `Miner's Fee`, header: `Miner's fee`,
size: 'sm', size: 'sm',
stripe: true, stripe: true,
width: 250, width: 250,
@ -280,7 +280,7 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
}, },
{ {
name: 'zeroConf', name: 'zeroConf',
header: 'Confidence Checking', header: 'Confidence checking',
size: 'sm', size: 'sm',
stripe: true, stripe: true,
view: (it, row) => { view: (it, row) => {
@ -304,7 +304,7 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
}, },
{ {
name: 'zeroConfLimit', name: 'zeroConfLimit',
header: '0-conf Limit', header: '0-conf limit',
size: 'sm', size: 'sm',
stripe: true, stripe: true,
view: (it, row) => view: (it, row) =>

View file

@ -5,7 +5,7 @@ import gql from 'graphql-tag'
import React, { useState } from 'react' import React, { useState } from 'react'
import InfoMessage from 'src/components/InfoMessage' import InfoMessage from 'src/components/InfoMessage'
import { HoverableTooltip } from 'src/components/Tooltip' import { HelpTooltip } from 'src/components/Tooltip'
import { Button, SupportLinkButton } from 'src/components/buttons' import { Button, SupportLinkButton } from 'src/components/buttons'
import { RadioGroup } from 'src/components/inputs' import { RadioGroup } from 'src/components/inputs'
import { H1, H4, P } from 'src/components/typography' import { H1, H4, P } from 'src/components/typography'
@ -102,7 +102,7 @@ function Twilio({ doContinue }) {
<H4 noMargin className={classnames(titleClasses)}> <H4 noMargin className={classnames(titleClasses)}>
Will you setup a two way machine or compliance? Will you setup a two way machine or compliance?
</H4> </H4>
<HoverableTooltip width={304}> <HelpTooltip width={304}>
<P> <P>
Two-way machines allow your customers not only to buy (cash-in) Two-way machines allow your customers not only to buy (cash-in)
but also sell cryptocurrencies (cash-out). but also sell cryptocurrencies (cash-out).
@ -111,7 +111,7 @@ function Twilio({ doContinue }) {
Youll need an SMS service for cash-out transactions and for any Youll need an SMS service for cash-out transactions and for any
compliance triggers compliance triggers
</P> </P>
</HoverableTooltip> </HelpTooltip>
</Box> </Box>
<RadioGroup <RadioGroup

View file

@ -49,7 +49,7 @@ const getLamassuRoutes = () => [
children: [ children: [
{ {
key: 'cash_units', key: 'cash_units',
label: 'Cash Units', label: 'Cash units',
route: '/maintenance/cash-units', route: '/maintenance/cash-units',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CashUnits component: CashUnits
@ -63,14 +63,14 @@ const getLamassuRoutes = () => [
}, },
{ {
key: 'logs', key: 'logs',
label: 'Machine Logs', label: 'Machine logs',
route: '/maintenance/logs', route: '/maintenance/logs',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineLogs component: MachineLogs
}, },
{ {
key: 'machine-status', key: 'machine-status',
label: 'Machine Status', label: 'Machine status',
route: '/maintenance/machine-status', route: '/maintenance/machine-status',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineStatus component: MachineStatus
@ -130,7 +130,7 @@ const getLamassuRoutes = () => [
}, },
{ {
key: 'services', key: 'services',
label: '3rd Party Services', label: 'Third-party services',
route: '/settings/3rd-party-services', route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services component: Services
@ -144,9 +144,9 @@ const getLamassuRoutes = () => [
}, },
{ {
key: namespaces.OPERATOR_INFO, key: namespaces.OPERATOR_INFO,
label: 'Operator Info', label: 'Operator info',
route: '/settings/operator-info', route: '/settings/operator-info',
title: 'Operator Information', title: 'Operator information',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() { get component() {
return () => ( return () => (
@ -232,7 +232,7 @@ const getLamassuRoutes = () => [
key: 'loyalty', key: 'loyalty',
label: 'Loyalty', label: 'Loyalty',
route: '/compliance/loyalty', route: '/compliance/loyalty',
title: 'Loyalty Panel', title: 'Loyalty panel',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() { get component() {
return () => ( return () => (
@ -247,14 +247,14 @@ const getLamassuRoutes = () => [
children: [ children: [
{ {
key: 'individual-discounts', key: 'individual-discounts',
label: 'Individual Discounts', label: 'Individual discounts',
route: '/compliance/loyalty/individual-discounts', route: '/compliance/loyalty/individual-discounts',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: IndividualDiscounts component: IndividualDiscounts
}, },
{ {
key: 'promo-codes', key: 'promo-codes',
label: 'Promo Codes', label: 'Promo codes',
route: '/compliance/loyalty/codes', route: '/compliance/loyalty/codes',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: PromoCodes component: PromoCodes
@ -280,14 +280,14 @@ const getLamassuRoutes = () => [
children: [ children: [
{ {
key: 'user-management', key: 'user-management',
label: 'User Management', label: 'User management',
route: '/system/user-management', route: '/system/user-management',
allowedRoles: [ROLES.SUPERUSER], allowedRoles: [ROLES.SUPERUSER],
component: UserManagement component: UserManagement
}, },
{ {
key: 'session-management', key: 'session-management',
label: 'Session Management', label: 'Session management',
route: '/system/session-management', route: '/system/session-management',
allowedRoles: [ROLES.SUPERUSER], allowedRoles: [ROLES.SUPERUSER],
component: SessionManagement component: SessionManagement

View file

@ -56,14 +56,14 @@ const getPazuzRoutes = () => [
}, },
{ {
key: 'logs', key: 'logs',
label: 'Machine Logs', label: 'Machine logs',
route: '/maintenance/logs', route: '/maintenance/logs',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineLogs component: MachineLogs
}, },
{ {
key: 'machine-status', key: 'machine-status',
label: 'Machine Status', label: 'Machine status',
route: '/maintenance/machine-status', route: '/maintenance/machine-status',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: MachineStatus component: MachineStatus
@ -123,9 +123,9 @@ const getPazuzRoutes = () => [
}, },
{ {
key: namespaces.OPERATOR_INFO, key: namespaces.OPERATOR_INFO,
label: 'Operator Info', label: 'Operator info',
route: '/settings/operator-info', route: '/settings/operator-info',
title: 'Operator Information', title: 'Operator information',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
get component() { get component() {
return () => ( return () => (
@ -225,14 +225,14 @@ const getPazuzRoutes = () => [
children: [ children: [
{ {
key: 'individual-discounts', key: 'individual-discounts',
label: 'Individual Discounts', label: 'Individual discounts',
route: '/compliance/loyalty/individual-discounts', route: '/compliance/loyalty/individual-discounts',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: IndividualDiscounts component: IndividualDiscounts
}, },
{ {
key: 'promo-codes', key: 'promo-codes',
label: 'Promo Codes', label: 'Promo codes',
route: '/compliance/loyalty/codes', route: '/compliance/loyalty/codes',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: PromoCodes component: PromoCodes
@ -290,14 +290,14 @@ const getPazuzRoutes = () => [
children: [ children: [
{ {
key: 'user-management', key: 'user-management',
label: 'User Management', label: 'User management',
route: '/system/user-management', route: '/system/user-management',
allowedRoles: [ROLES.SUPERUSER], allowedRoles: [ROLES.SUPERUSER],
component: UserManagement component: UserManagement
}, },
{ {
key: 'session-management', key: 'session-management',
label: 'Session Management', label: 'Session management',
route: '/system/session-management', route: '/system/session-management',
allowedRoles: [ROLES.SUPERUSER], allowedRoles: [ROLES.SUPERUSER],
component: SessionManagement component: SessionManagement

View file

@ -64,6 +64,9 @@ export default {
// forcing styling onto inner container // forcing styling onto inner container
'.ReactVirtualized__Grid__innerScrollContainer': { '.ReactVirtualized__Grid__innerScrollContainer': {
overflow: 'inherit !important' overflow: 'inherit !important'
},
'.ReactVirtualized__Grid.ReactVirtualized__List': {
overflowY: 'overlay !important'
} }
} }
} }

View file

@ -26,7 +26,15 @@ const startCase = R.compose(
splitOnUpper splitOnUpper
) )
const sentenceCase = R.compose(onlyFirstToUpper, S.joinWith(' '), splitOnUpper)
const singularOrPlural = (amount, singularStr, pluralStr) => const singularOrPlural = (amount, singularStr, pluralStr) =>
parseInt(amount) === 1 ? singularStr : pluralStr parseInt(amount) === 1 ? singularStr : pluralStr
export { startCase, onlyFirstToUpper, formatLong, singularOrPlural } export {
startCase,
onlyFirstToUpper,
formatLong,
singularOrPlural,
sentenceCase
}