chore: use monorepo organization

This commit is contained in:
Rafael Taranto 2025-05-12 10:52:54 +01:00
parent deaf7d6ecc
commit a687827f7e
1099 changed files with 8184 additions and 11535 deletions

View file

@ -0,0 +1,276 @@
import { useMutation, useQuery, gql } from '@apollo/client'
import Dialog from '@mui/material/Dialog'
import DialogContent from '@mui/material/DialogContent'
import SvgIcon from '@mui/material/SvgIcon'
import IconButton from '@mui/material/IconButton'
import classnames from 'classnames'
import { Form, Formik, FastField } from 'formik'
import { QRCodeSVG as QRCode } from 'qrcode.react'
import * as R from 'ramda'
import React, { memo, useState, useEffect, useRef } from 'react'
import Title from 'src/components/Title'
import Sidebar from 'src/components/layout/Sidebar'
import { Info2, P } from 'src/components/typography'
import CameraIcon from 'src/styling/icons/ID/photo/zodiac.svg?react'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import CompleteStageIconSpring from 'src/styling/icons/stage/spring/complete.svg?react'
import CompleteStageIconZodiac from 'src/styling/icons/stage/zodiac/complete.svg?react'
import CurrentStageIconZodiac from 'src/styling/icons/stage/zodiac/current.svg?react'
import EmptyStageIconZodiac from 'src/styling/icons/stage/zodiac/empty.svg?react'
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { primaryColor } from 'src/styling/variables'
const SAVE_CONFIG = gql`
mutation createPairingTotem($name: String!) {
createPairingTotem(name: $name)
}
`
const GET_MACHINES = gql`
{
machines {
name
deviceId
}
}
`
const getSize = R.compose(R.length, R.pathOr([], ['machines']))
const QrCodeComponent = ({ qrCode, name, count, onPaired }) => {
const timeout = useRef(null)
const CLOSE_SCREEN_TIMEOUT = 2000
const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 })
useEffect(() => {
return () => {
if (timeout.current) {
clearTimeout(timeout.current)
}
}
}, [])
const addedMachine = data?.machines?.find(m => m.name === name)
const hasNewMachine = getSize(data) > count && addedMachine
if (hasNewMachine) {
timeout.current = setTimeout(
() => onPaired(addedMachine),
CLOSE_SCREEN_TIMEOUT
)
}
return (
<>
<Info2>Scan QR code with your new cryptomat</Info2>
<div className="flex gap-20 pt-6">
<div
className="bg-white p-1 rounded-2xl border-solid border-zodiac border-5"
data-cy={qrCode}>
<QRCode
size={280}
fgColor={primaryColor}
marginSize={3}
value={qrCode}
/>
<div className="flex items-center mb-5 ml-5">
<CameraIcon />
<P noMargin className="ml-3">
Snap a picture and scan
</P>
</div>
</div>
<div className="max-w-100">
<div className="flex gap-4 mb-4">
<div>
<WarningIcon />
</div>
<P noMargin>
To pair the machine you need scan the QR code with your machine.
To do this either snap a picture of this QR code or download it
through the button above and scan it with the scanning bay on your
machine.
</P>
</div>
{hasNewMachine && (
<div className="bg-spring3 flex gap-4 p-2">
<div className="flex items-center">
<CompleteStageIconSpring />
</div>
<Info2 className="text-spring2 m-0">
Machine has been successfully paired!
</Info2>
</div>
)}
</div>
</div>
</>
)
}
const initialValues = {
name: ''
}
const validationSchema = Yup.object().shape({
name: Yup.string()
.required('Machine name is required.')
.max(50)
.test(
'unique-name',
'Machine name is already in use.',
(value, context) =>
!R.includes(
R.toLower(value),
R.map(R.toLower, context.options.context.machineNames)
)
)
})
const MachineNameComponent = ({ nextStep, setQrCode, setName }) => {
const [register] = useMutation(SAVE_CONFIG, {
onCompleted: ({ createPairingTotem }) => {
if (process.env.NODE_ENV === 'development') {
console.log(`totem: "${createPairingTotem}" `)
}
setQrCode(createPairingTotem)
nextStep()
},
onError: e => console.log(e)
})
const { data } = useQuery(GET_MACHINES)
const machineNames = R.map(R.prop('name'), data?.machines || {})
const uniqueNameValidator = value => {
try {
validationSchema.validateSync(value, {
context: { machineNames: machineNames }
})
} catch (error) {
return error
}
}
return (
<>
<Info2 className="mb-6">Machine Name (ex: Coffee shop 01)</Info2>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validate={uniqueNameValidator}
onSubmit={({ name }) => {
setName(name)
register({ variables: { name } })
}}>
{({ errors }) => (
<Form>
<div>
<FastField
name="name"
label="Enter machine name"
component={TextInput}
/>
</div>
{errors && <P className="text-tomato">{errors.message}</P>}
<div className="mt-16">
<Button type="submit">Submit</Button>
</div>
</Form>
)}
</Formik>
</>
)
}
const steps = [
{
label: 'Machine name',
component: MachineNameComponent
},
{
label: 'Scan QR code',
component: QrCodeComponent
}
]
const renderStepper = (step, it, idx) => {
const active = step === idx
const past = idx < step
const future = idx > step
return (
<div className="flex relative my-3">
<span
className={classnames({
'mr-6 text-comet': true,
'text-zodiac font-bold': active,
'text-zodiac': past
})}>
{it.label}
</span>
{active && <CurrentStageIconZodiac />}
{past && <CompleteStageIconZodiac />}
{future && <EmptyStageIconZodiac />}
{idx < steps.length - 1 && (
<div
className={classnames({
'absolute h-7 w-px border border-comet border-solid right-2 top-[18px]': true,
'border-zodiac': past
})}></div>
)}
</div>
)
}
const AddMachine = memo(({ close, onPaired }) => {
const { data } = useQuery(GET_MACHINES)
const [qrCode, setQrCode] = useState('')
const [name, setName] = useState('')
const [step, setStep] = useState(0)
const count = getSize(data)
const Component = steps[step].component
return (
<div>
<Dialog fullScreen open={true} aria-labelledby="form-dialog-title">
<DialogContent className="p-0 pt-5 bg-ghost">
<div
className=" mx-auto flex flex-col flex-col flex-col flex-col
flex-col flex-col flex-col flex-col w-[1200px] h-full ">
<div className="flex items-center justify-between">
<Title>Add Machine</Title>
<IconButton onClick={close} size="large">
<SvgIcon color="error">
<CloseIcon />
</SvgIcon>
</IconButton>
</div>
<div className="flex flex-1">
<Sidebar>
{steps.map((it, idx) => renderStepper(step, it, idx))}
</Sidebar>
<div className="px-12 flex-1">
<Component
nextStep={() => setStep(1)}
count={count}
onPaired={onPaired}
qrCode={qrCode}
setQrCode={setQrCode}
name={name}
setName={setName}
/>
</div>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
})
export default AddMachine

View file

@ -0,0 +1,3 @@
import AddMachine from './AddMachine'
export default AddMachine

View file

@ -0,0 +1,387 @@
import { useQuery, gql } from '@apollo/client'
import classnames from 'classnames'
import { endOfToday } from 'date-fns'
import { subDays, format, add, startOfWeek } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState } from 'react'
import TitleSection from 'src/components/layout/TitleSection'
import { Info2, P } from 'src/components/typography'
import DownIcon from 'src/styling/icons/dashboard/down.svg?react'
import EqualIcon from 'src/styling/icons/dashboard/equal.svg?react'
import UpIcon from 'src/styling/icons/dashboard/up.svg?react'
import { Select } from 'src/components/inputs'
import { fromNamespace } from 'src/utils/config'
import { numberToFiatAmount } from 'src/utils/number'
import { DAY, WEEK, MONTH } from 'src/utils/time'
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 MACHINE_OPTIONS = [{ code: 'all', display: 'All machines' }]
const REPRESENTING_OPTIONS = [
{ code: 'overTime', display: 'Over time' },
{ 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
}
const DAY_OPTIONS = R.map(
it => ({
code: R.toLower(it),
display: it
}),
Array.from(Array(7)).map((_, i) =>
format('EEEE', add({ days: i }, startOfWeek(new Date())))
)
)
const GET_TRANSACTIONS = gql`
query transactions(
$from: DateTimeISO
$until: DateTimeISO
$excludeTestingCustomers: Boolean
) {
transactions(
from: $from
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id
txClass
txHash
toAddress
commissionPercentage
expired
machineName
operatorCompleted
sendConfirmed
dispense
hasError: error
deviceId
fiat
fixedFee
fiatCode
cryptoAtoms
cryptoCode
toAddress
created
profit
}
}
`
const GET_DATA = gql`
query getData {
config
machines {
name
deviceId
}
fiatRates {
code
name
rate
}
}
`
const VerticalLine = () => (
<div className="h-16 border-solid border border-comet2" />
)
const OverviewEntry = ({ label, value, oldValue, currency }) => {
const _oldValue = !oldValue || R.equals(oldValue, 0) ? 1 : oldValue
const growthRate = ((value - oldValue) * 100) / _oldValue
const growthClasses = {
'font-bold': true,
'text-malachite': R.gt(value, oldValue),
'text-tomato': R.gt(oldValue, value)
}
return (
<div>
<P noMargin>{label}</P>
<Info2 noMargin className="mt-[6px] mb-[6px]">
<span className="text-[24px]">{numberToFiatAmount(value)}</span>
{!!currency && ` ${currency}`}
</Info2>
<span className="flex items-center gap-1">
{R.gt(growthRate, 0) && <UpIcon height={10} />}
{R.lt(growthRate, 0) && <DownIcon height={10} />}
{R.equals(growthRate, 0) && <EqualIcon height={10} />}
<P noMargin className={classnames(growthClasses)}>
{numberToFiatAmount(growthRate)}%
</P>
</span>
</div>
)
}
const Analytics = () => {
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, {
variables: {
from: subDays(65, endOfToday()),
until: endOfToday(),
excludeTestingCustomers: true
}
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0])
const [period, setPeriod] = useState(PERIOD_OPTIONS[0])
const [machine, setMachine] = useState(MACHINE_OPTIONS[0])
const [selectedDay, setSelectedDay] = useState(
R.equals(representing.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
)
const loading = txLoading || configLoading
const transactions = R.path(['transactions'])(txResponse) ?? []
const machines = R.path(['machines'])(configResponse) ?? []
const config = R.path(['config'])(configResponse) ?? []
const rates = R.path(['fiatRates'])(configResponse) ?? []
const fiatLocale = fromNamespace('locale')(config).fiatCurrency
const timezone = config?.locale_timezone
const convertFiatToLocale = item => {
if (item.fiatCode === fiatLocale) return item
const itemRate = R.find(R.propEq('code', item.fiatCode))(rates)
const localeRate = R.find(R.propEq('code', fiatLocale))(rates)
const multiplier = localeRate?.rate / itemRate?.rate
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
}
const data =
R.map(convertFiatToLocale)(
transactions?.filter(
tx =>
(!tx.dispensed || !tx.expired) &&
(tx.sendConfirmed || tx.dispense) &&
!tx.hasError
)
) ?? []
const machineOptions = R.clone(MACHINE_OPTIONS)
R.forEach(
m => machineOptions.push({ code: m.deviceId, display: m.name }),
machines
)
const machineTxs = R.filter(
tx => (machine.code === 'all' ? true : tx.deviceId === machine.code),
data
)
const filteredData = timeInterval => ({
current:
machineTxs.filter(d => {
const txDay = new Date(d.created)
const isSameWeekday = !R.isNil(selectedDay)
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
: true
return isSameWeekday && txDay >= Date.now() - TIME_OPTIONS[timeInterval]
}) ?? [],
previous:
machineTxs.filter(d => {
const txDay = new Date(d.created)
const isSameWeekday = !R.isNil(selectedDay)
? R.equals(R.toLower(format('EEEE', txDay)), selectedDay.code)
: true
return (
isSameWeekday &&
txDay < Date.now() - TIME_OPTIONS[timeInterval] &&
txDay >= Date.now() - 2 * TIME_OPTIONS[timeInterval]
)
}) ?? []
})
const txs = {
current: filteredData(period.code).current.length,
previous: filteredData(period.code).previous.length
}
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 = {
current: R.sum(R.map(d => d.fiat, filteredData(period.code).current)),
previous: R.sum(R.map(d => d.fiat, filteredData(period.code).previous))
}
const commissions = {
current: R.sum(R.map(d => d.profit, filteredData(period.code).current)),
previous: R.sum(R.map(d => d.profit, filteredData(period.code).previous))
}
const handleRepresentationChange = newRepresentation => {
setRepresenting(newRepresentation)
setSelectedDay(
R.equals(newRepresentation.code, 'hourOfTheDay') ? DAY_OPTIONS[0] : null
)
}
const getGraphInfo = representing => {
switch (representing.code) {
case 'overTime':
return (
<OverTimeWrapper
title="Transactions 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 '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
title="Top 5 Machines"
representing={representing}
period={period}
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
machines={machineOptions}
selectedMachine={machine}
handleMachineChange={setMachine}
timezone={timezone}
currency={fiatLocale}
/>
)
case 'hourOfTheDay':
return (
<HourOfDayWrapper
title="Avg. transactions per hour of the day"
representing={representing}
period={period}
data={R.map(convertFiatToLocale)(filteredData(period.code).current)}
machines={machineOptions}
selectedMachine={machine}
handleMachineChange={setMachine}
selectedDay={selectedDay}
dayOptions={DAY_OPTIONS}
handleDayChange={setSelectedDay}
timezone={timezone}
currency={fiatLocale}
/>
)
default:
throw new Error(`There's no graph info to represent ${representing}`)
}
}
return (
!loading && (
<>
<TitleSection title="Analytics">
<div className="flex gap-6 justify-end">
<LegendEntry
IconComponent={UpIcon}
label={'Up since last period'}
/>
<LegendEntry
IconComponent={DownIcon}
label={'Down since last period'}
/>
<LegendEntry
IconComponent={EqualIcon}
label={'Same since last period'}
/>
</div>
</TitleSection>
<div className="flex justify-between items-center mb-4">
<div className="flex gap-6">
<Select
label="Representing"
onSelectedItemChange={handleRepresentationChange}
items={REPRESENTING_OPTIONS}
default={REPRESENTING_OPTIONS[0]}
selectedItem={representing}
defaultAsFilter
/>
<Select
label="Time period"
onSelectedItemChange={setPeriod}
items={PERIOD_OPTIONS}
default={PERIOD_OPTIONS[0]}
selectedItem={period}
defaultAsFilter
/>
</div>
<div className="flex items-center gap-10">
<OverviewEntry
label="Transactions"
value={txs.current}
oldValue={txs.previous}
/>
<VerticalLine />
<OverviewEntry
label="Median amount"
value={medianAmount.current}
oldValue={medianAmount.previous}
currency={fiatLocale}
/>
<VerticalLine />
<OverviewEntry
label="Volume"
value={txVolume.current}
oldValue={txVolume.previous}
currency={fiatLocale}
/>
<VerticalLine />
<OverviewEntry
label="Commissions"
value={commissions.current}
oldValue={commissions.previous}
currency={fiatLocale}
/>
</div>
</div>
{getGraphInfo(representing)}
</>
)
)
}
export default Analytics

View file

@ -0,0 +1,14 @@
import React from 'react'
import { P } from 'src/components/typography'
const LegendEntry = ({ IconElement, IconComponent, label }) => {
return (
<span className="flex items-center gap-2">
{!!IconComponent && <IconComponent height={12} />}
{!!IconElement && IconElement}
<P>{label}</P>
</span>
)
}
export default LegendEntry

View file

@ -0,0 +1,74 @@
import Paper from '@mui/material/Paper'
import * as R from 'ramda'
import React, { memo } from 'react'
import { Info2, Label3, P } from 'src/components/typography'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { numberToFiatAmount } from 'src/utils/number'
import { singularOrPlural } from 'src/utils/string'
import { formatDate, formatDateNonUtc } from 'src/utils/timezones'
const GraphTooltip = ({
coords,
data,
dateInterval,
period,
currency,
representing
}) => {
const formattedDateInterval = !R.includes('hourOfDay', representing.code)
? [
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')
]
const transactions = R.reduce(
(acc, value) => {
acc.volume += parseInt(value.fiat)
if (value.txClass === 'cashIn') acc.cashIn++
if (value.txClass === 'cashOut') acc.cashOut++
return acc
},
{ volume: 0, cashIn: 0, cashOut: 0 },
data
)
return (
<Paper
className="absolute top-[351px] w-[150px] p-3 rounded-lg"
style={{ left: coords?.x ?? 0 }}>
{!R.includes('hourOfDay', representing.code) && (
<Info2 noMargin>{`${formattedDateInterval[0]}`}</Info2>
)}
<Info2 noMargin>
{`${formattedDateInterval[1]} - ${formattedDateInterval[2]}`}
</Info2>
<P noMargin className="my-2">
{R.length(data)}{' '}
{singularOrPlural(R.length(data), 'transaction', 'transactions')}
</P>
<P noMargin className="text-comet">
{numberToFiatAmount(transactions.volume)} {currency} in volume
</P>
<div className="mt-4">
<Label3 noMargin>
<TxInIcon />
<span className="ml-1">{transactions.cashIn} cash-in</span>
</Label3>
<Label3 noMargin className="mt-1">
<TxOutIcon />
<span className="ml-1">{transactions.cashOut} cash-out</span>
</Label3>
</div>
</Paper>
)
}
export default memo(GraphTooltip, (prev, next) => prev.coords === next.coords)

View file

@ -0,0 +1,124 @@
import { getTimezoneOffset } from 'date-fns-tz'
import * as R from 'ramda'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { MINUTE } from 'src/utils/time'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const options = [
{ code: 'hourOfDayTransactions', display: 'Transactions' },
{ code: 'hourOfDayVolume', display: 'Volume' }
]
const HourOfDayBarGraphHeader = ({
title,
period,
data,
machines,
selectedMachine,
handleMachineChange,
selectedDay,
dayOptions,
handleDayChange,
timezone,
currency
}) => {
const [graphType /*, setGraphType */] = useState(options[0].code)
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>
}
const offset = getTimezoneOffset(timezone)
const txsPerWeekday = R.reduce(
(acc, value) => {
const created = new Date(value.created)
created.setTime(
created.getTime() + created.getTimezoneOffset() * MINUTE + offset
)
switch (created.getDay()) {
case 0:
acc.sunday.push(value)
break
case 1:
acc.monday.push(value)
break
case 2:
acc.tuesday.push(value)
break
case 3:
acc.wednesday.push(value)
break
case 4:
acc.thursday.push(value)
break
case 5:
acc.friday.push(value)
break
case 6:
acc.saturday.push(value)
break
default:
throw new Error('Day of week not recognized')
}
return acc
},
R.fromPairs(R.map(it => [it.code, []], dayOptions)),
data
)
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
{/* <RadioGroup
options={options}
className={classes.topMachinesRadio}
value={graphType}
onChange={e => setGraphType(e.target.value)}
/> */}
<Select
label="Day of the week"
items={dayOptions}
default={dayOptions[0]}
selectedItem={selectedDay}
onSelectedItemChange={handleDayChange}
/>
<Select
label="Machines"
onSelectedItemChange={handleMachineChange}
items={machines}
default={machines[0]}
selectedItem={selectedMachine}
/>
</div>
</div>
<Graph
representing={R.find(it => it.code === graphType)(options)}
period={period}
data={txsPerWeekday[selectedDay.code]}
timezone={timezone}
currency={currency}
selectedMachine={selectedMachine}
machines={machines}
selectedDay={selectedDay}
/>
</>
)
}
export default HourOfDayBarGraphHeader

View file

@ -0,0 +1,89 @@
import Switch from '@mui/material/Switch'
import React, { useState } from 'react'
import { H2, Label1 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { primaryColor } from 'src/styling/variables'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const OverTimeDotGraphHeader = ({
title,
representing,
period,
data,
machines,
selectedMachine,
handleMachineChange,
timezone,
currency
}) => {
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>,
transaction: <div className={classes.txIcon}></div>,
median: (
<svg height="12" width="18">
<path
stroke={primaryColor}
strokeWidth="3"
strokeDasharray="5, 2"
d="M 5 6 l 20 0"
/>
</svg>
)
}
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
<LegendEntry
IconElement={legend.transaction}
label={'One transaction'}
/>
<LegendEntry IconElement={legend.median} label={'Median'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<Label1 noMargin className="mb-1 text-comet">
Log. scale
</Label1>
<Switch
className="m-0"
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 OverTimeDotGraphHeader

View file

@ -0,0 +1,62 @@
import * as R from 'ramda'
import React, { useState } from 'react'
import { H2 } from 'src/components/typography'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const options = [
{ code: 'topMachinesTransactions', display: 'Transactions' },
{ code: 'topMachinesVolume', display: 'Volume' }
]
const TopMachinesBarGraphHeader = ({
title,
period,
data,
machines,
selectedMachine,
timezone,
currency
}) => {
const [graphType /*, setGraphType */] = useState(options[0].code)
const legend = {
cashIn: <div className={classes.cashInIcon}></div>,
cashOut: <div className={classes.cashOutIcon}></div>
}
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
{/* <RadioGroup
options={options}
className={classes.topMachinesRadio}
value={graphType}
onChange={e => setGraphType(e.target.value)}
/> */}
</div>
</div>
<Graph
representing={R.find(R.propEq('code', graphType), options)}
period={period}
data={data}
timezone={timezone}
currency={currency}
selectedMachine={selectedMachine}
machines={machines}
/>
</>
)
}
export default TopMachinesBarGraphHeader

View file

@ -0,0 +1,91 @@
import Switch from '@mui/material/Switch'
import React, { useState } from 'react'
import { H2, Label1 } from 'src/components/typography'
import { Select } from 'src/components/inputs'
import { neon, java } from 'src/styling/variables'
import Graph from '../../graphs/Graph'
import LegendEntry from '../LegendEntry'
import classes from './wrappers.module.css'
const VolumeOverTimeGraphHeader = ({
title,
representing,
period,
data,
machines,
selectedMachine,
handleMachineChange,
timezone,
currency
}) => {
const [logarithmic, setLogarithmic] = useState()
const legend = {
cashIn: (
<svg height="12" width="18">
<path
stroke={java}
strokeWidth="3"
d="M 3 6 l 12 0"
strokeLinecap="round"
/>
</svg>
),
cashOut: (
<svg height="12" width="18">
<path
stroke={neon}
strokeWidth="3"
d="M 3 6 l 12 0"
strokeLinecap="round"
/>
</svg>
)
}
return (
<>
<div className={classes.graphHeaderWrapper}>
<div className={classes.graphHeaderLeft}>
<H2 noMargin>{title}</H2>
<div className={classes.graphLegend}>
<LegendEntry IconElement={legend.cashIn} label={'Cash-in'} />
<LegendEntry IconElement={legend.cashOut} label={'Cash-out'} />
</div>
</div>
<div className={classes.graphHeaderRight}>
<div className={classes.graphHeaderSwitchBox}>
<Label1 noMargin className="mb-1 text-comet">
Log. scale
</Label1>
<Switch
className="m-0"
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

@ -0,0 +1,56 @@
.graphHeaderWrapper {
display: flex;
justify-content: space-between;
margin-bottom: 40px;
}
.graphHeaderLeft {
display: flex;
flex-direction: column;
}
.graphHeaderRight {
margin-top: 15px;
display: flex;
gap: 30px
}
.cashInIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: var(--java);
}
.cashOutIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: var(--neon);
}
.graphLegend {
display: flex;
align-items: center;
gap: 24px;
}
.txIcon {
width: 12px;
height: 12px;
border-radius: 12px;
background-color: #000;
}
.graphHeaderSwitchBox {
display: flex;
flex-direction: column;
/*'& > *': {*/
/* margin: 0*/
/*},*/
/*'& > :first-child': {*/
/* marginBottom: 2,*/
/* extend: label1,*/
/* color: offColor*/
/*}*/
}

View file

@ -0,0 +1,135 @@
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import GraphTooltip from '../components/tooltips/GraphTooltip'
import HourOfDayBarGraph from './HourOfDayBarGraph'
import OverTimeDotGraph from './OverTimeDotGraph'
import OverTimeLineGraph from './OverTimeLineGraph'
import TopMachinesBarGraph from './TopMachinesBarGraph'
const GraphWrapper = ({
data,
representing,
period,
timezone,
currency,
selectedMachine,
machines,
selectedDay,
log
}) => {
const [selectionCoords, setSelectionCoords] = useState(null)
const [selectionDateInterval, setSelectionDateInterval] = useState(null)
const [selectionData, setSelectionData] = useState(null)
const getGraph = representing => {
switch (representing.code) {
case 'overTime':
return (
<OverTimeDotGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
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':
return (
<TopMachinesBarGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
machines={R.filter(it => it.code !== 'all', machines)}
currency={currency}
/>
)
case 'topMachinesTransactions':
return (
<TopMachinesBarGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
machines={R.filter(it => it.code !== 'all', machines)}
currency={currency}
/>
)
case 'hourOfDayVolume':
return (
<HourOfDayBarGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
machines={R.filter(it => it.code !== 'all', machines)}
currency={currency}
selectedDay={selectedDay}
/>
)
case 'hourOfDayTransactions':
return (
<HourOfDayBarGraph
data={data}
period={period}
timezone={timezone}
setSelectionCoords={setSelectionCoords}
setSelectionDateInterval={setSelectionDateInterval}
setSelectionData={setSelectionData}
selectedMachine={selectedMachine}
machines={R.filter(it => it.code !== 'all', machines)}
currency={currency}
selectedDay={selectedDay}
/>
)
default:
throw new Error(`There's no graph to represent ${representing}`)
}
}
return (
<div>
{!R.isNil(selectionCoords) && (
<GraphTooltip
coords={selectionCoords}
dateInterval={selectionDateInterval}
data={selectionData}
period={period}
currency={currency}
timezone={timezone}
representing={representing}
/>
)}
{getGraph(representing)}
</div>
)
}
export default memo(GraphWrapper)

View file

@ -0,0 +1,437 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, startOfDay } from 'date-fns/fp'
import * as R from 'ramda'
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import {
java,
neon,
subheaderDarkColor,
fontColor,
fontSecondary,
subheaderColor
} from 'src/styling/variables'
import { MINUTE } from 'src/utils/time'
import { toUtc } from 'src/utils/timezones'
const Graph = ({
data,
timezone,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
selectedMachine
}) => {
const ref = useRef(null)
const GRAPH_POPOVER_WIDTH = 150
const GRAPH_POPOVER_MARGIN = 25
const BAR_MARGIN = 10
const GRAPH_HEIGHT = 401
const GRAPH_WIDTH = 1163
const GRAPH_MARGIN = useMemo(
() => ({
top: 25,
right: 0.5,
bottom: 27,
left: 36.5
}),
[]
)
const offset = getTimezoneOffset(timezone)
const getTickIntervals = (domain, interval) => {
const ticks = []
const start = new Date(domain[0])
const end = new Date(domain[1])
const step = R.clone(start)
while (step <= end) {
ticks.push(R.clone(step))
step.setUTCHours(step.getUTCHours() + interval)
}
return ticks
}
const filterByHourInterval = useCallback(
(lowerBound, upperBound) =>
R.filter(it => {
const tzCreated = new Date(it.created).setTime(
new Date(it.created).getTime() +
new Date(it.created).getTimezoneOffset() * MINUTE +
offset
)
const created = new Date(tzCreated)
return (
(lowerBound.getUTCHours() < upperBound.getUTCHours() &&
created.getUTCHours() >= new Date(lowerBound).getUTCHours() &&
created.getUTCHours() < new Date(upperBound).getUTCHours()) ||
(lowerBound.getUTCHours() > upperBound.getUTCHours() &&
created.getUTCHours() <= new Date(lowerBound).getUTCHours() &&
created.getUTCHours() < new Date(upperBound).getUTCHours())
)
}, data),
[data, offset]
)
const txClassByHourInterval = useCallback(
(lowerBound, upperBound) =>
R.reduce(
(acc, value) => {
if (value.txClass === 'cashIn')
acc.cashIn += BigNumber(value.fiat).toNumber()
if (value.txClass === 'cashOut')
acc.cashOut += BigNumber(value.fiat).toNumber()
return acc
},
{ cashIn: 0, cashOut: 0 },
filterByHourInterval(lowerBound, upperBound)
),
[filterByHourInterval]
)
const x = d3
.scaleUtc()
.domain([
toUtc(startOfDay(new Date())),
toUtc(add({ days: 1 }, startOfDay(new Date())))
])
.rangeRound([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
const groupedByDateInterval = R.map(
it => {
const lowerBound = R.clone(it)
it.setUTCHours(it.getUTCHours() + 2)
const upperBound = R.clone(it)
return [lowerBound, filterByHourInterval(lowerBound, upperBound)]
},
R.init(getTickIntervals(x.domain(), 2))
)
const groupedByTxClass = R.map(
it => {
const lowerBound = R.clone(it)
it.setUTCHours(it.getUTCHours() + 2)
const upperBound = R.clone(it)
return [lowerBound, txClassByHourInterval(lowerBound, upperBound)]
},
R.init(getTickIntervals(x.domain(), 2))
)
const y = d3
.scaleLinear()
.domain([
0,
d3.max(
groupedByTxClass.map(it => it[1]),
d => d.cashIn + d.cashOut
) !== 0
? d3.max(
groupedByTxClass.map(it => it[1]),
d => d.cashIn + d.cashOut
)
: 50
])
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const buildXAxis = useCallback(
g =>
g
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.ticks(d3.timeHour.every(2))
.tickFormat(d3.timeFormat('%H:%M'))
),
[GRAPH_MARGIN, x]
)
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(
d3
.axisLeft(y)
.ticks(GRAPH_HEIGHT / 100)
.tickSize(0)
.tickFormat(``)
)
.call(g => g.select('.domain').remove()),
[GRAPH_MARGIN, y]
)
const buildVerticalLines = useCallback(
g =>
g
.attr('stroke', subheaderDarkColor)
.append('g')
.selectAll('line')
.data(getTickIntervals(x.domain(), 2))
.join('line')
.attr('x1', d => {
const xValue = x(d)
const intervals = getTickIntervals(x.domain(), 2)
return xValue === x(intervals[R.length(intervals) - 1])
? xValue - 1
: 0.5 + xValue
})
.attr('x2', d => {
const xValue = x(d)
const intervals = getTickIntervals(x.domain(), 2)
return xValue === x(intervals[R.length(intervals) - 1])
? xValue - 1
: 0.5 + xValue
})
.attr('y1', GRAPH_MARGIN.top)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom),
[GRAPH_MARGIN, x]
)
const buildHoverableEventRects = useCallback(
g =>
g
.append('g')
.selectAll('line')
.data(getTickIntervals(x.domain(), 2))
.join('rect')
.attr('x', d => x(d))
.attr('y', GRAPH_MARGIN.top)
.attr('width', d => {
const xValue = Math.round(x(d) * 100) / 100
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
const index = R.findIndex(it => it === xValue, ticks)
const width =
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
return Math.round(width * 100) / 100
})
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
.attr('stroke', 'transparent')
.attr('fill', 'transparent')
.on('mouseover', d => {
const date = R.clone(new Date(d.target.__data__))
const startDate = R.clone(date)
date.setUTCHours(date.getUTCHours() + 2)
const endDate = R.clone(date)
const filteredData = groupedByDateInterval.find(it =>
R.equals(startDate, it[0])
)[1]
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([endDate, startDate])
setSelectionData(filteredData)
setSelectionCoords({
x: Math.round(xCoord),
y: Math.round(yCoord)
})
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
'fill',
subheaderColor
)
})
.on('mouseleave', d => {
d3.select(`#event-rect-${x(d.target.__data__)}`).attr(
'fill',
'transparent'
)
setSelectionDateInterval(null)
setSelectionData(null)
setSelectionCoords(null)
}),
[
GRAPH_MARGIN,
groupedByDateInterval,
setSelectionCoords,
setSelectionData,
setSelectionDateInterval,
x
]
)
const buildEventRects = useCallback(
g =>
g
.append('g')
.selectAll('line')
.data(getTickIntervals(x.domain(), 2))
.join('rect')
.attr('id', d => `event-rect-${x(d)}`)
.attr('x', d => x(d))
.attr('y', GRAPH_MARGIN.top)
.attr('width', d => {
const xValue = Math.round(x(d) * 100) / 100
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
const index = R.findIndex(it => it === xValue, ticks)
const width =
index + 1 === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
return Math.round(width * 100) / 100
})
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.bottom - GRAPH_MARGIN.top)
.attr('stroke', 'transparent')
.attr('fill', 'transparent'),
[GRAPH_MARGIN, x]
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', fontColor)
.style('fill', fontColor)
.style('stroke-width', 0.5)
.style('font-family', fontSecondary),
[]
)
const drawCashIn = useCallback(
g => {
g.selectAll('rect')
.data(R.init(getTickIntervals(x.domain(), 2)))
.join('rect')
.attr('stroke', java)
.attr('fill', java)
.attr('x', d => {
return x(d) + BAR_MARGIN / 2
})
.attr('y', d => {
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
return y(interval[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom
})
.attr('height', d => {
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
return R.clamp(
0,
GRAPH_HEIGHT,
GRAPH_HEIGHT -
y(interval[1].cashIn) -
GRAPH_MARGIN.bottom -
BAR_MARGIN / 2
)
})
.attr('width', d => {
const xValue = Math.round(x(d) * 100) / 100
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
const index = R.findIndex(it => it === xValue, ticks)
const width =
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
return Math.round((width - BAR_MARGIN) * 100) / 100
})
.attr('rx', 2.5)
},
[x, y, GRAPH_MARGIN, groupedByTxClass]
)
const drawCashOut = useCallback(
g => {
g.selectAll('rect')
.data(R.init(getTickIntervals(x.domain(), 2)))
.join('rect')
.attr('stroke', neon)
.attr('fill', neon)
.attr('x', d => {
return x(d) + BAR_MARGIN / 2
})
.attr('y', d => {
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
return (
y(interval[1].cashIn + interval[1].cashOut) -
GRAPH_MARGIN.top +
GRAPH_MARGIN.bottom
)
})
.attr('height', d => {
const interval = R.find(it => R.equals(it[0], d), groupedByTxClass)
return R.clamp(
0,
GRAPH_HEIGHT,
GRAPH_HEIGHT -
y(interval[1].cashOut) -
GRAPH_MARGIN.bottom -
BAR_MARGIN / 2
)
})
.attr('width', d => {
const xValue = Math.round(x(d) * 100) / 100
const ticks = getTickIntervals(x.domain(), 2).map(it => x(it))
const index = R.findIndex(it => it === xValue, ticks)
const width =
index === R.length(ticks) ? 0 : ticks[index + 1] - ticks[index]
return Math.round((width - BAR_MARGIN) * 100) / 100
})
.attr('rx', 2.5)
},
[x, y, GRAPH_MARGIN, groupedByTxClass]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildXAxis)
svg.append('g').call(buildYAxis)
svg.append('g').call(buildVerticalLines)
svg.append('g').call(buildEventRects)
svg.append('g').call(formatTicksText)
svg.append('g').call(drawCashIn)
svg.append('g').call(drawCashOut)
svg.append('g').call(buildHoverableEventRects)
return svg.node()
}, [
buildXAxis,
buildYAxis,
buildEventRects,
buildHoverableEventRects,
buildVerticalLines,
drawCashIn,
formatTicksText,
drawCashOut
])
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.selectedDay, next.selectedDay) &&
R.equals(prev.selectedMachine, next.selectedMachine)
)

View file

