Merge pull request #389 from mautematico/feat-add-cash-out-screen

feat: add cash out screen
This commit is contained in:
Rafael Taranto 2020-05-09 19:57:59 +01:00 committed by GitHub
commit d23067f679
10 changed files with 549 additions and 0 deletions

View file

@ -0,0 +1,112 @@
import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { memo } from 'react'
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
import { ReactComponent as CurrentStageIconSpring } from 'src/styling/icons/stage/spring/current.svg'
import { ReactComponent as EmptyStageIconSpring } from 'src/styling/icons/stage/spring/empty.svg'
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
import {
primaryColor,
secondaryColor,
offColor,
disabledColor
} from 'src/styling/variables'
const styles = {
stages: {
display: 'flex',
alignItems: 'center'
},
wrapper: {
display: 'flex',
alignItems: 'center',
margin: 0
},
stage: {
display: 'flex',
height: 28,
width: 28,
zIndex: 2,
'& > svg': {
height: '100%',
width: '100%',
overflow: 'visible'
}
},
separator: {
width: 28,
height: 2,
border: [[2, 'solid']],
zIndex: 1
},
separatorSpring: {
borderColor: secondaryColor
},
separatorZodiac: {
borderColor: primaryColor
},
separatorSpringEmpty: {
borderColor: disabledColor
},
separatorZodiacEmpty: {
borderColor: offColor
}
}
const useStyles = makeStyles(styles)
const Stage = memo(({ stages, currentStage, color = 'spring', className }) => {
if (currentStage < 1 || currentStage > stages)
throw Error('Value of currentStage is invalid')
if (stages < 1) throw Error('Value of stages is invalid')
const classes = useStyles()
const separatorClasses = {
[classes.separator]: true,
[classes.separatorSpring]: color === 'spring',
[classes.separatorZodiac]: color === 'zodiac'
}
const separatorEmptyClasses = {
[classes.separator]: true,
[classes.separatorSpringEmpty]: color === 'spring',
[classes.separatorZodiacEmpty]: color === 'zodiac'
}
return (
<div className={classnames(className, classes.stages)}>
{R.range(1, currentStage).map(idx => (
<div key={idx} className={classes.wrapper}>
{idx > 1 && <div className={classnames(separatorClasses)} />}
<div className={classes.stage}>
{color === 'spring' && <CompleteStageIconSpring />}
{color === 'zodiac' && <CompleteStageIconZodiac />}
</div>
</div>
))}
<div className={classes.wrapper}>
{currentStage > 1 && <div className={classnames(separatorClasses)} />}
<div className={classes.stage}>
{color === 'spring' && <CurrentStageIconSpring />}
{color === 'zodiac' && <CurrentStageIconZodiac />}
</div>
</div>
{R.range(currentStage + 1, stages + 1).map(idx => (
<div key={idx} className={classes.wrapper}>
<div className={classnames(separatorEmptyClasses)} />
<div className={classes.stage}>
{color === 'spring' && <EmptyStageIconSpring />}
{color === 'zodiac' && <EmptyStageIconZodiac />}
</div>
</div>
))}
</div>
)
})
export default Stage

View file

@ -0,0 +1,91 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { gql } from 'apollo-boost'
import * as R from 'ramda'
import React, { useState } from 'react'
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
import TitleSection from 'src/components/layout/TitleSection'
import { fromNamespace, toNamespace } from 'src/utils/config'
import Wizard from './Wizard'
import { DenominationsSchema, getElements } from './helper'
const SAVE_CONFIG = gql`
mutation Save($config: JSONObject, $accounts: [JSONObject]) {
saveConfig(config: $config)
saveAccounts(accounts: $accounts)
}
`
const GET_INFO = gql`
query getData {
machines {
name
deviceId
cashbox
cassette1
cassette2
}
config
}
`
const CashOut = ({ name: SCREEN_KEY }) => {
const [wizard, setWizard] = useState(false)
const [error, setError] = useState(false)
const { data } = useQuery(GET_INFO)
const [saveConfig] = useMutation(SAVE_CONFIG, {
onCompleted: () => setWizard(false),
onError: () => setError(true),
refetchQueries: () => ['getData']
})
const save = (rawConfig, accounts) => {
const config = toNamespace(SCREEN_KEY)(rawConfig)
setError(false)
return saveConfig({ variables: { config, accounts } })
}
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
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 }))
}
return (
<>
<TitleSection title="Cash-out" error={error} />
<EditableTable
name="test"
namespaces={R.map(R.path(['deviceId']))(machines)}
data={config}
stripeWhen={it => !DenominationsSchema.isValidSync(it)}
enableEdit
editWidth={134}
enableToggle
toggleWidth={109}
onToggle={onToggle}
save={save}
validationSchema={DenominationsSchema}
disableRowEdit={R.compose(R.not, R.path(['active']))}
elements={getElements(machines, locale)}
/>
{wizard && (
<Wizard
machine={R.find(R.propEq('deviceId', wizard))(machines)}
onClose={() => setWizard(false)}
save={save}
error={error}
/>
)}
</>
)
}
export default CashOut

