Merge branch 'dev' into backport/csv-empty-txs

This commit is contained in:
Rafael Taranto 2024-11-29 13:47:38 +00:00 committed by GitHub
commit 0c289f7d37
104 changed files with 1784 additions and 380 deletions

View file

@ -55,6 +55,20 @@ function updateCore (coinRec, isCurrentlyRunning) {
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()) {
common.logger.info('Starting wallet...')
common.es(`sudo supervisorctl start bitcoin`)

View file

@ -150,16 +150,15 @@ function postProcess (r, pi, isBlacklisted, addressReuse, walletScore) {
.catch(err => {
// Important: We don't know what kind of error this is
// 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
// not to send.
const sendPending = err.name !== 'InsufficientFundsError'
// Setting sendPending to true ensures that the transaction gets
// silently terminated and no retries are done
return {
sendTime: 'now()^',
error: err.message,
errorCode: err.name,
sendPending
sendPending: true
}
})
.then(sendRec => {

View file

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

View file

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

View file

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

View file

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

View file

@ -6,6 +6,7 @@ type Coin {
display: String!
minimumTx: String!
cashInFee: String!
cashOutFee: String!
cashInCommission: String!
cashOutCommission: 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 pairing = require('./pairing.resolver')
const rates = require('./rates.resolver')
const sanctions = require('./sanctions.resolver')
const scalar = require('./scalar.resolver')
const settings = require('./settings.resolver')
const sms = require('./sms.resolver')
@ -37,6 +38,7 @@ const resolvers = [
notification,
pairing,
rates,
sanctions,
scalar,
settings,
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 pairing = require('./pairing.type')
const rates = require('./rates.type')
const sanctions = require('./sanctions.type')
const scalar = require('./scalar.type')
const settings = require('./settings.type')
const sms = require('./sms.type')
@ -37,6 +38,7 @@ const types = [
notification,
pairing,
rates,
sanctions,
scalar,
settings,
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
operatorCompleted: Boolean
sendPending: Boolean
cashInFee: String
fixedFee: String
minimumTx: Float
customerId: ID
isAnonymous: Boolean

View file

@ -50,8 +50,20 @@ function batch (
excludeTestingCustomers = false,
simplified
) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addProfits, addNames)
const isCsvExport = _.isBoolean(simplified)
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.*,
c.phone AS customer_phone,
@ -154,7 +166,7 @@ function batch (
function advancedBatch (data) {
const fields = ['txClass', 'id', 'deviceId', 'toAddress', 'cryptoAtoms',
'cryptoCode', 'fiat', 'fiatCode', 'fee', 'status', 'fiatProfit', 'cryptoAmount',
'dispense', 'notified', 'redeem', 'phone', 'error',
'dispense', 'notified', 'redeem', 'phone', 'error', 'fixedFee',
'created', 'confirmedAt', 'hdIndex', 'swept', 'timedout',
'dispenseConfirmed', 'provisioned1', 'provisioned2', 'provisioned3', 'provisioned4',
'provisionedRecycler1', 'provisionedRecycler2', 'provisionedRecycler3', 'provisionedRecycler4', 'provisionedRecycler5', 'provisionedRecycler6',
@ -170,7 +182,9 @@ function advancedBatch (data) {
...it,
status: getStatus(it),
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)

View file

@ -100,16 +100,19 @@ function loadAccounts (schemaVersion) {
.then(_.compose(_.defaultTo({}), _.get('data.accounts')))
}
function hideSecretFields (accounts) {
return _.flow(
_.filter(path => !_.isEmpty(_.get(path, accounts))),
_.reduce(
(accounts, path) => _.assoc(path, PASSWORD_FILLED, accounts),
accounts
)
)(SECRET_FIELDS)
}
function showAccounts (schemaVersion) {
return loadAccounts(schemaVersion)
.then(accounts => {
const filledSecretPaths = _.compact(_.map(path => {
if (!_.isEmpty(_.get(path, accounts))) {
return path
}
}, SECRET_FIELDS))
return _.compose(_.map(path => _.assoc(path, PASSWORD_FILLED), filledSecretPaths))(accounts)
})
.then(hideSecretFields)
}
const insertConfigRow = (dbOrTx, data) =>

View file

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

View file

@ -7,10 +7,11 @@ const nocache = require('nocache')
const logger = require('./logger')
const addRWBytes = require('./middlewares/addRWBytes')
const authorize = require('./middlewares/authorize')
const computeSchema = require('./middlewares/compute-schema')
const errorHandler = require('./middlewares/errorHandler')
const filterOldRequests = require('./middlewares/filterOldRequests')
const computeSchema = require('./middlewares/compute-schema')
const findOperatorId = require('./middlewares/operatorId')
const populateDeviceId = require('./middlewares/populateDeviceId')
const populateSettings = require('./middlewares/populateSettings')
@ -50,11 +51,24 @@ const configRequiredRoutes = [
]
// middleware setup
app.use(addRWBytes())
app.use(compression({ threshold: 500 }))
app.use(helmet())
app.use(nocache())
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.use('/', pairingRoutes)

View file

@ -108,7 +108,7 @@ function updateCustomer (req, res, next) {
.then(_.merge(patch))
.then(newPatch => customers.updatePhotoCard(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 => {
createPendingManualComplianceNotifs(settings, customer, deviceId)
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),
fiat: new BN(r.fiat),
fixedFee: new BN(r.fixedFee),
rawTickerPrice: r.rawTickerPrice ? new BN(r.rawTickerPrice) : null,
commissionPercentage: new BN(r.commissionPercentage)
}
@ -69,7 +70,7 @@ function cancel (txId) {
}
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,
((NOT txIn.send_confirmed) AND (txIn.created <= now() - interval $3)) AS expired
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'
},
imgInner: {
objectFit: 'cover',
objectFit: 'contain',
objectPosition: 'center',
width: 500,
height: 400,
marginBottom: 40
}
})

View file

@ -105,20 +105,29 @@ const Popover = ({
const classes = useStyles()
const arrowClasses = {
const getArrowClasses = placement => ({
[classes.arrow]: true,
[classes.arrowBottom]: props.placement === 'bottom',
[classes.arrowTop]: props.placement === 'top',
[classes.arrowRight]: props.placement === 'right',
[classes.arrowLeft]: props.placement === 'left'
[classes.arrowBottom]: placement === 'bottom',
[classes.arrowTop]: placement === 'top',
[classes.arrowRight]: placement === 'right',
[classes.arrowLeft]: placement === 'left'
})
const flipPlacements = {
top: ['bottom'],
bottom: ['top'],
left: ['right'],
right: ['left']
}
const modifiers = R.merge(props.modifiers, {
const modifiers = R.mergeDeepLeft(props.modifiers, {
flip: {
enabled: false
enabled: R.defaultTo(false, props.flip),
allowedAutoPlacements: flipPlacements[props.placement],
boundary: 'clippingParents'
},
preventOverflow: {
enabled: true,
enabled: R.defaultTo(true, props.preventOverflow),
boundariesElement: 'scrollParent'
},
offset: {
@ -126,7 +135,7 @@ const Popover = ({
offset: '0, 10'
},
arrow: {
enabled: true,
enabled: R.defaultTo(true, props.showArrow),
element: arrowRef
},
computeStyle: {
@ -134,6 +143,12 @@ const Popover = ({
}
})
if (props.preventOverflow === false) {
modifiers.hide = {
enabled: false
}
}
return (
<>
<MaterialPopper
@ -141,10 +156,15 @@ const Popover = ({
modifiers={modifiers}
className={classes.popover}
{...props}>
{({ placement }) => (
<Paper className={classnames(classes.root, className)}>
<span className={classnames(arrowClasses)} ref={setArrowRef} />
<span
className={classnames(getArrowClasses(placement))}
ref={setArrowRef}
/>
{children}
</Paper>
)}
</MaterialPopper>
</>
)

View file

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

View file

@ -56,7 +56,8 @@ const styles = {
alignItems: 'center',
borderRadius: 4,
'& img': {
maxHeight: 145
height: 145,
minWidth: 200
}
}
}
@ -127,7 +128,8 @@ const IDButton = memo(
anchorEl={anchorEl}
onClose={handleClose}
arrowSize={3}
placement="top">
placement="top"
flip>
<div className={classes.popoverContent}>
<div>{children}</div>
</div>

View file

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

View file

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

View file

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

View file

@ -23,22 +23,26 @@ import LegendEntry from './components/LegendEntry'
import HourOfDayWrapper from './components/wrappers/HourOfDayWrapper'
import OverTimeWrapper from './components/wrappers/OverTimeWrapper'
import TopMachinesWrapper from './components/wrappers/TopMachinesWrapper'
import VolumeOverTimeWrapper from './components/wrappers/VolumeOverTimeWrapper'
const useStyles = makeStyles(styles)
const MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
const REPRESENTING_OPTIONS = [
{ 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' }
]
const PERIOD_OPTIONS = [
{ code: 'day', display: 'Last 24 hours' },
{ code: 'threeDays', display: 'Last 3 days' },
{ code: 'week', display: 'Last 7 days' },
{ code: 'month', display: 'Last 30 days' }
]
const TIME_OPTIONS = {
day: DAY,
threeDays: 3 * DAY,
week: WEEK,
month: MONTH
}
@ -77,7 +81,7 @@ const GET_TRANSACTIONS = gql`
hasError: error
deviceId
fiat
cashInFee
fixedFee
fiatCode
cryptoAtoms
cryptoCode
@ -223,13 +227,11 @@ const Analytics = () => {
previous: filteredData(period.code).previous.length
}
const avgAmount = {
current:
R.sum(R.map(d => d.fiat, filteredData(period.code).current)) /
(txs.current === 0 ? 1 : txs.current),
previous:
R.sum(R.map(d => d.fiat, filteredData(period.code).previous)) /
(txs.previous === 0 ? 1 : txs.previous)
const median = values => (values.length === 0 ? 0 : R.median(values))
const medianAmount = {
current: median(R.map(d => d.fiat, filteredData(period.code).current)),
previous: median(R.map(d => d.fiat, filteredData(period.code).previous))
}
const txVolume = {
@ -265,6 +267,20 @@ const Analytics = () => {
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':
return (
<TopMachinesWrapper
@ -347,9 +363,9 @@ const Analytics = () => {
/>
<div className={classes.verticalLine} />
<OverviewEntry
label="Avg. txn amount"
value={avgAmount.current}
oldValue={avgAmount.previous}
label="Median amount"
value={medianAmount.current}
oldValue={medianAmount.previous}
currency={fiatLocale}
/>
<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 = {
overviewLegend: {
@ -135,6 +145,18 @@ const styles = {
topMachinesRadio: {
display: 'flex',
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)
? [
formatDate(
dateInterval[1],
null,
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'),
formatDate(dateInterval[1], null, 'HH:mm'),
formatDate(dateInterval[0], null, 'HH:mm')
]
: [
formatDate(dateInterval[1], null, 'MMM d'),
formatDateNonUtc(dateInterval[1], 'HH:mm'),
formatDateNonUtc(dateInterval[0], 'HH:mm')
]
@ -55,10 +49,11 @@ const GraphTooltip = ({
return (
<Paper className={classes.dotOtWrapper}>
{!R.includes('hourOfDay', representing.code) && (
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
)}
<Info2 noMargin>
{period.code === 'day' || R.includes('hourOfDay', representing.code)
? `${formattedDateInterval[0]} - ${formattedDateInterval[1]}`
: `${formattedDateInterval[0]}`}
{`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
</Info2>
<P noMargin className={classes.dotOtTransactionAmount}>
{R.length(data)}{' '}

View file

@ -1,8 +1,8 @@
import { Box } from '@material-ui/core'
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 { primaryColor } from 'src/styling/variables'
@ -25,11 +25,13 @@ const OverTimeDotGraphHeader = ({
}) => {
const classes = useStyles()
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>,
transaction: <div className={classes.txIcon}></div>,
average: (
median: (
<svg height="12" width="18">
<path
stroke={primaryColor}
@ -53,10 +55,14 @@ const OverTimeDotGraphHeader = ({
IconElement={legend.transaction}
label={'One transaction'}
/>
<LegendEntry IconElement={legend.average} label={'Average'} />
<LegendEntry IconElement={legend.median} label={'Median'} />
</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}
@ -74,6 +80,7 @@ const OverTimeDotGraphHeader = ({
currency={currency}
selectedMachine={selectedMachine}
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 OverTimeDotGraph from './OverTimeDotGraph'
import OverTimeLineGraph from './OverTimeLineGraph'
import TopMachinesBarGraph from './TopMachinesBarGraph'
const GraphWrapper = ({
@ -15,7 +16,8 @@ const GraphWrapper = ({
currency,
selectedMachine,
machines,
selectedDay
selectedDay,
log
}) => {
const [selectionCoords, setSelectionCoords] = useState(null)
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
@ -33,6 +35,20 @@ const GraphWrapper = ({
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
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':

View file

@ -15,6 +15,7 @@ import {
fontSecondary,
subheaderColor
} from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({
@ -23,7 +24,8 @@ const Graph = ({
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval
setSelectionDateInterval,
log = false
}) => {
const ref = useRef(null)
@ -36,7 +38,7 @@ const Graph = ({
top: 25,
right: 3.5,
bottom: 27,
left: 36.5
left: 38
}),
[]
)
@ -46,6 +48,7 @@ const Graph = ({
const periodDomains = {
day: [NOW - DAY, NOW],
threeDays: [NOW - 3 * DAY, NOW],
week: [NOW - WEEK, NOW],
month: [NOW - MONTH, NOW]
}
@ -58,6 +61,12 @@ const Graph = ({
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,
@ -164,7 +173,7 @@ const Graph = ({
.domain(periodDomains[period.code])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH])
const y = d3
const yLin = d3
.scaleLinear()
.domain([
0,
@ -173,6 +182,16 @@ const Graph = ({
.nice()
.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 fullBreakpoints = [
graphLimits[1],
@ -219,14 +238,11 @@ const Graph = ({
d.getTime() + d.getTimezoneOffset() * MINUTE
)
})
.tickSizeOuter(0)
)
.call(g => g.select('.domain').remove())
.call(g =>
g
.append('line')
.attr('x1', GRAPH_MARGIN.left)
.attr('y1', -GRAPH_HEIGHT + GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
.attr('x2', GRAPH_MARGIN.left)
.select('.domain')
.attr('stroke', primaryColor)
.attr('stroke-width', 1)
),
@ -237,18 +253,23 @@ const Graph = ({
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(d3.axisLeft(y).ticks(GRAPH_HEIGHT / 100))
.call(g => g.select('.domain').remove())
.call(g =>
g
.selectAll('.tick line')
.filter(d => d === 0)
.clone()
.attr('x2', GRAPH_WIDTH - GRAPH_MARGIN.left)
.attr('stroke-width', 1)
.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)
),
[GRAPH_MARGIN, y]
.attr('stroke-width', 1),
[GRAPH_MARGIN, y, log]
)
const buildGrid = useCallback(
@ -477,25 +498,23 @@ const Graph = ({
const buildAvg = useCallback(
g => {
const median = d3.median(data, d => new BigNumber(d.fiat).toNumber()) ?? 0
if (log && median === 0) return
g.attr('stroke', primaryColor)
.attr('stroke-width', 3)
.attr('stroke-dasharray', '10, 5')
.call(g =>
g
.append('line')
.attr(
'y1',
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('y1', 0.5 + y(median))
.attr('y2', 0.5 + y(median))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH)
)
},
[GRAPH_MARGIN, y, data]
[GRAPH_MARGIN, y, data, log]
)
const drawData = useCallback(
@ -554,5 +573,6 @@ export default memo(
Graph,
(prev, next) =>
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 React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip'
import { Link, Button, IconButton } from 'src/components/buttons'
import { HelpTooltip } from 'src/components/Tooltip'
import {
Link,
Button,
IconButton,
SupportLinkButton
} from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import Sidebar from 'src/components/layout/Sidebar'
import TitleSection from 'src/components/layout/TitleSection'
@ -251,13 +256,13 @@ const Blacklist = () => {
value={enablePaperWalletOnly}
/>
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
The "Enable paper wallet (only)" option means that only paper
wallets will be printed for users, and they won't be permitted
to scan an address from their own wallet.
</P>
</HoverableTooltip>
</HelpTooltip>
</Box>
<Box
display="flex"
@ -273,13 +278,21 @@ const Blacklist = () => {
value={rejectAddressReuse}
/>
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
The "Reject reused addresses" option means that all addresses
that are used once will be automatically rejected if there's
an attempt to use them again on a new transaction.
</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>
<BlacklistTable

View file

@ -4,7 +4,8 @@ import gql from 'graphql-tag'
import * as R from 'ramda'
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 { Switch } from 'src/components/inputs'
import TitleSection from 'src/components/layout/TitleSection'
@ -92,7 +93,21 @@ const CashOut = ({ name: SCREEN_KEY }) => {
return (
!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}>
<P>Transaction fudge factor</P>
<Switch
@ -105,7 +120,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
<Label2 className={classes.switchLabel}>
{fudgeFactorActive ? 'On' : 'Off'}
</Label2>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
Automatically accept customer deposits as complete if their
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.
E.g., satoshis in Bitcoin, or wei in Ethereum.)
</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>
</TitleSection>
<EditableTable

View file

@ -134,7 +134,7 @@ const WizardStep = ({
to zero. Make sure you physically put cash inside the cash
cassettes to allow the machine to dispense it to your users. If
you already did, make sure you set the correct cash 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.
</P>
<Info2 className={classes.title}>Default Commissions</Info2>

View file

@ -4,12 +4,16 @@ import gql from 'graphql-tag'
import * as R from 'ramda'
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 { 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 OverrideLabelIcon } from 'src/styling/icons/status/spring2.svg'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { P } from '../../components/typography'
import CommissionsDetails from './components/CommissionsDetails'
import CommissionsList from './components/CommissionsList'
@ -118,6 +122,24 @@ const Commissions = ({ name: SCREEN_KEY }) => {
}
]}
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 && (

View file

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

View file

@ -91,7 +91,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
},
{
name: 'cryptoCurrencies',
width: 280,
width: 145,
size: 'sm',
view: displayCodeArray(cryptoData),
input: Autocomplete,
@ -108,7 +108,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
header: cashInHeader,
name: 'cashIn',
display: 'Cash-in',
width: 130,
width: 123,
input: NumberInput,
textAlign: 'right',
suffix: '%',
@ -121,7 +121,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
header: cashOutHeader,
name: 'cashOut',
display: 'Cash-out',
width: 130,
width: 127,
input: NumberInput,
textAlign: 'right',
suffix: '%',
@ -133,7 +133,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
{
name: 'fixedFee',
display: 'Fixed fee',
width: 144,
width: 126,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
@ -146,7 +146,7 @@ const getOverridesFields = (getData, currency, auxElements) => {
{
name: 'minimumTx',
display: 'Minimum Tx',
width: 169,
width: 140,
doubleHeader: 'Cash-in only',
textAlign: 'center',
editingAlign: 'right',
@ -156,6 +156,20 @@ const getOverridesFields = (getData, currency, auxElements) => {
inputProps: {
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: {
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)
.required(),
fixedFee: Yup.number()
.label('Fixed Fee')
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
@ -253,6 +282,11 @@ const getSchema = locale => {
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
@ -326,7 +360,7 @@ const getOverridesSchema = (values, rawData, locale) => {
return true
}
})
.label('Crypto Currencies')
.label('Crypto currencies')
.required()
.min(1),
cashIn: Yup.number()
@ -340,7 +374,7 @@ const getOverridesSchema = (values, rawData, locale) => {
.max(percentMax)
.required(),
fixedFee: Yup.number()
.label('Fixed Fee')
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
@ -348,6 +382,11 @@ const getOverridesSchema = (values, rawData, locale) => {
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
@ -356,7 +395,8 @@ const defaults = {
cashIn: '',
cashOut: '',
fixedFee: '',
minimumTx: ''
minimumTx: '',
cashOutFixedFee: ''
}
const overridesDefaults = {
@ -365,7 +405,8 @@ const overridesDefaults = {
cashIn: '',
cashOut: '',
fixedFee: '',
minimumTx: ''
minimumTx: '',
cashOutFixedFee: ''
}
const getOrder = ({ machine, cryptoCurrencies }) => {
@ -385,6 +426,7 @@ const createCommissions = (cryptoCode, deviceId, isDefault, config) => {
fixedFee: config.fixedFee,
cashOut: config.cashOut,
cashIn: config.cashIn,
cashOutFixedFee: config.cashOutFixedFee,
machine: deviceId,
cryptoCurrencies: [cryptoCode],
default: isDefault,
@ -437,7 +479,7 @@ const getListCommissionsSchema = locale => {
.label('Machine')
.required(),
cryptoCurrencies: Yup.array()
.label('Crypto Currency')
.label('Crypto currency')
.required()
.min(1),
cashIn: Yup.number()
@ -451,7 +493,7 @@ const getListCommissionsSchema = locale => {
.max(percentMax)
.required(),
fixedFee: Yup.number()
.label('Fixed Fee')
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
@ -459,6 +501,11 @@ const getListCommissionsSchema = locale => {
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
@ -487,7 +534,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{
name: 'cryptoCurrencies',
display: 'Crypto Currency',
width: 255,
width: 150,
view: R.prop(0),
size: 'sm',
editable: false
@ -496,7 +543,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
header: cashInHeader,
name: 'cashIn',
display: 'Cash-in',
width: 130,
width: 120,
input: NumberInput,
textAlign: 'right',
suffix: '%',
@ -509,7 +556,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
header: cashOutHeader,
name: 'cashOut',
display: 'Cash-out',
width: 140,
width: 126,
input: NumberInput,
textAlign: 'right',
greenText: true,
@ -522,7 +569,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{
name: 'fixedFee',
display: 'Fixed fee',
width: 144,
width: 140,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
@ -535,7 +582,7 @@ const getListCommissionsFields = (getData, currency, defaults) => {
{
name: 'minimumTx',
display: 'Minimum Tx',
width: 144,
width: 140,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
@ -544,6 +591,20 @@ const getListCommissionsFields = (getData, currency, defaults) => {
inputProps: {
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,
updateCustomEntry,
retrieveAdditionalDataDialog,
setRetrieve
setRetrieve,
checkAgainstSanctions
}) => {
const classes = useStyles()
const [listView, setListView] = useState(false)
const [previewPhoto, setPreviewPhoto] = useState(null)
const [previewCard, setPreviewCard] = useState(null)
const idData = R.path(['idCardData'])(customer)
const rawExpirationDate = R.path(['expirationDate'])(idData)
@ -172,6 +175,12 @@ const CustomerData = ({
idCardData: R.merge(idData, formatDates(values))
}),
validationSchema: customerDataSchemas.idCardData,
checkAgainstSanctions: () =>
checkAgainstSanctions({
variables: {
customerId: R.path(['id'])(customer)
}
}),
initialValues: initialValues.idCardData,
isAvailable: !R.isNil(idData),
editable: true
@ -213,9 +222,6 @@ const CustomerData = ({
{
title: 'Name',
titleIcon: <EditIcon className={classes.editIcon} />,
authorize: () => {},
reject: () => {},
save: () => {},
isAvailable: false,
editable: true
},
@ -226,7 +232,7 @@ const CustomerData = ({
authorize: () =>
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
children: <Info3>{sanctionsDisplay}</Info3>,
children: () => <Info3>{sanctionsDisplay}</Info3>,
isAvailable: !R.isNil(sanctions),
editable: true
},
@ -238,20 +244,33 @@ const CustomerData = ({
authorize: () =>
updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }),
save: values =>
replacePhoto({
save: values => {
setPreviewPhoto(null)
return replacePhoto({
newPhoto: values.frontCamera,
photoType: 'frontCamera'
}),
})
},
cancel: () => setPreviewPhoto(null),
deleteEditedData: () => deleteEditedData({ frontCamera: null }),
children: customer.frontCameraPath ? (
children: values => {
if (values.frontCamera !== previewPhoto) {
setPreviewPhoto(values.frontCamera)
}
return customer.frontCameraPath ? (
<Photo
show={customer.frontCameraPath}
src={`${URI}/front-camera-photo/${R.path(['frontCameraPath'])(
src={
!R.isNil(previewPhoto)
? URL.createObjectURL(previewPhoto)
: `${URI}/front-camera-photo/${R.path(['frontCameraPath'])(
customer
)}`}
)}`
}
/>
) : null,
) : null
},
hasImage: true,
validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera,
@ -266,18 +285,33 @@ const CustomerData = ({
authorize: () =>
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }),
save: values =>
replacePhoto({
save: values => {
setPreviewCard(null)
return replacePhoto({
newPhoto: values.idCardPhoto,
photoType: 'idCardPhoto'
}),
})
},
cancel: () => setPreviewCard(null),
deleteEditedData: () => deleteEditedData({ idCardPhoto: null }),
children: customer.idCardPhotoPath ? (
children: values => {
if (values.idCardPhoto !== previewCard) {
setPreviewCard(values.idCardPhoto)
}
return customer.idCardPhotoPath ? (
<Photo
show={customer.idCardPhotoPath}
src={`${URI}/id-card-photo/${R.path(['idCardPhotoPath'])(customer)}`}
src={
!R.isNil(previewCard)
? URL.createObjectURL(previewCard)
: `${URI}/id-card-photo/${R.path(['idCardPhotoPath'])(
customer
)}`
}
/>
) : null,
) : null
},
hasImage: true,
validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto,
@ -292,6 +326,7 @@ const CustomerData = ({
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
save: values => editCustomer(values),
children: () => {},
deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: customerDataSchemas.usSsn,
initialValues: initialValues.usSsn,
@ -427,6 +462,7 @@ const CustomerData = ({
titleIcon,
fields,
save,
cancel,
deleteEditedData,
retrieveAdditionalData,
children,
@ -434,7 +470,8 @@ const CustomerData = ({
initialValues,
hasImage,
hasAdditionalData,
editable
editable,
checkAgainstSanctions
},
idx
) => {
@ -453,8 +490,10 @@ const CustomerData = ({
validationSchema={validationSchema}
initialValues={initialValues}
save={save}
cancel={cancel}
deleteEditedData={deleteEditedData}
retrieveAdditionalData={retrieveAdditionalData}
checkAgainstSanctions={checkAgainstSanctions}
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 {
makeStyles,
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 history = useHistory()
@ -400,6 +408,10 @@ const CustomerProfile = memo(() => {
onCompleted: () => getCustomer()
})
const [checkAgainstSanctions] = useLazyQuery(CHECK_AGAINST_SANCTIONS, {
onCompleted: () => getCustomer()
})
const updateCustomer = it =>
setCustomer({
variables: {
@ -662,6 +674,7 @@ const CustomerProfile = memo(() => {
authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry}
setRetrieve={setRetrieve}
checkAgainstSanctions={checkAgainstSanctions}
retrieveAdditionalDataDialog={
<RetrieveDataDialog
onDismissed={() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import {
fontSecondary,
backgroundColor
} from 'src/styling/variables'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({ data, timeFrame, timezone }) => {
@ -172,7 +173,16 @@ const Graph = ({ data, timeFrame, timezone }) => {
g =>
g
.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())
.selectAll('text')
.attr('dy', '-0.25rem'),

View file

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

View file

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

View file

@ -5,7 +5,8 @@ import * as R from 'ramda'
import React, { useState } from 'react'
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 Section from 'src/components/layout/Section'
import TitleSection from 'src/components/layout/TitleSection'
@ -61,8 +62,9 @@ const GET_DATA = gql`
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
mutation Save($config: JSONObject, $accounts: JSONObject) {
saveConfig(config: $config)
saveAccounts(accounts: $accounts)
}
`
@ -134,9 +136,9 @@ const Locales = ({ name: SCREEN_KEY }) => {
return save(newConfig)
}
const save = config => {
const save = (config, accounts) => {
setDataToSave(null)
return saveConfig({ variables: { config } })
return saveConfig({ variables: { config, accounts } })
}
const saveOverrides = it => {
@ -162,8 +164,8 @@ const Locales = ({ name: SCREEN_KEY }) => {
const onEditingDefault = (it, editing) => setEditingDefault(editing)
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
const wizardSave = it =>
save(toNamespace(namespaces.WALLETS)(it)).then(it => {
const wizardSave = (config, accounts) =>
save(toNamespace(namespaces.WALLETS)(config), accounts).then(it => {
onChangeFunction()
setOnChangeFunction(null)
return it
@ -176,7 +178,22 @@ const Locales = ({ name: SCREEN_KEY }) => {
close={() => setDataToSave(null)}
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>
<EditableTable
title="Default settings"

View file

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

View file

@ -6,7 +6,7 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
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 { NumberInput, Autocomplete } from 'src/components/inputs/formik'
import { H3, TL1, P } from 'src/components/typography'
@ -99,7 +99,7 @@ const IndividualDiscountModal = ({
<div>
<div className={classes.discountRateWrapper}>
<H3>Define discount rate</H3>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
This is a percentage discount off of your existing
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%
on transactions using the code.
</P>
</HoverableTooltip>
</HelpTooltip>
</div>
<div className={classes.discountInput}>
<Field

View file

@ -6,7 +6,7 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
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 { TextInput, NumberInput } from 'src/components/inputs/formik'
import { H3, TL1, P } from 'src/components/typography'
@ -69,7 +69,7 @@ const PromoCodesModal = ({ showModal, onClose, errorMsg, addCode }) => {
/>
<div className={classes.modalLabel2Wrapper}>
<H3 className={classes.modalLabel2}>Define discount rate</H3>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
This is a percentage discount off of your existing
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
transactions using the code.
</P>
</HoverableTooltip>
</HelpTooltip>
</div>
<div className={classes.discountInput}>
<Field

View file

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

View file

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

View file

@ -61,6 +61,14 @@ const getOverridesFields = currency => {
doubleHeader: 'Cash-in only',
textAlign: 'right',
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
deviceId
fiat
cashInFee
fixedFee
fiatCode
cryptoAtoms
cryptoCode

View file

@ -6,7 +6,8 @@ import React, { useState } from 'react'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper'
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 TitleSection from 'src/components/layout/TitleSection'
import { EmptyTable } from 'src/components/table'
@ -204,7 +205,7 @@ const CashCassettes = () => {
!dataLoading && (
<>
<TitleSection
title="Cash Boxes & Cassettes"
title="Cash boxes & cassettes"
buttons={[
{
text: 'Cash box history',
@ -229,7 +230,20 @@ const CashCassettes = () => {
}
]}
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 && (
<Box alignItems="center" justifyContent="flex-end">
<Label1 className={classes.cashboxReset}>Cash box resets</Label1>

View file

@ -5,7 +5,7 @@ export default {
height: 36
},
tBody: {
maxHeight: '65vh',
maxHeight: 'calc(100vh - 350px)',
overflow: 'auto'
},
tableWidth: {

View file

@ -51,6 +51,12 @@ const styles = {
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between'
},
tableWrapper: {
display: 'flex',
flexDirection: 'column',
flex: 1,
marginBottom: 80
}
}
@ -158,7 +164,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
},
{
name: 'billCount',
header: 'Bill Count',
header: 'Bill count',
width: 115,
textAlign: 'left',
input: NumberInput,
@ -243,6 +249,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
]
return (
<div className={classes.tableWrapper}>
<DataTable
loading={loading}
name="cashboxHistory"
@ -250,6 +257,7 @@ const CashboxHistory = ({ machines, currency, timezone }) => {
data={batches}
emptyText="No cash box batches so far"
/>
</div>
)
}

View file

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

View file

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

View file

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

View file

@ -4,6 +4,8 @@ import * as R from 'ramda'
import React, { useState } from 'react'
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 { P } from 'src/components/typography'
import FormRenderer from 'src/pages/Services/FormRenderer'
@ -159,7 +161,24 @@ const Notifications = ({
!loading && (
<>
<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 && (
<Section
title="Third party providers"

View file

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

View file

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

View file

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

View file

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

View file

@ -9,7 +9,8 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
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 { TextInput } from 'src/components/inputs/formik'
import { P, H4, Info3, Label1, Label2, Label3 } from 'src/components/typography'
@ -136,7 +137,7 @@ const ContactInfo = ({ wizard }) => {
const fields = [
{
name: 'name',
label: 'Full name',
label: 'Company name',
value: info.name ?? '',
component: TextInput
},
@ -160,7 +161,7 @@ const ContactInfo = ({ wizard }) => {
},
{
name: 'companyNumber',
label: 'Company number',
label: 'Company registration number',
value: info.companyNumber ?? '',
component: TextInput
}
@ -189,6 +190,17 @@ const ContactInfo = ({ wizard }) => {
<>
<div className={classes.header}>
<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 className={classes.switchRow}>
<P>Info card enabled?</P>

View file

@ -4,11 +4,14 @@ import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { memo } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import { BooleanPropertiesTable } from 'src/components/booleanPropertiesTable'
import { Switch } from 'src/components/inputs'
import { H4, P, Label2 } from 'src/components/typography'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { SupportLinkButton } from '../../components/buttons'
import { global } from './OperatorInfo.styles'
const useStyles = makeStyles(global)
@ -47,6 +50,17 @@ const ReceiptPrinting = memo(({ wizard }) => {
<>
<div className={classes.header}>
<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 className={classes.switchRow}>
<P>Enable receipt printing</P>
@ -109,7 +123,7 @@ const ReceiptPrinting = memo(({ wizard }) => {
},
{
name: 'companyNumber',
display: 'Company number'
display: 'Company registration number'
},
{
name: 'machineLocation',

View file

@ -4,8 +4,8 @@ import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip'
import { IconButton } from 'src/components/buttons'
import { HelpTooltip } from 'src/components/Tooltip'
import { IconButton, SupportLinkButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import DataTable from 'src/components/tables/DataTable'
import { H4, P, Label3 } from 'src/components/typography'
@ -162,9 +162,9 @@ const SMSNotices = () => {
!R.isEmpty(TOOLTIPS[it.event]) ? (
<div className={classes.messageWithTooltip}>
{R.prop('messageName', it)}
<HoverableTooltip width={250}>
<HelpTooltip width={250}>
<P>{TOOLTIPS[it.event]}</P>
</HoverableTooltip>
</HelpTooltip>
</div>
) : (
R.prop('messageName', it)
@ -237,6 +237,17 @@ const SMSNotices = () => {
<>
<div className={classes.header}>
<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>
{showModal && (
<CustomSMSModal

View file

@ -9,7 +9,8 @@ import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
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 { TextInput } from 'src/components/inputs/formik'
import { H4, Info2, Info3, Label2, Label3, P } from 'src/components/typography'
@ -171,6 +172,17 @@ const TermsConditions = () => {
<>
<div className={classes.header}>
<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 className={classes.switchRow}>
<P>Show on screen</P>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import typographyStyles from 'src/components/typography/styles'
import { offColor, comet, white, tomato } from 'src/styling/variables'
const { p } = typographyStyles
const { p, label3 } = typographyStyles
export default {
wrapper: {
@ -137,5 +137,10 @@ export default {
},
swept: {
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 SearchFilter from 'src/components/SearchFilter'
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 { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
@ -105,7 +107,7 @@ const GET_TRANSACTIONS = gql`
deviceId
fiat
fee
cashInFee
fixedFee
fiatCode
cryptoAtoms
cryptoCode
@ -233,7 +235,22 @@ const Transactions = () => {
},
{
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',
size: 'sm',
width: 80
@ -323,7 +340,7 @@ const Transactions = () => {
loading={filtersLoading}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search Transactions'}
inputPlaceholder={'Search transactions'}
onChange={onFilterChange}
/>
</div>

View file

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

View file

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

View file

@ -5,6 +5,8 @@ import * as R from 'ramda'
import React, { useState } from 'react'
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 TitleSection from 'src/components/layout/TitleSection'
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 { fromNamespace, toNamespace } from 'src/utils/config'
import { P } from '../../components/typography'
import AdvancedWallet from './AdvancedWallet'
import styles from './Wallet.styles.js'
import Wizard from './Wizard'
@ -115,7 +119,7 @@ const Wallet = ({ name: SCREEN_KEY }) => {
<>
<div className={classes.header}>
<TitleSection
title="Wallet Settings"
title="Wallet settings"
buttons={[
{
text: 'Advanced settings',
@ -124,6 +128,19 @@ const Wallet = ({ name: SCREEN_KEY }) => {
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>
{!advancedSettings && (

View file

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

View file

@ -5,7 +5,7 @@ import gql from 'graphql-tag'
import React, { useState } from 'react'
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 { RadioGroup } from 'src/components/inputs'
import { H1, H4, P } from 'src/components/typography'
@ -102,7 +102,7 @@ function Twilio({ doContinue }) {
<H4 noMargin className={classnames(titleClasses)}>
Will you setup a two way machine or compliance?
</H4>
<HoverableTooltip width={304}>
<HelpTooltip width={304}>
<P>
Two-way machines allow your customers not only to buy (cash-in)
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
compliance triggers
</P>
</HoverableTooltip>
</HelpTooltip>
</Box>
<RadioGroup

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