@ -0,0 +1,570 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, 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 yLin = d3
.scaleLinear()
.domain([
0,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.03
])
.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],
...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 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(median))
.attr('y2', 0.5 + y(median))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH)
)
},
[GRAPH_MARGIN, y, data, log]
)
const drawData = useCallback(
g => {
g.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => {
const created = new Date(d.created)
return x(created.setTime(created.getTime() + offset))
})
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
.attr('fill', d => (d.txClass === 'cashIn' ? java : neon))
.attr('r', 3.5)
},
[data, offset, x, y]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildGrid)
svg.append('g').call(buildAvg)
svg.append('g').call(buildXAxis)
svg.append('g').call(buildYAxis)
svg.append('g').call(formatTicksText)
svg.append('g').call(formatText)
svg.append('g').call(formatTicks)
svg.append('g').call(drawData)
return svg.node()
}, [
buildAvg,
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

@ -0,0 +1,646 @@
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

@ -0,0 +1,314 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import * as R from 'ramda'
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
import {
java,
neon,
subheaderDarkColor,
fontColor,
fontSecondary
} from 'src/styling/variables'
const Graph = ({ data, machines, currency, selectedMachine }) => {
const ref = useRef(null)
const AMOUNT_OF_MACHINES = 5
const BAR_PADDING = 0.15
const BAR_MARGIN = 10
const GRAPH_HEIGHT = 401
const GRAPH_WIDTH = 1163
const GRAPH_MARGIN = useMemo(
() => ({
top: 25,
right: 0.5,
bottom: 27,
left: 36.5
}),
[]
)
const machinesClone = R.clone(machines)
// This ensures that the graph renders a minimum amount of machines
// and avoids having a single bar for cases with one machine
const filledMachines =
R.length(machines) >= AMOUNT_OF_MACHINES
? machinesClone
: R.map(
it => {
if (!R.isNil(machinesClone[it])) return machinesClone[it]
return { code: `ghostMachine${it}`, display: `` }
},
R.times(R.identity, AMOUNT_OF_MACHINES)
)
const txByDevice = R.reduce(
(acc, value) => {
acc[value.code] = R.filter(it => it.deviceId === value.code, data)
return acc
},
{},
filledMachines
)
const getDeviceVolume = deviceId =>
R.reduce(
(acc, value) => acc + BigNumber(value.fiat).toNumber(),
0,
txByDevice[deviceId]
)
const getDeviceVolumeByTxClass = deviceId =>
R.reduce(
(acc, value) => {
if (value.txClass === 'cashIn')
acc.cashIn += BigNumber(value.fiat).toNumber()
if (value.txClass === 'cashOut')
acc.cashOut += BigNumber(value.fiat).toNumber()
return acc
},
{ cashIn: 0, cashOut: 0 },
txByDevice[deviceId]
)
const devicesByVolume = R.sort(
(a, b) => b[1] - a[1],
R.map(m => [m.code, getDeviceVolume(m.code)], filledMachines)
)
const topMachines = R.take(AMOUNT_OF_MACHINES, devicesByVolume)
const txClassVolumeByDevice = R.fromPairs(
R.map(v => [v[0], getDeviceVolumeByTxClass(v[0])], topMachines)
)
const x = d3
.scaleBand()
.domain(topMachines)
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
.paddingInner(BAR_PADDING)
const y = d3
.scaleLinear()
.domain([
0,
d3.max(topMachines, d => d[1]) !== 0 ? d3.max(topMachines, d => d[1]) : 50
])
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const buildXAxis = useCallback(
g =>
g
.attr('class', 'x-axis-1')
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.tickFormat(
d =>
`${
R.find(it => it.code === d[0], filledMachines).display ?? ''
}`
)
.tickSize(0)
.tickPadding(10)
),
[GRAPH_MARGIN, x, filledMachines]
)
const buildXAxis2 = useCallback(
g => {
g.attr('class', 'x-axis-2')
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.tickFormat(d =>
R.includes(`ghostMachine`, d[0])
? ``
: `${d[1].toFixed(2)} ${currency}`
)
.tickSize(0)
.tickPadding(10)
)
},
[GRAPH_MARGIN, x, currency]
)
const positionXAxisLabels = useCallback(() => {
d3.selectAll('.x-axis-1 .tick text').attr('transform', function (d) {
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
return `translate(${-widthPerEntry / 2.25 + this.getBBox().width / 2}, 0)`
})
}, [x])
const positionXAxis2Labels = useCallback(() => {
d3.selectAll('.x-axis-2 .tick text').attr('transform', function (d) {
const widthPerEntry = (x.range()[1] - x.range()[0]) / AMOUNT_OF_MACHINES
return `translate(${widthPerEntry / 2.25 - this.getBBox().width / 2}, 0)`
})
}, [x])
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.call(
d3
.axisLeft(y)
.ticks(GRAPH_HEIGHT / 100)
.tickSize(0)
.tickFormat(``)
)
.call(g => g.select('.domain').remove()),
[GRAPH_MARGIN, y]
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', fontColor)
.style('fill', fontColor)
.style('stroke-width', 0.5)
.style('font-family', fontSecondary),
[]
)
const buildGrid = useCallback(
g => {
g.attr('stroke', subheaderDarkColor)
.attr('fill', subheaderDarkColor)
// Vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(R.tail(x.domain()))
.join('line')
.attr('x1', d => {
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
const xValue =
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
const paddedXValue = xValue * (BAR_PADDING / 2)
return 0.5 + x(d) - paddedXValue
})
.attr('x2', d => {
const domainIndex = R.findIndex(it => R.equals(it, d), x.domain())
const xValue =
x(x.domain()[domainIndex]) - x(x.domain()[domainIndex - 1])
const paddedXValue = xValue * (BAR_PADDING / 2)
return 0.5 + x(d) - paddedXValue
})
.attr('y1', GRAPH_MARGIN.top)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
)
},
[GRAPH_MARGIN, x]
)
const drawCashIn = useCallback(
g => {
g.selectAll('rect')
.data(R.toPairs(txClassVolumeByDevice))
.join('rect')
.attr('fill', java)
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
.attr('y', d => y(d[1].cashIn) - GRAPH_MARGIN.top + GRAPH_MARGIN.bottom)
.attr('height', d =>
R.clamp(
0,
GRAPH_HEIGHT,
GRAPH_HEIGHT - y(d[1].cashIn) - GRAPH_MARGIN.bottom - BAR_MARGIN
)
)
.attr('width', x.bandwidth())
.attr('rx', 2.5)
},
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
)
const drawCashOut = useCallback(
g => {
g.selectAll('rect')
.data(R.toPairs(txClassVolumeByDevice))
.join('rect')
.attr('fill', neon)
.attr('x', d => x([d[0], d[1].cashIn + d[1].cashOut]))
.attr(
'y',
d =>
y(d[1].cashIn + d[1].cashOut) -
GRAPH_MARGIN.top +
GRAPH_MARGIN.bottom
)
.attr('height', d => {
return R.clamp(
0,
GRAPH_HEIGHT,
GRAPH_HEIGHT -
y(d[1].cashOut) -
GRAPH_MARGIN.bottom -
BAR_MARGIN / 2
)
})
.attr('width', x.bandwidth())
.attr('rx', 2.5)
},
[txClassVolumeByDevice, x, y, GRAPH_MARGIN]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildXAxis)
svg.append('g').call(buildXAxis2)
svg.append('g').call(buildYAxis)
svg.append('g').call(formatTicksText)
svg.append('g').call(buildGrid)
svg.append('g').call(drawCashIn)
svg.append('g').call(drawCashOut)
svg.append('g').call(positionXAxisLabels)
svg.append('g').call(positionXAxis2Labels)
return svg.node()
}, [
buildXAxis,
buildXAxis2,
positionXAxisLabels,
positionXAxis2Labels,
buildYAxis,
formatTicksText,
buildGrid,
drawCashIn,
drawCashOut
])
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)
)

View file

@ -0,0 +1,3 @@
import Analytics from './Analytics'
export default Analytics

View file

@ -0,0 +1,73 @@
.welcomeBackground {
background: var(--ghost) url(/wizard-background.svg) no-repeat fixed center center;
background-size: cover;
height: 100vh;
width: 100vw;
position: relative;
left: 50%;
right: 50%;
margin-left: -50vw;
margin-right: -50vw;
min-height: 100vh;
}
.wrapper {
padding: 2.5em 4em;
width: 575px;
display: flex;
flex-direction: column;
}
.titleWrapper {
display: flex;
flex-direction: row;
align-items: center;
margin-bottom: 30px
}
.icon {
transform: scale(1.5);
margin-right: 25px;
}
.title {
padding-top: 8px;
}
.infoWrapper {
margin-bottom: 3vh;
}
.info2 {
text-align: justify;
}
.qrCodeWrapper {
display: flex;
justify-content: center;
margin-bottom: 3vh;
}
.secretWrapper {
display: flex;
justify-content: center;
align-items: center;
}
.secretLabel {
margin-right: 15px;
}
.secret {
margin-right: 35px;
}
.hiddenSecret {
margin-right: 35px;
filter: blur(8px);
}
.confirm2FAInput {
margin-top: 25px;
}

View file

@ -0,0 +1,125 @@
import { useMutation, useLazyQuery, gql } from '@apollo/client'
import { Form, Formik } from 'formik'
import React, { useContext, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { TL1, P } from 'src/components/typography'
import AppContext from 'src/AppContext'
import { Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { STATES } from './states'
const INPUT_2FA = gql`
mutation input2FA(
$username: String!
$password: String!
$code: String!
$rememberMe: Boolean!
) {
input2FA(
username: $username
password: $password
code: $code
rememberMe: $rememberMe
)
}
`
const GET_USER_DATA = gql`
{
userData {
id
username
role
}
}
`
const Input2FAState = ({ state, dispatch }) => {
const history = useHistory()
const { setUserData } = useContext(AppContext)
const [invalidToken, setInvalidToken] = useState(false)
const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, {
onCompleted: ({ userData }) => {
setUserData(userData)
history.push('/')
}
})
const [input2FA, { error: mutationError }] = useMutation(INPUT_2FA, {
onCompleted: ({ input2FA: success }) => {
if (success) {
return getUserData()
}
return setInvalidToken(true)
}
})
const handle2FAChange = value => {
dispatch({
type: STATES.INPUT_2FA,
payload: {
twoFAField: value
}
})
setInvalidToken(false)
}
const handleSubmit = () => {
if (state.twoFAField.length !== 6) {
setInvalidToken(true)
return
}
const options = {
variables: {
username: state.clientField,
password: state.passwordField,
code: state.twoFAField,
rememberMe: state.rememberMeField
}
}
input2FA(options)
}
const getErrorMsg = () => {
if (queryError) return 'Internal server error'
if (state.twoFAField.length !== 6 && invalidToken)
return 'The code should have 6 characters!'
if (mutationError || invalidToken)
return 'Code is invalid. Please try again.'
return null
}
const errorMessage = getErrorMsg()
return (
<>
<TL1 className="mb-8">Enter your two-factor authentication code</TL1>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={state.twoFAField}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
/>
<div className="mt-9">
{errorMessage && <P className="text-tomato">{errorMessage}</P>}
<Button onClick={handleSubmit} buttonClassName="w-full">
Login
</Button>
</div>
</Form>
</Formik>
</>
)
}
export default Input2FAState

View file

@ -0,0 +1,207 @@
import { useMutation, useLazyQuery, gql } from '@apollo/client'
import { startAssertion } from '@simplewebauthn/browser'
import { Field, Form, Formik } from 'formik'
import React, { useState, useContext } from 'react'
import { useHistory } from 'react-router-dom'
import { H2, Label2, P } from 'src/components/typography'
import * as Yup from 'yup'
import AppContext from 'src/AppContext'
import { Button } from 'src/components/buttons'
import { Checkbox, TextInput } from 'src/components/inputs/formik'
const GET_USER_DATA = gql`
{
userData {
id
username
role
}
}
`
const validationSchema = Yup.object().shape({
localClient: Yup.string()
.required('Client field is required!')
.email('Username field should be in an email format!'),
localRememberMe: Yup.boolean()
})
const initialValues = {
localClient: '',
localRememberMe: false
}
const InputFIDOState = ({ state, strategy }) => {
const GENERATE_ASSERTION = gql`
query generateAssertionOptions($username: String!${
strategy === 'FIDO2FA' ? `, $password: String!` : ``
}, $domain: String!) {
generateAssertionOptions(username: $username${
strategy === 'FIDO2FA' ? `, password: $password` : ``
}, domain: $domain)
}
`
const VALIDATE_ASSERTION = gql`
mutation validateAssertion(
$username: String!
${strategy === 'FIDO2FA' ? `, $password: String!` : ``}
$rememberMe: Boolean!
$assertionResponse: JSONObject!
$domain: String!
) {
validateAssertion(
username: $username
${strategy === 'FIDO2FA' ? `password: $password` : ``}
rememberMe: $rememberMe
assertionResponse: $assertionResponse
domain: $domain
)
}
`
const history = useHistory()
const { setUserData } = useContext(AppContext)
const [localClientField, setLocalClientField] = useState('')
const [localRememberMeField, setLocalRememberMeField] = useState(false)
const [invalidUsername, setInvalidUsername] = useState(false)
const [invalidToken, setInvalidToken] = useState(false)
const [validateAssertion, { error: mutationError }] = useMutation(
VALIDATE_ASSERTION,
{
onCompleted: ({ validateAssertion: success }) => {
success ? getUserData() : setInvalidToken(true)
}
}
)
const [assertionOptions, { error: assertionQueryError }] = useLazyQuery(
GENERATE_ASSERTION,
{
variables:
strategy === 'FIDO2FA'
? {
username: state.clientField,
password: state.passwordField,
domain: window.location.hostname
}
: {
username: localClientField,
domain: window.location.hostname
},
onCompleted: ({ generateAssertionOptions: options }) => {
startAssertion(options)
.then(res => {
const variables =
strategy === 'FIDO2FA'
? {
username: state.clientField,
password: state.passwordField,
rememberMe: state.rememberMeField,
assertionResponse: res,
domain: window.location.hostname
}
: {
username: localClientField,
rememberMe: localRememberMeField,
assertionResponse: res,
domain: window.location.hostname
}
validateAssertion({
variables
})
})
.catch(err => {
console.error(err)
setInvalidToken(true)
})
}
}
)
const [getUserData, { error: queryError }] = useLazyQuery(GET_USER_DATA, {
onCompleted: ({ userData }) => {
setUserData(userData)
history.push('/')
}
})
const getErrorMsg = (formikErrors, formikTouched) => {
if (!formikErrors || !formikTouched) return null
if (assertionQueryError || queryError || mutationError)
return 'Internal server error'
if (formikErrors.client && formikTouched.client) return formikErrors.client
if (invalidUsername) return 'Invalid login.'
if (invalidToken) return 'Code is invalid. Please try again.'
return null
}
return (
<>
{strategy === 'FIDOPasswordless' && (
<Formik
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values => {
setInvalidUsername(false)
setLocalClientField(values.localClient)
setLocalRememberMeField(values.localRememberMe)
assertionOptions()
}}>
{({ errors, touched }) => (
<Form id="fido-form">
<Field
name="localClient"
label="Client"
size="lg"
component={TextInput}
fullWidth
autoFocus
className="-mt-4 mb-6"
error={getErrorMsg(errors, touched)}
onKeyUp={() => {
if (invalidUsername) setInvalidUsername(false)
}}
/>
<div className="mt-9 flex">
<Field
name="localRememberMe"
className="-ml-2 transform-[scale(1.5)]"
component={Checkbox}
/>
<Label2>Keep me logged in</Label2>
</div>
<div className="mt-9">
{getErrorMsg(errors, touched) && (
<P className="text-tomato">{getErrorMsg(errors, touched)}</P>
)}
<Button type="submit" form="fido-form" buttonClassName="w-full">
Use FIDO
</Button>
</div>
</Form>
)}
</Formik>
)}
{strategy === 'FIDO2FA' && (
<>
<H2 className="mb-8">
Insert your hardware key and follow the instructions
</H2>
<Button
type="button"
form="fido-form"
onClick={() => assertionOptions()}
buttonClassName="w-full">
Use FIDO
</Button>
</>
)}
</>
)
}
export default InputFIDOState

View file

@ -0,0 +1,23 @@
import Grid from '@mui/material/Grid'
import React from 'react'
import LoginCard from './LoginCard'
import classes from './Authentication.module.css'
const Login = () => {
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.welcomeBackground}>
<Grid>
<LoginCard />
</Grid>
</Grid>
)
}
export default Login

View file

@ -0,0 +1,68 @@
import Paper from '@mui/material/Paper'
import React, { useReducer } from 'react'
import { H5 } from 'src/components/typography'
import Logo from 'src/styling/icons/menu/logo.svg?react'
import Input2FAState from './Input2FAState'
import InputFIDOState from './InputFIDOState'
import LoginState from './LoginState'
import Setup2FAState from './Setup2FAState'
import { STATES } from './states'
import classes from './Authentication.module.css'
// FIDO2FA, FIDOPasswordless or FIDOUsernameless
const AUTHENTICATION_STRATEGY = 'FIDO2FA'
const initialState = {
twoFAField: '',
clientField: '',
passwordField: '',
rememberMeField: false,
loginState: STATES.LOGIN
}
const reducer = (state, action) => {
const { type, payload } = action
return { ...state, ...payload, loginState: type }
}
const LoginCard = () => {
const [state, dispatch] = useReducer(reducer, initialState)
const renderState = () => {
switch (state.loginState) {
case STATES.LOGIN:
return (
<LoginState
state={state}
dispatch={dispatch}
strategy={AUTHENTICATION_STRATEGY}
/>
)
case STATES.INPUT_2FA:
return <Input2FAState state={state} dispatch={dispatch} />
case STATES.SETUP_2FA:
return <Setup2FAState state={state} dispatch={dispatch} />
case STATES.FIDO:
return (
<InputFIDOState state={state} strategy={AUTHENTICATION_STRATEGY} />
)
default:
break
}
}
return (
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<h3 className={classes.title}>Lamassu Admin</h3>
</div>
{renderState()}
</div>
</Paper>
)
}
export default LoginCard

View file

@ -0,0 +1,227 @@
import { useMutation, useLazyQuery, gql } from '@apollo/client'
import { startAssertion } from '@simplewebauthn/browser'
import { Field, Form, Formik } from 'formik'
import React, { useContext } from 'react'
import { useHistory } from 'react-router-dom'
import { Label3, P } from 'src/components/typography'
import * as Yup from 'yup'
import AppContext from 'src/AppContext'
import { Button } from 'src/components/buttons'
import { Checkbox, SecretInput, TextInput } from 'src/components/inputs/formik'
const LOGIN = gql`
mutation login($username: String!, $password: String!) {
login(username: $username, password: $password)
}
`
const GENERATE_ASSERTION = gql`
query generateAssertionOptions($domain: String!) {
generateAssertionOptions(domain: $domain)
}
`
const VALIDATE_ASSERTION = gql`
mutation validateAssertion(
$assertionResponse: JSONObject!
$domain: String!
) {
validateAssertion(assertionResponse: $assertionResponse, domain: $domain)
}
`
const GET_USER_DATA = gql`
{
userData {
id
username
role
}
}
`
const validationSchema = Yup.object().shape({
email: Yup.string().label('Email').required().email(),
password: Yup.string().required('Password field is required'),
rememberMe: Yup.boolean()
})
const initialValues = {
email: '',
password: '',
rememberMe: false
}
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Invalid email/password combination'
if (formikErrors.email && formikTouched.email) return formikErrors.email
if (formikErrors.password && formikTouched.password)
return formikErrors.password
return null
}
const LoginState = ({ state, dispatch, strategy }) => {
const history = useHistory()
const { setUserData } = useContext(AppContext)
const [login, { error: loginMutationError }] = useMutation(LOGIN)
const submitLogin = async (username, password, rememberMe) => {
const options = {
variables: {
username,
password
}
}
const { data: loginResponse } = await login(options)
if (!loginResponse.login) return
return dispatch({
type: loginResponse.login,
payload: {
clientField: username,
passwordField: password,
rememberMeField: rememberMe
}
})
}
const [validateAssertion, { error: FIDOMutationError }] = useMutation(
VALIDATE_ASSERTION,
{
onCompleted: ({ validateAssertion: success }) => success && getUserData()
}
)
const [assertionOptions, { error: assertionQueryError }] = useLazyQuery(
GENERATE_ASSERTION,
{
onCompleted: ({ generateAssertionOptions: options }) => {
startAssertion(options)
.then(res => {
validateAssertion({
variables: {
assertionResponse: res,
domain: window.location.hostname
}
})
})
.catch(err => {
console.error(err)
})
}
}
)
const [getUserData, { error: userDataQueryError }] = useLazyQuery(
GET_USER_DATA,
{
onCompleted: ({ userData }) => {
setUserData(userData)
history.push('/')
}
}
)
return (
<Formik
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values =>
submitLogin(values.email, values.password, values.rememberMe)
}>
{({ errors, touched }) => (
<Form id="login-form">
<Field
name="email"
label="Email"
size="lg"
component={TextInput}
fullWidth
autoFocus
className="-mt-4 mb-6"
error={getErrorMsg(
errors,
touched,
loginMutationError ||
FIDOMutationError ||
assertionQueryError ||
userDataQueryError
)}
/>
<Field
name="password"
size="lg"
component={SecretInput}
label="Password"
fullWidth
error={getErrorMsg(
errors,
touched,
loginMutationError ||
FIDOMutationError ||
assertionQueryError ||
userDataQueryError
)}
/>
<div className="mt-9 flex">
<Field
name="rememberMe"
className="-ml-2 transform-[scale(1.5)]"
component={Checkbox}
size="medium"
/>
<Label3>Keep me logged in</Label3>
</div>
<div className="mt-15">
{getErrorMsg(
errors,
touched,
loginMutationError ||
FIDOMutationError ||
assertionQueryError ||
userDataQueryError
) && (
<P className="text-tomato">
{getErrorMsg(
errors,
touched,
loginMutationError ||
FIDOMutationError ||
assertionQueryError ||
userDataQueryError
)}
</P>
)}
{strategy !== 'FIDO2FA' && (
<Button
type="button"
onClick={() => {
return strategy === 'FIDOUsernameless'
? assertionOptions({
variables: { domain: window.location.hostname }
})
: dispatch({
type: 'FIDO',
payload: {}
})
}}
buttonClassName="w-full"
className="mb-3">
I have a hardware key
</Button>
)}
<Button type="submit" form="login-form" buttonClassName="w-full">
Login
</Button>
</div>
</Form>
)}
</Formik>
)
}
export default LoginState

View file

@ -0,0 +1,217 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Grid from '@mui/material/Grid'
import Paper from '@mui/material/Paper'
import { Field, Form, Formik } from 'formik'
import React, { useReducer } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { H2, Label3, P } from 'src/components/typography'
import Logo from 'src/styling/icons/menu/logo.svg?react'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { SecretInput } from 'src/components/inputs/formik'
import classes from './Authentication.module.css'
const QueryParams = () => new URLSearchParams(useLocation().search)
const VALIDATE_REGISTER_LINK = gql`
query validateRegisterLink($token: String!) {
validateRegisterLink(token: $token) {
username
role
}
}
`
const REGISTER = gql`
mutation register(
$token: String!
$username: String!
$password: String!
$role: String!
) {
register(
token: $token
username: $username
password: $password
role: $role
)
}
`
const PASSWORD_MIN_LENGTH = 8
const validationSchema = Yup.object({
password: Yup.string()
.required('A password is required')
.min(
PASSWORD_MIN_LENGTH,
`Your password must contain at least ${PASSWORD_MIN_LENGTH} characters`
),
confirmPassword: Yup.string()
.required('Please confirm the password')
.oneOf([Yup.ref('password')], 'Passwords must match')
})
const initialValues = {
password: '',
confirmPassword: ''
}
const initialState = {
username: null,
role: null,
result: ''
}
const reducer = (state, action) => {
const { type, payload } = action
return { ...state, ...payload, result: type }
}
const getErrorMsg = (
formikErrors,
formikTouched,
queryError,
mutationError
) => {
if (!formikErrors || !formikTouched) return null
if (queryError || mutationError) return 'Internal server error'
if (formikErrors.password && formikTouched.password)
return formikErrors.password
if (formikErrors.confirmPassword && formikTouched.confirmPassword)
return formikErrors.confirmPassword
return null
}
const Register = () => {
const history = useHistory()
const token = QueryParams().get('t')
const [state, dispatch] = useReducer(reducer, initialState)
const queryOptions = {
variables: { token: token },
onCompleted: ({ validateRegisterLink: info }) => {
if (!info) {
return dispatch({
type: 'failure'
})
}
dispatch({
type: 'success',
payload: {
username: info.username,
role: info.role
}
})
},
onError: () =>
dispatch({
type: 'failure'
})
}
const { error: queryError, loading } = useQuery(
VALIDATE_REGISTER_LINK,
queryOptions
)
const [register, { error: mutationError }] = useMutation(REGISTER, {
onCompleted: ({ register: success }) => {
if (success) history.push('/wizard', { fromAuthRegister: true })
}
})
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!loading && state.result === 'success' && (
<Formik
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values => {
register({
variables: {
token: token,
username: state.username,
password: values.password,
role: state.role
}
})
}}>
{({ errors, touched }) => (
<Form id="register-form">
<Field
name="password"
label="Insert a password"
autoFocus
component={SecretInput}
size="lg"
fullWidth
className="-mt-4 mb-6"
/>
<Field
name="confirmPassword"
label="Confirm your password"
component={SecretInput}
size="lg"
fullWidth
/>
<div className="mt-15">
{getErrorMsg(
errors,
touched,
queryError,
mutationError
) && (
<P className="text-tomato">
{getErrorMsg(
errors,
touched,
queryError,
mutationError
)}
</P>
)}
<Button
type="submit"
form="register-form"
buttonClassName="w-full">
Done
</Button>
</div>
</Form>
)}
</Formik>
)}
{!loading && state.result === 'failure' && (
<>
<Label3>Link has expired</Label3>
<Label3>
To obtain a new link, run the command{' '}
<strong>lamassu-register</strong> in your servers terminal.
</Label3>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default Register

View file

@ -0,0 +1,204 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Grid from '@mui/material/Grid'
import Paper from '@mui/material/Paper'
import { Form, Formik } from 'formik'
import { QRCodeSVG as QRCode } from 'qrcode.react'
import React, { useReducer, useState } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { H2, Label2, Label3, P } from 'src/components/typography'
import Logo from 'src/styling/icons/menu/logo.svg?react'
import { ActionButton, Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { primaryColor } from 'src/styling/variables'
import classes from './Authentication.module.css'
const VALIDATE_RESET_2FA_LINK = gql`
query validateReset2FALink($token: String!) {
validateReset2FALink(token: $token) {
user_id
secret
otpauth
}
}
`
const RESET_2FA = gql`
mutation reset2FA($token: String!, $userID: ID!, $code: String!) {
reset2FA(token: $token, userID: $userID, code: $code)
}
`
const initialState = {
userID: null,
secret: null,
otpauth: null,
result: null
}
const reducer = (state, action) => {
const { type, payload } = action
return { ...state, ...payload, result: type }
}
const Reset2FA = () => {
const history = useHistory()
const QueryParams = () => new URLSearchParams(useLocation().search)
const token = QueryParams().get('t')
const [isShowing, setShowing] = useState(false)
const [invalidToken, setInvalidToken] = useState(false)
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
const [state, dispatch] = useReducer(reducer, initialState)
const handle2FAChange = value => {
setTwoFAConfirmation(value)
setInvalidToken(false)
}
const { error: queryError, loading } = useQuery(VALIDATE_RESET_2FA_LINK, {
variables: { token: token },
onCompleted: ({ validateReset2FALink: info }) => {
if (!info) {
dispatch({
type: 'failure'
})
} else {
dispatch({
type: 'success',
payload: {
userID: info.user_id,
secret: info.secret,
otpauth: info.otpauth
}
})
}
},
onError: () => {
dispatch({
type: 'failure'
})
}
})
const [reset2FA, { error: mutationError }] = useMutation(RESET_2FA, {
onCompleted: ({ reset2FA: success }) => {
success ? history.push('/') : setInvalidToken(true)
}
})
const getErrorMsg = () => {
if (queryError) return 'Internal server error'
if (twoFAConfirmation.length !== 6 && invalidToken)
return 'The code should have 6 characters!'
if (mutationError || invalidToken)
return 'Code is invalid. Please try again.'
return null
}
const handleSubmit = () => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
reset2FA({
variables: {
token: token,
userID: state.userID,
code: twoFAConfirmation
}
})
}
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!loading && state.result === 'success' && (
<>
<div className={classes.infoWrapper}>
<Label2 className={classes.info2}>
To finish this process, please scan the following QR code
or insert the secret further below on an authentication
app of your choice, such Google Authenticator or Authy.
</Label2>
</div>
<div className={classes.qrCodeWrapper}>
<QRCode
size={240}
fgColor={primaryColor}
value={state.otpauth}
/>
</div>
<div className={classes.secretWrapper}>
<Label2 className={classes.secretLabel}>
Your secret:
</Label2>
<Label2
className={
isShowing ? classes.secret : classes.hiddenSecret
}>
{state.secret}
</Label2>
<ActionButton
color="primary"
onClick={() => {
setShowing(!isShowing)
}}>
{isShowing ? 'Hide' : 'Show'}
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
/>
<div className="mt-9">
{getErrorMsg() && (
<P className="text-tomato">{getErrorMsg()}</P>
)}
<Button
onClick={handleSubmit}
buttonClassName="w-full">
Done
</Button>
</div>
</Form>
</Formik>
</div>
</>
)}
{!loading && state.result === 'failure' && (
<>
<Label3>Link has expired</Label3>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default Reset2FA

View file

@ -0,0 +1,167 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Grid from '@mui/material/Grid'
import Paper from '@mui/material/Paper'
import { Field, Form, Formik } from 'formik'
import React, { useState } from 'react'
import { useLocation, useHistory } from 'react-router-dom'
import { H2, Label3, P } from 'src/components/typography'
import Logo from 'src/styling/icons/menu/logo.svg?react'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { SecretInput } from 'src/components/inputs/formik/'
import classes from './Authentication.module.css'
const VALIDATE_RESET_PASSWORD_LINK = gql`
query validateResetPasswordLink($token: String!) {
validateResetPasswordLink(token: $token) {
id
}
}
`
const RESET_PASSWORD = gql`
mutation resetPassword($token: String!, $userID: ID!, $newPassword: String!) {
resetPassword(token: $token, userID: $userID, newPassword: $newPassword)
}
`
const validationSchema = Yup.object().shape({
password: Yup.string()
.required('A new password is required')
.test(
'len',
'New password must contain more than 8 characters',
val => val.length >= 8
),
confirmPassword: Yup.string().oneOf(
[Yup.ref('password'), null],
'Passwords must match'
)
})
const initialValues = {
password: '',
confirmPassword: ''
}
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Internal server error'
if (formikErrors.password && formikTouched.password)
return formikErrors.password
if (formikErrors.confirmPassword && formikTouched.confirmPassword)
return formikErrors.confirmPassword
return null
}
const ResetPassword = () => {
const history = useHistory()
const QueryParams = () => new URLSearchParams(useLocation().search)
const token = QueryParams().get('t')
const [userID, setUserID] = useState(null)
const [isLoading, setLoading] = useState(true)
const [wasSuccessful, setSuccess] = useState(false)
useQuery(VALIDATE_RESET_PASSWORD_LINK, {
variables: { token: token },
onCompleted: ({ validateResetPasswordLink: info }) => {
setLoading(false)
if (!info) {
setSuccess(false)
} else {
setSuccess(true)
setUserID(info.id)
}
},
onError: () => {
setLoading(false)
setSuccess(false)
}
})
const [resetPassword, { error }] = useMutation(RESET_PASSWORD, {
onCompleted: ({ resetPassword: success }) => {
if (success) history.push('/')
}
})
return (
<Grid
container
spacing={0}
direction="column"
alignItems="center"
justifyContent="center"
className={classes.welcomeBackground}>
<Grid>
<div>
<Paper elevation={1}>
<div className={classes.wrapper}>
<div className={classes.titleWrapper}>
<Logo className={classes.icon} />
<H2 className={classes.title}>Lamassu Admin</H2>
</div>
{!isLoading && wasSuccessful && (
<Formik
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values => {
resetPassword({
variables: {
token: token,
userID: userID,
newPassword: values.confirmPassword
}
})
}}>
{({ errors, touched }) => (
<Form id="reset-password">
<Field
name="password"
autoFocus
size="lg"
component={SecretInput}
label="New password"
fullWidth
className="-mt-4 mb-6"
/>
<Field
name="confirmPassword"
size="lg"
component={SecretInput}
label="Confirm your password"
fullWidth
/>
<div className="mt-15">
{getErrorMsg(errors, touched, error) && (
<P className="text-tomato">
{getErrorMsg(errors, touched, error)}
</P>
)}
<Button
type="submit"
form="reset-password"
buttonClassName="w-full">
Done
</Button>
</div>
</Form>
)}
</Formik>
)}
{!isLoading && !wasSuccessful && (
<>
<Label3>Link has expired</Label3>
</>
)}
</div>
</Paper>
</div>
</Grid>
</Grid>
)
}
export default ResetPassword

View file

@ -0,0 +1,174 @@
import { useMutation, useQuery, useLazyQuery, gql } from '@apollo/client'
import { Form, Formik } from 'formik'
import { QRCodeSVG as QRCode } from 'qrcode.react'
import React, { useContext, useState } from 'react'
import { useHistory } from 'react-router-dom'
import { Label3, P } from 'src/components/typography'
import AppContext from 'src/AppContext'
import { ActionButton, Button } from 'src/components/buttons'
import { CodeInput } from 'src/components/inputs/base'
import { primaryColor } from 'src/styling/variables'
import classes from './Authentication.module.css'
const SETUP_2FA = gql`
mutation setup2FA(
$username: String!
$password: String!
$rememberMe: Boolean!
$codeConfirmation: String!
) {
setup2FA(
username: $username
password: $password
rememberMe: $rememberMe
codeConfirmation: $codeConfirmation
)
}
`
const GET_2FA_SECRET = gql`
query get2FASecret($username: String!, $password: String!) {
get2FASecret(username: $username, password: $password) {
secret
otpauth
}
}
`
const GET_USER_DATA = gql`
{
userData {
id
username
role
}
}
`
const Setup2FAState = ({ state, dispatch }) => {
const history = useHistory()
const { setUserData } = useContext(AppContext)
const [secret, setSecret] = useState(null)
const [otpauth, setOtpauth] = useState(null)
const [isShowing, setShowing] = useState(false)
const [invalidToken, setInvalidToken] = useState(false)
const [twoFAConfirmation, setTwoFAConfirmation] = useState('')
const handle2FAChange = value => {
setTwoFAConfirmation(value)
setInvalidToken(false)
}
const queryOptions = {
variables: { username: state.clientField, password: state.passwordField },
onCompleted: ({ get2FASecret }) => {
setSecret(get2FASecret.secret)
setOtpauth(get2FASecret.otpauth)
}
}
const mutationOptions = {
variables: {
username: state.clientField,
password: state.passwordField,
rememberMe: state.rememberMeField,
codeConfirmation: twoFAConfirmation
}
}
const { error: queryError } = useQuery(GET_2FA_SECRET, queryOptions)
const [getUserData] = useLazyQuery(GET_USER_DATA, {
onCompleted: ({ userData }) => {
setUserData(userData)
history.push('/')
}
})
const [setup2FA, { error: mutationError }] = useMutation(SETUP_2FA, {
onCompleted: ({ setup2FA: success }) => {
success ? getUserData() : setInvalidToken(true)
}
})
const getErrorMsg = () => {
if (mutationError || queryError) return 'Internal server error.'
if (twoFAConfirmation.length !== 6 && invalidToken)
return 'The code should have 6 characters!'
if (invalidToken) return 'Code is invalid. Please try again.'
return null
}
const handleSubmit = () => {
if (twoFAConfirmation.length !== 6) {
setInvalidToken(true)
return
}
setup2FA(mutationOptions)
}
return (
secret &&
otpauth && (
<>
<div className={classes.infoWrapper}>
<Label3 className={classes.info2}>
This account does not yet have two-factor authentication enabled. To
secure the admin, two-factor authentication is required.
</Label3>
<Label3 className={classes.info2}>
To complete the registration process, scan the following QR code or
insert the secret below on a 2FA app, such as Google Authenticator
or AndOTP.
</Label3>
</div>
<div className={classes.qrCodeWrapper}>
<QRCode size={240} fgColor={primaryColor} value={otpauth} />
</div>
<div className={classes.secretWrapper}>
<Label3 className={classes.secretLabel}>Your secret:</Label3>
<Label3 className={isShowing ? classes.secret : classes.hiddenSecret}>
{secret}
</Label3>
<ActionButton
disabled={!secret && !otpauth}
color="primary"
onClick={() => {
setShowing(!isShowing)
}}>
{isShowing ? 'Hide' : 'Show'}
</ActionButton>
</div>
<div className={classes.confirm2FAInput}>
{/* TODO: refactor the 2FA CodeInput to properly use Formik */}
<Formik onSubmit={() => {}} initialValues={{}}>
<Form>
<CodeInput
name="2fa"
value={twoFAConfirmation}
onChange={handle2FAChange}
numInputs={6}
error={invalidToken}
shouldAutoFocus
/>
<div className="mt-9">
{getErrorMsg() && (
<P className="text-tomato">{getErrorMsg()}</P>
)}
<Button onClick={handleSubmit} buttonClassName="w-full">
Done
</Button>
</div>
</Form>
</Formik>
</div>
</>
)
)
}
export default Setup2FAState

View file

@ -0,0 +1,8 @@
const STATES = {
LOGIN: 'LOGIN',
SETUP_2FA: 'SETUP2FA',
INPUT_2FA: 'INPUT2FA',
FIDO: 'FIDO'
}
export { STATES }

View file

@ -0,0 +1,313 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Dialog from '@mui/material/Dialog'
import DialogTitle from '@mui/material/DialogTitle'
import DialogContent from '@mui/material/DialogContent'
import Switch from '@mui/material/Switch'
import SvgIcon from '@mui/material/SvgIcon'
import IconButton from '@mui/material/IconButton'
import * as R from 'ramda'
import React, { useState } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import { H2, Label2, P, Info3, Info2 } from 'src/components/typography'
import CloseIcon from 'src/styling/icons/action/close/zodiac.svg?react'
import ReverseSettingsIcon from 'src/styling/icons/circle buttons/settings/white.svg?react'
import SettingsIcon from 'src/styling/icons/circle buttons/settings/zodiac.svg?react'
import { Link, Button, SupportLinkButton } from 'src/components/buttons'
import { fromNamespace, toNamespace } from 'src/utils/config'
import BlackListAdvanced from './BlacklistAdvanced'
import BlackListModal from './BlacklistModal'
import BlacklistTable from './BlacklistTable'
const DELETE_ROW = gql`
mutation DeleteBlacklistRow($address: String!) {
deleteBlacklistRow(address: $address) {
address
}
}
`
const GET_BLACKLIST = gql`
query getBlacklistData {
blacklist {
address
}
cryptoCurrencies {
display
code
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const GET_INFO = gql`
query getData {
config
}
`
const ADD_ROW = gql`
mutation InsertBlacklistRow($address: String!) {
insertBlacklistRow(address: $address) {
address
}
}
`
const GET_BLACKLIST_MESSAGES = gql`
query getBlacklistMessages {
blacklistMessages {
id
label
content
allowToggle
}
}
`
const EDIT_BLACKLIST_MESSAGE = gql`
mutation editBlacklistMessage($id: ID, $content: String) {
editBlacklistMessage(id: $id, content: $content) {
id
}
}
`
const PaperWalletDialog = ({ onConfirmed, onDissmised, open, props }) => {
return (
<Dialog
open={open}
aria-labelledby="form-dialog-title"
PaperProps={{
style: {
borderRadius: 8,
minWidth: 656,
bottom: 125,
right: 7
}
}}
{...props}>
<div className="p-2">
<DialogTitle className="flex flex-col">
<IconButton
aria-label="close"
onClick={onDissmised}
className="-mt-2 -mr-4 ml-auto">
<SvgIcon>
<CloseIcon />
</SvgIcon>
</IconButton>
<H2 noMargin>{'Are you sure you want to enable this?'}</H2>
</DialogTitle>
<DialogContent>
<Info3>{`This mode means that only paper wallets will be printed for users, and they won't be permitted to scan an address from their own wallet.`}</Info3>
<Info3>{`This mode is only useful for countries like Switzerland which mandates such a feature.\n`}</Info3>
<Info2>{`Don't enable this if you want users to be able to scan an address of their choosing.`}</Info2>
<div className="flex justify-end mt-8">
<Button onClick={() => onConfirmed(true)}>Confirm</Button>
</div>
</DialogContent>
</div>
</Dialog>
)
}
const Blacklist = () => {
const { data: blacklistResponse } = useQuery(GET_BLACKLIST)
const { data: configData } = useQuery(GET_INFO)
const { data: messagesResponse, refetch } = useQuery(GET_BLACKLIST_MESSAGES)
const [showModal, setShowModal] = useState(false)
const [errorMsg, setErrorMsg] = useState(null)
const [editMessageError, setEditMessageError] = useState(null)
const [deleteDialog, setDeleteDialog] = useState(false)
const [confirmDialog, setConfirmDialog] = useState(false)
const [advancedSettings, setAdvancedSettings] = useState(false)
const [deleteEntry] = useMutation(DELETE_ROW, {
onError: ({ message }) => {
const errorMessage = message ?? 'Error while deleting row'
setErrorMsg(errorMessage)
},
onCompleted: () => setDeleteDialog(false),
refetchQueries: () => ['getBlacklistData']
})
const [addEntry] = useMutation(ADD_ROW, {
refetchQueries: () => ['getBlacklistData']
})
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData']
})
const [editMessage] = useMutation(EDIT_BLACKLIST_MESSAGE, {
onError: e => setEditMessageError(e),
refetchQueries: () => ['getBlacklistData']
})
const blacklistData = R.path(['blacklist'])(blacklistResponse) ?? []
const complianceConfig =
configData?.config && fromNamespace('compliance')(configData.config)
const rejectAddressReuse = !!complianceConfig?.rejectAddressReuse
const enablePaperWalletOnly = !!complianceConfig?.enablePaperWalletOnly
const addressReuseSave = rawConfig => {
const config = toNamespace('compliance')(rawConfig)
return saveConfig({ variables: { config } })
}
const handleDeleteEntry = address => {
deleteEntry({ variables: { address } })
}
const handleConfirmDialog = confirm => {
addressReuseSave({
enablePaperWalletOnly: confirm
})
setConfirmDialog(false)
}
const addToBlacklist = async address => {
setErrorMsg(null)
try {
const res = await addEntry({ variables: { address } })
if (!res?.errors) {
return setShowModal(false)
}
const duplicateKeyError = res?.errors?.some(e => {
return e.message.includes('duplicate')
})
if (duplicateKeyError) {
setErrorMsg('This address is already being blocked')
} else {
setErrorMsg(`Server error${': ' + res?.errors[0]?.message}`)
}
} catch (e) {
setErrorMsg('Server error')
}
}
const editBlacklistMessage = r => {
editMessage({
variables: {
id: r.id,
content: r.content
}
})
}
return (
<>
<PaperWalletDialog
open={confirmDialog}
onConfirmed={handleConfirmDialog}
onDissmised={() => {
setConfirmDialog(false)
}}
/>
<TitleSection
title="Blacklisted addresses"
buttons={[
{
text: 'Advanced settings',
icon: SettingsIcon,
inverseIcon: ReverseSettingsIcon,
toggle: setAdvancedSettings
}
]}>
{!advancedSettings && (
<div className="flex items-center justify-end">
<div className="flex items-center justify-end mr-4">
<P>Enable paper wallet (only)</P>
<Switch
checked={enablePaperWalletOnly}
onChange={e =>
enablePaperWalletOnly
? addressReuseSave({
enablePaperWalletOnly: e.target.checked
})
: setConfirmDialog(true)
}
value={enablePaperWalletOnly}
/>
<Label2>{enablePaperWalletOnly ? 'On' : 'Off'}</Label2>
<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>
</HelpTooltip>
</div>
<div className="flex items-center justify-end mr-4">
<P>Reject reused addresses</P>
<Switch
checked={rejectAddressReuse}
onChange={event => {
addressReuseSave({ rejectAddressReuse: event.target.checked })
}}
value={rejectAddressReuse}
/>
<Label2>{rejectAddressReuse ? 'On' : 'Off'}</Label2>
<HelpTooltip width={304}>
<P>
For details about rejecting address reuse, 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"
/>
</HelpTooltip>
</div>
<Link color="primary" onClick={() => setShowModal(true)}>
Blacklist new addresses
</Link>
</div>
)}
</TitleSection>
{!advancedSettings && (
<div className="flex flex-col flex-1">
<BlacklistTable
data={blacklistData}
handleDeleteEntry={handleDeleteEntry}
errorMessage={errorMsg}
setErrorMessage={setErrorMsg}
deleteDialog={deleteDialog}
setDeleteDialog={setDeleteDialog}
/>
</div>
)}
{advancedSettings && (
<BlackListAdvanced
data={messagesResponse}
editBlacklistMessage={editBlacklistMessage}
mutationError={editMessageError}
onClose={() => refetch()}
/>
)}
{showModal && (
<BlackListModal
onClose={() => {
setErrorMsg(null)
setShowModal(false)
}}
errorMsg={errorMsg}
addToBlacklist={addToBlacklist}
/>
)}
</>
)
}
export default Blacklist