View file

@ -0,0 +1,71 @@
import * as R from 'ramda'
import React, { useState } from 'react'
import Modal from 'src/components/Modal'
import { toNamespace } from 'src/utils/config'
import WizardSplash from './WizardSplash'
import WizardStep from './WizardStep'
const LAST_STEP = 3
const MODAL_WIDTH = 554
const Wizard = ({ machine, onClose, save, error }) => {
const [{ step, config }, setState] = useState({
step: 0,
config: { active: true }
})
const title = `Enable cash-out`
const isLastStep = step === LAST_STEP
const onContinue = async it => {
const newConfig = R.merge(config, it)
if (isLastStep) {
return save(toNamespace(machine.deviceId, newConfig))
}
setState({
step: step + 1,
config: newConfig
})
}
const getStepData = () => {
switch (step) {
case 1:
return { type: 'top', display: 'Cassete 1 (Top)' }
case 2:
return { type: 'bottom', display: 'Cassete 2' }
case 3:
return { type: 'agreed' }
default:
return null
}
}
return (
<Modal
title={step === 0 ? null : title}
handleClose={onClose}
width={MODAL_WIDTH}
open={true}>
{step === 0 && (
<WizardSplash name={machine.name} onContinue={() => onContinue()} />
)}
{step !== 0 && (
<WizardStep
step={step}
name={machine.name}
error={error}
lastStep={isLastStep}
{...getStepData()}
onContinue={onContinue}
/>
)}
</Modal>
)
}
export default Wizard

View file

@ -0,0 +1,53 @@
import { makeStyles } from '@material-ui/core'
import React from 'react'
import { Button } from 'src/components/buttons'
import { H1, P } from 'src/components/typography'
const styles = {
logo: {
maxHeight: 80,
maxWidth: 200
},
title: {
margin: [[24, 0, 32, 0]]
},
text: {
margin: 0
},
button: {
marginTop: 'auto',
marginBottom: 58
},
modalContent: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: [[0, 42]],
flex: 1
}
}
const useStyles = makeStyles(styles)
const WizardSplash = ({ name, onContinue }) => {
const classes = useStyles()
return (
<div className={classes.modalContent}>
<H1 className={classes.title}>Enable cash-out</H1>
<P className={classes.text}>
You are about to activate cash-out functionality on your {name} machine
which will allow your customers to sell crypto to you.
<br />
In order to activate cash-out for this machine, please enter the
denominations for the machine.
</P>
<Button className={classes.button} onClick={onContinue}>
Start configuration
</Button>
</div>
)
}
export default WizardSplash

View file

