387 lines
12 KiB
JavaScript
387 lines
12 KiB
JavaScript
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 '../../components/layout/TitleSection'
|
|
import { Info2, P } from '../../components/typography'
|
|
import DownIcon from '../../styling/icons/dashboard/down.svg?react'
|
|
import EqualIcon from '../../styling/icons/dashboard/equal.svg?react'
|
|
import UpIcon from '../../styling/icons/dashboard/up.svg?react'
|
|
|
|
import { Select } from '../../components/inputs'
|
|
import { fromNamespace } from '../../utils/config'
|
|
import { numberToFiatAmount } from '../../utils/number'
|
|
import { DAY, WEEK, MONTH } from '../../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(item.fiatCode, 'code'))(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
|