View file

@ -0,0 +1,172 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React, { useState } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import DataTable from 'src/components/tables/DataTable'
import DisabledDeleteIcon from 'src/styling/icons/action/delete/disabled.svg?react'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import DefaultIconReverse from 'src/styling/icons/button/retry/white.svg?react'
import DefaultIcon from 'src/styling/icons/button/retry/zodiac.svg?react'
import * as Yup from 'yup'
import { ActionButton, Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
const DEFAULT_MESSAGE = `This address may be associated with a deceptive offer or a prohibited group. Please make sure you're using an address from your own wallet.`
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (mutationError) return 'Internal server error'
if (!formikErrors || !formikTouched) return null
if (formikErrors.event && formikTouched.event) return formikErrors.event
if (formikErrors.message && formikTouched.message) return formikErrors.message
return null
}
const BlacklistAdvanced = ({
data,
editBlacklistMessage,
onClose,
mutationError
}) => {
const [selectedMessage, setSelectedMessage] = useState(null)
const elements = [
{
name: 'label',
header: 'Label',
width: 250,
textAlign: 'left',
size: 'sm',
view: it => R.path(['label'], it)
},
{
name: 'content',
header: 'Content',
width: 690,
textAlign: 'left',
size: 'sm',
view: it => R.path(['content'], it)
},
{
name: 'edit',
header: 'Edit',
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton className="pl-3" onClick={() => setSelectedMessage(it)}>
<SvgIcon>
<EditIcon />
</SvgIcon>
</IconButton>
)
},
{
name: 'deleteButton',
header: 'Delete',
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton
className="pl-3"
disabled={
!R.isNil(R.path(['allowToggle'], it)) &&
!R.path(['allowToggle'], it)
}>
<SvgIcon>
{R.path(['allowToggle'], it) ? (
<DeleteIcon />
) : (
<DisabledDeleteIcon />
)}
</SvgIcon>
</IconButton>
)
}
]
const handleModalClose = () => {
setSelectedMessage(null)
}
const handleSubmit = values => {
editBlacklistMessage(values)
handleModalClose()
!R.isNil(onClose) && onClose()
}
const initialValues = {
label: !R.isNil(selectedMessage) ? selectedMessage.label : '',
content: !R.isNil(selectedMessage) ? selectedMessage.content : ''
}
const validationSchema = Yup.object().shape({
label: Yup.string().required('A label is required!'),
content: Yup.string().required('The message content is required!').trim()
})
return (
<>
<DataTable
data={R.path(['blacklistMessages'], data)}
elements={elements}
emptyText="No blacklisted addresses so far"
name="blacklistTable"
/>
{selectedMessage && (
<Modal
title={`Blacklist message - ${selectedMessage?.label}`}
open={true}
width={676}
height={400}
handleClose={handleModalClose}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={values =>
handleSubmit({ id: selectedMessage.id, ...values })
}>
{({ errors, touched, setFieldValue }) => (
<Form className="flex flex-col h-full gap-5 py-5">
<ActionButton
color="primary"
Icon={DefaultIcon}
InverseIcon={DefaultIconReverse}
className="w-36"
type="button"
onClick={() => setFieldValue('content', DEFAULT_MESSAGE)}>
Reset to default
</ActionButton>
<Field
name="content"
label="Message content"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
<div className="flex flex-row ml-auto mt-auto">
{getErrorMsg(errors, touched, mutationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, mutationError)}
</ErrorMessage>
)}
<Button type="submit">Confirm</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)}
</>
)
}
export default BlacklistAdvanced

View file

@ -0,0 +1,62 @@
import { Formik, Form, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { H3 } from 'src/components/typography'
import * as Yup from 'yup'
import { Link } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
const BlackListModal = ({ onClose, addToBlacklist, errorMsg }) => {
const handleAddToBlacklist = address => {
addToBlacklist(address)
}
const placeholderAddress = '1ADwinnimZKGgQ3dpyfoUZvJh4p1UWSSpD'
return (
<Modal
closeOnBackdropClick={true}
width={676}
height={200}
handleClose={onClose}
open={true}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={{
address: ''
}}
validationSchema={Yup.object({
address: Yup.string().trim().required('An address is required')
})}
onSubmit={({ address }) => {
handleAddToBlacklist(address.trim())
}}>
<Form id="address-form" className="flex flex-col">
<H3 className="mt-auto mb-2">Blacklist new address</H3>
<Field
name="address"
fullWidth
autoComplete="off"
label="Paste new address to blacklist here"
placeholder={`ex: ${placeholderAddress}`}
component={TextInput}
/>
<div className="flex flex-row mt-auto">
{!R.isNil(errorMsg) && <ErrorMessage>{errorMsg}</ErrorMessage>}
<div className="flex ml-auto mt-12">
<Link type="submit" form="address-form">
Blacklist address
</Link>
</div>
</div>
</Form>
</Formik>
</Modal>
)
}
export default BlackListModal

View file

@ -0,0 +1,78 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import * as R from 'ramda'
import React, { useState } from 'react'
import { DeleteDialog } from 'src/components/DeleteDialog'
import DataTable from 'src/components/tables/DataTable'
import CopyToClipboard from 'src/components/CopyToClipboard.jsx'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
const BlacklistTable = ({
data,
handleDeleteEntry,
errorMessage,
setErrorMessage,
deleteDialog,
setDeleteDialog
}) => {
const [toBeDeleted, setToBeDeleted] = useState()
const elements = [
{
name: 'address',
header: 'Address',
width: 1070,
textAlign: 'left',
size: 'sm',
view: it => (
<div className="ml-2">
<CopyToClipboard>{R.path(['address'], it)}</CopyToClipboard>
</div>
)
},
{
name: 'deleteButton',
header: 'Delete',
width: 130,
textAlign: 'center',
size: 'sm',
view: it => (
<IconButton
className="pl-3"
onClick={() => {
setDeleteDialog(true)
setToBeDeleted(it)
}}>
<SvgIcon>
<DeleteIcon />
</SvgIcon>
</IconButton>
)
}
]
return (
<>
<DataTable
data={data}
elements={elements}
emptyText="No blacklisted addresses so far"
name="blacklistTable"
/>
<DeleteDialog
open={deleteDialog}
onDismissed={() => {
setDeleteDialog(false)
setErrorMessage(null)
}}
onConfirmed={() => {
setErrorMessage(null)
handleDeleteEntry(R.path(['address'], toBeDeleted))
}}
errorMessage={errorMessage}
/>
</>
)
}
export default BlacklistTable

View file

@ -0,0 +1,3 @@
import Blacklist from './Blacklist'
export default Blacklist

View file

@ -0,0 +1,154 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import Switch from '@mui/material/Switch'
import * as R from 'ramda'
import React, { useState } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import { P, Label2 } from 'src/components/typography'
import { SupportLinkButton } from 'src/components/buttons'
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
import { EmptyTable } from 'src/components/table'
import { fromNamespace, toNamespace } from 'src/utils/config'
import Wizard from './Wizard'
import { DenominationsSchema, getElements } from './helper'
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const GET_INFO = gql`
query getData {
machines {
name
deviceId
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
recycler1
recycler2
recycler3
recycler4
recycler5
recycler6
}
numberOfCassettes
numberOfRecyclers
}
config
}
`
const CashOut = ({ name: SCREEN_KEY }) => {
const [wizard, setWizard] = useState(false)
const { data, loading } = useQuery(GET_INFO)
const [saveConfig, { error }] = useMutation(SAVE_CONFIG, {
onCompleted: () => setWizard(false),
refetchQueries: () => ['getData']
})
const save = (rawConfig, accounts) => {
const config = toNamespace(SCREEN_KEY)(rawConfig)
return saveConfig({ variables: { config, accounts } })
}
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
const fudgeFactorActive = config?.fudgeFactorActive ?? false
const locale = data?.config && fromNamespace('locale')(data.config)
const machines = data?.machines ?? []
const onToggle = id => {
const namespaced = fromNamespace(id)(config)
if (!DenominationsSchema.isValidSync(namespaced)) return setWizard(id)
save(toNamespace(id, { active: !namespaced?.active }))
}
const wasNeverEnabled = it => R.compose(R.length, R.keys)(it) === 1
return (
!loading && (
<>
<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="flex items-center">
<P>Transaction fudge factor</P>
<Switch
checked={fudgeFactorActive}
onChange={event => {
save({ fudgeFactorActive: event.target.checked })
}}
value={fudgeFactorActive}
/>
<Label2 className="m-1 w-6">
{fudgeFactorActive ? 'On' : 'Off'}
</Label2>
<HelpTooltip width={304}>
<P>
Automatically accept customer deposits as complete if their
received amount is 100 crypto atoms or less.
</P>
<P>
(Crypto atoms are the smallest unit in each cryptocurrency.
E.g., satoshis in Bitcoin, or wei in Ethereum.)
</P>
<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
namespaces={R.map(R.path(['deviceId']))(machines)}
data={config}
stripeWhen={wasNeverEnabled}
enableEdit
editWidth={95}
enableToggle
toggleWidth={100}
onToggle={onToggle}
save={save}
error={error?.message}
validationSchema={DenominationsSchema}
disableRowEdit={R.compose(R.not, R.path(['active']))}
elements={getElements(machines, locale)}
/>
{R.isEmpty(machines) && <EmptyTable message="No machines so far" />}
{wizard && (
<Wizard
machine={R.find(R.propEq('deviceId', wizard))(machines)}
onClose={() => setWizard(false)}
save={save}
error={error?.message}
locale={locale}
/>
)}
</>
)
)
}
export default CashOut

View file

@ -0,0 +1,153 @@
import * as R from 'ramda'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import * as Yup from 'yup'
import { Autocomplete } from 'src/components/inputs/formik'
import denominations from 'src/utils/bill-denominations'
import { getBillOptions } from 'src/utils/bill-options'
import { toNamespace } from 'src/utils/config'
import { transformNumber } from 'src/utils/number'
import WizardSplash from './WizardSplash'
import WizardStep from './WizardStep'
import { DenominationsSchema } from './helper'
const MODAL_WIDTH = 554
const MODAL_HEIGHT = 520
const Wizard = ({ machine, locale, onClose, save, error }) => {
// Each stacker counts as two steps, one for front and another for rear
const LAST_STEP = machine.numberOfCassettes + machine.numberOfRecyclers + 1
const [{ step, config }, setState] = useState({
step: 0,
config: { active: true }
})
const options = getBillOptions(locale, denominations)
const title = `Enable cash-out`
const isLastStep = step === LAST_STEP
const onContinue = async it => {
if (isLastStep) {
return save(
toNamespace(
machine.deviceId,
DenominationsSchema.cast(config, { assert: false })
)
)
}
const newConfig = R.merge(config, it)
setState({
step: step + 1,
config: newConfig
})
}
const steps = R.concat(
R.map(
it => ({
model: 'cassette',
type: `cassette${it}`,
display: `Cassette ${it}`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
}),
R.range(1, machine.numberOfCassettes + 1)
),
R.map(
it => ({
type: `recycler${it}`,
model: 'recycler',
display: `Recycler ${it}`,
component: Autocomplete,
inputProps: {
options: options,
labelProp: 'display',
valueProp: 'code'
}
}),
R.range(1, machine.numberOfRecyclers + 1)
)
)
const schema = () =>
Yup.object().shape({
cassette1:
machine.numberOfCassettes >= 1 && step >= 1
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
cassette2:
machine.numberOfCassettes >= 2 && step >= 2
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
cassette3:
machine.numberOfCassettes >= 3 && step >= 3
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
cassette4:
machine.numberOfCassettes >= 4 && step >= 4
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler1:
machine.numberOfRecyclers >= 1 && step >= machine.numberOfCassettes + 1
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler2:
machine.numberOfRecyclers >= 2 && step >= machine.numberOfCassettes + 2
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler3:
machine.numberOfRecyclers >= 3 && step >= machine.numberOfCassettes + 3
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler4:
machine.numberOfRecyclers >= 4 && step >= machine.numberOfCassettes + 4
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler5:
machine.numberOfRecyclers >= 5 && step >= machine.numberOfCassettes + 5
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable(),
recycler6:
machine.numberOfRecyclers >= 6 && step >= machine.numberOfCassettes + 6
? Yup.number().required()
: Yup.number().transform(transformNumber).nullable()
})
return (
<Modal
title={step === 0 ? null : title}
handleClose={onClose}
width={MODAL_WIDTH}
height={MODAL_HEIGHT}
open={true}>
{step === 0 && (
<WizardSplash name={machine.name} onContinue={() => onContinue()} />
)}
{step !== 0 && (
<WizardStep
step={step}
name={machine.name}
numberOfCassettes={machine.numberOfCassettes}
error={error}
isLastStep={isLastStep}
steps={steps}
fiatCurrency={locale.fiatCurrency}
options={options}
schema={schema()}
onContinue={onContinue}
/>
)}
</Modal>
)
}
export default Wizard

View file

@ -0,0 +1,34 @@
import React from 'react'
import { H1, P, Info2 } from 'src/components/typography'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { Button } from 'src/components/buttons'
const WizardSplash = ({ name, onContinue }) => {
return (
<div className="flex flex-col flex-1 px-8 gap-18">
<div>
<H1 className="text-neon text-center mb-3 mt-8">
<TxOutIcon className="align-bottom mr-3 w-6 h-7" />
<span>Enable cash-out</span>
</H1>
<Info2 noMargin className="mb-10 text-center">
{name}
</Info2>
<P>
You are about to activate cash-out functionality on your {name}{' '}
machine which will allow your customers to sell crypto to you.
</P>
<P>
In order to activate cash-out for this machine, please enter the
denominations for the machine.
</P>
</div>
<Button className="mx-auto" onClick={onContinue}>
Start configuration
</Button>
</div>
)
}
export default WizardSplash

View file

@ -0,0 +1,153 @@
import { Formik, Form, Field } from 'formik'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Stepper from 'src/components/Stepper'
import { Info2, H4, P, Info1, Label1 } from 'src/components/typography'
import WarningIcon from 'src/styling/icons/warning-icon/comet.svg?react'
import { Button } from 'src/components/buttons'
import { NumberInput } from 'src/components/inputs/formik'
import cassetteOne from 'src/styling/icons/cassettes/cashout-cassette-1.svg'
import cassetteTwo from 'src/styling/icons/cassettes/cashout-cassette-2.svg'
import tejo3CassetteOne from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-1-left.svg'
import tejo3CassetteTwo from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-2-left.svg'
import tejo3CassetteThree from 'src/styling/icons/cassettes/tejo/3-cassettes/3-cassettes-open-3-left.svg'
import tejo4CassetteOne from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-1-left.svg'
import tejo4CassetteTwo from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-2-left.svg'
import tejo4CassetteThree from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-3-left.svg'
import tejo4CassetteFour from 'src/styling/icons/cassettes/tejo/4-cassettes/4-cassettes-open-4-left.svg'
const getCassetesArtworks = () => ({
1: {
1: cassetteOne
},
2: {
1: cassetteOne,
2: cassetteTwo
},
3: {
1: tejo3CassetteOne,
2: tejo3CassetteTwo,
3: tejo3CassetteThree
},
4: {
1: tejo4CassetteOne,
2: tejo4CassetteTwo,
3: tejo4CassetteThree,
4: tejo4CassetteFour
}
})
const WizardStep = ({
name,
step,
schema,
error,
isLastStep,
onContinue,
steps,
fiatCurrency,
options,
numberOfCassettes
}) => {
const label = isLastStep ? 'Finish' : 'Next'
const cassetteIcon = getCassetesArtworks()[numberOfCassettes]
return (
<>
<div className="pb-8">
<Info2 className="m-0 mb-3">{name}</Info2>
<Stepper steps={steps.length + 1} currentStep={step} />
</div>
{!isLastStep && (
<Formik
validateOnBlur={false}
validateOnChange={false}
onSubmit={onContinue}
initialValues={{
cassette1: '',
cassette2: '',
cassette3: '',
cassette4: ''
}}
enableReinitialize
validationSchema={schema}>
<Form className="flex flex-col justify-between grow-2 pb-8">
<div className="flex">
{steps.map(
({ type, display, component }, idx) =>
1 + idx === step && (
<div key={idx} className="flex-1">
<H4 noMargin>Edit {display}</H4>
<Label1>Choose bill denomination</Label1>
<div className="flex items-center justify-end w-33">
<Field
className="w-full"
type="text"
size="lg"
autoFocus={1 + idx === step}
component={
options?.length > 0 ? component : NumberInput
}
fullWidth
decimalPlaces={0}
name={type}
options={options}
valueProp={'code'}
labelProp={'display'}></Field>
<Info1 noMargin className="pl-4">
{fiatCurrency}
</Info1>
</div>
</div>
)
)}
<img
className="relative -top-5 right-4"
alt="cassette"
width="148"
height="205"
src={cassetteIcon ? cassetteIcon[step] : null}></img>
</div>
<Button className="self-end" type="submit">
{label}
</Button>
</Form>
</Formik>
)}
{isLastStep && (
<div className="flex flex-col justify-between grow-2 pb-8">
<div>
<Info2 className="m-0 mb-3">Cash Cassette Bill Count</Info2>
<P>
<WarningIcon className="float-left mr-4 mb-12" />
When enabling cash-out, your bill count will be automatically set
to zero. Make sure you physically put cash inside the cash
cassettes to allow the machine to dispense it to your users. If
you already did, make sure you set the correct cash cassette bill
count for this machine on your Cash boxes & cassettes tab under
Maintenance.
</P>
<Info2 className="m-0 mb-3">Default Commissions</Info2>
<P>
<WarningIcon className="float-left mr-4 mb-12" />
When enabling cash-out, default commissions will be set. To change
commissions for this machine, please go to the Commissions tab
under Settings where you can set exceptions for each of the
available cryptocurrencies.
</P>
</div>
{error && <ErrorMessage>Failed to save</ErrorMessage>}
<Button className="self-end" onClick={() => onContinue()}>
{label}
</Button>
</div>
)}
</>
)
}
export default WizardStep

View file

@ -0,0 +1,196 @@
import * as R from 'ramda'
import * as Yup from 'yup'
import { Autocomplete, NumberInput } from 'src/components/inputs/formik'
import { bold } from 'src/styling/helpers'
import denominations from 'src/utils/bill-denominations'
import { getBillOptions } from 'src/utils/bill-options'
import { CURRENCY_MAX } from 'src/utils/constants'
import { transformNumber } from 'src/utils/number'
const widthsByNumberOfUnits = {
2: { machine: 325, cassette: 340 },
3: { machine: 300, cassette: 235 },
4: { machine: 205, cassette: 200 },
5: { machine: 180, cassette: 165 },
6: { machine: 165, cassette: 140 },
7: { machine: 130, cassette: 125 }
}
const denominationKeys = [
'cassette1',
'cassette2',
'cassette3',
'cassette4',
'recycler1',
'recycler2',
'recycler3',
'recycler4',
'recycler5',
'recycler6'
]
const DenominationsSchema = Yup.object()
.shape({
cassette1: Yup.number()
.label('Cassette 1')
.min(1)
.nullable()
.max(CURRENCY_MAX),
cassette2: Yup.number()
.label('Cassette 2')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
cassette3: Yup.number()
.label('Cassette 3')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
cassette4: Yup.number()
.label('Cassette 4')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler1: Yup.number()
.label('Recycler 1')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler2: Yup.number()
.label('Recycler 2')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler3: Yup.number()
.label('Recycler 3')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler4: Yup.number()
.label('Recycler 4')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler5: Yup.number()
.label('Recycler 5')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber),
recycler6: Yup.number()
.label('Recycler 6')
.min(1)
.max(CURRENCY_MAX)
.nullable()
.transform(transformNumber)
})
.test((values, context) =>
R.any(key => !R.isNil(values[key]), denominationKeys)
? true
: context.createError({
path: '',
message:
'The recyclers or at least one of the cassettes must have a value'
})
)
const getElements = (machines, locale = {}) => {
const fiatCurrency = R.prop('fiatCurrency')(locale)
const maxNumberOfCassettes = Math.max(
...R.map(it => it.numberOfCassettes, machines),
0
)
const maxNumberOfRecyclers = Math.max(
...R.map(it => it.numberOfRecyclers, machines),
0
)
const numberOfCashUnits =
maxNumberOfCassettes + Math.ceil(maxNumberOfRecyclers / 2)
const options = getBillOptions(locale, denominations)
const cassetteProps =
options?.length > 0
? {
options: options,
labelProp: 'display',
valueProp: 'code',
className: 'w-full'
}
: { decimalPlaces: 0 }
const elements = [
{
name: 'id',
header: 'Machine',
width: widthsByNumberOfUnits[numberOfCashUnits]?.machine,
view: it => machines.find(({ deviceId }) => deviceId === it).name,
size: 'sm',
editable: false
}
]
R.until(
R.gt(R.__, maxNumberOfCassettes),
it => {
elements.push({
name: `cassette${it}`,
header: `Cassette ${it}`,
size: 'sm',
stripe: true,
textAlign: 'right',
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
suffix: fiatCurrency,
bold: bold,
view: it => it,
input: options?.length > 0 ? Autocomplete : NumberInput,
inputProps: cassetteProps,
doubleHeader: 'Denominations of Cassettes & Recyclers',
isHidden: machine =>
it >
machines.find(({ deviceId }) => deviceId === machine.id)
.numberOfCassettes
})
return R.add(1, it)
},
1
)
R.until(
R.gt(R.__, Math.ceil(maxNumberOfRecyclers / 2)),
it => {
elements.push({
names: [`recycler${it * 2 - 1}`, `recycler${it * 2}`],
header: `Recyclers ${it * 2 - 1} - ${it * 2}`,
size: 'sm',
stripe: true,
textAlign: 'right',
width: widthsByNumberOfUnits[numberOfCashUnits]?.cassette,
suffix: fiatCurrency,
bold: bold,
input: options?.length > 0 ? Autocomplete : NumberInput,
inputProps: cassetteProps,
doubleHeader: 'Denominations of Cassettes & Recyclers',
isHidden: machine =>
it >
Math.ceil(
machines.find(({ deviceId }) => deviceId === machine.id)
.numberOfRecyclers / 2
)
})
return R.add(1, it)
},
1
)
return elements
}
export { DenominationsSchema, getElements }

View file

@ -0,0 +1,3 @@
import Cashout from './Cashout'
export default Cashout

View file

@ -0,0 +1,159 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { HelpTooltip } from 'src/components/Tooltip'
import TitleSection from 'src/components/layout/TitleSection'
import ReverseListingViewIcon from 'src/styling/icons/circle buttons/listing-view/white.svg?react'
import ListingViewIcon from 'src/styling/icons/circle buttons/listing-view/zodiac.svg?react'
import OverrideLabelIcon from 'src/styling/icons/status/spring2.svg?react'
import { SupportLinkButton } from 'src/components/buttons'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { P } from '../../components/typography'
import CommissionsDetails from './components/CommissionsDetails'
import CommissionsList from './components/CommissionsList'
const GET_DATA = gql`
query getData {
config
cryptoCurrencies {
code
display
}
machines {
name
deviceId
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject) {
saveConfig(config: $config)
}
`
const removeCoinFromOverride = crypto => override =>
R.mergeRight(override, {
cryptoCurrencies: R.without([crypto], override.cryptoCurrencies)
})
const Commissions = ({ name: SCREEN_KEY }) => {
const [showMachines, setShowMachines] = useState(false)
const [error, setError] = useState(null)
const { data, loading } = useQuery(GET_DATA)
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
const localeConfig =
data?.config && fromNamespace(namespaces.LOCALE)(data.config)
const currency = R.prop('fiatCurrency')(localeConfig)
const overrides = R.prop('overrides')(config)
const save = it => {
const config = toNamespace(SCREEN_KEY)(it.commissions[0])
return saveConfig({ variables: { config } })
}
const saveOverrides = it => {
const config = toNamespace(SCREEN_KEY)(it)
setError(null)
return saveConfig({ variables: { config } })
}
const saveOverridesFromList = it => (_, override) => {
const cryptoOverridden = R.path(['cryptoCurrencies', 0], override)
const sameMachine = R.eqProps('machine', override)
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
const removeCoin = removeCoinFromOverride(cryptoOverridden)
const machineOverrides = R.map(removeCoin)(filterMachine(it))
const overrides = machineOverrides.concat(
R.filter(it => !sameMachine(it), it)
)
const config = {
commissions_overrides: R.prepend(override, overrides)
}
return saveConfig({ variables: { config } })
}
const labels = showMachines
? [
{
label: 'Override value',
icon: <OverrideLabelIcon />
}
]
: []
return (
<>
<TitleSection
title="Commissions"
labels={labels}
buttons={[
{
text: 'List view',
icon: ListingViewIcon,
inverseIcon: ReverseListingViewIcon,
toggle: setShowMachines
}
]}
iconClassName="ml-1"
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="Commissions and Profit Calculations"
bottomSpace="1"
/>
</HelpTooltip>
}
/>
{!showMachines && !loading && (
<CommissionsDetails
config={config}
locale={localeConfig}
currency={currency}
data={data}
error={error}
save={save}
saveOverrides={saveOverrides}
/>
)}
{showMachines && !loading && (
<CommissionsList
config={config}
localeConfig={localeConfig}
currency={currency}
data={data}
error={error}
saveOverrides={saveOverridesFromList(overrides)}
/>
)}
</>
)
}
export default Commissions

View file