@ -0,0 +1,130 @@
import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import * as R from 'ramda'
import React, { useReducer, useEffect } from 'react'
import ErrorMessage from 'src/components/ErrorMessage'
import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs'
import { Info2, H4, P } from 'src/components/typography'
import styles from './WizardStep.styles'
const useStyles = makeStyles(styles)
const initialState = {
selected: null,
iError: false
}
const reducer = (state, action) => {
switch (action.type) {
case 'select':
return {
form: null,
selected: action.selected,
isNew: null,
iError: false
}
case 'form':
return {
form: action.form,
selected: action.form.code,
isNew: true,
iError: false
}
case 'error':
return R.merge(state, { iError: true })
case 'reset':
return initialState
default:
throw new Error()
}
}
const WizardStep = ({
type,
name,
step,
error,
lastStep,
onContinue,
display
}) => {
const classes = useStyles()
const [{ iError, selected }, dispatch] = useReducer(reducer, initialState)
useEffect(() => {
dispatch({ type: 'reset' })
}, [step])
const iContinue = config => {
if (lastStep) config[type] = true
if (!config || !config[type]) {
return dispatch({ type: 'error' })
}
onContinue(config)
}
const label = lastStep ? 'Finish' : 'Next'
const subtitleClass = {
[classes.subtitle]: true,
[classes.error]: iError
}
return (
<>
<Info2 className={classes.title}>{name}</Info2>
<Stepper steps={3} currentStep={step} />
{display && <H4 className={classnames(subtitleClass)}>Edit {display}</H4>}
{!lastStep && (
<TextInput
label={'Choose bill denomination'}
onChange={evt =>
dispatch({ type: 'select', selected: evt.target.value })
}
autoFocus
id="confirm-input"
type="text"
size="lg"
touched={{}}
error={false}
InputLabelProps={{ shrink: true }}
/>
// TODO: there was a disabled link here showing the currency code; restore it
)}
{lastStep && (
<>
<P>
When enabling cash out, your bill count will be authomatically set
to zero. Make sure you physically put cash inside the cashboxes to
allow the machine to dispense it to your users. If you already did,
make sure you set the correct cash out bill count for this machine
on your Cashboxes tab under Maintenance.
</P>
<P>
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 className={classes.submit}>
{error && <ErrorMessage>Failed to save</ErrorMessage>}
<Button
className={classes.button}
onClick={() => iContinue({ [type]: selected })}>
{label}
</Button>
</div>
</>
)
}
export default WizardStep

View file

@ -0,0 +1,26 @@
import { errorColor } from 'src/styling/variables'
const LABEL_WIDTH = 150
export default {
title: {
margin: [[0, 0, 12, 0]]
},
subtitle: {
margin: [[32, 0, 21, 0]]
},
error: {
color: errorColor
},
button: {
marginLeft: 'auto'
},
submit: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, 24]]
},
picker: {
width: LABEL_WIDTH
}
}

View file

@ -0,0 +1,41 @@
import * as Yup from 'yup'
import TextInput from 'src/components/inputs/formik/TextInput'
const DenominationsSchema = Yup.object().shape({
top: Yup.number().required('Required'),
bottom: Yup.number().required('Required')
})
const getElements = (machines, { fiatCurrency } = {}) => {
return [
{
name: 'id',
header: 'Machine',
width: 254,
view: it => machines.find(({ deviceId }) => deviceId === it).name,
size: 'sm',
editable: false
},
{
name: 'top',
header: 'Cassette 1 (Top)',
view: it => `${it} ${fiatCurrency}`,
size: 'sm',
stripe: true,
width: 265,
input: TextInput
},
{
name: 'bottom',
header: 'Cassette 2',
view: it => `${it} ${fiatCurrency}`,
size: 'sm',
stripe: true,
width: 265,
input: TextInput
}
]
}
export { DenominationsSchema, getElements }

View file

@ -0,0 +1,17 @@
export default {
titleWrapper: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexDirection: 'row'
},
titleAndButtonsContainer: {
display: 'flex'
},
iconButton: {
border: 'none',
outline: 0,
backgroundColor: 'transparent',
cursor: 'pointer'
}
}

View file

@ -3,6 +3,7 @@ import React from 'react'
import { Route, Redirect, Switch } from 'react-router-dom'
import AuthRegister from 'src/pages/AuthRegister'
import Cashout from 'src/pages/Cashout/Cashout'
import Commissions from 'src/pages/Commissions'
import { Customers, CustomerProfile } from 'src/pages/Customers'
import Funding from 'src/pages/Funding'
@ -87,6 +88,12 @@ const tree = [
route: '/settings/locale',
component: Locales
},
{
key: namespaces.CASH_OUT,
label: 'Cash-out',
route: '/settings/cash-out',
component: Cashout
},
{
key: namespaces.SERVICES,
label: '3rd party services',

View file

@ -1,6 +1,7 @@
import * as R from 'ramda'
const namespaces = {
CASH_OUT: 'denominations',
WALLETS: 'wallets',
OPERATOR_INFO: 'operatorInfo',
NOTIFICATIONS: 'notifications',