@ -0,0 +1,79 @@
import * as R from 'ramda'
import React, { useState, memo } from 'react'
import Section from 'src/components/layout/Section'
import {
mainFields,
overrides,
getSchema,
getOverridesSchema,
defaults,
overridesDefaults,
getOrder
} from 'src/pages/Commissions/helper'
import { Table as EditableTable } from 'src/components/editableTable'
const CommissionsDetails = memo(
({ config, locale, currency, data, error, save, saveOverrides }) => {
const [isEditingDefault, setEditingDefault] = useState(false)
const [isEditingOverrides, setEditingOverrides] = useState(false)
const commission = config && !R.isEmpty(config) ? config : defaults
const commissionOverrides = commission?.overrides ?? []
const orderedCommissionsOverrides = R.sortWith([
R.ascend(getOrder),
R.ascend(R.prop('machine'))
])(commissionOverrides)
const onEditingDefault = (it, editing) => setEditingDefault(editing)
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
return (
<>
<Section>
<EditableTable
error={error?.message}
title="Default setup"
rowSize="lg"
titleLg
name="commissions"
enableEdit
initialValues={commission}
save={save}
validationSchema={getSchema(locale)}
data={R.of(commission)}
elements={mainFields(currency)}
setEditing={onEditingDefault}
forceDisable={isEditingOverrides}
/>
</Section>
<Section>
<EditableTable
error={error?.message}
title="Overrides"
titleLg
name="overrides"
enableDelete
enableEdit
enableCreate
groupBy={getOrder}
initialValues={overridesDefaults}
save={saveOverrides}
validationSchema={getOverridesSchema(
orderedCommissionsOverrides,
data,
locale
)}
data={orderedCommissionsOverrides}
elements={overrides(data, currency, orderedCommissionsOverrides)}
setEditing={onEditingOverrides}
forceDisable={isEditingDefault}
/>
</Section>
</>
)
}
)
export default CommissionsDetails

View file

@ -0,0 +1,161 @@
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import {
overridesDefaults,
getCommissions,
getListCommissionsSchema,
commissionsList
} from 'src/pages/Commissions/helper'
import { Table as EditableTable } from 'src/components/editableTable'
import { Select } from 'src/components/inputs'
const SHOW_ALL = {
code: 'SHOW_ALL',
display: 'Show all'
}
const ORDER_OPTIONS = [
{
code: 'machine',
display: 'Machine name'
},
{
code: 'cryptoCurrencies',
display: 'Cryptocurrency'
},
{
code: 'cashIn',
display: 'Cash-in'
},
{
code: 'cashOut',
display: 'Cash-out'
},
{
code: 'fixedFee',
display: 'Fixed fee'
},
{
code: 'minimumTx',
display: 'Minimum Tx'
}
]
const getElement = (code, display) => ({
code: code,
display: display || code
})
const sortCommissionsBy = prop => {
switch (prop) {
case ORDER_OPTIONS[0]:
return R.sortBy(R.find(R.propEq('code', R.prop('machine'))))
case ORDER_OPTIONS[1]:
return R.sortBy(R.path(['cryptoCurrencies', 0]))
default:
return R.sortBy(R.prop(prop.code))
}
}
const filterCommissions = (coinFilter, machineFilter) =>
R.compose(
R.filter(
it => (machineFilter === SHOW_ALL) | (machineFilter.code === it.machine)
),
R.filter(
it =>
(coinFilter === SHOW_ALL) | (coinFilter.code === it.cryptoCurrencies[0])
)
)
const CommissionsList = memo(
({ config, localeConfig, currency, data, error, saveOverrides }) => {
const [machineFilter, setMachineFilter] = useState(SHOW_ALL)
const [coinFilter, setCoinFilter] = useState(SHOW_ALL)
const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0])
const coins = R.prop('cryptoCurrencies', localeConfig) ?? []
const getMachineCoins = deviceId => {
const override = R.prop('overrides', localeConfig)?.find(
R.propEq('machine', deviceId)
)
const machineCoins = override
? R.prop('cryptoCurrencies', override)
: coins
return R.xprod([deviceId], machineCoins)
}
const getMachineElement = it =>
getElement(R.prop('deviceId', it), R.prop('name', it))
const cryptoData = R.map(getElement)(coins)
const machineData = R.sortBy(
R.prop('display'),
R.map(getMachineElement)(R.prop('machines', data))
)
const machinesCoinsTuples = R.unnest(
R.map(getMachineCoins)(machineData.map(R.prop('code')))
)
const commissions = R.map(([deviceId, cryptoCode]) =>
getCommissions(cryptoCode, deviceId, config)
)(machinesCoinsTuples)
const tableData = R.compose(
sortCommissionsBy(orderProp),
filterCommissions(coinFilter, machineFilter)
)(commissions)
return (
<div>
<div className="flex mb-6">
<Select
className="mr-5"
onSelectedItemChange={setMachineFilter}
label="Machines"
default={SHOW_ALL}
items={[SHOW_ALL].concat(machineData)}
selectedItem={machineFilter}
/>
<Select
className="mr-5"
onSelectedItemChange={setCoinFilter}
label="Cryptocurrency"
default={SHOW_ALL}
items={[SHOW_ALL].concat(cryptoData)}
selectedItem={coinFilter}
/>
<Select
onSelectedItemChange={setOrderProp}
label="Sort by"
default={ORDER_OPTIONS[0]}
items={ORDER_OPTIONS}
selectedItem={orderProp}
defaultAsFilter
/>
</div>
<div className="flex-1 w-full max-h-[70vh] overflow-y-auto">
<EditableTable
error={error?.message}
name="comissionsList"
enableEdit
save={saveOverrides}
initialValues={overridesDefaults}
validationSchema={getListCommissionsSchema(localeConfig)}
data={tableData}
elements={commissionsList(data, currency)}
orderedBy={orderProp}
/>
</div>
</div>
)
}
)
export default CommissionsList

View file

@ -0,0 +1,614 @@
import * as R from 'ramda'
import React from 'react'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { v4 as uuidv4 } from 'uuid'
import * as Yup from 'yup'
import { Autocomplete, NumberInput } from 'src/components/inputs/formik'
import { bold } from 'src/styling/helpers'
import { primaryColor, secondaryColorDark } from 'src/styling/variables'
import denominations from 'src/utils/bill-denominations'
import { getBillOptions } from 'src/utils/bill-options'
import { CURRENCY_MAX } from 'src/utils/constants'
const ALL_MACHINES = {
name: 'All Machines',
deviceId: 'ALL_MACHINES'
}
const ALL_COINS = {
display: 'All Coins',
code: 'ALL_COINS'
}
const cashInAndOutHeaderStyle = { marginLeft: 6, whiteSpace: 'nowrap' }
const cashInHeader = (
<div>
<TxInIcon />
<span style={cashInAndOutHeaderStyle}>Cash-in</span>
</div>
)
const cashOutHeader = (
<div>
<TxOutIcon />
<span style={cashInAndOutHeaderStyle}>Cash-out</span>
</div>
)
const getView = (data, code, compare) => it => {
if (!data) return ''
// The following boolean should come undefined if it is rendering an unpaired machine
const attribute = R.find(R.propEq(compare ?? 'code', it))(data)
return attribute ? R.prop(code, attribute) : 'Unpaired machine'
}
const displayCodeArray = data => it => {
if (!it) return it
return R.compose(R.join(', '), R.map(getView(data, 'display')))(it)
}
const onCryptoChange = (prev, curr, setValue) => {
const hasAllCoins = R.includes(ALL_COINS.code)(curr)
const hadAllCoins = R.includes(ALL_COINS.code)(prev)
if (hasAllCoins && hadAllCoins && R.length(curr) > 1) {
return setValue(R.reject(R.equals(ALL_COINS.code))(curr))
}
if (hasAllCoins && !hadAllCoins) {
return setValue([ALL_COINS.code])
}
setValue(curr)
}
const getOverridesFields = (getData, currency, auxElements) => {
const machineData = [ALL_MACHINES].concat(getData(['machines']))
const rawCryptos = getData(['cryptoCurrencies'])
const cryptoData = [ALL_COINS].concat(
R.map(it => ({ display: it.code, code: it.code }))(rawCryptos ?? [])
)
return [
{
name: 'machine',
width: 196,
size: 'sm',
view: getView(machineData, 'name', 'deviceId'),
input: Autocomplete,
inputProps: {
options: machineData,
valueProp: 'deviceId',
labelProp: 'name'
}
},
{
name: 'cryptoCurrencies',
width: 145,
size: 'sm',
view: displayCodeArray(cryptoData),
input: Autocomplete,
inputProps: {
options: cryptoData,
valueProp: 'code',
labelProp: 'display',
multiple: true,
onChange: onCryptoChange,
shouldStayOpen: true
}
},
{
header: cashInHeader,
name: 'cashIn',
display: 'Cash-in',
width: 123,
input: NumberInput,
textAlign: 'right',
suffix: '%',
bold: bold,
inputProps: {
decimalPlaces: 3
}
},
{
header: cashOutHeader,
name: 'cashOut',
display: 'Cash-out',
width: 127,
input: NumberInput,
textAlign: 'right',
suffix: '%',
bold: bold,
inputProps: {
decimalPlaces: 3
}
},
{
name: 'fixedFee',
display: 'Fixed fee',
width: 126,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
suffix: currency,
bold: bold,
inputProps: {
decimalPlaces: 2
}
},
{
name: 'minimumTx',
display: 'Minimum Tx',
width: 140,
doubleHeader: 'Cash-in only',
textAlign: 'center',
editingAlign: 'right',
input: NumberInput,
suffix: currency,
bold: bold,
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
}
}
]
}
const mainFields = currency => [
{
header: cashInHeader,
name: 'cashIn',
display: 'Cash-in',
width: 169,
size: 'lg',
editingAlign: 'right',
input: NumberInput,
suffix: '%',
bold: bold,
inputProps: {
decimalPlaces: 3
}
},
{
header: cashOutHeader,
name: 'cashOut',
display: 'Cash-out',
width: 169,
size: 'lg',
editingAlign: 'right',
input: NumberInput,
suffix: '%',
bold: bold,
inputProps: {
decimalPlaces: 3
}
},
{
name: 'fixedFee',
display: 'Fixed fee',
width: 169,
size: 'lg',
doubleHeader: 'Cash-in only',
textAlign: 'center',
editingAlign: 'right',
input: NumberInput,
suffix: currency,
bold: bold,
inputProps: {
decimalPlaces: 2
}
},
{
name: 'minimumTx',
display: 'Minimum Tx',
width: 169,
size: 'lg',
doubleHeader: 'Cash-in only',
textAlign: 'center',
editingAlign: 'right',
input: NumberInput,
suffix: currency,
bold: bold,
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
}
}
]
const overrides = (auxData, currency, auxElements) => {
const getData = R.path(R.__, auxData)
return getOverridesFields(getData, currency, auxElements)
}
const percentMin = -15
const percentMax = 100
const getSchema = locale => {
const bills = getBillOptions(locale, denominations).map(it => it.code)
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
return Yup.object().shape({
cashIn: Yup.number()
.label('Cash-in')
.min(percentMin)
.max(percentMax)
.required(),
cashOut: Yup.number()
.label('Cash-out')
.min(percentMin)
.max(percentMax)
.required(),
fixedFee: Yup.number()
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
minimumTx: Yup.number()
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
const getAlreadyUsed = (id, machine, values) => {
const getCrypto = R.prop('cryptoCurrencies')
const getMachineId = R.prop('machine')
const filteredOverrides = R.filter(R.propEq('machine', machine))(values)
const originalValue = R.find(R.propEq('id', id))(values)
const originalCryptos = getCrypto(originalValue)
const originalMachineId = getMachineId(originalValue)
const alreadyUsed = R.compose(
R.uniq,
R.flatten,
R.map(getCrypto)
)(filteredOverrides)
if (machine !== originalMachineId) return alreadyUsed ?? []
return R.difference(alreadyUsed, originalCryptos)
}
const getOverridesSchema = (values, rawData, locale) => {
const getData = R.path(R.__, rawData)
const machineData = [ALL_MACHINES].concat(getData(['machines']))
const rawCryptos = getData(['cryptoCurrencies'])
const cryptoData = [ALL_COINS].concat(
R.map(it => ({ display: it.code, code: it.code }))(rawCryptos ?? [])
)
const bills = getBillOptions(locale, denominations).map(it =>
parseInt(it.code)
)
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
return Yup.object().shape({
machine: Yup.string().nullable().label('Machine').required(),
cryptoCurrencies: Yup.array()
.test({
test() {
const { id, machine, cryptoCurrencies } = this.parent
const alreadyUsed = getAlreadyUsed(id, machine, values)
const isAllMachines = machine === ALL_MACHINES.deviceId
const isAllCoins = R.includes(ALL_COINS.code, cryptoCurrencies)
if (isAllMachines && isAllCoins) {
return this.createError({
message: `All machines and all coins should be configured in the default setup table`
})
}
const repeated = R.intersection(alreadyUsed, cryptoCurrencies)
if (!R.isEmpty(repeated)) {
const codes = displayCodeArray(cryptoData)(repeated)
const machineView = getView(
machineData,
'name',
'deviceId'
)(machine)
const message = `${codes} already overridden for machine: ${machineView}`
return this.createError({ message })
}
return true
}
})
.label('Crypto currencies')
.required()
.min(1),
cashIn: Yup.number()
.label('Cash-in')
.min(percentMin)
.max(percentMax)
.required(),
cashOut: Yup.number()
.label('Cash-out')
.min(percentMin)
.max(percentMax)
.required(),
fixedFee: Yup.number()
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
minimumTx: Yup.number()
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
const defaults = {
cashIn: '',
cashOut: '',
fixedFee: '',
minimumTx: '',
cashOutFixedFee: ''
}
const overridesDefaults = {
machine: null,
cryptoCurrencies: [],
cashIn: '',
cashOut: '',
fixedFee: '',
minimumTx: '',
cashOutFixedFee: ''
}
const getOrder = ({ machine, cryptoCurrencies }) => {
const isAllMachines = machine === ALL_MACHINES.deviceId
const isAllCoins = R.contains(ALL_COINS.code, cryptoCurrencies)
if (isAllMachines && isAllCoins) return 0
if (isAllMachines) return 1
if (isAllCoins) return 2
return 3
}
const createCommissions = (cryptoCode, deviceId, isDefault, config) => {
return {
minimumTx: config.minimumTx,
fixedFee: config.fixedFee,
cashOut: config.cashOut,
cashIn: config.cashIn,
cashOutFixedFee: config.cashOutFixedFee,
machine: deviceId,
cryptoCurrencies: [cryptoCode],
default: isDefault,
id: uuidv4()
}
}
const getCommissions = (cryptoCode, deviceId, config) => {
const overrides = R.prop('overrides', config) ?? []
if (!overrides && R.isEmpty(overrides)) {
return createCommissions(cryptoCode, deviceId, true, config)
}
const specificOverride = R.find(
it => it.machine === deviceId && R.includes(cryptoCode)(it.cryptoCurrencies)
)(overrides)
if (specificOverride !== undefined)
return createCommissions(cryptoCode, deviceId, false, specificOverride)
const machineOverride = R.find(
it =>
it.machine === deviceId && R.includes('ALL_COINS')(it.cryptoCurrencies)
)(overrides)
if (machineOverride !== undefined)
return createCommissions(cryptoCode, deviceId, false, machineOverride)
const coinOverride = R.find(
it =>
it.machine === 'ALL_MACHINES' &&
R.includes(cryptoCode)(it.cryptoCurrencies)
)(overrides)
if (coinOverride !== undefined)
return createCommissions(cryptoCode, deviceId, false, coinOverride)
return createCommissions(cryptoCode, deviceId, true, config)
}
const getListCommissionsSchema = locale => {
const bills = getBillOptions(locale, denominations).map(it =>
parseInt(it.code)
)
const highestBill = R.isEmpty(bills) ? CURRENCY_MAX : Math.max(...bills)
return Yup.object().shape({
machine: Yup.string().label('Machine').required(),
cryptoCurrencies: Yup.array().label('Crypto currency').required().min(1),
cashIn: Yup.number()
.label('Cash-in')
.min(percentMin)
.max(percentMax)
.required(),
cashOut: Yup.number()
.label('Cash-out')
.min(percentMin)
.max(percentMax)
.required(),
fixedFee: Yup.number()
.label('Cash-in fixed fee')
.min(0)
.max(highestBill)
.required(),
minimumTx: Yup.number()
.label('Minimum Tx')
.min(0)
.max(highestBill)
.required(),
cashOutFixedFee: Yup.number()
.label('Cash-out fixed fee')
.min(0)
.max(highestBill)
.required()
})
}
const getTextStyle = (obj, isEditing) => {
return { color: obj.default ? primaryColor : secondaryColorDark }
}
const commissionsList = (auxData, currency, auxElements) => {
const getData = R.path(R.__, auxData)
return getListCommissionsFields(getData, currency, defaults)
}
const getListCommissionsFields = (getData, currency, defaults) => {
const machineData = [ALL_MACHINES].concat(getData(['machines']))
return [
{
name: 'machine',
width: 196,
size: 'sm',
view: getView(machineData, 'name', 'deviceId'),
editable: false
},
{
name: 'cryptoCurrencies',
display: 'Crypto Currency',
width: 150,
view: R.prop(0),
size: 'sm',
editable: false
},
{
header: cashInHeader,
name: 'cashIn',
display: 'Cash-in',
width: 120,
input: NumberInput,
textAlign: 'right',
suffix: '%',
textStyle: obj => getTextStyle(obj),
inputProps: {
decimalPlaces: 3
}
},
{
header: cashOutHeader,
name: 'cashOut',
display: 'Cash-out',
width: 126,
input: NumberInput,
textAlign: 'right',
greenText: true,
suffix: '%',
textStyle: obj => getTextStyle(obj),
inputProps: {
decimalPlaces: 3
}
},
{
name: 'fixedFee',
display: 'Fixed fee',
width: 140,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
suffix: currency,
textStyle: obj => getTextStyle(obj),
inputProps: {
decimalPlaces: 2
}
},
{
name: 'minimumTx',
display: 'Minimum Tx',
width: 140,
input: NumberInput,
doubleHeader: 'Cash-in only',
textAlign: 'right',
suffix: currency,
textStyle: obj => getTextStyle(obj),
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
}
}
]
}
export {
mainFields,
overrides,
getSchema,
getOverridesSchema,
defaults,
overridesDefaults,
getOrder,
getCommissions,
getListCommissionsSchema,
commissionsList
}

View file

@ -0,0 +1,3 @@
import Commissions from './Commissions'
export default Commissions

View file

@ -0,0 +1,548 @@
import * as R from 'ramda'
import { useState, React } from 'react'
import ImagePopper from 'src/components/ImagePopper'
import { H3, Info3 } from 'src/components/typography'
import CardIcon from 'src/styling/icons/ID/card/comet.svg?react'
import PhoneIcon from 'src/styling/icons/ID/phone/comet.svg?react'
import EditIcon from 'src/styling/icons/action/edit/comet.svg?react'
import * as Yup from 'yup'
import { TextInput } from 'src/components/inputs/formik'
import {
OVERRIDE_AUTHORIZED,
OVERRIDE_REJECTED
} from 'src/pages/Customers/components/consts'
import { onlyFirstToUpper } from 'src/utils/string'
import { EditableCard } from './components'
import {
customerDataElements,
customerDataSchemas,
formatDates,
tryFormatDate,
getFormattedPhone
} from './helper'
const IMAGE_WIDTH = 165
const IMAGE_HEIGHT = 32
const POPUP_IMAGE_WIDTH = 360
const POPUP_IMAGE_HEIGHT = 240
const Photo = ({ src }) => {
return (
<ImagePopper
src={src}
width={IMAGE_WIDTH}
height={IMAGE_HEIGHT}
popupWidth={POPUP_IMAGE_WIDTH}
popupHeight={POPUP_IMAGE_HEIGHT}
/>
)
}
const CustomerData = ({
locale,
customer = {},
updateCustomer,
replacePhoto,
editCustomer,
deleteEditedData,
updateCustomRequest,
authorizeCustomRequest,
updateCustomEntry,
checkAgainstSanctions
}) => {
const [previewPhoto, setPreviewPhoto] = useState(null)
const [previewCard, setPreviewCard] = useState(null)
const idData = R.path(['idCardData'])(customer)
const rawExpirationDate = R.path(['expirationDate'])(idData)
const rawDob = R.path(['dateOfBirth'])(idData)
const sanctions = R.path(['sanctions'])(customer)
const sanctionsAt = R.path(['sanctionsAt'])(customer)
const sanctionsDisplay = !sanctionsAt
? 'Not checked yet'
: sanctions
? 'Passed'
: 'Failed'
const sortByName = R.sortBy(
R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name']))
)
const customFields = []
const customRequirements = []
const customInfoRequests = sortByName(
R.path(['customInfoRequests'])(customer) ?? []
)
const phone = R.path(['phone'])(customer)
const email = R.path(['email'])(customer)
const smsData = R.path(['subscriberInfo'])(customer)
const isEven = elem => elem % 2 === 0
const getVisibleCards = R.filter(elem => elem.isAvailable)
const initialValues = {
idCardData: {
firstName: R.path(['firstName'])(idData) ?? '',
lastName: R.path(['lastName'])(idData) ?? '',
documentNumber: R.path(['documentNumber'])(idData) ?? '',
dateOfBirth: tryFormatDate(rawDob),
gender: R.path(['gender'])(idData) ?? '',
country: R.path(['country'])(idData) ?? '',
expirationDate: tryFormatDate(rawExpirationDate)
},
usSsn: {
usSsn: customer.usSsn ?? ''
},
frontCamera: {
frontCamera: null
},
idCardPhoto: {
idCardPhoto: null
},
email: {
email
},
smsData: {
phoneNumber: getFormattedPhone(phone, locale.country)
}
}
const smsDataElements = [
{
name: 'phoneNumber',
label: 'Phone number',
component: TextInput,
editable: false
}
]
const smsDataSchema = {
smsData: Yup.lazy(values => {
const additionalData = R.omit(['phoneNumber'])(values)
const fields = R.keys(additionalData)
if (R.length(fields) === 2) {
return Yup.object().shape({
[R.head(fields)]: Yup.string().required(),
[R.last(fields)]: Yup.string().required()
})
}
})
}
const cards = [
{
fields: customerDataElements.idCardData,
title: 'ID Scan',
titleIcon: <CardIcon />,
state: R.path(['idCardDataOverride'])(customer),
authorize: () =>
updateCustomer({ idCardDataOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardDataOverride: OVERRIDE_REJECTED }),
deleteEditedData: () => deleteEditedData({ idCardData: null }),
save: values =>
editCustomer({
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
},
{
fields: smsDataElements,
title: 'SMS data',
titleIcon: <PhoneIcon />,
state: R.path(['phoneOverride'])(customer),
authorize: () => updateCustomer({ phoneOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ phoneOverride: OVERRIDE_REJECTED }),
save: values => {
editCustomer({
subscriberInfo: {
result: R.merge(smsData, R.omit(['phoneNumber'])(values))
}
})
},
validationSchema: smsDataSchema.smsData,
initialValues: initialValues.smsData,
isAvailable: !R.isNil(phone),
hasAdditionalData: !R.isNil(smsData) && !R.isEmpty(smsData),
editable: false
},
{
title: 'Email',
fields: customerDataElements.email,
titleIcon: <CardIcon />,
// state: R.path(['emailOverride'])(customer),
// authorize: () => updateCustomer({ emailOverride: OVERRIDE_AUTHORIZED }),
// reject: () => updateCustomer({ emailOverride: OVERRIDE_REJECTED }),
save: values => editCustomer(values),
deleteEditedData: () => deleteEditedData({ email: null }),
initialValues: initialValues.email,
isAvailable: !R.isNil(customer.email),
editable: false
},
{
title: 'Name',
titleIcon: <EditIcon />,
isAvailable: false,
editable: true
},
{
title: 'Sanctions check',
titleIcon: <EditIcon />,
state: R.path(['sanctionsOverride'])(customer),
authorize: () =>
updateCustomer({ sanctionsOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ sanctionsOverride: OVERRIDE_REJECTED }),
children: () => <Info3>{sanctionsDisplay}</Info3>,
isAvailable: !R.isNil(sanctions),
editable: true
},
{
fields: customerDataElements.frontCamera,
title: 'Front facing camera',
titleIcon: <EditIcon />,
state: R.path(['frontCameraOverride'])(customer),
authorize: () =>
updateCustomer({ frontCameraOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ frontCameraOverride: OVERRIDE_REJECTED }),
save: values => {
setPreviewPhoto(null)
return replacePhoto({
newPhoto: values.frontCamera,
photoType: 'frontCamera'
})
},
cancel: () => setPreviewPhoto(null),
deleteEditedData: () => deleteEditedData({ frontCamera: null }),
children: values => {
if (values.frontCamera !== previewPhoto) {
setPreviewPhoto(values.frontCamera)
}
return customer.frontCameraPath ? (
<Photo
src={
!R.isNil(previewPhoto)
? URL.createObjectURL(previewPhoto)
: `/front-camera-photo/${R.path(['frontCameraPath'])(customer)}`
}
/>
) : null
},
hasImage: true,
validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera,
isAvailable: !R.isNil(customer.frontCameraPath),
editable: true
},
{
fields: customerDataElements.idCardPhoto,
title: 'ID card image',
titleIcon: <EditIcon />,
state: R.path(['idCardPhotoOverride'])(customer),
authorize: () =>
updateCustomer({ idCardPhotoOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ idCardPhotoOverride: OVERRIDE_REJECTED }),
save: values => {
setPreviewCard(null)
return replacePhoto({
newPhoto: values.idCardPhoto,
photoType: 'idCardPhoto'
})
},
cancel: () => setPreviewCard(null),
deleteEditedData: () => deleteEditedData({ idCardPhoto: null }),
children: values => {
if (values.idCardPhoto !== previewCard) {
setPreviewCard(values.idCardPhoto)
}
return customer.idCardPhotoPath ? (
<Photo
src={
!R.isNil(previewCard)
? URL.createObjectURL(previewCard)
: `/id-card-photo/${R.path(['idCardPhotoPath'])(customer)}`
}
/>
) : null
},
hasImage: true,
validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto,
isAvailable: !R.isNil(customer.idCardPhotoPath),
editable: true
},
{
fields: customerDataElements.usSsn,
title: 'US SSN',
titleIcon: <CardIcon />,
state: R.path(['usSsnOverride'])(customer),
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,
isAvailable: !R.isNil(customer.usSsn),
editable: true
}
]
R.forEach(it => {
customRequirements.push({
fields: [
{
name: it.customInfoRequest.id,
label: it.customInfoRequest.customRequest.name,
value: it.customerData.data ?? '',
component: TextInput,
editable: true
}
],
title: it.customInfoRequest.customRequest.name,
titleIcon: <CardIcon />,
state: R.path(['override'])(it),
authorize: () =>
authorizeCustomRequest({
variables: {
customerId: it.customerId,
infoRequestId: it.customInfoRequest.id,
override: OVERRIDE_AUTHORIZED
}
}),
reject: () =>
authorizeCustomRequest({
variables: {
customerId: it.customerId,
infoRequestId: it.customInfoRequest.id,
override: OVERRIDE_REJECTED
}
}),
save: values => {
updateCustomRequest({
variables: {
customerId: it.customerId,
infoRequestId: it.customInfoRequest.id,
data: {
info_request_id: it.customInfoRequest.id,
data: values[it.customInfoRequest.id]
}
}
})
},
deleteEditedData: () => {},
validationSchema: Yup.object().shape({
[it.customInfoRequest.id]: Yup.string()
}),
initialValues: {
[it.customInfoRequest.id]: it.customerData.data ?? ''
}
})
}, customInfoRequests)
R.forEach(
it => {
customFields.push({
fields: [
{
name: it.label,
label: it.label,
value: it.value ?? '',
component: TextInput,
editable: true
}
],
title: it.label,
titleIcon: <EditIcon />,
save: values => {
updateCustomEntry({
fieldId: it.id,
value: values[it.label]
})
},
deleteEditedData: () => {},
validationSchema: Yup.object().shape({
[it.label]: Yup.string()
}),
initialValues: {
[it.label]: it.value ?? ''
}
})
},
R.path(['customFields'])(customer) ?? []
)
R.forEach(
it => {
initialValues.smsData[it] = smsData[it]
smsDataElements.push({
name: it,
label: onlyFirstToUpper(it),
component: TextInput,
editable: false
})
},
R.keys(smsData) ?? []
)
const externalCompliance = R.map(it => ({
fields: [
{
name: 'externalId',
label: 'Third Party ID',
editable: false
},
{
name: 'lastKnownStatus',
label: 'Last Known Status',
editable: false
},
{
name: 'lastUpdated',
label: 'Last Updated',
editable: false
}
],
titleIcon: <CardIcon />,
title: `External Info [${it.service}]`,
initialValues: it ?? {
externalId: '',
lastKnownStatus: '',
lastUpdated: ''
}
}))(customer.externalCompliance ?? [])
const editableCard = (
{
title,
authorize,
reject,
state,
titleIcon,
fields,
save,
cancel,
deleteEditedData,
children,
validationSchema,
initialValues,
hasImage,
hasAdditionalData,
editable,
checkAgainstSanctions
},
idx
) => {
return (
<div className="mb-4">
<EditableCard
title={title}
key={idx}
authorize={authorize}
reject={reject}
state={state}
titleIcon={titleIcon}
hasImage={hasImage}
hasAdditionalData={hasAdditionalData}
fields={fields}
validationSchema={validationSchema}
initialValues={initialValues}
save={save}
cancel={cancel}
deleteEditedData={deleteEditedData}
checkAgainstSanctions={checkAgainstSanctions}
editable={editable}>
{children}
</EditableCard>
</div>
)
}
const nonEditableCard = (
{ title, state, titleIcon, fields, hasImage, initialValues, children },
idx
) => {
return (
<div className="mb-4">
<EditableCard
title={title}
key={idx}
state={state}
initialValues={initialValues}
titleIcon={titleIcon}
editable={false}
hasImage={hasImage}
fields={fields}>
{children}
</EditableCard>
</div>
)
}
const visibleCards = getVisibleCards(cards)
const Separator = ({ title }) => (
<div className="w-full my-4 col-span-all">
<div className="flex items-center">
<div className="h-px bg-comet grow-1"></div>
<span className="mx-4 text-comet font-medium">{title}</span>
<div className="h-px bg-comet grow-5"></div>
</div>
</div>
)
return (
<div>
<H3 className="mt-1 mb-7">{'Customer data'}</H3>
<div>
{customer && (
<div className="columns-2 gap-4">
{visibleCards.map((elem, idx) => {
return editableCard(elem, idx)
})}
{!R.isEmpty(customFields) && (
<>
<Separator title="Custom data entry" />
{customFields.map((elem, idx) => {
return editableCard(elem, idx)
})}
</>
)}
{!R.isEmpty(customRequirements) && (
<>
<Separator title="Custom requirements" />
{customRequirements.map((elem, idx) => {
return editableCard(elem, idx)
})}
</>
)}
{!R.isEmpty(externalCompliance) && (
<>
<Separator title="External compliance information" />
{externalCompliance.map((elem, idx) => {
return nonEditableCard(elem, idx)
})}
</>
)}
</div>
)}
</div>
</div>
)
}
export default CustomerData

View file

@ -0,0 +1,84 @@
import * as R from 'ramda'
import { React, useState } from 'react'
import { H3 } from 'src/components/typography'
import NewNoteCard from './components/notes/NewNoteCard'
import NewNoteModal from './components/notes/NewNoteModal'
import NoteCard from './components/notes/NoteCard'
import NoteEdit from './components/notes/NoteEdit'
const CustomerNotes = ({
customer,
createNote,
deleteNote,
editNote,
timezone
}) => {
const [openModal, setOpenModal] = useState(false)
const [editing, setEditing] = useState(null)
const customerNotes = R.sort(
(a, b) => new Date(b?.created).getTime() - new Date(a?.created).getTime(),
customer.notes ?? []
)
const handleModalClose = () => {
setOpenModal(false)
}
const handleModalSubmit = it => {
createNote(it)
return handleModalClose()
}
const cancelNoteEditing = () => {
setEditing(null)
}
const submitNoteEditing = it => {
if (!R.equals(it.newContent, it.oldContent)) {
editNote({
noteId: it.noteId,
newContent: it.newContent
})
}
setEditing(null)
}
return (
<div>
<H3 className="mt-1 mb-7">{'Notes'}</H3>
{R.isNil(editing) && (
<div className="grid grid-cols-[repeat(4,_200px)] gap-5 auto-rows-[200px]">
<NewNoteCard setOpenModal={setOpenModal} />
{customerNotes.map((it, idx) => (
<NoteCard
key={idx}
note={it}
deleteNote={deleteNote}
handleClick={setEditing}
timezone={timezone}
/>
))}
</div>
)}
{!R.isNil(editing) && (
<NoteEdit
note={editing}
cancel={cancelNoteEditing}
edit={submitNoteEditing}
timezone={timezone}
/>
)}
{openModal && (
<NewNoteModal
showModal={openModal}
onClose={handleModalClose}
onSubmit={handleModalSubmit}
/>
)}
</div>
)
}
export default CustomerNotes

View file

@ -0,0 +1,74 @@
import Paper from '@mui/material/Paper'
import { format } from 'date-fns/fp'
import * as R from 'ramda'
import { React, useState } from 'react'
import { InformativeDialog } from 'src/components/InformativeDialog'
import { Label2, H3 } from 'src/components/typography'
import CameraIcon from 'src/styling/icons/ID/photo/comet.svg?react'
import PhotosCarousel from './components/PhotosCarousel'
const CustomerPhotos = ({ photosData, timezone }) => {
const [photosDialog, setPhotosDialog] = useState(false)
const [photoClickedIndex, setPhotoClickIndex] = useState(null)
const orderedPhotosData = !R.isNil(photoClickedIndex)
? R.compose(R.flatten, R.reverse, R.splitAt(photoClickedIndex))(photosData)
: photosData
return (
<div>
<H3 className="mt-1 mb-7">{'Photos & files'}</H3>
<div className="flex flex-wrap gap-4">
{photosData.map((elem, idx) => (
<PhotoCard
key={idx}
date={elem.date}
src={`/${elem.photoDir}/${elem.path}`}
setPhotosDialog={setPhotosDialog}
setPhotoClickIndex={setPhotoClickIndex}
/>
))}
</div>
<InformativeDialog
open={photosDialog}
title={`Photo roll`}
data={
<PhotosCarousel photosData={orderedPhotosData} timezone={timezone} />
}
onDissmised={() => {
setPhotosDialog(false)
setPhotoClickIndex(null)
}}
/>
</div>
)
}
export const PhotoCard = ({
idx,
date,
src,
setPhotosDialog,
setPhotoClickIndex
}) => {
return (
<Paper
className="cursor-pointer overflow-hidden"
onClick={() => {
setPhotoClickIndex(idx)
setPhotosDialog(true)
}}>
<img
className="w-56 h-50 object-cover object-center block"
src={src}
alt=""
/>
<div className="flex p-3 gap-3">
<CameraIcon />
<Label2 noMargin>{format('yyyy-MM-dd', new Date(date))}</Label2>
</div>
</Paper>
)
}
export default CustomerPhotos

View file

@ -0,0 +1,685 @@
import { useQuery, useMutation, useLazyQuery, gql } from '@apollo/client'
import Breadcrumbs from '@mui/material/Breadcrumbs'
import Switch from '@mui/material/Switch'
import NavigateNextIcon from '@mui/icons-material/NavigateNext'
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import { useHistory, useParams } from 'react-router-dom'
import { Label1, Label2 } from 'src/components/typography'
import AuthorizeReversedIcon from 'src/styling/icons/button/authorize/white.svg?react'
import AuthorizeIcon from 'src/styling/icons/button/authorize/zodiac.svg?react'
import BlockReversedIcon from 'src/styling/icons/button/block/white.svg?react'
import BlockIcon from 'src/styling/icons/button/block/zodiac.svg?react'
import DataReversedIcon from 'src/styling/icons/button/data/white.svg?react'
import DataIcon from 'src/styling/icons/button/data/zodiac.svg?react'
import { ActionButton } from 'src/components/buttons'
import {
OVERRIDE_AUTHORIZED,
OVERRIDE_REJECTED
} from 'src/pages/Customers/components/consts'
// TODO: Enable for next release
// import DiscountReversedIcon from 'src/styling/icons/button/discount/white.svg?react'
// import Discount from 'src/styling/icons/button/discount/zodiac.svg?react'
import { fromNamespace, namespaces } from 'src/utils/config'
import CustomerData from './CustomerData'
import CustomerNotes from './CustomerNotes'
import CustomerPhotos from './CustomerPhotos'
import {
CustomerDetails,
TransactionsList,
CustomerSidebar,
Wizard
} from './components'
import { getFormattedPhone, getName, formatPhotosData } from './helper'
const GET_CUSTOMER = gql`
query customer($customerId: ID!) {
config
customer(customerId: $customerId) {
id
authorizedOverride
frontCameraPath
frontCameraAt
frontCameraOverride
phone
email
isAnonymous
smsOverride
idCardData
idCardDataOverride
idCardDataExpiration
idCardPhotoPath
idCardPhotoOverride
idCardPhotoAt
usSsn
usSsnOverride
sanctions
sanctionsAt
sanctionsOverride
totalTxs
totalSpent
lastActive
lastUsedMachineName
lastTxFiat
lastTxFiatCode
lastTxClass
daysSuspended
isSuspended
isTestCustomer
subscriberInfo
phoneOverride
externalCompliance
customFields {
id
label
value
}
notes {
id
customerId
title
content
created
lastEditedAt
}
transactions {
txClass
id
fiat
fiatCode
cryptoAtoms
cryptoCode
created
machineName
errorMessage: error
error: errorCode
txCustomerPhotoAt
txCustomerPhotoPath
}
customInfoRequests {
customerId
override
overrideBy
overrideAt
customerData
customInfoRequest {
id
enabled
customRequest
}
}
}
}
`
const SET_CUSTOMER = gql`
mutation setCustomer($customerId: ID!, $customerInput: CustomerInput) {
setCustomer(customerId: $customerId, customerInput: $customerInput) {
id
authorizedOverride
frontCameraPath
frontCameraOverride
phone
email
smsOverride
idCardData
idCardDataOverride
idCardDataExpiration
idCardPhotoPath
idCardPhotoOverride
usSsn
usSsnOverride
sanctions
sanctionsAt
sanctionsOverride
totalTxs
totalSpent
lastActive
lastTxFiat
lastTxFiatCode
lastTxClass
phoneOverride
externalCompliance
}
}
`
const EDIT_CUSTOMER = gql`
mutation editCustomer($customerId: ID!, $customerEdit: CustomerEdit) {
editCustomer(customerId: $customerId, customerEdit: $customerEdit) {
id
idCardData
usSsn
}
}
`
const REPLACE_CUSTOMER_PHOTO = gql`
mutation replacePhoto(
$customerId: ID!
$photoType: String
$newPhoto: Upload
) {
replacePhoto(
customerId: $customerId
photoType: $photoType
newPhoto: $newPhoto
) {
id
newPhoto
photoType
}
}
`
const DELETE_EDITED_CUSTOMER = gql`
mutation deleteEditedData($customerId: ID!, $customerEdit: CustomerEdit) {
deleteEditedData(customerId: $customerId, customerEdit: $customerEdit) {
id
frontCameraPath
idCardData
idCardPhotoPath
usSsn
}
}
`
const SET_AUTHORIZED_REQUEST = gql`
mutation setAuthorizedCustomRequest(
$customerId: ID!
$infoRequestId: ID!
$override: String!
) {
setAuthorizedCustomRequest(
customerId: $customerId
infoRequestId: $infoRequestId
override: $override
)
}
`
const SET_CUSTOMER_CUSTOM_INFO_REQUEST = gql`
mutation setCustomerCustomInfoRequest(
$customerId: ID!
$infoRequestId: ID!
$data: JSON!
) {
setCustomerCustomInfoRequest(
customerId: $customerId
infoRequestId: $infoRequestId
data: $data
)
}
`
const CREATE_NOTE = gql`
mutation createCustomerNote(
$customerId: ID!
$title: String!
$content: String!
) {
createCustomerNote(
customerId: $customerId
title: $title
content: $content
)
}
`
const DELETE_NOTE = gql`
mutation deleteCustomerNote($noteId: ID!) {
deleteCustomerNote(noteId: $noteId)
}
`
const EDIT_NOTE = gql`
mutation editCustomerNote($noteId: ID!, $newContent: String!) {
editCustomerNote(noteId: $noteId, newContent: $newContent)
}
`
const ENABLE_TEST_CUSTOMER = gql`
mutation enableTestCustomer($customerId: ID!) {
enableTestCustomer(customerId: $customerId)
}
`
const DISABLE_TEST_CUSTOMER = gql`
mutation disableTestCustomer($customerId: ID!) {
disableTestCustomer(customerId: $customerId)
}
`
const GET_DATA = gql`
query getData {
config
}
`
const SET_CUSTOM_ENTRY = gql`
mutation addCustomField($customerId: ID!, $label: String!, $value: String!) {
addCustomField(customerId: $customerId, label: $label, value: $value)
}
`
const EDIT_CUSTOM_ENTRY = gql`
mutation saveCustomField($customerId: ID!, $fieldId: ID!, $value: String!) {
saveCustomField(customerId: $customerId, fieldId: $fieldId, value: $value)
}
`
const GET_ACTIVE_CUSTOM_REQUESTS = gql`
query customInfoRequests($onlyEnabled: Boolean) {
customInfoRequests(onlyEnabled: $onlyEnabled) {
id
customRequest
}
}
`
const CHECK_AGAINST_SANCTIONS = gql`
query checkAgainstSanctions($customerId: ID) {
checkAgainstSanctions(customerId: $customerId) {
ofacSanctioned
}
}
`
const CustomerProfile = memo(() => {
const history = useHistory()
const [showCompliance, setShowCompliance] = useState(false)
const [wizard, setWizard] = useState(false)
const [error, setError] = useState(null)
const [clickedItem, setClickedItem] = useState('overview')
const { id: customerId } = useParams()
const {
data: customerResponse,
refetch: getCustomer,
loading: customerLoading
} = useQuery(GET_CUSTOMER, {
variables: { customerId }
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const { data: activeCustomRequests } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
variables: {
onlyEnabled: true
}
})
const [setCustomEntry] = useMutation(SET_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [editCustomEntry] = useMutation(EDIT_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
onCompleted: () => getCustomer()
})
const [editCustomerData] = useMutation(EDIT_CUSTOMER, {
onCompleted: () => getCustomer()
})
const [deleteCustomerEditedData] = useMutation(DELETE_EDITED_CUSTOMER, {
onCompleted: () => getCustomer()
})
const [setCustomer] = useMutation(SET_CUSTOMER, {
onCompleted: () => {
getCustomer()
},
onError: error => setError(error)
})
const [authorizeCustomRequest] = useMutation(SET_AUTHORIZED_REQUEST, {
onCompleted: () => getCustomer()
})
const [setCustomerCustomInfoRequest] = useMutation(
SET_CUSTOMER_CUSTOM_INFO_REQUEST,
{
onCompleted: () => getCustomer()
}
)
const [createNote] = useMutation(CREATE_NOTE, {
onCompleted: () => getCustomer()
})
const [deleteNote] = useMutation(DELETE_NOTE, {
onCompleted: () => getCustomer()
})
const [editNote] = useMutation(EDIT_NOTE, {
onCompleted: () => getCustomer()
})
const saveCustomEntry = it => {
setCustomEntry({
variables: {
customerId,
label: it.title,
value: it.data
}
})
setWizard(null)
}
const updateCustomEntry = it => {
editCustomEntry({
variables: {
customerId,
fieldId: it.fieldId,
value: it.value
}
})
}
const [enableTestCustomer] = useMutation(ENABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const [disableTestCustomer] = useMutation(DISABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const [checkAgainstSanctions] = useLazyQuery(CHECK_AGAINST_SANCTIONS, {
onCompleted: () => getCustomer()
})
const updateCustomer = it =>
setCustomer({
variables: {
customerId,
customerInput: it
}
})
const replacePhoto = it => {
replaceCustomerPhoto({
variables: {
customerId,
newPhoto: it.newPhoto,
photoType: it.photoType
}
})
setWizard(null)
}
const editCustomer = it => {
editCustomerData({
variables: {
customerId,
customerEdit: it
}
})
setWizard(null)
}
const deleteEditedData = it =>
deleteCustomerEditedData({
variables: {
customerId,
customerEdit: it
}
})
const createCustomerNote = it =>
createNote({
variables: {
customerId,
title: it.title,
content: it.content
}
})
const deleteCustomerNote = it =>
deleteNote({
variables: {
noteId: it.noteId
}
})
const editCustomerNote = it =>
editNote({
variables: {
noteId: it.noteId,
newContent: it.newContent
}
})
const onClickSidebarItem = code => setClickedItem(code)
const configData = R.path(['config'])(customerResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const customerData = R.path(['customer'])(customerResponse) ?? []
const rawTransactions = R.path(['transactions'])(customerData) ?? []
const sortedTransactions = R.sort(R.descend(R.prop('cryptoAtoms')))(
rawTransactions
)
const name = getName(customerData)
const blocked =
R.path(['authorizedOverride'])(customerData) === OVERRIDE_REJECTED
const isSuspended = customerData.isSuspended
const isCustomerData = clickedItem === 'customerData'
const isOverview = clickedItem === 'overview'
const isNotes = clickedItem === 'notes'
const isPhotos = clickedItem === 'photos'
const frontCameraData = R.pick(['frontCameraPath', 'frontCameraAt'])(
customerData
)
const txPhotosData =
sortedTransactions &&
R.map(R.pick(['id', 'txCustomerPhotoPath', 'txCustomerPhotoAt']))(
sortedTransactions
)
const photosData = formatPhotosData(R.append(frontCameraData, txPhotosData))
const IDphotoData = customerData.idCardPhotoPath
? [
{
photoDir: 'id-card-photo',
path: customerData.idCardPhotoPath,
date: customerData.idCardPhotoAt
}
]
: []
const loading = customerLoading || configLoading
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const customInfoRequirementOptions =
activeCustomRequests?.customInfoRequests?.map(it => ({
value: it.id,
display: it.customRequest.name
})) ?? []
const email = R.path(['email'])(customerData)
const phone = R.path(['phone'])(customerData)
return (
<>
<Breadcrumbs
className="my-5"
separator={<NavigateNextIcon fontSize="small" />}
aria-label="breadcrumb">
<Label1
noMargin
className="cursor-pointer text-comet"
onClick={() => history.push('/compliance/customers')}>
Customers
</Label1>
<Label2 noMargin className="cursor-pointer text-comet">
{name.length
? name
: email?.length
? email
: getFormattedPhone(phone, locale.country)}
</Label2>
</Breadcrumbs>
<div className="flex gap-20">
<div className="w-55 flex flex-col gap-6">
{!loading && !customerData.isAnonymous && (
<>
<CustomerSidebar
isSelected={code => code === clickedItem}
onClick={onClickSidebarItem}
/>
<div>
<Label1 noMargin className="text-comet my-1">
Actions
</Label1>
<div className="flex flex-col gap-1">
<ActionButton
center
color="primary"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => setWizard(true)}>
{`Manual data entry`}
</ActionButton>
{/* <ActionButton
color="primary"
Icon={Discount}
InverseIcon={DiscountReversedIcon}
onClick={() => {}}>
{`Add individual discount`}
</ActionButton> */}
{isSuspended && (
<ActionButton
center
color="primary"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeReversedIcon}
onClick={() =>
updateCustomer({
suspendedUntil: null
})
}>
{`Unsuspend customer`}
</ActionButton>
)}
<ActionButton
color="primary"
center
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
}
onClick={() =>
updateCustomer({
authorizedOverride: blocked
? OVERRIDE_AUTHORIZED
: OVERRIDE_REJECTED
})
}>
{`${blocked ? 'Authorize' : 'Block'} customer`}
</ActionButton>
</div>
</div>
<div>
<Label1 className="text-comet my-1">
{`Special user status`}
</Label1>
<div className="flex flex-col">
<div className="flex items-center bg-zircon px-1 rounded-lg">
<Switch
checked={!!R.path(['isTestCustomer'])(customerData)}
value={!!R.path(['isTestCustomer'])(customerData)}
onChange={() =>
R.path(['isTestCustomer'])(customerData)
? disableTestCustomer()
: enableTestCustomer()
}
/>
<Label1 noMargin>Test user</Label1>
</div>
</div>
</div>
</>
)}
</div>
<div className="flex-1">
{isOverview && (
<div>
<div className="flex justify-between mb-5">
<CustomerDetails
customer={customerData}
photosData={photosData}
locale={locale}
setShowCompliance={() => setShowCompliance(!showCompliance)}
timezone={timezone}
/>
</div>
<div>
<TransactionsList
customer={customerData}
data={sortedTransactions}
loading={loading}
/>
</div>
</div>
)}
{isCustomerData && (
<div>
<CustomerData
locale={locale}
customer={customerData}
updateCustomer={updateCustomer}
replacePhoto={replacePhoto}
editCustomer={editCustomer}
deleteEditedData={deleteEditedData}
updateCustomRequest={setCustomerCustomInfoRequest}
authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry}
checkAgainstSanctions={checkAgainstSanctions}
/>
</div>
)}
{isNotes && (
<div>
<CustomerNotes
customer={customerData}
createNote={createCustomerNote}
deleteNote={deleteCustomerNote}
editNote={editCustomerNote}
timezone={timezone}></CustomerNotes>
</div>
)}
{isPhotos && (
<div>
<CustomerPhotos
photosData={R.concat(photosData, IDphotoData)}
timezone={timezone}
/>
</div>
)}
</div>
{wizard && (
<Wizard
error={error?.message}
save={saveCustomEntry}
addPhoto={replacePhoto}
addCustomerData={editCustomer}
onClose={() => setWizard(null)}
customInfoRequirementOptions={customInfoRequirementOptions}
/>
)}
</div>
</>
)
})
export default CustomerProfile

View file

@ -0,0 +1,256 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import SearchBox from 'src/components/SearchBox'
import SearchFilter from 'src/components/SearchFilter'
import TitleSection from 'src/components/layout/TitleSection'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { Link } from 'src/components/buttons'
import { fromNamespace, namespaces } from 'src/utils/config'
import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal'
import { getAuthorizedStatus } from './helper'
const GET_CUSTOMER_FILTERS = gql`
query filters {
customerFilters {
type
value
}
}
`
const GET_CUSTOMERS = gql`
query configAndCustomers(
$phone: String
$name: String
$email: String
$address: String
$id: String
) {
config
customers(
phone: $phone
email: $email
name: $name
address: $address
id: $id
) {
id
idCardData
phone
email
totalTxs
totalSpent
lastActive
lastTxFiat
lastTxFiatCode
lastTxClass
authorizedOverride
frontCameraPath
frontCameraOverride
idCardPhotoPath
idCardPhotoOverride
idCardData
idCardDataOverride
usSsn
usSsnOverride
sanctions
sanctionsOverride
daysSuspended
isSuspended
customInfoRequests {
customerId
infoRequestId
override
overrideAt
overrideBy
customerData
customInfoRequest {
id
enabled
customRequest
}
}
}
customInfoRequests {
id
}
}
`
const CREATE_CUSTOMER = gql`
mutation createCustomer($phoneNumber: String) {
createCustomer(phoneNumber: $phoneNumber) {
phone
}
}
`
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const Customers = () => {
const history = useHistory()
const handleCustomerClicked = customer =>
history.push(`/compliance/customer/${customer.id}`)
const [filteredCustomers, setFilteredCustomers] = useState([])
const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false)
const {
data: customersResponse,
loading: customerLoading,
refetch
} = useQuery(GET_CUSTOMERS, {
variables,
onCompleted: data => setFilteredCustomers(R.path(['customers'])(data))
})
const { data: filtersResponse, loading: loadingFilters } =
useQuery(GET_CUSTOMER_FILTERS)
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [
{
query: GET_CUSTOMERS,
variables
}
]
})
const configData = R.path(['config'])(customersResponse) ?? []
const customRequirementsData =
R.path(['customInfoRequests'], customersResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
const setAuthorizedStatus = c =>
R.assoc(
'authorizedStatus',
getAuthorizedStatus(c, triggers, customRequirementsData),
c
)
const byAuthorized = c => (c.authorizedStatus.label === 'Pending' ? 0 : 1)
const byLastActive = c => new Date(R.prop('lastActive', c) ?? '0')
const customersData = R.pipe(
R.map(setAuthorizedStatus),
R.sortWith([R.ascend(byAuthorized), R.descend(byLastActive)])
)(filteredCustomers ?? [])
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)
setFilters(filters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter)
)(filters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
email: filtersObject.email,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const filterOptions = R.path(['customerFilters'])(filtersResponse)
return (
<>
<TitleSection
title="Customers"
appendix={
<div className="flex ml-4">
<SearchBox
loading={loadingFilters}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search customers'}
onChange={onFilterChange}
/>
</div>
}
appendixRight={
<div className="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}>
Add new user
</Link>
</div>
}
labels={[
{ label: 'Cash-in', icon: <TxInIcon /> },
{ label: 'Cash-out', icon: <TxOutIcon /> }
]}
/>
{filters.length > 0 && (
<SearchFilter
entries={customersData.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<CustomersList
data={customersData}
locale={locale}
onClick={handleCustomerClicked}
loading={customerLoading}
triggers={triggers}
customRequests={customRequirementsData}
/>
<CreateCustomerModal
showModal={showCreationModal}
handleClose={() => setShowCreationModal(false)}
locale={locale}
onSubmit={createNewCustomer}
/>
</>
)
}
export default Customers

View file

@ -0,0 +1,87 @@
import { format } from 'date-fns/fp'
import * as R from 'ramda'
import React from 'react'
import { MainStatus } from 'src/components/Status'
import DataTable from 'src/components/tables/DataTable'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { getFormattedPhone, getName } from './helper'
const CustomersList = ({
data,
locale,
onClick,
loading,
triggers,
customRequests
}) => {
const elements = [
{
header: 'Phone/email',
width: 199,
view: it => `${getFormattedPhone(it.phone, locale.country) || ''}
${it.email || ''}`
},
{
header: 'Name',
width: 241,
view: getName
},
{
header: 'Total Txs',
width: 126,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`
},
{
header: 'Total spent',
width: 152,
textAlign: 'right',
view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
},
{
header: 'Last active',
width: 133,
view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
},
{
header: 'Last transaction',
width: 161,
textAlign: 'right',
view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode)
const LastTxIcon = it.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const lastIcon = <LastTxIcon className="ml-3" />
return (
<>
{hasLastTx &&
`${parseFloat(it.lastTxFiat)} ${it.lastTxFiatCode ?? ''}`}
{hasLastTx && lastIcon}
</>
)
}
},
{
header: 'Status',
width: 191,
view: it => <MainStatus statuses={[it.authorizedStatus]} />
}
]
return (
<>
<DataTable
loading={loading}
emptyText="No customers so far"
elements={elements}
data={data}
onClick={onClick}
/>
</>
)
}
export default CustomersList

View file

@ -0,0 +1,130 @@
import { Form, Formik } from 'formik'
import * as R from 'ramda'
import React, { useState, Fragment } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons'
import {
entryType,
customElements,
requirementElements,
formatDates,
REQUIREMENT,
ID_CARD_DATA
} from './helper'
const LAST_STEP = 2
const getStep = (step, selectedValues) => {
const elements =
selectedValues?.entryType === REQUIREMENT &&
!R.isNil(selectedValues?.requirement)
? requirementElements[selectedValues?.requirement]
: customElements[selectedValues?.dataType]
switch (step) {
case 1:
return entryType
case 2:
return elements
default:
return Fragment
}
}
const Wizard = ({
onClose,
save,
error,
customInfoRequirementOptions,
addCustomerData,
addPhoto
}) => {
const [selectedValues, setSelectedValues] = useState(null)
const [{ step, config }, setState] = useState({
step: 1
})
const isIdCardData = values => values?.requirement === ID_CARD_DATA
const formatCustomerData = (it, newConfig) =>
isIdCardData(newConfig) ? { [newConfig.requirement]: formatDates(it) } : it
const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, selectedValues)
const onContinue = async it => {
const newConfig = R.mergeRight(config, stepOptions.schema.cast(it))
setSelectedValues(newConfig)
if (isLastStep) {
switch (stepOptions.saveType) {
case 'customerData':
return addCustomerData(formatCustomerData(it, newConfig))
case 'customerDataUpload':
return addPhoto({
newPhoto: R.head(R.values(it)),
photoType: R.head(R.keys(it))
})
case 'customEntry':
return save(newConfig)
case 'customInfoRequirement':
return
// case 'customerEntryUpload':
// break
default:
break
}
}
setState({
step: step + 1,
config: newConfig
})
}
return (
<>
<Modal
title="Manual data entry"
handleClose={onClose}
width={520}
height={520}
open={true}>
<Stepper steps={LAST_STEP} currentStep={step} className="my-4" />
<Formik
validateOnBlur={false}
validateOnChange={false}
enableReinitialize
onSubmit={onContinue}
initialValues={stepOptions.initialValues}
validationSchema={stepOptions.schema}>
{({ errors }) => (
<Form className="h-full flex flex-col">
<stepOptions.Component
selectedValues={selectedValues}
customInfoRequirementOptions={customInfoRequirementOptions}
errors={errors}
{...stepOptions.props}
/>
<div className="flex mt-auto mb-6">
{error && <ErrorMessage>Failed to save</ErrorMessage>}
{Object.keys(errors).length > 0 && (
<ErrorMessage>{Object.values(errors)[0]}</ErrorMessage>
)}
<Button className="ml-auto" type="submit">
{isLastStep ? 'Add Data' : 'Next'}
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
</>
)
}
export default Wizard

View file

@ -0,0 +1,106 @@
import { Field, Form, Formik } from 'formik'
import { parsePhoneNumberWithError } from 'libphonenumber-js'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { H1 } from 'src/components/typography'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
const getValidationSchema = countryCodes =>
Yup.object().shape({
phoneNumber: Yup.string()
.required('A phone number is required')
.test('is-valid-number', 'That is not a valid phone number', value => {
try {
return countryCodes.some(countryCode =>
parsePhoneNumberWithError(value, countryCode).isValid()
)
} catch (e) {
return false
}
})
.trim()
})
const formatPhoneNumber = (countryCodes, numberStr) => {
const matchedCountry = R.find(it => {
const number = parsePhoneNumberWithError(numberStr, it)
return number.isValid()
}, countryCodes)
return parsePhoneNumberWithError(numberStr, matchedCountry).number
}
const initialValues = {
phoneNumber: ''
}
const getErrorMsg = (formikErrors, formikTouched) => {
if (!formikErrors || !formikTouched) return null
if (formikErrors.phoneNumber && formikTouched.phoneNumber)
return formikErrors.phoneNumber
return null
}
const CreateCustomerModal = ({ showModal, handleClose, onSubmit, locale }) => {
const possibleCountries = R.append(
locale?.country,
R.map(it => it.country, locale?.overrides ?? [])
)
return (
<Modal
closeOnBackdropClick={true}
width={600}
height={300}
handleClose={handleClose}
open={showModal}>
<Formik
validationSchema={getValidationSchema(possibleCountries)}
initialValues={initialValues}
validateOnChange={false}
onSubmit={values => {
onSubmit({
variables: {
phoneNumber: formatPhoneNumber(
possibleCountries,
values.phoneNumber
)
}
})
}}>
{({ errors, touched }) => (
<Form
id="customer-registration-form"
className="flex flex-col h-full">
<H1 className="-mt-2">Create new customer</H1>
<Field
component={TextInput}
name="phoneNumber"
width={338}
autoFocus
label="Phone number"
/>
<div className="flex flex-row mt-auto mb-6">
{getErrorMsg(errors, touched) && (
<ErrorMessage>{getErrorMsg(errors, touched)}</ErrorMessage>
)}
<Button
type="submit"
form="customer-registration-form"
className="ml-auto">
Finish
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)
}
export default CreateCustomerModal

View file

@ -0,0 +1,83 @@
import * as R from 'ramda'
import React, { memo } from 'react'
import { H2, Label1, P } from 'src/components/typography'
import IdIcon from 'src/styling/icons/ID/card/zodiac.svg?react'
import { getFormattedPhone, getName } from '../helper'
import PhotosCard from './PhotosCard'
const CustomerDetails = memo(({ customer, photosData, locale, timezone }) => {
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
const usSsn = R.path(['usSsn'])(customer)
const name = getName(customer)
const email = R.path(['email'])(customer)
const phone = R.path(['phone'])(customer)
const elements = [
{
header: 'Phone number',
size: 172,
value: getFormattedPhone(customer.phone, locale.country)
}
]
if (idNumber)
elements.push({
header: 'ID number',
size: 172,
value: idNumber
})
if (usSsn)
elements.push({
header: 'US SSN',
size: 127,
value: usSsn
})
if (email)
elements.push({
header: 'Email',
size: 190,
value: email
})
return (
<div className="flex gap-7">
<PhotosCard photosData={photosData} timezone={timezone} />
<div className="flex flex-col">
<div className="flex items-center gap-2">
<IdIcon />
<H2 noMargin>
{name.length
? name
: email?.length
? email
: getFormattedPhone(phone, locale.country)}
</H2>
</div>
<div className="flex mt-auto">
{elements.map(({ size, header }, idx) => (
<Label1
noMargin
key={idx}
className="mb-1 text-comet"
style={{ width: size }}>
{header}
</Label1>
))}
</div>
<div className="flex">
{elements.map(({ size, value }, idx) => (
<P noMargin key={idx} style={{ width: size }}>
{value}
</P>
))}
</div>
</div>
</div>
)
})
export default CustomerDetails

View file

@ -0,0 +1,67 @@
import classnames from 'classnames'
import React from 'react'
import CustomerDataReversedIcon from 'src/styling/icons/customer-nav/data/comet.svg?react'
import CustomerDataIcon from 'src/styling/icons/customer-nav/data/white.svg?react'
import NoteReversedIcon from 'src/styling/icons/customer-nav/note/comet.svg?react'
import NoteIcon from 'src/styling/icons/customer-nav/note/white.svg?react'
import OverviewReversedIcon from 'src/styling/icons/customer-nav/overview/comet.svg?react'
import OverviewIcon from 'src/styling/icons/customer-nav/overview/white.svg?react'
import PhotosReversedIcon from 'src/styling/icons/customer-nav/photos/comet.svg?react'
import Photos from 'src/styling/icons/customer-nav/photos/white.svg?react'
import { P } from '/src/components/typography/index.jsx'
const CustomerSidebar = ({ isSelected, onClick }) => {
const sideBarOptions = [
{
code: 'overview',
display: 'Overview',
Icon: OverviewIcon,
InverseIcon: OverviewReversedIcon
},
{
code: 'customerData',
display: 'Customer data',
Icon: CustomerDataIcon,
InverseIcon: CustomerDataReversedIcon
},
{
code: 'notes',
display: 'Notes',
Icon: NoteIcon,
InverseIcon: NoteReversedIcon
},
{
code: 'photos',
display: 'Photos & files',
Icon: Photos,
InverseIcon: PhotosReversedIcon
}
]
return (
<div className="flex flex-col rounded-sm w-55 bg-zircon overflow-hidden">
{sideBarOptions?.map(({ Icon, InverseIcon, display, code }, idx) => (
<div
key={idx}
className={classnames({
'gap-4 p-4 cursor-pointer flex items-center': true,
'bg-comet2': isSelected(code)
})}
onClick={() => onClick(code)}>
{isSelected(code) ? <Icon /> : <InverseIcon />}
<P
noMargin
className={classnames({
'text-comet2': true,
'text-white font-bold': isSelected(code)
})}>
{display}
</P>
</div>
))}
</div>
)
}
export default CustomerSidebar

View file

@ -0,0 +1,294 @@
import CardContent from '@mui/material/CardContent'
import Card from '@mui/material/Card'
import { Form, Formik, Field as FormikField } from 'formik'
import * as R from 'ramda'
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 { Label1, P, H3 } from 'src/components/typography'
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
import EditReversedIcon from 'src/styling/icons/action/edit/white.svg?react'
import AuthorizeIcon from 'src/styling/icons/button/authorize/white.svg?react'
import BlockIcon from 'src/styling/icons/button/block/white.svg?react'
import CancelReversedIcon from 'src/styling/icons/button/cancel/white.svg?react'
import DataReversedIcon from 'src/styling/icons/button/data/white.svg?react'
import DataIcon from 'src/styling/icons/button/data/zodiac.svg?react'
import ReplaceReversedIcon from 'src/styling/icons/button/replace/white.svg?react'
import SaveReversedIcon from 'src/styling/icons/circle buttons/save/white.svg?react'
import { ActionButton } from 'src/components/buttons'
import {
OVERRIDE_REJECTED,
OVERRIDE_PENDING
} from 'src/pages/Customers/components/consts'
const ReadOnlyField = ({ field, value }) => {
return (
<div className="h-12">
<Label1 noMargin className="text-comet">
{field.label}
</Label1>
<P noMargin className="overflow-hidden whitespace-nowrap text-ellipsis">
{value}
</P>
</div>
)
}
const EditableField = ({ editing, field, value, size, ...props }) => {
if (!editing) return <ReadOnlyField field={field} value={value} />
return (
<div className="h-12">
{editing && (
<>
<Label1 noMargin className="text-comet">
{field.label}
</Label1>
<FormikField
inputClasses="p-0 text-sm -mt-1"
id={field.name}
name={field.name}
component={field.component}
type={field.type}
width={size}
{...props}
/>
</>
)}
</div>
)
}
const EditableCard = ({
fields,
save = () => {},
cancel = () => {},
authorize = () => {},
hasImage,
reject = () => {},
state,
title,
titleIcon,
children = () => {},
validationSchema,
initialValues,
deleteEditedData,
editable,
checkAgainstSanctions
}) => {
const formRef = useRef()
const [editing, setEditing] = useState(false)
const [input, setInput] = useState(null)
const [error, setError] = useState(null)
const triggerInput = () => input.click()
const authorized =
state === OVERRIDE_PENDING
? { label: 'Pending', type: 'default' }
: state === OVERRIDE_REJECTED
? { label: 'Rejected', type: 'error' }
: { label: 'Accepted', type: 'success' }
return (
<Card className="rounded-xl">
<CardContent>
<div className="flex justify-between h-10">
<div className="flex mb-4 gap-4">
{titleIcon}
<H3 noMargin>{title}</H3>
</div>
{state && authorize && <MainStatus statuses={[authorized]} />}
</div>
{children(formRef.current?.values ?? {})}
<Formik
innerRef={formRef}
validateOnBlur={false}
validateOnChange={false}
enableReinitialize
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values => {
save(values)
setEditing(false)
}}
onReset={() => {
setEditing(false)
setError(false)
}}>
{({ setFieldValue }) => (
<Form>
<PromptWhenDirty />
<div className="flex">
<div className="flex flex-col w-1/2">
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 0 && idx < 4 ? (
!field.editable ? (
<ReadOnlyField
field={field}
value={initialValues[field.name]}
/>
) : (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
)
) : null
})}
</div>
<div className="flex flex-col w-1/2">
{!hasImage &&
fields?.map((field, idx) => {
return idx >= 4 ? (
!field.editable ? (
<ReadOnlyField
field={field}
value={initialValues[field.name]}
/>
) : (
<EditableField
field={field}
value={initialValues[field.name]}
editing={editing}
size={180}
/>
)
) : null
})}
</div>
</div>
<div className="flex justify-end mt-5 gap-2">
{!editing && (
<>
{checkAgainstSanctions && (
<ActionButton
color="primary"
type="button"
Icon={DataIcon}
InverseIcon={DataReversedIcon}
onClick={() => checkAgainstSanctions()}>
Check against OFAC sanction list
</ActionButton>
)}
{editable && (
<ActionButton
color="primary"
Icon={EditIcon}
InverseIcon={EditReversedIcon}
onClick={() => setEditing(true)}>
Edit
</ActionButton>
)}
{!editable &&
authorize &&
authorized.label !== 'Accepted' && (
<ActionButton
color="spring"
type="button"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeIcon}
onClick={() => authorize()}>
Authorize
</ActionButton>
)}
{!editable &&
authorize &&
authorized.label !== 'Rejected' && (
<ActionButton
color="tomato"
type="button"
Icon={BlockIcon}
InverseIcon={BlockIcon}
onClick={() => reject()}>
Reject
</ActionButton>
)}
</>
)}
{editing && (
<>
{hasImage && state !== OVERRIDE_PENDING && (
<ActionButton
color="secondary"
type="button"
Icon={ReplaceReversedIcon}
InverseIcon={ReplaceReversedIcon}
onClick={() => triggerInput()}>
{
<div>
<input
type="file"
alt=""
accept="image/*"
className="hidden"
ref={fileInput => setInput(fileInput)}
onChange={event => {
// need to store it locally if we want to display it even after saving to db
const file = R.head(event.target.files)
if (!file) return
setFieldValue(R.head(fields).name, file)
}}
/>
Replace
</div>
}
</ActionButton>
)}
{fields && (
<ActionButton
color="secondary"
Icon={SaveReversedIcon}
InverseIcon={SaveReversedIcon}
type="submit">
Save
</ActionButton>
)}
<ActionButton
color="secondary"
Icon={CancelReversedIcon}
InverseIcon={CancelReversedIcon}
onClick={() => cancel()}
type="reset">
Cancel
</ActionButton>
{authorize && authorized.label !== 'Accepted' && (
<ActionButton
color="spring"
type="button"
Icon={AuthorizeIcon}
InverseIcon={AuthorizeIcon}
onClick={() => authorize()}>
Authorize
</ActionButton>
)}
{authorize && authorized.label !== 'Rejected' && (
<ActionButton
color="tomato"
type="button"
Icon={BlockIcon}
InverseIcon={BlockIcon}
onClick={() => reject()}>
Reject
</ActionButton>
)}
{error && (
<ErrorMessage>Failed to save changes</ErrorMessage>
)}
</>
)}
</div>
</Form>
)}
</Formik>
</CardContent>
</Card>
)
}
export default EditableCard

View file

@ -0,0 +1,62 @@
import ButtonBase from '@mui/material/ButtonBase'
import Paper from '@mui/material/Card'
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import { InformativeDialog } from 'src/components/InformativeDialog'
import { Info2 } from 'src/components/typography'
import CrossedCameraIcon from 'src/styling/icons/ID/photo/crossed-camera.svg?react'
import PhotosCarousel from './PhotosCarousel'
const PhotosCard = memo(({ photosData, timezone }) => {
const [photosDialog, setPhotosDialog] = useState(false)
const sortedPhotosData = R.sortWith(
[(a, b) => R.has('id', a) - R.has('id', b), R.descend(R.prop('date'))],
photosData
)
const singlePhoto = R.head(sortedPhotosData)
return (
<>
<Paper
className="flex justify-center items-center bg-zircon rounded-lg h-34 w-34"
elevation={0}>
<ButtonBase
disabled={!singlePhoto}
onClick={() => {
setPhotosDialog(true)
}}>
{singlePhoto ? (
<div>
<img
className="w-34 h-34 object-center object-cover block"
src={`/${singlePhoto.photoDir}/${singlePhoto.path}`}
alt=""
/>
<div className=""></div>
<circle className="absolute top-0 right-0 mr-1 mt-1 bg-ghost rounded-full w-6 h-6 flex items-center justify-center">
<Info2>{sortedPhotosData.length}</Info2>
</circle>
</div>
) : (
<CrossedCameraIcon />
)}
</ButtonBase>
</Paper>
<InformativeDialog
open={photosDialog}
title={`Photo roll`}
data={
<PhotosCarousel photosData={sortedPhotosData} timezone={timezone} />
}
onDissmised={() => {
setPhotosDialog(false)
}}
/>
</>
)
})
export default PhotosCard

View file

@ -0,0 +1,62 @@
import * as R from 'ramda'
import React, { memo, useState } from 'react'
import { Carousel } from 'src/components/Carousel'
import { Label1 } from 'src/components/typography'
import { formatDate } from 'src/utils/timezones'
import CopyToClipboard from '../../../components/CopyToClipboard.jsx'
const PhotosCarousel = memo(({ photosData, timezone }) => {
const [currentIndex, setCurrentIndex] = useState(0)
const Label = ({ children }) => {
return (
<Label1 noMargin className="mb-1 text-comet">
{children}
</Label1>
)
}
const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex])
const slidePhoto = index => setCurrentIndex(index)
// TODO hide copy to clipboard shit
return (
<>
<Carousel photosData={photosData} slidePhoto={slidePhoto} />
{!isFaceCustomerPhoto && (
<div className="flex flex-col p-2">
<Label>Session ID</Label>
<CopyToClipboard>
{photosData && photosData[currentIndex]?.id}
</CopyToClipboard>
</div>
)}
<div className="text-zodiac flex p-2 gap-8">
<div>
<>
<Label>Date</Label>
<div>
{photosData &&
formatDate(
photosData[currentIndex]?.date,
timezone,
'yyyy-MM-dd HH:mm'
)}
</div>
</>
</div>
<div>
<Label>Taken by</Label>
<div>
{!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'}
</div>
</div>
</div>
</>
)
})
export default PhotosCarousel

View file

@ -0,0 +1,166 @@
import { toUnit } from '@lamassu/coins/lightUtils'
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React from 'react'
import DataTable from 'src/components/tables/DataTable'
import { H3, H4, Label1, Label2, P } from 'src/components/typography'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { ifNotNull } from 'src/utils/nullCheck'
import { formatDate } from 'src/utils/timezones'
import CopyToClipboard from '../../../components/CopyToClipboard.jsx'
const TransactionsList = ({ customer, data, loading }) => {
const LastTxIcon = customer.lastTxClass === 'cashOut' ? TxOutIcon : TxInIcon
const hasData = !(R.isEmpty(data) || R.isNil(data))
const { lastUsedMachineName } = customer
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const summaryElements = [
{
header: 'Transactions',
size: 127,
value: ifNotNull(
customer.totalTxs,
`${Number.parseInt(customer.totalTxs)}`
)
},
{
header: 'Transaction volume',
size: 167,
value: ifNotNull(
customer.totalSpent,
`${Number.parseFloat(customer.totalSpent)} ${customer.lastTxFiatCode}`
)
},
{
header: 'Last active',
size: 142,
value:
!R.isNil(timezone) &&
((customer.lastActive &&
formatDate(customer.lastActive, timezone, 'yyyy-MM-dd')) ??
'')
},
{
header: 'Last transaction',
size: 198,
value: ifNotNull(
customer.lastTxFiat,
<>
<LastTxIcon className="mr-3" />
{`${Number.parseFloat(customer.lastTxFiat)}
${customer.lastTxFiatCode}`}
</>
)
},
{
header: 'Last used machine',
size: 198,
value: ifNotNull(lastUsedMachineName, <>{lastUsedMachineName}</>)
}
]
const tableElements = [
{
width: 40,
view: it => (
<>
{it.txClass === 'cashOut' ? (
<TxOutIcon className="mr-3" />
) : (
<TxInIcon className="mr-3" />
)}
</>
)
},
{
header: 'Machine',
width: 160,
view: R.path(['machineName'])
},
{
header: 'Transaction ID',
width: 145,
view: it => (
<CopyToClipboard className="font-museo whitespace-nowrap overflow-hidden text-ellipsis">
{it.id}
</CopyToClipboard>
)
},
{
header: 'Cash',
width: 155,
textAlign: 'right',
view: it => (
<>
{`${Number.parseFloat(it.fiat)} `}
<Label2 inline>{it.fiatCode}</Label2>
</>
)
},
{
header: 'Crypto',
width: 145,
textAlign: 'right',
view: it => (
<>
{`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode).toFormat(
5
)} `}
<Label2 inline>{it.cryptoCode}</Label2>
</>
)
},
{
header: 'Date',
width: 100,
view: it => formatDate(it.created, timezone, 'yyyyMMdd')
},
{
header: 'Time (h:m:s)',
width: 130,
view: it => formatDate(it.created, timezone, 'HH:mm:ss')
}
]
return (
<>
<H3>Transactions</H3>
<div className="flex flex-col">
<div className="flex mt-auto">
{summaryElements.map(({ size, header }, idx) => (
<Label1
noMargin
key={idx}
className="text-comet mb-1 mr-6"
style={{ width: size }}>
{header}
</Label1>
))}
</div>
<div className="flex">
{summaryElements.map(({ size, value }, idx) => (
<P noMargin key={idx} className="h-4 mr-6" style={{ width: size }}>
{value}
</P>
))}
</div>
</div>
<div className="flex mt-5">
{loading ? (
<H4>Loading</H4>
) : hasData ? (
<DataTable elements={tableElements} data={data} />
) : (
<H4>No transactions so far</H4>
)}
</div>
</>
)
}
export default TransactionsList

View file

@ -0,0 +1,65 @@
import { useFormikContext } from 'formik'
import * as R from 'ramda'
import React, { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
import { Label3, H3 } from 'src/components/typography'
import UploadPhotoIcon from 'src/styling/icons/button/photo/zodiac-resized.svg?react'
import UploadFileIcon from 'src/styling/icons/button/upload-file/zodiac-resized.svg?react'
import classes from './Upload.module.css'
const Upload = ({ type }) => {
const [data, setData] = useState({})
const { setFieldValue } = useFormikContext()
const IMAGE = 'image'
const ID_CARD_PHOTO = 'idCardPhoto'
const FRONT_CAMERA = 'frontCamera'
const isImage =
type === IMAGE || type === FRONT_CAMERA || type === ID_CARD_PHOTO
const onDrop = useCallback(
acceptedData => {
setFieldValue(type, R.head(acceptedData))
setData({
preview: isImage
? URL.createObjectURL(R.head(acceptedData))
: R.head(acceptedData).name
})
},
[isImage, type, setFieldValue]
)
const { getRootProps, getInputProps } = useDropzone({ onDrop })
return (
<>
<div {...getRootProps()} className="mt-10 w-112 h-30">
{R.isEmpty(data) && (
<div className={classes.box}>
<input {...getInputProps()} />
{isImage ? <UploadPhotoIcon /> : <UploadFileIcon />}
<Label3>{`Drag and drop ${
isImage ? 'an image' : 'a file'
} or click to open the explorer`}</Label3>
</div>
)}
{!R.isEmpty(data) && isImage && (
<div key={data.name}>
<img src={data.preview} className={classes.box} alt=""></img>
</div>
)}
{!R.isEmpty(data) && !isImage && (
<div className={classes.box}>
<H3 className="mt-12 flex">{data.preview}</H3>
</div>
)}
</div>
</>
)
}
export default Upload

View file

@ -0,0 +1,14 @@
.box {
box-sizing: border-box;
width: 450px;
height: 120px;
border-style: dashed;
border-color: var(--comet);
border-radius: 4px;
border-width: 1px;
background-color: var(--zircon);
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
}

View file

@ -0,0 +1,5 @@
const OVERRIDE_PENDING = 'automatic'
const OVERRIDE_AUTHORIZED = 'verified'
const OVERRIDE_REJECTED = 'blocked'
export { OVERRIDE_PENDING, OVERRIDE_AUTHORIZED, OVERRIDE_REJECTED }

View file

@ -0,0 +1,16 @@
import Wizard from '../Wizard'
import CustomerDetails from './CustomerDetails'
import CustomerSidebar from './CustomerSidebar'
import EditableCard from './EditableCard'
import TransactionsList from './TransactionsList'
import Upload from './Upload'
export {
CustomerDetails,
TransactionsList,
CustomerSidebar,
EditableCard,
Wizard,
Upload
}

View file

@ -0,0 +1,17 @@
import Paper from '@mui/material/Paper'
import { React } from 'react'
import { P } from 'src/components/typography'
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
const NewNoteCard = ({ setOpenModal }) => {
return (
<Paper
className="cursor-pointer bg-zircon flex flex-col justify-center items-center"
onClick={() => setOpenModal(true)}>
<AddIcon width={20} height={20} />
<P>Add new</P>
</Paper>
)
}
export default NewNoteCard

View file

@ -0,0 +1,74 @@
import { Form, Formik, Field } from 'formik'
import { React } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
const initialValues = {
title: '',
content: ''
}
const validationSchema = Yup.object().shape({
title: Yup.string().required().trim().max(25),
content: Yup.string().required()
})
const NewNoteModal = ({ showModal, onClose, onSubmit, errorMsg }) => {
return (
<>
<Modal
title="New note"
closeOnBackdropClick={true}
width={416}
height={472}
handleClose={onClose}
open={showModal}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={({ title, content }) => {
onSubmit({ title, content })
}}>
<Form id="note-form" className="flex flex-col h-full gap-5">
<Field
name="title"
autofocus
size="md"
autoComplete="off"
width={350}
component={TextInput}
label="Note title"
/>
<Field
name="content"
size="sm"
autoComplete="off"
width={350}
component={TextInput}
multiline={true}
rows={11}
label="Note content"
/>
<div className="flex flex-row mt-auto mb-6">
{errorMsg && <ErrorMessage>{errorMsg}</ErrorMessage>}
<Button
type="submit"
form="note-form"
className="mt-auto ml-auto">
Add note
</Button>
</div>
</Form>
</Formik>
</Modal>
</>
)
}
export default NewNoteModal

View file

@ -0,0 +1,48 @@
import Paper from '@mui/material/Paper'
import * as R from 'ramda'
import { React } from 'react'
import { H3, P } from 'src/components/typography'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
import { formatDate } from 'src/utils/timezones'
const formatContent = content => {
const fragments = R.split(/\n/)(content)
return R.map((it, idx) => {
if (idx === fragments.length) return <>{it}</>
return (
<>
{it}
<br />
</>
)
}, fragments)
}
const NoteCard = ({ note, deleteNote, handleClick, timezone }) => {
return (
<Paper
className="p-2 cursor-pointer overflow-hidden text-ellipsis"
onClick={() => handleClick(note)}>
<div className="flex flex-row justify-between w-full">
<div className="overflow-hidden whitespace-nowrap overflow-ellipsis">
<H3 noMargin>{note?.title}</H3>
<P noMargin>{formatDate(note?.created, timezone, 'yyyy-MM-dd')}</P>
</div>
<div>
<DeleteIcon
onClick={e => {
e.stopPropagation()
deleteNote({ noteId: note.id })
}}
/>
</div>
</div>
<P noMargin className="mt-2 line-clamp-8">
{formatContent(note?.content)}
</P>
</Paper>
)
}
export default NoteCard

View file

@ -0,0 +1,98 @@
import Paper from '@mui/material/Paper'
import { formatDurationWithOptions, intervalToDuration } from 'date-fns/fp'
import { Form, Formik, Field } from 'formik'
import { React, useRef } from 'react'
import { P } from 'src/components/typography'
import CancelIconInverse from 'src/styling/icons/button/cancel/white.svg?react'
import CancelIcon from 'src/styling/icons/button/cancel/zodiac.svg?react'
import SaveIconInverse from 'src/styling/icons/circle buttons/save/white.svg?react'
import SaveIcon from 'src/styling/icons/circle buttons/save/zodiac.svg?react'
import * as Yup from 'yup'
import { ActionButton } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { toTimezone } from 'src/utils/timezones'
const NoteEdit = ({ note, cancel, edit, timezone }) => {
const formRef = useRef()
const validationSchema = Yup.object().shape({
content: Yup.string()
})
const initialValues = {
content: note.content
}
return (
<Paper className="p-4">
<div className="flex flex-row justify-between items-center mb-4">
<P noMargin>
{`Last edited `}
{formatDurationWithOptions(
{ delimited: ', ' },
intervalToDuration({
start: toTimezone(new Date(note.lastEditedAt), timezone),
end: toTimezone(new Date(), timezone)
})
)}
{` ago`}
</P>
<div className="flex flex-row items-center gap-2">
<ActionButton
color="primary"
type="button"
Icon={CancelIcon}
InverseIcon={CancelIconInverse}
onClick={cancel}>
{`Cancel`}
</ActionButton>
<ActionButton
color="primary"
type="submit"
form="edit-note"
Icon={SaveIcon}
InverseIcon={SaveIconInverse}>
{`Save changes`}
</ActionButton>
<ActionButton
color="primary"
type="button"
Icon={CancelIcon}
InverseIcon={CancelIconInverse}
onClick={() => formRef.current.setFieldValue('content', '')}>
{`Clear content`}
</ActionButton>
</div>
</div>
<Formik
validateOnChange={false}
validateOnBlur={false}
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={({ content }) =>
edit({
noteId: note.id,
newContent: content,
oldContent: note.content
})
}
innerRef={formRef}>
<Form id="edit-note">
<Field
name="content"
component={TextInput}
InputProps={{ disableUnderline: true }}
size="sm"
autoComplete="off"
fullWidth
multiline={true}
rows={15}
/>
</Form>
</Formik>
</Paper>
)
}
export default NoteEdit

View file

@ -0,0 +1,547 @@
import React from 'react'
import { parse, isValid, format } from 'date-fns/fp'
import { Field, useFormikContext } from 'formik'
import { parsePhoneNumberFromString } from 'libphonenumber-js'
import * as R from 'ramda'
import { H4 } from 'src/components/typography'
import { validate as uuidValidate } from 'uuid'
import * as Yup from 'yup'
import {
RadioGroup,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { MANUAL } from 'src/utils/constants'
import { Upload } from './components'
const CUSTOMER_BLOCKED = 'blocked'
const CUSTOM = 'custom'
const REQUIREMENT = 'requirement'
const ID_CARD_DATA = 'idCardData'
const getAuthorizedStatus = (it, triggers, customRequests) => {
const fields = R.concat(
['frontCamera', 'idCardData', 'idCardPhoto', 'email', 'usSsn', 'sanctions'],
R.map(ite => ite.id, customRequests)
)
const fieldsWithPathSuffix = ['frontCamera', 'idCardPhoto']
const isManualField = fieldName => {
const triggerName = R.equals(fieldName, 'frontCamera')
? 'facephoto'
: fieldName
const manualOverrides = R.filter(
ite => R.equals(R.toLower(ite.automation), MANUAL),
triggers?.overrides ?? []
)
return (
!!R.find(
ite => R.equals(ite.requirement, triggerName),
manualOverrides
) || R.equals(R.toLower(triggers.automation ?? ''), MANUAL)
)
}
const pendingFieldStatus = R.map(ite => {
if (isManualField(ite)) {
if (uuidValidate(ite)) {
const request = R.find(
iter => iter.infoRequestId === ite,
it.customInfoRequests
)
return !R.isNil(request) && R.equals(request.override, 'automatic')
}
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
? it[`${ite}Path`]
: it[`${ite}`]
if (R.isNil(regularFieldValue)) return false
return R.equals(it[`${ite}Override`], 'automatic')
}
return false
}, fields)
const rejectedFieldStatus = R.map(ite => {
if (isManualField(ite)) {
if (uuidValidate(ite)) {
const request = R.find(
iter => iter.infoRequestId === ite,
it.customInfoRequests
)
return !R.isNil(request) && R.equals(request.override, 'blocked')
}
const regularFieldValue = R.includes(ite, fieldsWithPathSuffix)
? it[`${ite}Path`]
: it[`${ite}`]
if (R.isNil(regularFieldValue)) return false
return R.equals(it[`${ite}Override`], 'blocked')
}
return false
}, fields)
if (it.authorizedOverride === CUSTOMER_BLOCKED)
return { label: 'Blocked', type: 'error' }
if (it.isSuspended)
return it.daysSuspended > 0
? { label: `${it.daysSuspended} day suspension`, type: 'warning' }
: { label: `< 1 day suspension`, type: 'warning' }
if (R.any(ite => ite === true, rejectedFieldStatus))
return { label: 'Rejected', type: 'error' }
if (R.any(ite => ite === true, pendingFieldStatus))
return { label: 'Pending', type: 'warning' }
return { label: 'Authorized', type: 'success' }
}
const getFormattedPhone = (phone, country) => {
const phoneNumber =
phone && country ? parsePhoneNumberFromString(phone, country) : null
return phoneNumber ? phoneNumber.formatInternational() : phone
}
const getName = it => {
const idData = R.path(['idCardData'])(it)
return `${R.path(['firstName'])(idData) ?? ''} ${
R.path(['lastName'])(idData) ?? ''
}`.trim()
}
// Manual Entry Wizard
const entryOptions = [
{ display: 'Custom entry', code: 'custom' },
{ display: 'Populate existing requirement', code: 'requirement' }
]
const dataOptions = [
{ display: 'Text', code: 'text' }
// TODO: Requires backend modifications to support File and Image
// { display: 'File', code: 'file' },
// { display: 'Image', code: 'image' }
]
const requirementOptions = [
{ display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' },
{ display: 'US SSN', code: 'usSsn' },
{ display: 'Email', code: 'email' },
{ display: 'Customer camera', code: 'frontCamera' }
]
const customTextOptions = [
{ label: 'Data entry title', name: 'title' },
{ label: 'Data entry', name: 'data' }
]
const customUploadOptions = [{ label: 'Data entry title', name: 'title' }]
const entryTypeSchema = Yup.lazy(values => {
if (values.entryType === 'custom') {
return Yup.object().shape({
entryType: Yup.string().required(),
dataType: Yup.string().required()
})
} else if (values.entryType === 'requirement') {
return Yup.object().shape({
entryType: Yup.string().required(),
requirement: Yup.string().required()
})
}
})
const customFileSchema = Yup.object().shape({
title: Yup.string().required(),
file: Yup.mixed().required()
})
const customImageSchema = Yup.object().shape({
title: Yup.string().required(),
image: Yup.mixed().required()
})
const customTextSchema = Yup.object().shape({
title: Yup.string().required(),
data: Yup.string().required()
})
const updateRequirementOptions = it => [
{
display: 'Custom information requirement',
code: 'custom'
},
...it
]
const EntryType = ({ customInfoRequirementOptions }) => {
const { values } = useFormikContext()
const displayCustomOptions = values.entryType === CUSTOM
const displayRequirementOptions = values.entryType === REQUIREMENT
const Entry = ({ title, name, options, className }) => (
<div>
<div className="flex items-center">
<H4>{title}</H4>
</div>
<Field
component={RadioGroup}
name={name}
options={options}
radioClassName="p-1 m-1"
labelClassName={className}
className="grid grid-cols-[182px_162px_141px]"
/>
</div>
)
return (
<>
<Entry
title="Type of entry"
name="entryType"
options={entryOptions}
className="w-62"
/>
{displayCustomOptions && (
<Entry title="Type of data" name="dataType" options={dataOptions} />
)}
{displayRequirementOptions && (
<Entry
title="Requirements"
name="requirement"
options={requirementOptions}
/>
)}
</>
)
}
const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
const typeOfEntrySelected = selectedValues?.entryType
const dataTypeSelected = selectedValues?.dataType
const requirementSelected = selectedValues?.requirement
const displayRequirements = typeOfEntrySelected === 'requirement'
const isCustomInfoRequirement = requirementSelected === CUSTOM
const updatedRequirementOptions = !R.isEmpty(customInfoRequirementOptions)
? updateRequirementOptions(requirementOptions)
: requirementOptions
const requirementName = displayRequirements
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions)
.display
: ''
const title = displayRequirements
? `Requirement ${requirementName}`
: `Custom ${dataTypeSelected} entry`
const elements = displayRequirements
? requirementElements[requirementSelected]
: customElements[dataTypeSelected]
const upload = displayRequirements
? requirementSelected === 'idCardPhoto' ||
requirementSelected === 'frontCamera'
: dataTypeSelected === 'file' || dataTypeSelected === 'image'
return (
<>
<div className="flex items-center">
<H4>{title}</H4>
</div>
{isCustomInfoRequirement && (
<Autocomplete
fullWidth
label={`Available requests`}
className="w-37"
isOptionEqualToValue={R.eqProps('code')}
labelProp={'display'}
options={customInfoRequirementOptions}
onChange={(evt, it) => {}}
/>
)}
<div className="mb-6">
{!upload &&
!isCustomInfoRequirement &&
elements.options.map(({ label, name }, idx) => (
<Field
key={idx}
name={name}
label={label}
component={TextInput}
width={390}
/>
))}
</div>
{upload && (
<Upload
type={
displayRequirements ? requirementSelected : dataTypeSelected
}></Upload>
)}
</>
)
}
const customElements = {
text: {
schema: customTextSchema,
options: customTextOptions,
Component: ManualDataEntry,
initialValues: { data: '', title: '' },
saveType: 'customEntry'
},
file: {
schema: customFileSchema,
options: customUploadOptions,
Component: ManualDataEntry,
initialValues: { file: null, title: '' },
saveType: 'customEntryUpload'
},
image: {
schema: customImageSchema,
options: customUploadOptions,
Component: ManualDataEntry,
initialValues: { image: null, title: '' },
saveType: 'customEntryUpload'
}
}
const entryType = {
schema: entryTypeSchema,
options: entryOptions,
Component: EntryType,
initialValues: { entryType: '' }
}
// Customer data
const customerDataElements = {
idCardData: [
{
name: 'firstName',
label: 'First name',
component: TextInput,
editable: true
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput,
editable: true
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput,
editable: true
},
{
name: 'gender',
label: 'Gender',
component: TextInput,
editable: true
},
{
name: 'lastName',
label: 'Last name',
component: TextInput,
editable: true
},
{
name: 'expirationDate',
label: 'Expiration date',
component: TextInput,
editable: true
},
{
name: 'country',
label: 'Country',
component: TextInput,
editable: true
}
],
usSsn: [
{
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190,
editable: true
}
],
email: [
{
name: 'email',
label: 'Email',
component: TextInput,
size: 190,
editable: false
}
],
idCardPhoto: [{ name: 'idCardPhoto' }],
frontCamera: [{ name: 'frontCamera' }]
}
const customerDataSchemas = {
idCardData: Yup.object().shape({
firstName: Yup.string().required(),
lastName: Yup.string().required(),
documentNumber: Yup.string().required(),
dateOfBirth: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)),
message: 'Date must be in format YYYY-MM-DD'
})
.required(),
gender: Yup.string().required(),
country: Yup.string().required(),
expirationDate: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val)),
message: 'Date must be in format YYYY-MM-DD'
})
.required()
}),
usSsn: Yup.object().shape({
usSsn: Yup.string().required()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed().required()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed().required()
}),
email: Yup.object().shape({
email: Yup.string().required()
})
}
const requirementElements = {
idCardData: {
schema: customerDataSchemas.idCardData,
options: customerDataElements.idCardData,
Component: ManualDataEntry,
initialValues: {
firstName: '',
lastName: '',
documentNumber: '',
dateOfBirth: '',
gender: '',
country: '',
expirationDate: ''
},
saveType: 'customerData'
},
usSsn: {
schema: customerDataSchemas.usSsn,
options: customerDataElements.usSsn,
Component: ManualDataEntry,
initialValues: { usSsn: '' },
saveType: 'customerData'
},
email: {
schema: customerDataSchemas.email,
options: customerDataElements.email,
Component: ManualDataEntry,
initialValues: { email: '' },
saveType: 'customerData'
},
idCardPhoto: {
schema: customerDataSchemas.idCardPhoto,
options: customerDataElements.idCardPhoto,
Component: ManualDataEntry,
initialValues: { idCardPhoto: null },
saveType: 'customerDataUpload'
},
frontCamera: {
schema: customerDataSchemas.frontCamera,
options: customerDataElements.frontCamera,
Component: ManualDataEntry,
initialValues: { frontCamera: null },
saveType: 'customerDataUpload'
},
custom: {
// schema: customerDataSchemas.customInfoRequirement,
Component: ManualDataEntry,
initialValues: { customInfoRequirement: null },
saveType: 'customInfoRequirement'
}
}
const tryFormatDate = rawDate => {
try {
return (
(rawDate &&
format('yyyy-MM-dd')(parse(new Date(), 'yyyyMMdd', rawDate))) ??
''
)
} catch (err) {
return ''
}
}
const formatDates = values => {
R.map(
elem =>
(values[elem] = format('yyyyMMdd')(
parse(new Date(), 'yyyy-MM-dd', values[elem])
))
)(['dateOfBirth', 'expirationDate'])
return values
}
const mapKeys = pair => {
const [key, value] = pair
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
return ['path', value]
}
if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') {
return ['date', value]
}
return pair
}
const addPhotoDir = R.map(it => {
const hasFrontCameraData = R.has('id')(it)
return hasFrontCameraData
? { ...it, photoDir: 'operator-data/customersphotos' }
: { ...it, photoDir: 'front-camera-photo' }
})
const standardizeKeys = R.map(R.compose(R.fromPairs, R.map(mapKeys), R.toPairs))
const filterByPhotoAvailable = R.filter(
tx => !R.isNil(tx.date) && !R.isNil(tx.path)
)
const formatPhotosData = R.compose(
filterByPhotoAvailable,
addPhotoDir,
standardizeKeys
)
export {
getAuthorizedStatus,
getFormattedPhone,
getName,
entryType,
customElements,
requirementElements,
formatPhotosData,
customerDataElements,
customerDataSchemas,
formatDates,
tryFormatDate,
REQUIREMENT,
CUSTOM,
ID_CARD_DATA
}

View file

@ -0,0 +1,4 @@
import CustomerProfile from './CustomerProfile'
import Customers from './Customers'
export { Customers, CustomerProfile }

View file

@ -0,0 +1,93 @@
import { useQuery, gql } from '@apollo/client'
import Button from '@mui/material/Button'
import Grid from '@mui/material/Grid'
import classnames from 'classnames'
import * as R from 'ramda'
import React from 'react'
import { cardState } from 'src/components/CollapsibleCard'
import { Label1, H4 } from 'src/components/typography'
import AlertsTable from './AlertsTable'
const NUM_TO_RENDER = 3
const GET_ALERTS = gql`
query getAlerts {
alerts {
id
type
detail
message
created
read
valid
}
machines {
deviceId
name
}
}
`
const Alerts = ({ onReset, onExpand, size }) => {
const showAllItems = size === cardState.EXPANDED
const { data } = useQuery(GET_ALERTS)
const alerts = R.path(['alerts'])(data) ?? []
const machines = R.compose(
R.map(R.prop('name')),
R.indexBy(R.prop('deviceId'))
)(data?.machines ?? [])
const alertsLength = alerts.length
return (
<>
<div className="flex justify-between">
<H4 noMargin>{`Alerts (${alertsLength})`}</H4>
{showAllItems && (
<Label1 noMargin className="-mt-1">
<Button
onClick={onReset}
size="small"
disableRipple
disableFocusRipple
className="p-0 text-zodiac normal-case">
{'Show less'}
</Button>
</Label1>
)}
</div>
<Grid
className={classnames({ 'm-0': true, 'max-h-115': showAllItems })}
container
spacing={1}>
<Grid item xs={12}>
{!alerts.length && (
<Label1 className="text-comet -ml-1 h-30">
No new alerts. Your system is running smoothly.
</Label1>
)}
<AlertsTable
numToRender={showAllItems ? alerts.length : NUM_TO_RENDER}
alerts={alerts}
machines={machines}
/>
</Grid>
</Grid>
{!showAllItems && alertsLength > NUM_TO_RENDER && (
<Grid item xs={12}>
<Label1 className="text-center mb-0">
<Button
onClick={() => onExpand('alerts')}
size="small"
disableRipple
disableFocusRipple
className="p-0 text-zodiac normal-case">
{`Show all (${alerts.length})`}
</Button>
</Label1>
</Grid>
)}
</>
)
}
export default Alerts

View file

@ -0,0 +1,57 @@
import List from '@mui/material/List'
import ListItem from '@mui/material/ListItem'
import * as R from 'ramda'
import React from 'react'
import { useHistory } from 'react-router-dom'
import { P } from 'src/components/typography/index'
import Wrench from 'src/styling/icons/action/wrench/zodiac.svg?react'
import CashBoxEmpty from 'src/styling/icons/cassettes/cashbox-empty.svg?react'
import AlertLinkIcon from 'src/styling/icons/month arrows/right.svg?react'
import WarningIcon from 'src/styling/icons/warning-icon/tomato.svg?react'
const icons = {
error: <WarningIcon style={{ height: 20, width: 20, marginRight: 12 }} />,
fiatBalance: (
<CashBoxEmpty style={{ height: 18, width: 18, marginRight: 14 }} />
)
}
const links = {
error: '/maintenance/machine-status',
fiatBalance: '/maintenance/cash-cassettes',
cryptoBalance: '/maintenance/funding'
}
const AlertsTable = ({ numToRender, alerts, machines }) => {
const history = useHistory()
const alertsToRender = R.slice(0, numToRender, alerts)
const alertMessage = alert => {
const deviceId = alert.detail.deviceId
if (!deviceId) return `${alert.message}`
const deviceName = R.defaultTo('Unpaired device', machines[deviceId])
return `${alert.message} - ${deviceName}`
}
return (
<List dense className="max-h-116 overflow-y-auto overflow-x-hidden">
{alertsToRender.map((alert, idx) => {
return (
<ListItem key={idx}>
{icons[alert.type] || (
<Wrench style={{ height: 23, width: 23, marginRight: 8 }} />
)}
<P className="my-2">{alertMessage(alert)}</P>
<AlertLinkIcon
className="ml-auto cursor-pointer"
onClick={() => history.push(links[alert.type] || '/dashboard')}
/>
</ListItem>
)
})}
</List>
)
}
export default AlertsTable

View file

@ -0,0 +1,2 @@
import Alerts from './Alerts'
export default Alerts

View file

@ -0,0 +1,103 @@
import { useQuery, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { useHistory } from 'react-router-dom'
import TitleSection from 'src/components/layout/TitleSection'
import { H1, Info2, TL2, Label1 } from 'src/components/typography'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { Button } from 'src/components/buttons'
import AddMachine from 'src/pages/AddMachine'
import { errorColor } from 'src/styling/variables'
import Footer from './Footer'
import RightSide from './RightSide'
import Paper from '@mui/material/Paper'
import SystemPerformance from './SystemPerformance/index.js'
const GET_DATA = gql`
query getData {
machines {
name
}
serverVersion
}
`
const Dashboard = () => {
const history = useHistory()
const [open, setOpen] = useState(false)
const { data, loading } = useQuery(GET_DATA)
const onPaired = machine => {
setOpen(false)
history.push('/maintenance/machine-status', { id: machine.deviceId })
}
return !loading ? (
!R.isEmpty(data.machines) ? (
<>
<TitleSection title="Dashboard">
<div className="flex gap-6">
<div className="flex items-center gap-2">
<TxInIcon />
<span>Cash-in</span>
</div>
<div className="flex items-center gap-2">
<TxOutIcon />
<span>Cash-out</span>
</div>
<div className="flex items-center gap-2">
<svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} />
</svg>
<span>Action Required</span>
</div>
</div>
</TitleSection>
<div className="flex mb-30 gap-4">
<div className="flex flex-col flex-1">
<Paper className="p-6">
<SystemPerformance />
</Paper>
</div>
<div className="flex flex-col flex-1">
<RightSide />
</div>
</div>
<Footer />
</>
) : (
<>
{open && (
<AddMachine close={() => setOpen(false)} onPaired={onPaired} />
)}
<TitleSection title="Dashboard">
<div className="flex flex-row">
<span>
<TL2 className="inline">{data?.serverVersion}</TL2>{' '}
<Label1 className="inline"> server version</Label1>
</span>
</div>
</TitleSection>
<div className="h-75 bg-zircon border-zircon2 border-2">
<div className="flex flex-col h-full justify-center items-center gap-6">
<H1 className="text-comet2">No machines on your system yet</H1>
<Info2 className="text-comet2">
To fully take advantage of Lamassu Admin, add a new machine to
your system
</Info2>
<Button onClick={() => setOpen(true)}>+ Add new machine</Button>
</div>
</div>
<Footer />
</>
)
) : (
<></>
)
}
export default Dashboard

View file

@ -0,0 +1,101 @@
import { useQuery, gql } from '@apollo/client'
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React from 'react'
import { Label2 } from 'src/components/typography'
import TxInIcon from 'src/styling/icons/direction/cash-in.svg?react'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import { fromNamespace } from 'src/utils/config'
import classes from './Footer.module.css'
const GET_DATA = gql`
query getData {
cryptoRates
cryptoCurrencies {
code
display
}
config
accountsConfig {
code
display
}
}
`
BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
const Footer = () => {
const { data } = useQuery(GET_DATA)
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
const config = R.path(['config'])(data) ?? {}
// const canExpand = R.keys(withCommissions).length > 4
const wallets = fromNamespace('wallets')(config)
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []
const accountsConfig = R.path(['accountsConfig'])(data) ?? []
const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? ''
const renderFooterItem = key => {
const idx = R.findIndex(R.propEq('code', key))(cryptoCurrencies)
const tickerCode = wallets[`${key}_ticker`]
const tickerIdx = R.findIndex(R.propEq('code', tickerCode))(accountsConfig)
const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : ''
const cashInNoCommission = parseFloat(
R.path(['cryptoRates', 'withoutCommissions', key, 'cashIn'])(data)
)
const cashOutNoCommission = parseFloat(
R.path(['cryptoRates', 'withoutCommissions', key, 'cashOut'])(data)
)
const avgOfAskBid = new BigNumber(
(cashInNoCommission + cashOutNoCommission) / 2
).toFormat(2)
const cashIn = new BigNumber(
parseFloat(
R.path(['cryptoRates', 'withCommissions', key, 'cashIn'])(data)
)
).toFormat(2)
const cashOut = new BigNumber(
parseFloat(
R.path(['cryptoRates', 'withCommissions', key, 'cashOut'])(data)
)
).toFormat(2)
return (
<div className="flex flex-col w-1/4">
<Label2 className="text-comet mt-3 mb-2">
{cryptoCurrencies[idx].display}
</Label2>
<div className="flex gap-6">
<div className="flex items-center gap-1">
<TxInIcon />
<Label2 noMargin>{`${cashIn} ${localeFiatCurrency}`}</Label2>
</div>
<div className="flex items-center gap-1">
<TxOutIcon />
<Label2 noMargin>{`${cashOut} ${localeFiatCurrency}`}</Label2>
</div>
</div>
<Label2 className="text-comet mt-2">
{`${tickerName}: ${avgOfAskBid} ${localeFiatCurrency}`}
</Label2>
</div>
)
}
return (
<div className={classes.footer1}>
<div className={classes.content1}>
{R.keys(withCommissions).map(key => renderFooterItem(key))}
</div>
</div>
)
}
export default Footer

View file

@ -0,0 +1,28 @@
.footer1 {
left: 0;
bottom: 0;
position: fixed;
width: 100vw;
background-color: white;
text-align: left;
z-index: 1;
box-shadow: 0 -1px 10px 0 rgba(50, 50, 50, 0.1);
min-height: 48px;
transition: min-height 0.5s ease-out;
}
.footer1:hover {
transition: min-height 0.5s ease-in;
min-height: 200px;
}
.content1 {
width: 1200px;
max-height: 100px;
background-color: white;
z-index: 2;
bottom: -8px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
}

View file

@ -0,0 +1,2 @@
import Footer from './Footer'
export default Footer

View file

@ -0,0 +1,89 @@
import Button from '@mui/material/Button'
import classnames from 'classnames'
import React, { useState } from 'react'
import CollapsibleCard, { cardState } from 'src/components/CollapsibleCard'
import { H4, Label1 } from 'src/components/typography'
import Alerts from './Alerts'
import SystemStatus from './SystemStatus'
const ShrunkCard = ({ title, buttonName, onUnshrink }) => {
return (
<div className="flex justify-between">
<H4 className="mt-0">{title}</H4>
<Label1 className="text-center my-0">
<Button
onClick={onUnshrink}
size="small"
disableRipple
disableFocusRipple
className="p-0 text-zodiac normal-case">
{buttonName}
</Button>
</Label1>
</div>
)
}
const RightSide = () => {
const [systemStatusSize, setSystemStatusSize] = useState(cardState.DEFAULT)
const [alertsSize, setAlertsSize] = useState(cardState.DEFAULT)
const onReset = () => {
setAlertsSize(cardState.DEFAULT)
setSystemStatusSize(cardState.DEFAULT)
}
return (
<div className="flex flex-1 flex-col">
<div className="flex-1 flex flex-col gap-4">
<CollapsibleCard
className={classnames({
'flex-[0.1]': alertsSize === cardState.SHRUNK,
'flex-[0.9]': alertsSize === cardState.EXPANDED
})}
state={alertsSize}
shrunkComponent={
<ShrunkCard
title={'Alerts'}
buttonName={'Show alerts'}
onUnshrink={onReset}
/>
}>
<Alerts
onExpand={() => {
setAlertsSize(cardState.EXPANDED)
setSystemStatusSize(cardState.SHRUNK)
}}
onReset={onReset}
size={alertsSize}
/>
</CollapsibleCard>
<CollapsibleCard
className={classnames({
'flex-[0.1]': systemStatusSize === cardState.SHRUNK,
'flex-1': systemStatusSize === cardState.DEFAULT,
'flex-[0.9]': systemStatusSize === cardState.EXPANDED
})}
state={systemStatusSize}
shrunkComponent={
<ShrunkCard
title={'System status'}
buttonName={'Show machines'}
onUnshrink={onReset}
/>
}>
<SystemStatus
onExpand={() => {
setSystemStatusSize(cardState.EXPANDED)
setAlertsSize(cardState.SHRUNK)
}}
onReset={onReset}
size={systemStatusSize}
/>
</CollapsibleCard>
</div>
</div>
)
}
export default RightSide

View file

@ -0,0 +1,34 @@
import classnames from 'classnames'
import React from 'react'
import { Label1 } from 'src/components/typography/index'
const PercentageChart = ({ cashIn, cashOut }) => {
const value = cashIn || cashOut !== 0 ? cashIn : 50
const buildPercentageView = value => {
if (value <= 15) return
return <Label1 className="text-white">{value}%</Label1>
}
const percentageClasses = {
'h-35 rounded-sm flex items-center justify-center': true,
'min-w-2 rounded-xs': value < 5 && value > 0
}
return (
<div className="flex h-35 gap-1">
<div
className={classnames(percentageClasses, 'bg-java')}
style={{ width: `${value}%` }}>
{buildPercentageView(value, 'cashIn')}
</div>
<div
className={classnames(percentageClasses, 'bg-neon')}
style={{ width: `${100 - value}%` }}>
{buildPercentageView(100 - value, 'cashOut')}
</div>
</div>
)
}
export default PercentageChart

View file

@ -0,0 +1,197 @@
import * as d3 from 'd3'
import * as R from 'ramda'
import React, { useEffect, useRef, useCallback } from 'react'
const transactionProfit = R.prop('profit')
const mockPoint = (tx, offsetMs, profit) => {
const date = new Date(new Date(tx.created).getTime() + offsetMs).toISOString()
return { created: date, profit }
}
// if we're viewing transactions for the past day, then we group by hour. If not, we group by day
const formatDay = ({ created }) =>
new Date(created).toISOString().substring(0, 10)
const formatHour = ({ created }) =>
new Date(created).toISOString().substring(0, 13)
const reducer = (acc, tx) => {
const currentProfit = acc.profit || 0
return { ...tx, profit: currentProfit + transactionProfit(tx) }
}
const timeFrameMS = {
Day: 24 * 3600 * 1000,
Week: 7 * 24 * 3600 * 1000,
Month: 30 * 24 * 3600 * 1000
}
const RefLineChart = ({
data: realData,
previousTimeData,
previousProfit,
timeFrame
}) => {
const svgRef = useRef()
const drawGraph = useCallback(() => {
const svg = d3.select(svgRef.current)
const margin = { top: 0, right: 0, bottom: 0, left: 0 }
const width = 336 - margin.left - margin.right
const height = 140 - margin.top - margin.bottom
const massageData = () => {
// if we're viewing transactions for the past day, then we group by hour. If not, we group by day
const method = timeFrame === 'Day' ? formatHour : formatDay
const aggregatedTX = R.values(R.reduceBy(reducer, [], method, realData))
// if no point exists, then return 2 points at y = 0
if (!aggregatedTX.length && !previousTimeData.length) {
const mockPoint1 = { created: new Date().toISOString(), profit: 0 }
const mockPoint2 = mockPoint(mockPoint1, -3600000, 0)
return [[mockPoint1, mockPoint2], true]
}
// if this time period has no txs, but previous time period has, then % change is -100%
if (!aggregatedTX.length && previousTimeData.length) {
const mockPoint1 = {
created: new Date().toISOString(),
profit: 0
}
const mockPoint2 = mockPoint(mockPoint1, -timeFrameMS[timeFrame], 1)
return [[mockPoint1, mockPoint2], false]
}
// if this time period has txs, but previous doesn't, then % change is +100%
if (aggregatedTX.length && !previousTimeData.length) {
const mockPoint1 = {
created: new Date().toISOString(),
profit: 1
}
const mockPoint2 = mockPoint(mockPoint1, -timeFrameMS[timeFrame], 0)
return [[mockPoint1, mockPoint2], false]
}
// if only one point exists, create point on the left - otherwise the line won't be drawn
if (aggregatedTX.length === 1) {
return [
R.append(
{
created: new Date(
Date.now() - timeFrameMS[timeFrame]
).toISOString(),
profit: previousProfit
},
aggregatedTX
),
false
]
}
// the boolean value is for zeroProfit. It makes the line render at y = 0 instead of y = 50% of container height
return [aggregatedTX, false]
}
/* Important step to make the graph look good!
This function groups transactions by either day or hour depending on the time frame
This makes the line look smooth and not all wonky when there are many transactions in a given time
*/
const [data, zeroProfit] = massageData()
// sets width of the graph
svg.attr('width', width)
// background color for the graph
svg
.append('rect')
.attr('x', 0)
.attr('y', -margin.top)
.attr('width', width + margin.left + margin.right)
.attr('height', height + margin.top)
.attr('fill', 'var(--ghost)')
.attr('transform', `translate(${0},${margin.top})`)
// gradient color for the graph (creates the "url", the color is applied by calling the url, in the area color fill )
svg
.append('linearGradient')
.attr('id', 'area-gradient')
.attr('gradientUnits', 'userSpaceOnUse')
.attr('x1', 0)
.attr('y1', 0)
.attr('x2', 0)
.attr('y2', '100%')
.selectAll('stop')
.data([
{ offset: '0%', color: 'var(--zircon)' },
{ offset: '25%', color: 'var(--zircon)' },
{ offset: '100%', color: 'var(--ghost)' }
])
.enter()
.append('stop')
.attr('offset', function (d) {
return d.offset
})
.attr('stop-color', function (d) {
return d.color
})
const g = svg
.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`)
const xDomain = d3.extent(data, t => t.created)
const yDomain = zeroProfit ? [0, 0.1] : [0, d3.max(data, t => t.profit)]
const y = d3
.scaleLinear()
// 30 is a margin so that the labels and the percentage change label can fit and not overlay the line path
.range([height, 40])
.domain([0, yDomain[1]])
const x = d3
.scaleTime()
.domain([new Date(xDomain[0]), new Date(xDomain[1])])
.range([0, width])
const line = d3
.line()
.x(function (d) {
return x(new Date(d.created))
})
.y(function (d) {
return y(d.profit)
})
const area = d3
.area()
.x(function (d) {
return x(new Date(d.created))
})
.y0(height)
.y1(function (d) {
return y(d.profit)
})
// area color fill
g.append('path')
.datum(data)
.attr('d', area)
.attr('fill', 'url(#area-gradient)')
// draw the line
g.append('path')
.datum(data)
.attr('d', line)
.attr('fill', 'none')
.attr('stroke-width', '2')
.attr('stroke-linejoin', 'round')
.attr('stroke', 'var(--zodiac)')
}, [realData, timeFrame, previousTimeData, previousProfit])
useEffect(() => {
// first we clear old chart DOM elements on component update
d3.select(svgRef.current).selectAll('*').remove()
drawGraph()
}, [drawGraph, realData])
return (
<>
<svg ref={svgRef} />
</>
)
}
export default RefLineChart

View file

@ -0,0 +1,348 @@
import BigNumber from 'bignumber.js'
import * as d3 from 'd3'
import { getTimezoneOffset } from 'date-fns-tz'
import { add, format, startOfWeek, startOfYear } from 'date-fns/fp'
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import { numberToFiatAmount } from 'src/utils/number'
import { MINUTE, DAY, WEEK, MONTH } from 'src/utils/time'
const Graph = ({ data, timeFrame, timezone }) => {
const ref = useRef(null)
const GRAPH_HEIGHT = 250
const GRAPH_WIDTH = 555
const GRAPH_MARGIN = useMemo(
() => ({
top: 20,
right: 3.5,
bottom: 27,
left: 33.5
}),
[]
)
const offset = getTimezoneOffset(timezone)
const NOW = Date.now() + offset
const periodDomains = {
Day: [NOW - DAY, NOW],
Week: [NOW - WEEK, NOW],
Month: [NOW - MONTH, NOW]
}
const dataPoints = useMemo(
() => ({
Day: {
freq: 24,
step: 60 * 60 * 1000,
tick: d3.utcHour.every(4),
labelFormat: '%H:%M'
},
Week: {
freq: 7,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(1),
labelFormat: '%a %d'
},
Month: {
freq: 30,
step: 24 * 60 * 60 * 1000,
tick: d3.utcDay.every(2),
labelFormat: '%d'
}
}),
[]
)
const filterDay = useCallback(
x => (timeFrame === 'Day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
[timeFrame]
)
const getPastAndCurrentDayLabels = useCallback(d => {
const currentDate = new Date(d)
const currentDateDay = currentDate.getUTCDate()
const currentDateWeekday = currentDate.getUTCDay()
const currentDateMonth = currentDate.getUTCMonth()
const previousDate = new Date(currentDate.getTime())
previousDate.setUTCDate(currentDateDay - 1)
const previousDateDay = previousDate.getUTCDate()
const previousDateWeekday = previousDate.getUTCDay()
const previousDateMonth = previousDate.getUTCMonth()
const daysOfWeek = Array.from(Array(7)).map((_, i) =>
format('EEE', add({ days: i }, startOfWeek(new Date())))
)
const months = Array.from(Array(12)).map((_, i) =>
format('LLL', add({ months: i }, startOfYear(new Date())))
)
return {
previous:
currentDateMonth !== previousDateMonth
? months[previousDateMonth]
: `${daysOfWeek[previousDateWeekday]} ${previousDateDay}`,
current:
currentDateMonth !== previousDateMonth
? months[currentDateMonth]
: `${daysOfWeek[currentDateWeekday]} ${currentDateDay}`
}
}, [])
const buildTicks = useCallback(
domain => {
const points = []
const roundDate = d => {
const step = dataPoints[timeFrame].step
return new Date(Math.ceil(d.valueOf() / step) * step)
}
for (let i = 0; i <= dataPoints[timeFrame].freq; i++) {
const stepDate = new Date(NOW - i * dataPoints[timeFrame].step)
if (roundDate(stepDate) > domain[1]) continue
if (stepDate < domain[0]) continue
points.push(roundDate(stepDate))
}
return points
},
[NOW, dataPoints, timeFrame]
)
const x = d3
.scaleUtc()
.domain(periodDomains[timeFrame])
.range([GRAPH_MARGIN.left, GRAPH_WIDTH - GRAPH_MARGIN.right])
const y = d3
.scaleLinear()
.domain([
0,
(d3.max(data, d => new BigNumber(d.fiat).toNumber()) ?? 1000) * 1.05
])
.nice()
.range([GRAPH_HEIGHT - GRAPH_MARGIN.bottom, GRAPH_MARGIN.top])
const buildBackground = useCallback(
g => {
g.append('rect')
.attr('x', 0)
.attr('y', GRAPH_MARGIN.top)
.attr('width', GRAPH_WIDTH)
.attr('height', GRAPH_HEIGHT - GRAPH_MARGIN.top - GRAPH_MARGIN.bottom)
.attr('fill', 'var(--ghost)')
},
[GRAPH_MARGIN]
)
const buildXAxis = useCallback(
g =>
g
.attr(
'transform',
`translate(0, ${GRAPH_HEIGHT - GRAPH_MARGIN.bottom})`
)
.call(
d3
.axisBottom(x)
.ticks(dataPoints[timeFrame].tick)
.tickFormat(d => {
return d3.timeFormat(dataPoints[timeFrame].labelFormat)(
d.getTime() + d.getTimezoneOffset() * MINUTE
)
})
)
.call(g => g.select('.domain').remove()),
[GRAPH_MARGIN, dataPoints, timeFrame, x]
)
const buildYAxis = useCallback(
g =>
g
.attr('transform', `translate(${GRAPH_MARGIN.left}, 0)`)
.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'),
[GRAPH_MARGIN, y]
)
const buildGrid = useCallback(
g => {
g.attr('stroke', 'var(--zircon2)')
.attr('fill', 'var(--zircon2)')
// Vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()))
.join('line')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 1)
)
// Horizontal lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(d3.axisLeft(y).scale().ticks(5))
.join('line')
.attr('y1', d => 0.5 + y(d))
.attr('y2', d => 0.5 + y(d))
.attr('x1', GRAPH_MARGIN.left)
.attr('x2', GRAPH_WIDTH)
)
// Thick vertical lines
.call(g =>
g
.append('g')
.selectAll('line')
.data(buildTicks(x.domain()).filter(filterDay))
.join('line')
.attr('class', 'dateSeparator')
.attr('x1', d => 0.5 + x(d))
.attr('x2', d => 0.5 + x(d))
.attr('y1', GRAPH_MARGIN.top - 10)
.attr('y2', GRAPH_HEIGHT - GRAPH_MARGIN.bottom)
.attr('stroke-width', 2)
.join('text')
)
// Left side breakpoint label
.call(g => {
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(filterDay)
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x - 7)
.attr('y', separator.y)
.attr('text-anchor', 'end')
.attr('dy', '.25em')
.text(labels.previous)
})
// Right side breakpoint label
.call(g => {
const separator = d3?.select('.dateSeparator')?.node()?.getBBox()
if (!separator) return
const breakpoint = buildTicks(x.domain()).filter(filterDay)
const labels = getPastAndCurrentDayLabels(breakpoint)
return g
.append('text')
.attr('x', separator.x + 7)
.attr('y', separator.y)
.attr('text-anchor', 'start')
.attr('dy', '.25em')
.text(labels.current)
})
},
[GRAPH_MARGIN, buildTicks, getPastAndCurrentDayLabels, x, y, filterDay]
)
const formatTicksText = useCallback(
() =>
d3
.selectAll('.tick text')
.style('stroke', 'var(--comet)')
.style('fill', 'var(--comet)')
.style('stroke-width', 0)
.style('font-family', 'var(--museo)'),
[]
)
const formatText = useCallback(
() =>
d3
.selectAll('text')
.style('stroke', 'var(--comet)')
.style('fill', 'var(--comet)')
.style('stroke-width', 0)
.style('font-family', 'var(--museo)'),
[]
)
const formatTicks = useCallback(() => {
d3.selectAll('.tick line')
.style('stroke', 'transparent')
.style('fill', 'transparent')
}, [])
const drawData = useCallback(
g => {
g.selectAll('circle')
.data(data)
.join('circle')
.attr('cx', d => {
const created = new Date(d.created)
return x(created.setTime(created.getTime() + offset))
})
.attr('cy', d => y(new BigNumber(d.fiat).toNumber()))
.attr('fill', d =>
d.txClass === 'cashIn' ? 'var(--java)' : 'var(--neon)'
)
.attr('r', 3.5)
},
[data, offset, x, y]
)
const drawChart = useCallback(() => {
const svg = d3
.select(ref.current)
.attr('viewBox', [0, 0, GRAPH_WIDTH, GRAPH_HEIGHT])
svg.append('g').call(buildBackground)
svg.append('g').call(buildGrid)
svg.append('g').call(buildXAxis)
svg.append('g').call(buildYAxis)
svg.append('g').call(formatTicksText)
svg.append('g').call(formatText)
svg.append('g').call(formatTicks)
svg.append('g').call(drawData)
return svg.node()
}, [
buildBackground,
buildGrid,
buildXAxis,
buildYAxis,
drawData,
formatText,
formatTicks,
formatTicksText
])
useEffect(() => {
d3.select(ref.current).selectAll('*').remove()
drawChart()
}, [drawChart])
return <svg ref={ref} />
}
export default Graph

View file

@ -0,0 +1,12 @@
import React from 'react'
import { Info1, Label1 } from 'src/components/typography/index'
const InfoWithLabel = ({ info, label }) => {
return (
<div className="flex flex-col">
<Info1 className="mb-0">{info}</Info1>
<Label1 className="m-0">{label}</Label1>
</div>
)
}
export default InfoWithLabel

View file

@ -0,0 +1,42 @@
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useState } from 'react'
import { H4 } from 'src/components/typography'
const ranges = ['Month', 'Week', 'Day']
const Nav = ({ handleSetRange, showPicker }) => {
const [clickedItem, setClickedItem] = useState('Day')
const isSelected = R.equals(clickedItem)
const handleClick = range => {
setClickedItem(range)
handleSetRange(range)
}
return (
<div className="flex justify-between items-center">
<H4 noMargin>{'System performance'}</H4>
{showPicker && (
<div className="flex gap-6">
{ranges.map((it, idx) => {
return (
<div
key={idx}
onClick={e => handleClick(e.target.innerText)}
className={classnames({
'cursor-pointer text-comet': true,
'font-bold text-zodiac border-b-zodiac border-b-2':
isSelected(it)
})}>
{it}
</div>
)
})}
</div>
)}
</div>
)
}
export default Nav

View file

@ -0,0 +1,283 @@
import { useQuery, gql } from '@apollo/client'
import BigNumber from 'bignumber.js'
import classnames from 'classnames'
import { isAfter } from 'date-fns/fp'
import * as R from 'ramda'
import React, { useState } from 'react'
import { Info2, Label1, Label2, P } from 'src/components/typography/index'
import PercentDownIcon from 'src/styling/icons/dashboard/down.svg?react'
import PercentNeutralIcon from 'src/styling/icons/dashboard/equal.svg?react'
import PercentUpIcon from 'src/styling/icons/dashboard/up.svg?react'
import { EmptyTable } from 'src/components/table'
import { java, neon } from 'src/styling/variables'
import { fromNamespace } from 'src/utils/config'
import { DAY, WEEK, MONTH } from 'src/utils/time'
import { timezones } from 'src/utils/timezone-list'
import { toTimezone } from 'src/utils/timezones'
import PercentageChart from './Graphs/PercentageChart'
import LineChart from './Graphs/RefLineChart'
import Scatterplot from './Graphs/RefScatterplot'
import InfoWithLabel from './InfoWithLabel'
import Nav from './Nav'
BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
const getFiats = R.map(R.prop('fiat'))
const GET_DATA = gql`
query getData($excludeTestingCustomers: Boolean) {
transactions(excludeTestingCustomers: $excludeTestingCustomers) {
fiatCode
fiat
fixedFee
commissionPercentage
created
txClass
error
profit
dispense
sendConfirmed
}
fiatRates {
code
name
rate
}
config
}
`
const SystemPerformance = () => {
const [selectedRange, setSelectedRange] = useState('Day')
const { data, loading } = useQuery(GET_DATA, {
variables: { excludeTestingCustomers: true }
})
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
const timezone = fromNamespace('locale')(data?.config).timezone
const NOW = Date.now()
const periodDomains = {
Day: [NOW - DAY, NOW],
Week: [NOW - WEEK, NOW],
Month: [NOW - MONTH, NOW]
}
const isInRangeAndNoError = getLastTimePeriod => t => {
if (t.error !== null) return false
if (t.txClass === 'cashOut' && !t.dispense) return false
if (t.txClass === 'cashIn' && !t.sendConfirmed) return false
if (!getLastTimePeriod) {
return (
t.error === null &&
isAfter(
toTimezone(t.created, timezone),
toTimezone(periodDomains[selectedRange][1], timezone)
) &&
isAfter(
toTimezone(periodDomains[selectedRange][0], timezone),
toTimezone(t.created, timezone)
)
)
}
return (
t.error === null &&
isAfter(
toTimezone(periodDomains[selectedRange][1], timezone),
toTimezone(t.created, timezone)
) &&
isAfter(
toTimezone(t.created, timezone),
toTimezone(periodDomains[selectedRange][0], timezone)
)
)
}
const convertFiatToLocale = item => {
if (item.fiatCode === fiatLocale) return item
const itemRate = R.find(R.propEq('code', item.fiatCode))(data.fiatRates)
const localeRate = R.find(R.propEq('code', fiatLocale))(data.fiatRates)
const multiplier = localeRate.rate / itemRate.rate
return { ...item, fiat: parseFloat(item.fiat) * multiplier }
}
const transactionsToShow = R.map(convertFiatToLocale)(
R.filter(isInRangeAndNoError(false), data?.transactions ?? [])
)
const transactionsLastTimePeriod = R.map(convertFiatToLocale)(
R.filter(isInRangeAndNoError(true), data?.transactions ?? [])
)
const getNumTransactions = () => {
return R.length(transactionsToShow)
}
const getFiatVolume = () =>
new BigNumber(R.sum(getFiats(transactionsToShow))).toFormat(2)
const getProfit = transactions => {
return R.reduce(
(acc, value) => acc.plus(value.profit),
new BigNumber(0),
transactions
)
}
const getPercentChange = () => {
const thisTimePeriodProfit = getProfit(transactionsToShow)
const previousTimePeriodProfit = getProfit(transactionsLastTimePeriod)
if (thisTimePeriodProfit.eq(previousTimePeriodProfit)) return 0
if (previousTimePeriodProfit.eq(0)) return 100
return thisTimePeriodProfit
.minus(previousTimePeriodProfit)
.times(100)
.div(previousTimePeriodProfit)
.toNumber()
}
const getDirectionPercent = () => {
const [cashIn, cashOut] = R.partition(R.propEq('txClass', 'cashIn'))(
transactionsToShow
)
const totalLength = cashIn.length + cashOut.length
if (totalLength === 0) {
return { cashIn: 0, cashOut: 0 }
}
return {
cashIn: Math.round((cashIn.length / totalLength) * 100),
cashOut: Math.round((cashOut.length / totalLength) * 100)
}
}
const percentChange = getPercentChange()
const percentageClasses = {
'text-tomato': percentChange < 0,
'text-spring4': percentChange > 0,
'text-comet': percentChange === 0,
'flex items-center justify-center gap-1': true
}
const getPercentageIcon = () => {
const className = 'w-4 h-4'
if (percentChange === 0) return <PercentNeutralIcon className={className} />
if (percentChange > 0) return <PercentUpIcon className={className} />
return <PercentDownIcon className={className} />
}
return (
<>
<Nav
showPicker={!loading && !R.isEmpty(data.transactions)}
handleSetRange={setSelectedRange}
/>
{!loading && R.isEmpty(data.transactions) && (
<EmptyTable className="pt-10" message="No transactions so far" />
)}
{!loading && !R.isEmpty(data.transactions) && (
<div className="flex flex-col gap-12">
<div className="flex gap-16">
<InfoWithLabel info={getNumTransactions()} label={'transactions'} />
<InfoWithLabel
info={getFiatVolume()}
label={`${data?.config.locale_fiatCurrency} volume`}
/>
</div>
<div className="h-62">
<div className="flex justify-between mb-4">
<Label2 noMargin>Transactions</Label2>
<div className="flex items-center">
<P noMargin>
{timezones[timezone]?.short ?? timezones[timezone]?.long}{' '}
timezone
</P>
<span className="h-4 w-[1px] bg-comet2 mr-4 ml-8" />
<div className="flex flex-row gap-4">
<div className="flex items-center">
<svg width={8} height={8}>
<rect width={8} height={8} rx={4} fill={java} />
</svg>
<Label1 noMargin className="ml-2">
In
</Label1>
</div>
<div className="flex items-center">
<svg width={8} height={8}>
<rect width={8} height={8} rx={4} fill={neon} />
</svg>
<Label1 noMargin className="ml-2">
Out
</Label1>
</div>
</div>
</div>
</div>
<Scatterplot
timeFrame={selectedRange}
data={transactionsToShow}
timezone={timezone}
/>
</div>
<div className="flex h-62">
<div className="flex-2">
<Label2 noMargin className="mb-4">
Profit from commissions
</Label2>
<div className="flex justify-between mt-6 mr-7 -mb-8 ml-4 relative">
<Info2 noMargin>
{`${getProfit(transactionsToShow).toFormat(2)} ${
data?.config.locale_fiatCurrency
}`}
</Info2>
<Info2 noMargin className={classnames(percentageClasses)}>
{getPercentageIcon()}
{`${new BigNumber(percentChange).toFormat(2)}%`}
</Info2>
</div>
<LineChart
timeFrame={selectedRange}
data={transactionsToShow}
previousTimeData={transactionsLastTimePeriod}
previousProfit={getProfit(transactionsLastTimePeriod)}
/>
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<Label2 noMargin>Direction</Label2>
<div className="flex flex-row gap-4">
<div className="flex items-center">
<svg width={8} height={8}>
<rect width={8} height={8} rx={2} fill={java} />
</svg>
<Label1 noMargin className="ml-2">
In
</Label1>
</div>
<div className="flex items-center">
<svg width={8} height={8}>
<rect width={8} height={8} rx={2} fill={neon} />
</svg>
<Label1 noMargin className="ml-2">
Out
</Label1>
</div>
</div>
</div>
<PercentageChart
cashIn={getDirectionPercent().cashIn}
cashOut={getDirectionPercent().cashOut}
/>
</div>
</div>
</div>
)}
</>
)
}
export default SystemPerformance

View file

@ -0,0 +1,2 @@
import SystemPerformance from './SystemPerformance'
export default SystemPerformance

View file

@ -0,0 +1,161 @@
import { useQuery, gql } from '@apollo/client'
import { styled } from '@mui/material/styles'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import * as R from 'ramda'
import React from 'react'
import { useHistory } from 'react-router-dom'
import { Status } from 'src/components/Status'
import { Label2, TL2 } from 'src/components/typography'
import TxOutIcon from 'src/styling/icons/direction/cash-out.svg?react'
import MachineLinkIcon from 'src/styling/icons/month arrows/right.svg?react'
import { fromNamespace } from 'src/utils/config'
// percentage threshold where below this number the text in the cash cassettes percentage turns red
const PERCENTAGE_THRESHOLD = 20
const GET_CONFIG = gql`
query getConfig {
config
}
`
const StyledCell = styled(TableCell)({
borderBottom: '4px solid white',
padding: 0,
paddingLeft: '15px'
})
const HeaderCell = styled(TableCell)({
borderBottom: '4px solid white',
padding: 0,
paddingLeft: '15px',
backgroundColor: 'white'
})
const MachinesTable = ({ machines = [], numToRender }) => {
const history = useHistory()
const { data } = useQuery(GET_CONFIG)
const fillingPercentageSettings = fromNamespace(
'notifications',
R.path(['config'], data) ?? {}
)
const getPercent = (notes, capacity = 500) => {
return Math.round((notes / capacity) * 100)
}
const makePercentageText = (cassetteIdx, notes, capacity = 500) => {
const percent = getPercent(notes, capacity)
const percentageThreshold = R.pipe(
R.path([`fillingPercentageCassette${cassetteIdx}`]),
R.defaultTo(PERCENTAGE_THRESHOLD)
)(fillingPercentageSettings)
return percent < percentageThreshold ? (
<TL2 className="text-tomato">{`${percent}%`}</TL2>
) : (
<TL2>{`${percent}%`}</TL2>
)
}
const redirect = ({ name, deviceId }) => {
return history.push(`/machines/${deviceId}`, {
selectedMachine: name
})
}
const maxNumberOfCassettes = Math.max(
...R.map(it => it.numberOfCassettes, machines),
0
)
return (
<TableContainer className="max-h-110">
<Table>
<TableHead>
<TableRow>
<HeaderCell>
<div className="flex items-center">
<Label2 noMargin className="text-comet">
Machines
</Label2>
</div>
</HeaderCell>
<HeaderCell>
<div className="flex items-center">
<Label2 noMargin className="text-comet">
Status
</Label2>
</div>
</HeaderCell>
{R.times(R.identity, maxNumberOfCassettes).map((it, idx) => (
<HeaderCell key={idx}>
<div className="flex items-center whitespace-pre">
<TxOutIcon />
<Label2 noMargin className="text-comet">
{' '}
{it + 1}
</Label2>
</div>
</HeaderCell>
))}
</TableRow>
</TableHead>
<TableBody>
{machines.map((machine, idx) => {
if (idx < numToRender) {
return (
<TableRow
onClick={() => redirect(machine)}
className="boder-b-0 bg-ghost"
key={machine.deviceId + idx}>
<TableCell
sx={{
borderBottom: '4px solid white',
padding: 0,
paddingLeft: '15px'
}}
align="left">
<div className="flex items-center">
<TL2>{machine.name}</TL2>
<MachineLinkIcon
className="cursor-pointer ml-2"
onClick={() => redirect(machine)}
/>
</div>
</TableCell>
<StyledCell>
<Status status={machine.statuses[0]} />
</StyledCell>
{R.range(1, maxNumberOfCassettes + 1).map((it, idx) =>
machine.numberOfCassettes >= it ? (
<StyledCell key={idx} align="left">
{makePercentageText(
it,
machine.cashUnits[`cassette${it}`]
)}
</StyledCell>
) : (
<StyledCell key={idx} align="left">
<TL2>{`— %`}</TL2>
</StyledCell>
)
)}
</TableRow>
)
}
return null
})}
</TableBody>
</Table>
</TableContainer>
)
}
export default MachinesTable

View file

@ -0,0 +1,77 @@
import {
backgroundColor,
offColor,
errorColor,
primaryColor
} from 'src/styling/variables'
const styles = {
label: {
margin: 0,
color: offColor
},
row: {
backgroundColor: backgroundColor,
borderBottom: 'none'
},
clickableRow: {
cursor: 'pointer'
},
header: {
display: 'flex',
alignItems: 'center',
whiteSpace: 'pre'
},
error: {
color: errorColor
},
button: {
color: primaryColor,
minHeight: 0,
minWidth: 0,
padding: 0,
textTransform: 'none',
'&:hover': {
backgroundColor: 'transparent'
},
marginBottom: -40
},
buttonLabel: {
position: 'absolute',
bottom: 160,
marginBottom: 0
},
statusHeader: {
marginLeft: 2
},
tableBody: {
overflow: 'auto'
},
tl2: {
display: 'inline'
},
label1: {
display: 'inline'
},
machinesTableContainer: {
height: 220
},
expandedMachinesTableContainer: {
height: 414
},
centerLabel: {
marginBottom: 0,
padding: 0,
textAlign: 'center'
},
machineNameWrapper: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
},
machineRedirectIcon: {
marginLeft: 10
}
}
export default styles

View file

@ -0,0 +1,115 @@
import { useQuery, gql } from '@apollo/client'
import Button from '@mui/material/Button'
import classnames from 'classnames'
import * as R from 'ramda'
import React from 'react'
import { cardState as cardState_ } from 'src/components/CollapsibleCard'
import { H4, TL2, Label1 } from 'src/components/typography'
import MachinesTable from './MachinesTable'
// number of machines in the table to render on page load
const NUM_TO_RENDER = 4
const GET_DATA = gql`
query getData {
machines {
name
deviceId
cashUnits {
cashbox
cassette1
cassette2
cassette3
cassette4
recycler1
recycler2
recycler3
recycler4
recycler5
recycler6
}
numberOfCassettes
numberOfRecyclers
statuses {
label
type
}
}
serverVersion
uptime {
name
state
uptime
}
}
`
/* const parseUptime = time => {
if (time < 60) return `${time}s`
if (time < 3600) return `${Math.floor(time / 60)}m`
if (time < 86400) return `${Math.floor(time / 60 / 60)}h`
return `${Math.floor(time / 60 / 60 / 24)}d`
} */
const SystemStatus = ({ onReset, onExpand, size }) => {
const { data, loading } = useQuery(GET_DATA)
const machines = R.path(['machines'])(data) ?? []
const showAllItems = size === cardState_.EXPANDED
const machinesTableContainerClasses = {
'h-55': !showAllItems,
'h-103': showAllItems
}
// const uptime = data?.uptime ?? [{}]
return (
<>
<div className="flex justify-between">
<H4 className="mt-0">System status</H4>
{showAllItems && (
<Label1 noMargin className="-mt-1">
<Button
onClick={onReset}
size="small"
disableRipple
disableFocusRipple
className="p-0 text-zodiac normal-case">
{'Show less'}
</Button>
</Label1>
)}
</div>
{!loading && (
<>
<div className="mb-4">
<TL2 className="inline">{data?.serverVersion}</TL2>
<Label1 className="inline"> server version</Label1>
</div>
<div className={classnames(machinesTableContainerClasses)}>
<MachinesTable
numToRender={showAllItems ? Infinity : NUM_TO_RENDER}
machines={machines}
/>
</div>
{!showAllItems && machines.length > NUM_TO_RENDER && (
<div>
<Label1 className="text-center mb-0">
<Button
onClick={() => onExpand()}
size="small"
disableRipple
disableFocusRipple
className="p-0 text-zodiac normal-case">
{`Show all (${machines.length})`}
</Button>
</Label1>
</div>
)}
</>
)}
</>
)
}
export default SystemStatus

View file

@ -0,0 +1,2 @@
import SystemStatus from './SystemStatus'
export default SystemStatus

View file

@ -0,0 +1,2 @@
import Dashboard from './Dashboard'
export default Dashboard

View file

@ -0,0 +1,303 @@
import { useQuery, gql } from '@apollo/client'
import { formatCryptoAddress } from '@lamassu/coins/lightUtils'
import BigNumber from 'bignumber.js'
import classnames from 'classnames'
import { format } from 'date-fns/fp'
import { QRCodeSVG as QRCode } from 'qrcode.react'
import * as R from 'ramda'
import React, { useState } from 'react'
import TableLabel from 'src/pages/Funding/TableLabel.jsx'
import Title from 'src/components/Title.jsx'
import {
Tr,
Td,
THead,
TBody,
Table
} from 'src/components/fake-table/Table.jsx'
import Sidebar from 'src/components/layout/Sidebar.jsx'
import {
H3,
Info1,
Info2,
Info3,
Label1,
Label3
} from 'src/components/typography/index.jsx'
import CopyToClipboard from 'src/components/CopyToClipboard.jsx'
import { primaryColor } from 'src/styling/variables.js'
import classes from './Funding.module.css'
const NODE_NOT_CONNECTED_ERR =
"Couldn't establish connection with the node. Make sure it is installed and try again"
const sizes = {
big: 165,
time: 140,
date: 130
}
const GET_FUNDING = gql`
{
funding {
cryptoCode
errorMsg
fundingAddress
fundingAddressUrl
confirmedBalance
pending
fiatConfirmedBalance
fiatPending
fiatCode
display
unitScale
}
}
`
const formatAddress = (cryptoCode = '', address = '') =>
formatCryptoAddress(cryptoCode, address).replace(/(.{4})/g, '$1 ')
const sumReducer = (acc, value) => acc.plus(value)
const formatNumber = it => new BigNumber(it).toFormat(2)
const getConfirmedTotal = list => {
return formatNumber(
list
.filter(it => !it.errorMsg)
.map(it => new BigNumber(it.fiatConfirmedBalance))
.reduce(sumReducer, new BigNumber(0))
)
}
const getPendingTotal = list => {
return formatNumber(
list
.filter(it => !it.errorMsg)
.map(it => new BigNumber(it.fiatPending))
.reduce(sumReducer, new BigNumber(0))
)
}
const Funding = () => {
const [selected, setSelected] = useState(null)
const [viewHistory] = useState(false)
const fundingHistory = [
{
cryptoAmount: 2.0,
balance: 10.23,
fiatValue: 1000.0,
date: new Date(),
performedBy: null,
pending: true
},
{
cryptoAmount: 10.0,
balance: 12.23,
fiatValue: 12000.0,
date: new Date(),
performedBy: null
},
{
cryptoAmount: 5.0,
balance: 5.0,
fiatValue: 50000.0,
date: new Date(),
performedBy: null
}
]
const isSelected = it => {
return selected && selected.cryptoCode === it.cryptoCode
}
const { data: fundingResponse, loading } = useQuery(GET_FUNDING)
const funding = R.path(['funding'])(fundingResponse) ?? []
if (funding.length && !selected) {
setSelected(funding[0])
}
const itemRender = (it, active) => {
const itemClass = {
[classes.item]: true,
[classes.inactiveItem]: !active
}
const wrapperClass = {
[classes.itemWrapper]: true,
[classes.error]: it.errorMsg
}
return (
<div className={classnames(wrapperClass)}>
<div className={classes.firstItem}>{it.display}</div>
{!it.errorMsg && (
<>
<div className={classnames(itemClass)}>
{formatNumber(it.fiatConfirmedBalance)} {it.fiatCode}
</div>
<div className={classnames(itemClass)}>
{it.confirmedBalance} {it.cryptoCode}
</div>
</>
)}
</div>
)
}
const pendingTotal = getPendingTotal(funding)
const signIfPositive = num => (num >= 0 ? '+' : '')
return (
<>
<div>
<Title>Funding</Title>
{/* <button onClick={it => setViewHistory(!viewHistory)}>history</button> */}
</div>
<div className={classes.wrapper}>
<Sidebar
data={funding}
isSelected={isSelected}
onClick={setSelected}
displayName={it => it.display}
itemRender={itemRender}
loading={loading}>
{funding.length && (
<div className={classes.total}>
<Label1 className={classes.totalTitle}>
Total crypto balance
</Label1>
<Info1 noMargin>
{getConfirmedTotal(funding)}
{funding[0].fiatCode}
</Info1>
<Label1 className={classes.totalPending}>
({signIfPositive(pendingTotal)} {pendingTotal} pending)
</Label1>
</div>
)}
</Sidebar>
{selected && !viewHistory && selected.errorMsg && (
<div className={classes.main}>
<div className={classes.firstSide}>
<Info3 className={classes.error}>
{R.includes('ECONNREFUSED', selected.errorMsg)
? NODE_NOT_CONNECTED_ERR
: selected.errorMsg}
</Info3>
</div>
</div>
)}
{selected && !viewHistory && !selected.errorMsg && (
<div className={classes.main}>
<div className={classes.firstSide}>
<H3>Balance</H3>
<div className={classes.coinTotal}>
<Info1 inline noMargin>
{`${selected.confirmedBalance} ${selected.cryptoCode}`}
</Info1>
<Info2 inline noMargin className="ml-2">
{`(${signIfPositive(selected.pending)} ${
selected.pending
} pending)`}
</Info2>
</div>
<div className={classes.coinTotal}>
<Info3 inline noMargin>
{`= ${formatNumber(selected.fiatConfirmedBalance)} ${
selected.fiatCode
}`}
</Info3>
<Label3 inline noMargin className="ml-2">
{`(${signIfPositive(selected.fiatPending)} ${formatNumber(
selected.fiatPending
)} pending)`}
</Label3>
</div>
<H3 className={classes.topSpacer}>Address</H3>
<div className={classes.addressWrapper}>
<div className={classes.mono}>
<strong>
<CopyToClipboard
buttonClassname={classes.copyToClipboard}
key={selected.cryptoCode}>
{formatAddress(
selected.cryptoCode,
selected.fundingAddress
)}
</CopyToClipboard>
</strong>
</div>
</div>
</div>
<div className={classes.secondSide}>
<Label1>Scan to send {selected.display}</Label1>
<QRCode
size={240}
fgColor={primaryColor}
value={selected.fundingAddressUrl}
/>
</div>
</div>
)}
{selected && viewHistory && (
<div>
<TableLabel
className={classes.tableLabel}
label="Pending"
color="#cacaca"
/>
<Table className={classes.table}>
<THead>
<Td header width={sizes.big}>
Amount Entered
</Td>
<Td header width={sizes.big}>
Balance After
</Td>
<Td header width={sizes.big}>
Cash Value
</Td>
<Td header width={sizes.date}>
Date
</Td>
<Td header width={sizes.time}>
Time (h:m:s)
</Td>
<Td header width={sizes.big}>
Performed By
</Td>
</THead>
<TBody>
{fundingHistory.map((it, idx) => (
<Tr
key={idx}
className={classnames({ [classes.pending]: it.pending })}>
<Td width={sizes.big}>
{it.cryptoAmount} {selected.cryptoCode}
</Td>
<Td width={sizes.big}>
{it.balance} {selected.cryptoCode}
</Td>
<Td width={sizes.big}>
{it.fiatValue} {selected.fiatCode}
</Td>
<Td width={sizes.date}>{format('yyyy-MM-dd', it.date)}</Td>
<Td width={sizes.time}>{format('hh:mm:ss', it.date)}</Td>
<Td width={sizes.big}>add</Td>
</Tr>
))}
</TBody>
</Table>
</div>
)}
</div>
</>
)
}
export default Funding

View file

@ -0,0 +1,107 @@
.wrapper {
display: flex;
flex: 1;
flex-direction: row;
height: 100%;
}
.main {
display: flex;
flex: 1;
}
.firstSide {
margin: 0 64px 0 48px;
}
.secondSide {
margin-top: -29px;
}
.error {
color: var(--tomato);
}
.coinTotal {
margin: 12px 0;
}
.topSpacer {
margin-top: 40px;
}
.addressWrapper {
display: flex;
flex-direction: column;
flex: 1;
background-color: var(--zircon);
}
.address {
width: 375px;
margin: 12px 24px;
}
.itemWrapper {
text-align: end;
}
.item {
font-family: var(--museo);
font-size: 14px;
font-weight: 500;
margin: 2px;
}
.inactiveItem {
color: var(--comet);
}
.firstItem {
font-weight: 700;
margin: 2px;
}
.total {
margin-top: auto;
text-align: right;
margin-right: 24px;
}
.totalPending {
margin-top: 2px;
}
.totalTitle {
color: var(--comet);
margin-bottom: 2px;
}
.table {
margin-top: 8px;
margin-left: 48px;
}
.tableLabel {
justify-content: end;
margin-top: -38px;
}
.pending {
background-color: var(--zircon);
}
.copyToClipboard {
margin-left: auto;
padding-top: 6px;
padding-left: 15px;
margin-right: -11px;
}
.mono {
font-family: var(--bpmono);
font-size: 14px;
font-weight: 400;
width: 375px;
margin: 12px 24px;
}

View file

@ -0,0 +1,20 @@
import classnames from 'classnames'
import React from 'react'
import { Label1 } from '../../components/typography/index.jsx'
const TableLabel = ({ className, label, color, ...props }) => {
return (
<div className={classnames('flex items-center', className)} {...props}>
{color && (
<div
className="rounded-sm h-3 w-3 mr-2"
style={{ backgroundColor: color }}
/>
)}
<Label1 {...props}>{label}</Label1>
</div>
)
}
export default TableLabel

View file

@ -0,0 +1,255 @@
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import Section from 'src/components/layout/Section'
import TitleSection from 'src/components/layout/TitleSection'
import { P } from 'src/components/typography'
import _schemas from 'src/pages/Services/schemas'
import Wizard from 'src/pages/Wallet/Wizard'
import { WalletSchema } from 'src/pages/Wallet/helper'
import { Link, SupportLinkButton } from 'src/components/buttons'
import { Table as EditableTable } from 'src/components/editableTable'
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import {
mainFields,
overrides,
LocaleSchema,
OverridesSchema,
localeDefaults,
overridesDefaults
} from './helper'
const GET_DATA = gql`
query getData {
config
accounts
accountsConfig {
code
display
class
cryptos
}
currencies {
code
display
}
countries {
code
display
}
cryptoCurrencies {
code
display
isBeta
}
languages {
code
display
}
machines {
name
deviceId
}
}
`
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject, $accounts: JSONObject) {
saveConfig(config: $config)
saveAccounts(accounts: $accounts)
}
`
const GET_MARKETS = gql`
query getMarkets {
getMarkets
}
`
const FiatCurrencyChangeAlert = ({ open, close, save }) => {
return (
<Modal
title={'Change fiat currency?'}
handleClose={close}
width={450}
height={310}
open={open}>
<P>
Please note that all values you set that were based on your prior fiat
currency are still the same. If you need to adjust these to reflect the
new fiat currency (such as minimum transaction amounts, fixed fees, and
compliance triggers, for example), please do so now.
</P>
<P>
Also, if you have cash-out enabled, you must define new dispenser bill
counts for the new currency for cash-out on the new currency to work.
</P>
<div className="ml-auto">
<Link onClick={close} color="secondary">
Cancel
</Link>
<Link className="ml-5" onClick={save} color="primary">
Save
</Link>
</div>
</Modal>
)
}
const Locales = ({ name: SCREEN_KEY }) => {
const [wizard, setWizard] = useState(false)
const [onChangeFunction, setOnChangeFunction] = useState(null)
const [error, setError] = useState(null)
const [isEditingDefault, setEditingDefault] = useState(false)
const [isEditingOverrides, setEditingOverrides] = useState(false)
const { data } = useQuery(GET_DATA)
const { data: marketsData } = useQuery(GET_MARKETS)
const schemas = _schemas(marketsData?.getMarkets)
const [saveConfig] = useMutation(SAVE_CONFIG, {
onCompleted: () => setWizard(false),
refetchQueries: () => ['getData'],
onError: error => setError(error)
})
const [dataToSave, setDataToSave] = useState(null)
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
const wallets = data?.config && fromNamespace(namespaces.WALLETS)(data.config)
const accountsConfig = data?.accountsConfig
const accounts = data?.accounts ?? []
const cryptoCurrencies = data?.cryptoCurrencies ?? []
const locale = config && !R.isEmpty(config) ? config : localeDefaults
const localeOverrides = locale.overrides ?? []
const handleSave = it => {
const newConfig = toNamespace(SCREEN_KEY)(it.locale[0])
if (
config.fiatCurrency &&
newConfig.locale_fiatCurrency !== config.fiatCurrency
)
return setDataToSave(newConfig)
return save(newConfig)
}
const save = (config, accounts) => {
setDataToSave(null)
return saveConfig({ variables: { config, accounts } })
}
const saveOverrides = it => {
const config = toNamespace(SCREEN_KEY)(it)
setError(null)
return saveConfig({ variables: { config } })
}
const onChangeCoin = (prev, curr, setValue) => {
const coin = R.difference(curr, prev)[0]
if (!coin) return setValue(curr)
const namespaced = fromNamespace(coin)(wallets)
if (!WalletSchema.isValidSync(namespaced)) {
setOnChangeFunction(() => () => setValue(curr))
setWizard(coin)
return
}
setValue(curr)
}
const onEditingDefault = (it, editing) => setEditingDefault(editing)
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
const wizardSave = (config, accounts) =>
save(toNamespace(namespaces.WALLETS)(config), accounts).then(it => {
onChangeFunction()
setOnChangeFunction(null)
return it
})
return (
<>
<FiatCurrencyChangeAlert
open={dataToSave}
close={() => setDataToSave(null)}
save={() => dataToSave && save(dataToSave)}
/>
<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"
error={error?.message}
titleLg
name="locale"
enableEdit
initialValues={locale}
save={handleSave}
validationSchema={LocaleSchema}
data={R.of(locale)}
elements={mainFields(data, onChangeCoin)}
setEditing={onEditingDefault}
forceDisable={isEditingOverrides}
/>
</Section>
<Section>
<EditableTable
error={error?.message}
title="Overrides"
titleLg
name="overrides"
enableDelete
enableEdit
enableCreate
initialValues={overridesDefaults}
save={saveOverrides}
validationSchema={OverridesSchema}
data={localeOverrides ?? []}
elements={overrides(data, localeOverrides, onChangeCoin)}
disableAdd={R.compose(R.isEmpty, R.difference)(
data?.machines.map(m => m.deviceId) ?? [],
localeOverrides?.map(o => o.machine) ?? []
)}
setEditing={onEditingOverrides}
forceDisable={isEditingDefault}
/>
</Section>
{wizard && (
<Wizard
schemas={schemas}
coin={R.find(R.propEq('code', wizard))(cryptoCurrencies)}
onClose={() => setWizard(false)}
save={wizardSave}
error={error?.message}
cryptoCurrencies={cryptoCurrencies}
userAccounts={data?.config?.accounts}
accounts={accounts}
accountsConfig={accountsConfig}
/>
)}
</>
)
}
export default Locales

View file

@ -0,0 +1,192 @@
import * as R from 'ramda'
import Autocomplete from 'src/components/inputs/formik/Autocomplete'
import * as Yup from 'yup'
import { labels as timezoneList } from 'src/utils/timezone-list'
const getFields = (getData, names, onChange, auxElements = []) => {
return R.filter(
it => R.includes(it.name, names),
allFields(getData, onChange, auxElements)
)
}
const allFields = (getData, onChange, auxElements = []) => {
const getView = (data, code, compare) => it => {
if (!data) return ''
return R.compose(
it => `${R.prop(code)(it)} ${it?.isBeta ? '(Beta)' : ''}`,
R.find(R.propEq(compare ?? 'code', it))
)(data)
}
const displayCodeArray = data => it => {
if (!it) return it
return R.compose(R.join(', '), R.map(getView(data, 'code')))(it)
}
const overriddenMachines = R.map(override => override.machine, auxElements)
const suggestionFilter = it =>
R.differenceWith((x, y) => x.deviceId === y, it, overriddenMachines)
const machineData = getData(['machines'])
const countryData = getData(['countries'])
const currencyData = getData(['currencies'])
const languageData = getData(['languages'])
const rawCryptoData = getData(['cryptoCurrencies'])
const cryptoData = rawCryptoData?.map(it => {
it.codeLabel = `${it.code}${it.isBeta ? ' (Beta)' : ''}`
return it
})
const timezonesData = timezoneList
const findSuggestion = it => {
const machine = R.find(R.propEq('deviceId', it.machine))(machineData)
return machine ? [machine] : []
}
return [
{
name: 'machine',
width: 200,
size: 'sm',
view: getView(machineData, 'name', 'deviceId'),
input: Autocomplete,
inputProps: {
options: it =>
R.concat(findSuggestion(it))(suggestionFilter(machineData)),
valueProp: 'deviceId',
labelProp: 'name'
}
},
{
name: 'country',
width: 200,
size: 'sm',
view: getView(countryData, 'display'),
input: Autocomplete,
inputProps: {
options: countryData,
valueProp: 'code',
labelProp: 'display'
}
},
{
name: 'fiatCurrency',
width: 150,
size: 'sm',
view: getView(currencyData, 'code'),
input: Autocomplete,
inputProps: {
options: currencyData,
valueProp: 'code',
labelProp: 'code'
}
},
{
name: 'languages',
width: 200,
size: 'sm',
view: displayCodeArray(languageData),
input: Autocomplete,
inputProps: {
options: languageData,
valueProp: 'code',
labelProp: 'display',
multiple: true
}
},
{
name: 'cryptoCurrencies',
width: 170,
size: 'sm',
view: displayCodeArray(cryptoData),
input: Autocomplete,
inputProps: {
options: cryptoData,
valueProp: 'code',
labelProp: 'codeLabel',
multiple: true,
optionsLimit: null,
onChange
}
},
{
name: 'timezone',
width: 320,
size: 'sm',
view: getView(timezonesData, 'label'),
input: Autocomplete,
inputProps: {
options: timezonesData,
valueProp: 'code',
labelProp: 'label'
}
}
]
}
const mainFields = (auxData, configureCoin) => {
const getData = R.path(R.__, auxData)
return getFields(
getData,
['country', 'fiatCurrency', 'languages', 'cryptoCurrencies', 'timezone'],
configureCoin,
undefined
)
}
const overrides = (auxData, auxElements, configureCoin) => {
const getData = R.path(R.__, auxData)
return getFields(
getData,
['machine', 'country', 'languages', 'cryptoCurrencies'],
configureCoin,
auxElements
)
}
const LocaleSchema = Yup.object().shape({
country: Yup.string().label('Country').required(),
fiatCurrency: Yup.string().label('Fiat currency').required(),
languages: Yup.array().label('Languages').required().min(1).max(4),
cryptoCurrencies: Yup.array().label('Crypto currencies').required().min(1),
timezone: Yup.string().label('Timezone').required()
})
const OverridesSchema = Yup.object().shape({
machine: Yup.string().label('Machine').required(),
country: Yup.string().label('Country').required(),
languages: Yup.array().label('Languages').required().min(1).max(4),
cryptoCurrencies: Yup.array().label('Crypto currencies').required().min(1)
})
const localeDefaults = {
country: '',
fiatCurrency: '',
languages: [],
cryptoCurrencies: [],
timezone: ''
}
const overridesDefaults = {
machine: '',
country: '',
languages: [],
cryptoCurrencies: []
}
export {
mainFields,
overrides,
LocaleSchema,
OverridesSchema,
localeDefaults,
overridesDefaults
}

View file

@ -0,0 +1,3 @@
import Locales from './Locales'
export default Locales

View file

@ -0,0 +1,74 @@
.titleWrapper {
display: flex;
justify-content: space-between;
align-items: center;
flex-direction: row;
}
.wrapper {
flex: 1;
display: flex;
flex-direction: row;
height: 100%;
}
.tableWrapper {
flex: 1;
margin-left: 40px;
display: block;
overflow-x: auto;
width: 100%;
max-width: 78%;
max-height: 70vh;
}
.table {
white-space: nowrap;
display: block;
}
.table th {
position: sticky;
top: 0;
}
.dateColumn {
min-width: 160px;
}
.levelColumn {
min-width: 100px;
}
.fillColumn {
width: 100%;
}
.shareButton {
margin: 8px;
display: flex;
align-items: center;
font-size: 13px;
padding: 0 12px;
}
.shareIcon {
margin-right: 6px;
}
.button {
margin: 8px;
}
.titleAndButtonsContainer {
display: flex;
}
.buttonsWrapper {
display: flex;
margin-left: 16px;
}
.buttonsWrapper > * {
margin: auto 6px;
}

View file

@ -0,0 +1,168 @@
import { useQuery, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper.jsx'
import Title from 'src/components/Title.jsx'
import Sidebar from 'src/components/layout/Sidebar.jsx'
import { Info3, H4 } from 'src/components/typography/index.jsx'
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell
} from 'src/components/table/index.js'
import { formatDate } from 'src/utils/timezones.js'
import classes from './Logs.module.css'
const GET_MACHINES = gql`
{
machines {
name
deviceId
}
}
`
const NUM_LOG_RESULTS = 500
const GET_MACHINE_LOGS_CSV = gql`
query MachineLogs(
$deviceId: ID!
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
$timezone: String
) {
machineLogsCsv(
deviceId: $deviceId
limit: $limit
from: $from
until: $until
timezone: $timezone
)
}
`
const GET_MACHINE_LOGS = gql`
query MachineLogs(
$deviceId: ID!
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
) {
machineLogs(
deviceId: $deviceId
limit: $limit
from: $from
until: $until
) {
logLevel
id
timestamp
message
}
}
`
const GET_DATA = gql`
query getData {
config
}
`
const Logs = () => {
const [selected, setSelected] = useState(null)
const [saveMessage, setSaveMessage] = useState(null)
const deviceId = selected?.deviceId
const { data: machineResponse, loading: machinesLoading } =
useQuery(GET_MACHINES)
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const { data: logsResponse, loading: logsLoading } = useQuery(
GET_MACHINE_LOGS,
{
variables: { deviceId, limit: NUM_LOG_RESULTS },
skip: !selected,
onCompleted: () => setSaveMessage('')
}
)
if (machineResponse?.machines?.length && !selected) {
setSelected(machineResponse?.machines[0])
}
const isSelected = it => {
return R.path(['deviceId'])(selected) === it.deviceId
}
const loading = machinesLoading || configLoading || logsLoading
return (
<>
<div className={classes.titleWrapper}>
<div className={classes.titleAndButtonsContainer}>
<Title>Machine logs</Title>
{logsResponse && (
<div className={classes.buttonsWrapper}>
<LogsDowloaderPopover
title="Download logs"
name={selected.name}
query={GET_MACHINE_LOGS_CSV}
args={{ deviceId, timezone }}
getLogs={logs => R.path(['machineLogsCsv'])(logs)}
timezone={timezone}
/>
<Info3>{saveMessage}</Info3>
</div>
)}
</div>
</div>
<div className={classes.wrapper}>
<Sidebar
displayName={it => it.name}
data={machineResponse?.machines || []}
isSelected={isSelected}
onClick={setSelected}
/>
<div className={classes.tableWrapper}>
<Table className={classes.table}>
<TableHead>
<TableRow header>
<TableHeader className={classes.dateColumn}>Date</TableHeader>
<TableHeader className={classes.levelColumn}>Level</TableHeader>
<TableHeader className={classes.fillColumn} />
</TableRow>
</TableHead>
<TableBody>
{logsResponse &&
logsResponse.machineLogs.map((log, idx) => (
<TableRow key={idx} size="sm">
<TableCell>
{timezone &&
formatDate(log.timestamp, timezone, 'yyyy-MM-dd HH:mm')}
</TableCell>
<TableCell>{log.logLevel}</TableCell>
<TableCell>{log.message}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{loading && <H4>{'Loading...'}</H4>}
{!loading && !logsResponse?.machineLogs?.length && (
<H4>{'No activity so far'}</H4>
)}
</div>
</div>
</>
)
}
export default Logs

View file

@ -0,0 +1,197 @@
import { useQuery, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState, useRef } from 'react'
import LogsDowloaderPopover from 'src/components/LogsDownloaderPopper.jsx'
import Title from 'src/components/Title.jsx'
import Uptime from 'src/pages/Logs/Uptime.jsx'
import { Info3, H4 } from 'src/components/typography/index.jsx'
import { Select } from 'src/components/inputs/index.js'
import {
Table,
TableHead,
TableRow,
TableHeader,
TableBody,
TableCell
} from 'src/components/table/index.js'
import { startCase } from 'src/utils/string.js'
import { formatDate } from 'src/utils/timezones.js'
import logsClasses from './Logs.module.css'
import classes from './ServerLogs.module.css'
const SHOW_ALL = { code: 'SHOW_ALL', display: 'Show all' }
const NUM_LOG_RESULTS = 500
const GET_CSV = gql`
query ServerData(
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
$timezone: String
) {
serverLogsCsv(
limit: $limit
from: $from
until: $until
timezone: $timezone
)
}
`
const GET_SERVER_DATA = gql`
query ServerData($limit: Int, $from: DateTimeISO, $until: DateTimeISO) {
serverVersion
uptime {
name
state
uptime
}
serverLogs(limit: $limit, from: $from, until: $until) {
logLevel
id
timestamp
message
}
}
`
const GET_DATA = gql`
query getData {
config
}
`
const Logs = () => {
const tableEl = useRef()
const [saveMessage, setSaveMessage] = useState(null)
const [logLevel, setLogLevel] = useState(SHOW_ALL)
const { data, loading: dataLoading } = useQuery(GET_SERVER_DATA, {
onCompleted: () => setSaveMessage(''),
variables: {
limit: NUM_LOG_RESULTS
}
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const defaultLogLevels = [
{ code: 'error', display: 'Error' },
{ code: 'info', display: 'Info' },
{ code: 'debug', display: 'Debug' }
]
const serverVersion = data?.serverVersion
const processStates = data?.uptime ?? []
const getLogLevels = R.compose(
R.prepend(SHOW_ALL),
R.uniq,
R.concat(defaultLogLevels),
R.map(it => ({
code: R.path(['logLevel'])(it),
display: startCase(R.path(['logLevel'])(it))
})),
R.path(['serverLogs'])
)
const handleLogLevelChange = logLevel => {
if (tableEl.current) tableEl.current.scrollTo(0, 0)
setLogLevel(logLevel)
}
const loading = dataLoading || configLoading
return (
<>
<div className={logsClasses.titleWrapper}>
<div className={logsClasses.titleAndButtonsContainer}>
<Title>Server</Title>
{data && (
<div className={logsClasses.buttonsWrapper}>
<LogsDowloaderPopover
title="Download logs"
name="server-logs"
query={GET_CSV}
args={{ timezone }}
logs={data.serverLogs}
getLogs={logs => R.path(['serverLogsCsv'])(logs)}
timezone={timezone}
/>
<Info3>{saveMessage}</Info3>
</div>
)}
</div>
<div className={classes.serverVersion}>
{serverVersion && <span>Server version: v{serverVersion}</span>}
</div>
</div>
<div className={classes.headerLine2}>
{data && (
<Select
onSelectedItemChange={handleLogLevelChange}
label="Level"
items={getLogLevels(data)}
default={SHOW_ALL}
selectedItem={logLevel}
/>
)}
<div className={classes.uptimeContainer}>
{processStates &&
processStates.map((process, idx) => (
<Uptime key={idx} process={process} />
))}
</div>
</div>
<div className={logsClasses.wrapper}>
<div ref={tableEl} className={classes.serverTableWrapper}>
<Table className={logsClasses.table}>
<TableHead>
<TableRow header>
<TableHeader className={logsClasses.dateColumn}>
Date
</TableHeader>
<TableHeader className={logsClasses.levelColumn}>
Level
</TableHeader>
<TableHeader className={logsClasses.fillColumn} />
</TableRow>
</TableHead>
<TableBody>
{data &&
data.serverLogs
.filter(
log =>
logLevel === SHOW_ALL || log.logLevel === logLevel.code
)
.map((log, idx) => (
<TableRow key={idx} size="sm">
<TableCell>
{timezone &&
formatDate(
log.timestamp,
timezone,
'yyyy-MM-dd HH:mm'
)}
</TableCell>
<TableCell>{log.logLevel}</TableCell>
<TableCell>{log.message}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{loading && <H4>{'Loading...'}</H4>}
{!loading && !data?.serverLogs?.length && (
<H4>{'No activity so far'}</H4>
)}
</div>
</div>
</>
)
}
export default Logs

View file

@ -0,0 +1,20 @@
.serverTableWrapper {
composes: tableWrapper from 'Logs.module.css';
max-width: 100%;
margin-left: 0;
}
.serverVersion {
color: var(--comet);
margin: auto 0 auto 0;
}
.headerLine2 {
display: flex;
justify-content: space-between;
margin-bottom: 24px;
}
.uptimeContainer {
margin: auto 0 auto 0;
}

View file

@ -0,0 +1,33 @@
import Chip from '@mui/material/Chip'
import * as R from 'ramda'
import React from 'react'
import { onlyFirstToUpper } from 'src/utils/string.js'
import { Label1 } from 'src/components/typography/index.jsx'
const Uptime = ({ process }) => {
const uptime = time => {
if (time < 60) return `${time}s`
if (time < 3600) return `${Math.floor(time / 60)}m`
if (time < 86400) return `${Math.floor(time / 60 / 60)}h`
return `${Math.floor(time / 60 / 60 / 24)}d`
}
return (
<div className="inline-block min-w-26 my-0 mx-5">
<Label1 noMargin className="pl-1 color-comet">
{R.toLower(process.name)}
</Label1>
<Chip
color={process.state === 'RUNNING' ? 'success' : 'error'}
label={
process.state === 'RUNNING'
? `Running for ${uptime(process.uptime)}`
: onlyFirstToUpper(process.state)
}
/>
</div>
)
}
export default Uptime

View file

@ -0,0 +1,144 @@
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { HelpTooltip } from 'src/components/Tooltip'
import { H1, H3, P } from 'src/components/typography'
import * as Yup from 'yup'
import { Button } from 'src/components/buttons'
import { NumberInput, Autocomplete } from 'src/components/inputs/formik'
const initialValues = {
customer: '',
discount: ''
}
const validationSchema = Yup.object().shape({
customer: Yup.string().required('A customer is required!'),
discount: Yup.number()
.required('A discount rate is required!')
.min(0, 'Discount rate should be a positive number!')
.max(100, 'Discount rate should have a maximum value of 100%!')
})
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Internal server error'
if (formikErrors.customer && formikTouched.customer)
return formikErrors.customer
if (formikErrors.discount && formikTouched.discount)
return formikErrors.discount
return null
}
const IndividualDiscountModal = ({
showModal,
setShowModal,
onClose,
creationError,
addDiscount,
customers
}) => {
const handleAddDiscount = (customer, discount) => {
addDiscount({
variables: {
customerId: customer,
discount: parseInt(discount)
}
})
setShowModal(false)
}
return (
<>
{showModal && (
<Modal
title="Add individual customer discount"
closeOnBackdropClick={true}
width={600}
height={500}
handleClose={onClose}
open={true}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={({ customer, discount }) => {
handleAddDiscount(customer, discount)
}}>
{({ errors, touched }) => (
<Form
id="individual-discount-form"
className="flex flex-col h-full gap-5">
<div className="mt-2 w-88">
<Field
name="customer"
label="Select a customer"
component={Autocomplete}
fullWidth
options={R.map(it => ({
code: it.id,
display: `${it?.idCardData?.firstName ?? ``}${
it?.idCardData?.firstName && it?.idCardData?.lastName
? ` `
: ``
}${it?.idCardData?.lastName ?? ``} (${it.phone})`
}))(customers)}
labelProp="display"
valueProp="code"
/>
</div>
<div>
<div className="flex items-center">
<H3>Define discount rate</H3>
<HelpTooltip width={304}>
<P>
This is a percentage discount off of your existing
commission rates for a customer entering this code at
the machine.
</P>
<P>
For instance, if you charge 8% commissions, and this
code is set for 50%, then you'll instead be charging 4%
on transactions using the code.
</P>
</HelpTooltip>
</div>
<div className="flex items-center">
<Field
name="discount"
size="lg"
autoComplete="off"
width={50}
decimalScale={0}
component={NumberInput}
/>
<H1 className="ml-2 mt-4 font-bold inline">%</H1>
</div>
</div>
<div className="flex mt-auto mb-6">
{getErrorMsg(errors, touched, creationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, creationError)}
</ErrorMessage>
)}
<Button
type="submit"
form="individual-discount-form"
className="ml-auto">
Add discount
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)}
</>
)
}
export default IndividualDiscountModal

View file

@ -0,0 +1,204 @@
import IconButton from '@mui/material/IconButton'
import SvgIcon from '@mui/material/SvgIcon'
import { useQuery, useMutation, gql } from '@apollo/client'
import * as R from 'ramda'
import React, { useState } from 'react'
import { Link, Button } from 'src/components/buttons'
import { DeleteDialog } from 'src/components/DeleteDialog'
import DataTable from 'src/components/tables/DataTable'
import { Label3, TL1 } from 'src/components/typography'
import PhoneIdIcon from 'src/styling/icons/ID/phone/zodiac.svg?react'
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
import IndividualDiscountModal from './IndividualDiscountModal'
import classnames from 'classnames'
const GET_INDIVIDUAL_DISCOUNTS = gql`
query individualDiscounts {
individualDiscounts {
id
customer {
id
phone
idCardData
}
discount
}
}
`
const DELETE_DISCOUNT = gql`
mutation deleteIndividualDiscount($discountId: ID!) {
deleteIndividualDiscount(discountId: $discountId) {
id
}
}
`
const CREATE_DISCOUNT = gql`
mutation createIndividualDiscount($customerId: ID!, $discount: Int!) {
createIndividualDiscount(customerId: $customerId, discount: $discount) {
id
}
}
`
const GET_CUSTOMERS = gql`
{
customers {
id
phone
idCardData
}
}
`
const IndividualDiscounts = () => {
const [deleteDialog, setDeleteDialog] = useState(false)
const [toBeDeleted, setToBeDeleted] = useState()
const [errorMsg, setErrorMsg] = useState('')
const [showModal, setShowModal] = useState(false)
const toggleModal = () => setShowModal(!showModal)
const { data: discountResponse, loading } = useQuery(GET_INDIVIDUAL_DISCOUNTS)
const { data: customerData, loading: customerLoading } =
useQuery(GET_CUSTOMERS)
const [createDiscount, { error: creationError }] = useMutation(
CREATE_DISCOUNT,
{
refetchQueries: () => ['individualDiscounts']
}
)
const [deleteDiscount] = useMutation(DELETE_DISCOUNT, {
onError: ({ message }) => {
const errorMessage = message ?? 'Error while deleting row'
setErrorMsg(errorMessage)
},
onCompleted: () => setDeleteDialog(false),
refetchQueries: () => ['individualDiscounts']
})
const elements = [
{
header: 'Identification',
width: 312,
textAlign: 'left',
size: 'sm',
view: t => {
return (
<div className="flex items-center gap-2">
<PhoneIdIcon />
<span>{t.customer.phone}</span>
</div>
)
}
},
{
header: 'Name',
width: 300,
textAlign: 'left',
size: 'sm',
view: t => {
const customer = t.customer
if (R.isNil(customer.idCardData)) {
return <>{'-'}</>
}
return (
<>{`${customer.idCardData.firstName ?? ``}${
customer.idCardData.firstName && customer.idCardData.lastName
? ` `
: ``
}${customer.idCardData.lastName ?? ``}`}</>
)
}
},
{
header: 'Discount rate',
width: 220,
textAlign: 'left',
size: 'sm',
view: t => (
<>
<TL1 inline>{t.discount}</TL1> %
</>
)
},
{
header: 'Revoke',
width: 100,
textAlign: 'center',
size: 'sm',
view: t => (
<IconButton
onClick={() => {
setDeleteDialog(true)
setToBeDeleted({ variables: { discountId: t.id } })
}}>
<SvgIcon>
<DeleteIcon />
</SvgIcon>
</IconButton>
)
}
]
return (
<>
{!loading && !R.isEmpty(discountResponse.individualDiscounts) && (
<>
<div className="flex justify-end mb-8 -mt-14">
<Link
color="primary"
onClick={toggleModal}
className={classnames({ 'cursor-wait': customerLoading })}
disabled={customerLoading}>
Add new code
</Link>
</div>
<DataTable
elements={elements}
data={R.path(['individualDiscounts'])(discountResponse)}
/>
<DeleteDialog
open={deleteDialog}
onDismissed={() => {
setDeleteDialog(false)
setErrorMsg(null)
}}
onConfirmed={() => {
setErrorMsg(null)
deleteDiscount(toBeDeleted)
}}
errorMessage={errorMsg}
/>
</>
)}
{!loading && R.isEmpty(discountResponse.individualDiscounts) && (
<div className="flex items-start flex-col">
<Label3>
It seems there are no active individual customer discounts on your
network.
</Label3>
<Button onClick={toggleModal}>Add individual discount</Button>
</div>
)}
<IndividualDiscountModal
showModal={showModal}
setShowModal={setShowModal}
onClose={() => {
setShowModal(false)
}}
creationError={creationError}
addDiscount={createDiscount}
customers={R.path(['customers'])(customerData)}
/>
</>
)
}
export default IndividualDiscounts

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