chore: use monorepo organization
This commit is contained in:
parent
deaf7d6ecc
commit
a687827f7e
1099 changed files with 8184 additions and 11535 deletions
|
|
@ -0,0 +1,297 @@
|
|||
import IconButton from '@mui/material/IconButton'
|
||||
import { useMutation, useQuery, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import { Info1, Info3, P } from 'src/components/typography'
|
||||
import DeleteIcon from 'src/styling/icons/action/delete/enabled.svg?react'
|
||||
import EditIcon from 'src/styling/icons/action/edit/enabled.svg?react'
|
||||
|
||||
import { Button, Link } from 'src/components/buttons'
|
||||
import { fromNamespace, namespaces, toNamespace } from 'src/utils/config'
|
||||
|
||||
import DetailsRow from './DetailsCard'
|
||||
import Wizard from './Wizard'
|
||||
import SvgIcon from '@mui/material/SvgIcon'
|
||||
|
||||
const inputTypeDisplay = {
|
||||
numerical: 'Numerical',
|
||||
text: 'Text',
|
||||
choiceList: 'Choice list'
|
||||
}
|
||||
|
||||
const constraintTypeDisplay = {
|
||||
date: 'Date',
|
||||
none: 'None',
|
||||
email: 'Email',
|
||||
length: 'Length',
|
||||
selectOne: 'Select one',
|
||||
selectMultiple: 'Select multiple',
|
||||
spaceSeparation: 'Space separation'
|
||||
}
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const ADD_ROW = gql`
|
||||
mutation insertCustomInfoRequest($customRequest: CustomRequestInput!) {
|
||||
insertCustomInfoRequest(customRequest: $customRequest) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
const EDIT_ROW = gql`
|
||||
mutation editCustomInfoRequest(
|
||||
$id: ID!
|
||||
$customRequest: CustomRequestInput!
|
||||
) {
|
||||
editCustomInfoRequest(id: $id, customRequest: $customRequest) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const REMOVE_ROW = gql`
|
||||
mutation removeCustomInfoRequest($id: ID!) {
|
||||
removeCustomInfoRequest(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CustomInfoRequests = ({
|
||||
showWizard,
|
||||
toggleWizard,
|
||||
data: customRequests
|
||||
}) => {
|
||||
const [toBeDeleted, setToBeDeleted] = useState()
|
||||
const [toBeEdited, setToBeEdited] = useState()
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
const [hasError, setHasError] = useState(false)
|
||||
|
||||
const { data: configData, loading: configLoading } = useQuery(GET_DATA)
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: () => setHasError(true)
|
||||
})
|
||||
|
||||
const [addEntry] = useMutation(ADD_ROW, {
|
||||
onError: () => {
|
||||
console.log('Error while adding custom info request')
|
||||
setHasError(true)
|
||||
},
|
||||
onCompleted: () => {
|
||||
setHasError(false)
|
||||
toggleWizard()
|
||||
},
|
||||
refetchQueries: () => ['customInfoRequests']
|
||||
})
|
||||
|
||||
const [editEntry] = useMutation(EDIT_ROW, {
|
||||
onError: () => {
|
||||
console.log('Error while editing custom info request')
|
||||
setHasError(true)
|
||||
},
|
||||
onCompleted: () => {
|
||||
setHasError(false)
|
||||
setToBeEdited(null)
|
||||
toggleWizard()
|
||||
},
|
||||
refetchQueries: () => ['getData', 'customInfoRequests']
|
||||
})
|
||||
|
||||
const [removeEntry] = useMutation(REMOVE_ROW, {
|
||||
onError: () => {
|
||||
console.log('Error while removing custom info request')
|
||||
setHasError(true)
|
||||
},
|
||||
onCompleted: () => {
|
||||
setDeleteDialog(false)
|
||||
setHasError(false)
|
||||
},
|
||||
refetchQueries: () => ['getData', 'customInfoRequests']
|
||||
})
|
||||
|
||||
const config = R.path(['config'])(configData) ?? []
|
||||
|
||||
const handleDelete = id => {
|
||||
removeEntry({
|
||||
variables: {
|
||||
id
|
||||
}
|
||||
}).then(() => {
|
||||
const triggersConfig =
|
||||
(config && fromNamespace(namespaces.TRIGGERS)(config)) ?? []
|
||||
const cleanConfig = {
|
||||
overrides: R.reject(
|
||||
it => it.requirement === id,
|
||||
triggersConfig.overrides
|
||||
)
|
||||
}
|
||||
const newConfig = toNamespace(namespaces.TRIGGERS)(cleanConfig)
|
||||
saveConfig({ variables: { config: newConfig } })
|
||||
})
|
||||
}
|
||||
|
||||
const handleSave = (values, isEditing) => {
|
||||
if (isEditing) {
|
||||
return editEntry({
|
||||
variables: {
|
||||
id: values.id,
|
||||
customRequest: R.omit(['id'])(values)
|
||||
}
|
||||
})
|
||||
}
|
||||
return addEntry({
|
||||
variables: {
|
||||
customRequest: {
|
||||
...values
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const detailedDeleteMsg = (
|
||||
<>
|
||||
<P noMargin>
|
||||
Deleting this item will result in the triggers using it to be removed,
|
||||
together with the advanced trigger overrides you defined for this item.
|
||||
</P>
|
||||
<P noMargin>
|
||||
This action is <b>permanent</b>.
|
||||
</P>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
!configLoading && (
|
||||
<>
|
||||
{customRequests.length > 0 && (
|
||||
<DataTable
|
||||
emptyText="No custom info requests so far"
|
||||
elements={[
|
||||
{
|
||||
header: 'Requirement name',
|
||||
width: 300,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it => it.customRequest.name
|
||||
},
|
||||
{
|
||||
header: 'Data entry type',
|
||||
width: 300,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it => inputTypeDisplay[it.customRequest.input.type]
|
||||
},
|
||||
{
|
||||
header: 'Constraints',
|
||||
width: 300,
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
view: it =>
|
||||
constraintTypeDisplay[it.customRequest.input.constraintType]
|
||||
},
|
||||
{
|
||||
header: 'Edit',
|
||||
width: 100,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: it => {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setToBeEdited(it)
|
||||
return toggleWizard()
|
||||
}}>
|
||||
<SvgIcon>
|
||||
<EditIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'Delete',
|
||||
width: 100,
|
||||
textAlign: 'center',
|
||||
size: 'sm',
|
||||
view: it => {
|
||||
return (
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
setToBeDeleted(it.id)
|
||||
return setDeleteDialog(true)
|
||||
}}>
|
||||
<SvgIcon>
|
||||
<DeleteIcon />
|
||||
</SvgIcon>
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
}
|
||||
]}
|
||||
data={customRequests}
|
||||
Details={DetailsRow}
|
||||
expandable
|
||||
rowSize="sm"
|
||||
/>
|
||||
)}
|
||||
{!customRequests.length && (
|
||||
<div className="flex flex-col items-center h-1/2 justify-center">
|
||||
<Info1 className="m-0 mb-3">
|
||||
It seems you haven't added any custom information requests yet.
|
||||
</Info1>
|
||||
<Info3 className="m-0 mb-3">
|
||||
Please read our{' '}
|
||||
<a href="https://support.lamassu.is/hc/en-us/sections/115000817232-Compliance">
|
||||
<Link>Support Article</Link>
|
||||
</a>{' '}
|
||||
on Compliance before adding new information requests.
|
||||
</Info3>
|
||||
<Button onClick={() => toggleWizard()}>
|
||||
Add custom information request
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{showWizard && (
|
||||
<Wizard
|
||||
hasError={hasError}
|
||||
onClose={() => {
|
||||
setToBeEdited(null)
|
||||
setHasError(false)
|
||||
toggleWizard()
|
||||
}}
|
||||
toBeEdited={toBeEdited}
|
||||
onSave={(...args) => handleSave(...args)}
|
||||
existingRequirements={customRequests}
|
||||
/>
|
||||
)}
|
||||
<DeleteDialog
|
||||
errorMessage={hasError ? 'Failed to delete' : ''}
|
||||
open={deleteDialog}
|
||||
onDismissed={() => {
|
||||
setDeleteDialog(false)
|
||||
setHasError(false)
|
||||
}}
|
||||
item={`custom information request`}
|
||||
extraMessage={detailedDeleteMsg}
|
||||
onConfirmed={() => handleDelete(toBeDeleted)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomInfoRequests
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react'
|
||||
import { Label1, Info2 } from 'src/components/typography'
|
||||
|
||||
const DetailsCard = ({ it }) => {
|
||||
const customRequest = it.customRequest
|
||||
const getScreen2Data = () => {
|
||||
const label1Display =
|
||||
customRequest.input.constraintType === 'spaceSeparation'
|
||||
? 'First word label'
|
||||
: 'Text entry label'
|
||||
switch (customRequest.input.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<>
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>{label1Display}</Info2>
|
||||
<Label1>{customRequest.input.label1}</Label1>
|
||||
</div>
|
||||
{customRequest.input.constraintType === 'spaceSeparation' && (
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>Second word label</Info2>
|
||||
<Label1>{customRequest.input.label2}</Label1>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>Screen 2 input title</Info2>
|
||||
<Label1>{customRequest.screen2.title}</Label1>
|
||||
</div>
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>Screen 2 input description</Info2>
|
||||
<Label1>{customRequest.screen2.text}</Label1>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const getInputData = () => {
|
||||
return (
|
||||
<>
|
||||
{customRequest.input.choiceList && (
|
||||
<>
|
||||
<Info2>Choices</Info2>
|
||||
{customRequest.input.choiceList.map((choice, idx) => {
|
||||
return <Label1 key={idx}>{choice}</Label1>
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{customRequest.input.numDigits && (
|
||||
<>
|
||||
<Info2>Number of digits</Info2>
|
||||
<Label1>{customRequest.input.numDigits}</Label1>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex mt-5">
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>Screen 1 title</Info2>
|
||||
<Label1>{customRequest.screen1.title}</Label1>
|
||||
</div>
|
||||
<div className="flex w-1/2 mb-4 mr-12">{getScreen2Data()}</div>
|
||||
</div>
|
||||
<div className="flex mb-5">
|
||||
<div className="w-1/2 mb-4 mr-12">
|
||||
<Info2>Screen 1 text</Info2>
|
||||
<Label1>{customRequest.screen1.text}</Label1>
|
||||
</div>
|
||||
<div className="w-1/2 mb-4 mr-12">{getInputData()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DetailsCard
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Field } from 'formik'
|
||||
import React from 'react'
|
||||
import ToggleButtonGroup from 'src/components/inputs/formik/ToggleButtonGroup'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import Keyboard from 'src/styling/icons/compliance/keyboard.svg?react'
|
||||
import Keypad from 'src/styling/icons/compliance/keypad.svg?react'
|
||||
import List from 'src/styling/icons/compliance/list.svg?react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { zircon } from 'src/styling/variables'
|
||||
|
||||
const MakeIcon = IconSvg => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: zircon,
|
||||
borderRadius: 4,
|
||||
maxWidth: 104,
|
||||
maxHeight: 64,
|
||||
minWidth: 104,
|
||||
minHeight: 64
|
||||
}}>
|
||||
<IconSvg style={{ maxWidth: 80 }} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ChooseType = () => {
|
||||
const options = [
|
||||
{
|
||||
value: 'numerical',
|
||||
title: 'Numerical entry',
|
||||
description:
|
||||
'User will enter information with a keypad. Good for dates, ID numbers, etc.',
|
||||
icon: () => MakeIcon(Keypad)
|
||||
},
|
||||
{
|
||||
value: 'text',
|
||||
title: 'Text entry',
|
||||
description:
|
||||
'User will entry information with a keyboard. Good for names, email, address, etc.',
|
||||
icon: () => MakeIcon(Keyboard)
|
||||
},
|
||||
{
|
||||
value: 'choiceList',
|
||||
title: 'Choice list',
|
||||
description: 'Gives user multiple options to choose from.',
|
||||
icon: () => MakeIcon(List)
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<H4>Choose the type of data entry</H4>
|
||||
<Field
|
||||
name="inputType"
|
||||
component={ToggleButtonGroup}
|
||||
orientation="vertical"
|
||||
exclusive
|
||||
options={options}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
inputType: Yup.string().label('Input type').required()
|
||||
})
|
||||
|
||||
const defaultValues = {
|
||||
inputType: ''
|
||||
}
|
||||
|
||||
export default ChooseType
|
||||
export { validationSchema, defaultValues }
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Field } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import TextInputFormik from 'src/components/inputs/formik/TextInput'
|
||||
import { H4, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
const NameOfRequirement = () => {
|
||||
return (
|
||||
<>
|
||||
<H4>Name of the requirement</H4> {/* TODO Add ? icon */}
|
||||
<P>
|
||||
The name of the requirement will only be visible to you on the dashboard
|
||||
on the requirement list, as well as on the custom information request
|
||||
list. The user won't see this name. Make sure to make it distinguishable
|
||||
and short.
|
||||
</P>
|
||||
<Field
|
||||
component={TextInputFormik}
|
||||
label="Requirement name"
|
||||
name="requirementName"
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const validationSchema = existingRequirements =>
|
||||
Yup.object().shape({
|
||||
requirementName: Yup.string()
|
||||
.required('Name is required')
|
||||
.test(
|
||||
'unique-name',
|
||||
'A custom information requirement with that name already exists',
|
||||
(value, _context) =>
|
||||
!R.includes(
|
||||
R.toLower(R.defaultTo('', value)),
|
||||
R.map(it => R.toLower(it.customRequest.name), existingRequirements)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const defaultValues = {
|
||||
requirementName: ''
|
||||
}
|
||||
|
||||
export default NameOfRequirement
|
||||
export { validationSchema, defaultValues }
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import { Field } from 'formik'
|
||||
import React from 'react'
|
||||
import TextInputFormik from 'src/components/inputs/formik/TextInput'
|
||||
import { H4, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
const Screen1Information = () => {
|
||||
return (
|
||||
<>
|
||||
<H4>Screen 1 Information</H4> {/* TODO Add ? icon */}
|
||||
<P>
|
||||
On screen 1 you will request the user if he agrees on providing this
|
||||
information, or if he wishes to terminate the transaction instead.
|
||||
</P>
|
||||
<Field
|
||||
component={TextInputFormik}
|
||||
label="Screen title"
|
||||
name="screen1Title"
|
||||
fullWidth
|
||||
/>
|
||||
<Field
|
||||
component={TextInputFormik}
|
||||
label="Screen text"
|
||||
name="screen1Text"
|
||||
multiline
|
||||
fullWidth
|
||||
rows={5}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
screen1Title: Yup.string().label('Screen title').required(),
|
||||
screen1Text: Yup.string().label('Screen text').required()
|
||||
})
|
||||
|
||||
const defaultValues = {
|
||||
screen1Title: '',
|
||||
screen1Text: ''
|
||||
}
|
||||
|
||||
export default Screen1Information
|
||||
export { validationSchema, defaultValues }
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Field } from 'formik'
|
||||
import React from 'react'
|
||||
import TextInputFormik from 'src/components/inputs/formik/TextInput'
|
||||
import { H4, P } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
const ScreenInformation = () => {
|
||||
return (
|
||||
<>
|
||||
<H4>Screen 2 Information</H4> {/* TODO Add ? icon */}
|
||||
<P>
|
||||
If the user agrees, on screen 2 is where the user will enter the custom
|
||||
information.
|
||||
</P>
|
||||
<Field
|
||||
component={TextInputFormik}
|
||||
label="Screen 2 input title"
|
||||
name="screen2Title"
|
||||
fullWidth
|
||||
/>
|
||||
<Field
|
||||
component={TextInputFormik}
|
||||
label="Screen 2 input description"
|
||||
name="screen2Text"
|
||||
fullWidth
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const validationSchema = Yup.object().shape({
|
||||
screen2Title: Yup.string().label('Screen title').required(),
|
||||
screen2Text: Yup.string().label('Screen text').required()
|
||||
})
|
||||
|
||||
const defaultValues = {
|
||||
screen2Title: '',
|
||||
screen2Text: ''
|
||||
}
|
||||
|
||||
export default ScreenInformation
|
||||
export { validationSchema, defaultValues }
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import classnames from 'classnames'
|
||||
import { Field, useFormikContext, FieldArray } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import Button from 'src/components/buttons/ActionButton'
|
||||
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
|
||||
import TextInput from 'src/components/inputs/formik/TextInput'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import AddIconInverse from 'src/styling/icons/button/add/white.svg?react'
|
||||
import AddIcon from 'src/styling/icons/button/add/zodiac.svg?react'
|
||||
|
||||
const nonEmptyStr = obj => obj.text && obj.text.length
|
||||
|
||||
const options = [
|
||||
{ display: 'Select just one', code: 'selectOne' },
|
||||
{ display: 'Select multiple', code: 'selectMultiple' }
|
||||
]
|
||||
|
||||
const ChoiceList = () => {
|
||||
const context = useFormikContext()
|
||||
const choiceListRef = useRef(null)
|
||||
const listChoices = R.path(['values', 'listChoices'])(context) ?? []
|
||||
const choiceListError = R.path(['errors', 'listChoices'])(context) ?? false
|
||||
|
||||
const showErrorColor = {
|
||||
'mb-0': true,
|
||||
'text-tomato':
|
||||
!R.path(['values', 'constraintType'])(context) &&
|
||||
R.path(['errors', 'constraintType'])(context)
|
||||
}
|
||||
|
||||
const hasError = choice => {
|
||||
return (
|
||||
choiceListError &&
|
||||
R.filter(nonEmptyStr)(listChoices).length < 2 &&
|
||||
choice.text.length === 0
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom()
|
||||
}, [listChoices.length])
|
||||
|
||||
const scrollToBottom = () => {
|
||||
choiceListRef.current?.scrollIntoView()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<H4 className={classnames(showErrorColor)}>Choice list constraints</H4>
|
||||
<Field
|
||||
component={RadioGroup}
|
||||
options={options}
|
||||
className="flex-col"
|
||||
name="constraintType"
|
||||
/>
|
||||
<FieldArray name="listChoices">
|
||||
{({ push }) => {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<H4 className="mb-0">Choices</H4>
|
||||
<div className="flex flex-col max-h-60">
|
||||
{listChoices.map((choice, idx) => {
|
||||
return (
|
||||
<div ref={choiceListRef} key={idx}>
|
||||
<Field
|
||||
className="w-105"
|
||||
error={hasError(choice)}
|
||||
component={TextInput}
|
||||
name={`listChoices[${idx}].text`}
|
||||
label={`Choice ${idx + 1}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
Icon={AddIcon}
|
||||
color="primary"
|
||||
InverseIcon={AddIconInverse}
|
||||
className="w-30 h-7 mt-7"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
return push({ text: '' })
|
||||
}}>
|
||||
Add choice
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FieldArray>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChoiceList
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import classnames from 'classnames'
|
||||
import { Field, useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import NumberInput from 'src/components/inputs/formik/NumberInput'
|
||||
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
|
||||
import { TL1, H4 } from 'src/components/typography'
|
||||
|
||||
const options = [
|
||||
{ display: 'None', code: 'none' },
|
||||
{ display: 'Date', code: 'date' },
|
||||
{ display: 'Length', code: 'length' }
|
||||
]
|
||||
|
||||
const NumericalEntry = () => {
|
||||
const context = useFormikContext()
|
||||
|
||||
const isLength =
|
||||
(R.path(['values', 'constraintType'])(useFormikContext()) ?? null) ===
|
||||
'length'
|
||||
|
||||
const showErrorColor = {
|
||||
'mb-0': true,
|
||||
'text-tomat':
|
||||
!R.path(['values', 'constraintType'])(context) &&
|
||||
R.path(['errors', 'constraintType'])(context)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<H4 className={classnames(showErrorColor)}>
|
||||
Numerical entry constraints
|
||||
</H4>
|
||||
<Field
|
||||
className="flex-row"
|
||||
component={RadioGroup}
|
||||
options={options}
|
||||
name="constraintType"
|
||||
/>
|
||||
{isLength && (
|
||||
<div className="flex mt-6 max-w-29">
|
||||
<Field
|
||||
component={NumberInput}
|
||||
name={'inputLength'}
|
||||
label={'Length'}
|
||||
decimalPlaces={0}
|
||||
allowNegative={false}
|
||||
/>
|
||||
<TL1 className="ml-2 mt-6">digits</TL1>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NumericalEntry
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import classnames from 'classnames'
|
||||
import { Field, useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import RadioGroup from 'src/components/inputs/formik/RadioGroup'
|
||||
import TextInput from 'src/components/inputs/formik/TextInput'
|
||||
import { H4 } from 'src/components/typography'
|
||||
|
||||
const options = [
|
||||
{ display: 'None', code: 'none' },
|
||||
{ display: 'Email', code: 'email' },
|
||||
{
|
||||
display: 'Space separation',
|
||||
subtitle: '(e.g. first and last name)',
|
||||
code: 'spaceSeparation'
|
||||
}
|
||||
]
|
||||
|
||||
const TextEntry = () => {
|
||||
const context = useFormikContext()
|
||||
const showErrorColor = {
|
||||
'mt-0': true,
|
||||
'text-tomato':
|
||||
!R.path(['values', 'constraintType'])(context) &&
|
||||
R.path(['errors', 'constraintType'])(context)
|
||||
}
|
||||
|
||||
const getLabelInputs = () => {
|
||||
switch (context.values.constraintType) {
|
||||
case 'spaceSeparation':
|
||||
return (
|
||||
<div className="flex">
|
||||
<Field
|
||||
className="w-50 mr-2"
|
||||
component={TextInput}
|
||||
name={'inputLabel1'}
|
||||
label={'First word label'}
|
||||
/>
|
||||
<Field
|
||||
className="w-50 mr-2"
|
||||
component={TextInput}
|
||||
name={'inputLabel2'}
|
||||
label={'Second word label'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<Field
|
||||
className="w-50 mr-2"
|
||||
component={TextInput}
|
||||
name={'inputLabel1'}
|
||||
label={'Text entry label'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<H4 className={classnames(showErrorColor)}>Text entry constraints</H4>
|
||||
<Field
|
||||
className="flex-row"
|
||||
component={RadioGroup}
|
||||
options={options}
|
||||
name="constraintType"
|
||||
/>
|
||||
{getLabelInputs()}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextEntry
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import ChoiceList from './ChoiceList'
|
||||
import NumericalEntry from './NumericalEntry'
|
||||
import TextEntry from './TextEntry'
|
||||
|
||||
const nonEmptyStr = obj => obj.text && obj.text.length
|
||||
|
||||
const getForm = inputType => {
|
||||
switch (inputType) {
|
||||
case 'numerical':
|
||||
return NumericalEntry
|
||||
case 'text':
|
||||
return TextEntry
|
||||
case 'choiceList':
|
||||
return ChoiceList
|
||||
default:
|
||||
return NumericalEntry
|
||||
}
|
||||
}
|
||||
|
||||
const TypeFields = () => {
|
||||
const inputType = R.path(['values', 'inputType'])(useFormikContext()) ?? null
|
||||
const Component = getForm(inputType)
|
||||
return inputType && <Component />
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
constraintType: '',
|
||||
inputLength: '',
|
||||
inputLabel1: '',
|
||||
inputLabel2: '',
|
||||
listChoices: [{ text: '' }, { text: '' }]
|
||||
}
|
||||
|
||||
const validationSchema = Yup.lazy(values => {
|
||||
switch (values.inputType) {
|
||||
case 'numerical':
|
||||
return Yup.object({
|
||||
constraintType: Yup.string().label('Constraint type').required(),
|
||||
inputLength: Yup.number().when('constraintType', {
|
||||
is: 'length',
|
||||
then: schema =>
|
||||
schema.min(0).required('The number of digits is required'),
|
||||
otherwise: schema => schema.notRequired()
|
||||
})
|
||||
})
|
||||
case 'text':
|
||||
return Yup.object({
|
||||
constraintType: Yup.string().label('Constraint type').required(),
|
||||
inputLabel1: Yup.string().label('Text entry label').required(),
|
||||
inputLabel2: Yup.string().when('constraintType', {
|
||||
is: 'spaceSeparation',
|
||||
then: schema => schema.label('Second word label').required(),
|
||||
otherwise: schema => schema.notRequired()
|
||||
})
|
||||
})
|
||||
case 'choiceList':
|
||||
return Yup.object({
|
||||
constraintType: Yup.string().label('Constraint type').required(),
|
||||
listChoices: Yup.array().test(
|
||||
'has-2-or-more',
|
||||
'Choice list needs to have two or more non empty fields',
|
||||
(values, ctx) => {
|
||||
return R.filter(nonEmptyStr)(values).length > 1
|
||||
}
|
||||
)
|
||||
})
|
||||
default:
|
||||
return Yup.mixed().notRequired()
|
||||
}
|
||||
})
|
||||
|
||||
export default TypeFields
|
||||
export { defaultValues, validationSchema }
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
import { Form, Formik } 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 Stepper from 'src/components/Stepper'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
|
||||
import ChooseType, {
|
||||
validationSchema as chooseTypeSchema,
|
||||
defaultValues as chooseTypeDefaults
|
||||
} from './Forms/ChooseType'
|
||||
import NameOfRequirement, {
|
||||
validationSchema as nameOfReqSchema,
|
||||
defaultValues as nameOfReqDefaults
|
||||
} from './Forms/NameOfRequirement'
|
||||
import Screen1Information, {
|
||||
validationSchema as screen1InfoSchema,
|
||||
defaultValues as screen1InfoDefaults
|
||||
} from './Forms/Screen1Information'
|
||||
import Screen2Information, {
|
||||
validationSchema as screen2InfoSchema,
|
||||
defaultValues as screen2InfoDefaults
|
||||
} from './Forms/Screen2Information'
|
||||
import TypeFields, {
|
||||
defaultValues as typeFieldsDefaults,
|
||||
validationSchema as typeFieldsValidationSchema
|
||||
} from './Forms/TypeFields'
|
||||
import WizardSplash from './WizardSplash'
|
||||
|
||||
const LAST_STEP = 5
|
||||
|
||||
const getStep = (step, existingRequirements) =>
|
||||
[
|
||||
{
|
||||
validationSchema: nameOfReqSchema(existingRequirements),
|
||||
Component: NameOfRequirement
|
||||
},
|
||||
{
|
||||
validationSchema: screen1InfoSchema,
|
||||
Component: Screen1Information
|
||||
},
|
||||
{ validationSchema: chooseTypeSchema, Component: ChooseType },
|
||||
{
|
||||
validationSchema: screen2InfoSchema,
|
||||
Component: Screen2Information
|
||||
},
|
||||
{
|
||||
validationSchema: typeFieldsValidationSchema,
|
||||
Component: TypeFields
|
||||
}
|
||||
][step - 1]
|
||||
|
||||
const nonEmptyStr = obj => obj.text && obj.text.length
|
||||
|
||||
const formatValues = (values, isEditing) => {
|
||||
const isChoiceList = values.inputType === 'choiceList'
|
||||
const choices = isChoiceList
|
||||
? isEditing
|
||||
? R.path(['listChoices'])(values)
|
||||
: R.map(o => o.text)(R.filter(nonEmptyStr)(values.listChoices) ?? [])
|
||||
: []
|
||||
|
||||
const hasInputLength = values.constraintType === 'length'
|
||||
const inputLength = hasInputLength ? values.inputLength : ''
|
||||
|
||||
let resObj = {
|
||||
name: values.requirementName,
|
||||
screen1: {
|
||||
text: values.screen1Text,
|
||||
title: values.screen1Title
|
||||
},
|
||||
screen2: {
|
||||
title: values.screen2Title,
|
||||
text: values.screen2Text
|
||||
},
|
||||
input: {
|
||||
type: values.inputType,
|
||||
constraintType: values.constraintType
|
||||
}
|
||||
}
|
||||
|
||||
if (isChoiceList) {
|
||||
resObj = R.assocPath(['input', 'choiceList'], choices, resObj)
|
||||
}
|
||||
|
||||
if (hasInputLength) {
|
||||
resObj = R.assocPath(['input', 'numDigits'], inputLength, resObj)
|
||||
}
|
||||
|
||||
if (values.inputLabel1) {
|
||||
resObj = R.assocPath(['input', 'label1'], values.inputLabel1, resObj)
|
||||
}
|
||||
|
||||
if (values.inputLabel2) {
|
||||
resObj = R.assocPath(['input', 'label2'], values.inputLabel2, resObj)
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
resObj = R.assocPath(['id'], values.id, resObj)
|
||||
}
|
||||
|
||||
return resObj
|
||||
}
|
||||
|
||||
const makeEditingValues = ({ customRequest, id }) => {
|
||||
return {
|
||||
id,
|
||||
requirementName: customRequest.name,
|
||||
screen1Title: customRequest.screen1.title,
|
||||
screen1Text: customRequest.screen1.text,
|
||||
screen2Title: customRequest.screen2.title,
|
||||
screen2Text: customRequest.screen2.text,
|
||||
inputType: customRequest.input.type,
|
||||
inputLabel1: customRequest.input.label1,
|
||||
inputLabel2: customRequest.input.label2,
|
||||
listChoices: customRequest.input.choiceList,
|
||||
constraintType: customRequest.input.constraintType,
|
||||
inputLength: customRequest.input.numDigits
|
||||
}
|
||||
}
|
||||
|
||||
const chooseNotNull = (a, b) => (R.isNil(b) ? a : b)
|
||||
|
||||
const Wizard = ({
|
||||
onClose,
|
||||
error = false,
|
||||
toBeEdited,
|
||||
onSave,
|
||||
hasError,
|
||||
existingRequirements
|
||||
}) => {
|
||||
const isEditing = !R.isNil(toBeEdited)
|
||||
const [step, setStep] = useState(isEditing ? 1 : 0)
|
||||
|
||||
const defaultValues = {
|
||||
...nameOfReqDefaults,
|
||||
...screen1InfoDefaults,
|
||||
...screen2InfoDefaults,
|
||||
...chooseTypeDefaults,
|
||||
...typeFieldsDefaults
|
||||
}
|
||||
|
||||
// If we're editing, filter out the requirement being edited so that validation schemas don't enter in circular conflicts
|
||||
existingRequirements = isEditing
|
||||
? R.filter(it => it.id !== toBeEdited.id, existingRequirements)
|
||||
: existingRequirements
|
||||
const stepOptions = getStep(step, existingRequirements)
|
||||
const isLastStep = step === LAST_STEP
|
||||
|
||||
const onContinue = (values, actions) => {
|
||||
const showScreen2 =
|
||||
values.inputType === 'numerical' || values.inputType === 'choiceList'
|
||||
if (isEditing && step === 2) {
|
||||
return showScreen2
|
||||
? setStep(4)
|
||||
: onSave(formatValues(values, isEditing), isEditing)
|
||||
}
|
||||
if (isEditing && step === 4) {
|
||||
return onSave(formatValues(values, isEditing), isEditing)
|
||||
}
|
||||
if (step === 3) {
|
||||
return showScreen2 ? setStep(step + 1) : setStep(step + 2)
|
||||
}
|
||||
if (!isLastStep) {
|
||||
return setStep(step + 1)
|
||||
}
|
||||
return onSave(formatValues(values, isEditing), isEditing)
|
||||
}
|
||||
|
||||
const editingValues = isEditing ? makeEditingValues(toBeEdited) : {}
|
||||
const initialValues = R.mergeWith(chooseNotNull, defaultValues, editingValues)
|
||||
const wizardTitle = isEditing
|
||||
? 'Editing custom requirement'
|
||||
: 'New custom requirement'
|
||||
return (
|
||||
<Modal
|
||||
title={step > 0 ? wizardTitle : ''}
|
||||
handleClose={onClose}
|
||||
width={520}
|
||||
height={620}
|
||||
open={true}>
|
||||
{step > 0 && (
|
||||
<Stepper
|
||||
className="mt-4 mb-4 mx-0"
|
||||
steps={LAST_STEP}
|
||||
currentStep={step}
|
||||
/>
|
||||
)}
|
||||
{step === 0 && !isEditing && <WizardSplash onContinue={onContinue} />}
|
||||
{step > 0 && (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
enableReinitialize={true}
|
||||
onSubmit={onContinue}
|
||||
initialValues={initialValues}
|
||||
validationSchema={stepOptions.validationSchema}>
|
||||
{({ errors }) => (
|
||||
<Form
|
||||
className="h-full flex flex-col"
|
||||
id={'custom-requirement-form'}>
|
||||
<stepOptions.Component />
|
||||
<div className="flex flex-row mt-auto mx-0 mb-4">
|
||||
{(hasError || !R.isEmpty(errors)) && (
|
||||
<ErrorMessage>
|
||||
{R.head(R.values(errors)) ?? `Failed to save`}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
<Button className="ml-auto" type="submit">
|
||||
{isLastStep ? 'Save' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wizard
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import React from 'react'
|
||||
import { H1, P } from 'src/components/typography'
|
||||
import CustomReqLogo from 'src/styling/icons/compliance/custom-requirement.svg?react'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
|
||||
const WizardSplash = ({ onContinue }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center py-0 px-10 flex-1">
|
||||
<CustomReqLogo className="max-h-37 max-w-50" />
|
||||
<H1 className="mt-6 mb-8 mx-0">Custom information request</H1>
|
||||
<P className="m-0">
|
||||
A custom information request allows you to have an extra option to ask
|
||||
specific information about your customers when adding a trigger that
|
||||
isn't an option on the default requirements list.
|
||||
</P>
|
||||
<P>
|
||||
Note that adding a custom information request isn't the same as adding
|
||||
triggers. You will still need to add a trigger with the new requirement
|
||||
to get this information from your customers.
|
||||
</P>
|
||||
<Button className="mt-auto mb-14" onClick={onContinue}>
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WizardSplash
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import CustomInfoRequests from './CustomInfoRequests'
|
||||
export default CustomInfoRequests
|
||||
93
packages/admin-ui/src/pages/Triggers/TriggerView.jsx
Normal file
93
packages/admin-ui/src/pages/Triggers/TriggerView.jsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import { useMutation, gql } from '@apollo/client'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import { H2 } from 'src/components/typography'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import { fromNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import Wizard from './Wizard'
|
||||
import { Schema, getElements, sortBy, toServer } from './helper'
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const TriggerView = ({
|
||||
triggers,
|
||||
showWizard,
|
||||
config,
|
||||
toggleWizard,
|
||||
addNewTriger,
|
||||
emailAuth,
|
||||
complianceServices,
|
||||
customInfoRequests
|
||||
}) => {
|
||||
const currency = R.path(['fiatCurrency'])(
|
||||
fromNamespace(namespaces.LOCALE)(config)
|
||||
)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
onCompleted: () => toggleWizard(true),
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const save = config => {
|
||||
setError(null)
|
||||
return saveConfig({
|
||||
variables: { config: { triggers: toServer(config.triggers) } }
|
||||
})
|
||||
}
|
||||
|
||||
const add = rawConfig => {
|
||||
const toSave = R.concat([
|
||||
{ id: uuidv4(), direction: 'both', ...rawConfig }
|
||||
])(triggers)
|
||||
return saveConfig({ variables: { config: { triggers: toServer(toSave) } } })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditableTable
|
||||
data={triggers}
|
||||
name="triggers"
|
||||
enableEdit
|
||||
sortBy={sortBy}
|
||||
groupBy="triggerType"
|
||||
enableDelete
|
||||
error={error?.message}
|
||||
save={save}
|
||||
validationSchema={Schema}
|
||||
elements={getElements(currency, customInfoRequests)}
|
||||
/>
|
||||
{showWizard && (
|
||||
<Wizard
|
||||
currency={currency}
|
||||
error={error?.message}
|
||||
save={add}
|
||||
onClose={() => toggleWizard(true)}
|
||||
customInfoRequests={customInfoRequests}
|
||||
complianceServices={complianceServices}
|
||||
emailAuth={emailAuth}
|
||||
triggers={triggers}
|
||||
/>
|
||||
)}
|
||||
{R.isEmpty(triggers) && (
|
||||
<div className="flex items-center flex-col mt-30">
|
||||
<H2>
|
||||
It seems there are no active compliance triggers on your network
|
||||
</H2>
|
||||
<Button onClick={addNewTriger}>Add first trigger</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TriggerView
|
||||
261
packages/admin-ui/src/pages/Triggers/Triggers.jsx
Normal file
261
packages/admin-ui/src/pages/Triggers/Triggers.jsx
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { useQuery, useMutation, gql } from '@apollo/client'
|
||||
import Switch from '@mui/material/Switch'
|
||||
import classnames from 'classnames'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
import Modal from 'src/components/Modal'
|
||||
import { HelpTooltip } from 'src/components/Tooltip'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { P, Label2 } from 'src/components/typography'
|
||||
import FormRenderer from 'src/pages/Services/FormRenderer'
|
||||
import ReverseCustomInfoIcon from 'src/styling/icons/circle buttons/filter/white.svg?react'
|
||||
import CustomInfoIcon from 'src/styling/icons/circle buttons/filter/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, SupportLinkButton } from 'src/components/buttons'
|
||||
import twilioSchema from 'src/pages/Services/schemas/twilio'
|
||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||
|
||||
import CustomInfoRequests from './CustomInfoRequests'
|
||||
import TriggerView from './TriggerView'
|
||||
import AdvancedTriggers from './components/AdvancedTriggers'
|
||||
import { fromServer } from './helper'
|
||||
|
||||
const SAVE_ACCOUNT = gql`
|
||||
mutation Save($accounts: JSONObject) {
|
||||
saveAccounts(accounts: $accounts)
|
||||
}
|
||||
`
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CONFIG = gql`
|
||||
query getData {
|
||||
config
|
||||
accounts
|
||||
accountsConfig {
|
||||
code
|
||||
display
|
||||
class
|
||||
cryptos
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CUSTOM_REQUESTS = gql`
|
||||
query customInfoRequests {
|
||||
customInfoRequests {
|
||||
id
|
||||
customRequest
|
||||
enabled
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const Triggers = () => {
|
||||
const [wizardType, setWizard] = useState(false)
|
||||
const { data, loading: configLoading, refetch } = useQuery(GET_CONFIG)
|
||||
const { data: customInfoReqData, loading: customInfoLoading } =
|
||||
useQuery(GET_CUSTOM_REQUESTS)
|
||||
const [error, setError] = useState(null)
|
||||
const [subMenu, setSubMenu] = useState(false)
|
||||
|
||||
const [twilioSetupPopup, setTwilioSetupPopup] = useState(false)
|
||||
|
||||
const enabledCustomInfoRequests = R.pipe(
|
||||
R.path(['customInfoRequests']),
|
||||
R.defaultTo([]),
|
||||
R.filter(R.propEq('enabled', true))
|
||||
)(customInfoReqData)
|
||||
|
||||
const emailAuth =
|
||||
data?.config?.triggersConfig_customerAuthentication === 'EMAIL'
|
||||
|
||||
const complianceServices = R.filter(R.propEq('class', 'compliance'))(
|
||||
data?.accountsConfig || []
|
||||
)
|
||||
const triggers = fromServer(data?.config?.triggers ?? [])
|
||||
const complianceConfig =
|
||||
data?.config && fromNamespace('compliance')(data.config)
|
||||
const rejectAddressReuse = complianceConfig?.rejectAddressReuse ?? false
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
onCompleted: () => setWizard(false),
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const [saveAccount] = useMutation(SAVE_ACCOUNT, {
|
||||
onCompleted: () => {
|
||||
setTwilioSetupPopup(false)
|
||||
toggleWizard('newTrigger')()
|
||||
},
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const addressReuseSave = rawConfig => {
|
||||
const config = toNamespace('compliance')(rawConfig)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const titleSectionWidth = {
|
||||
'w-230': !subMenu === 'customInfoRequests'
|
||||
}
|
||||
|
||||
const setBlur = shouldBlur => {
|
||||
return shouldBlur
|
||||
? document.querySelector('#root').classList.add('root-blur')
|
||||
: document.querySelector('#root').classList.remove('root-blur')
|
||||
}
|
||||
|
||||
const toggleWizard = wizardName => forceDisable => {
|
||||
if (wizardType === wizardName || forceDisable) {
|
||||
setBlur(false)
|
||||
return setWizard(null)
|
||||
}
|
||||
setBlur(true)
|
||||
return setWizard(wizardName)
|
||||
}
|
||||
|
||||
const loading = configLoading || customInfoLoading
|
||||
|
||||
const twilioSave = it => {
|
||||
setError(null)
|
||||
return saveAccount({
|
||||
variables: { accounts: { twilio: it } }
|
||||
})
|
||||
}
|
||||
const addNewTriger = () => {
|
||||
if (!R.has('twilio', data?.accounts || {})) setTwilioSetupPopup(true)
|
||||
else toggleWizard('newTrigger')()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection
|
||||
title="Compliance triggers"
|
||||
buttons={[
|
||||
{
|
||||
text: 'Advanced settings',
|
||||
icon: SettingsIcon,
|
||||
inverseIcon: ReverseSettingsIcon,
|
||||
forceDisable: !(subMenu === 'advancedSettings'),
|
||||
toggle: show => {
|
||||
refetch()
|
||||
setSubMenu(show ? 'advancedSettings' : false)
|
||||
}
|
||||
},
|
||||
{
|
||||
text: 'Custom info requests',
|
||||
icon: CustomInfoIcon,
|
||||
inverseIcon: ReverseCustomInfoIcon,
|
||||
forceDisable: !(subMenu === 'customInfoRequests'),
|
||||
toggle: show => {
|
||||
refetch()
|
||||
setSubMenu(show ? 'customInfoRequests' : false)
|
||||
}
|
||||
}
|
||||
]}
|
||||
className={classnames(titleSectionWidth)}>
|
||||
{!subMenu && (
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center justify-end -mr-1">
|
||||
<P>Reject reused addresses</P>
|
||||
<Switch
|
||||
checked={rejectAddressReuse}
|
||||
onChange={event => {
|
||||
addressReuseSave({ rejectAddressReuse: event.target.checked })
|
||||
}}
|
||||
value={rejectAddressReuse}
|
||||
/>
|
||||
<Label2 className="m-3 w-6">
|
||||
{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>
|
||||
</div>
|
||||
)}
|
||||
{subMenu === 'customInfoRequests' &&
|
||||
!R.isEmpty(enabledCustomInfoRequests) && (
|
||||
<div className="flex justify-end">
|
||||
<Link
|
||||
color="primary"
|
||||
onClick={() => toggleWizard('newCustomRequest')()}>
|
||||
+ Add new custom info request
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !subMenu && !R.isEmpty(triggers) && (
|
||||
<div className="flex justify-end">
|
||||
<Link color="primary" onClick={addNewTriger}>
|
||||
+ Add new trigger
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</TitleSection>
|
||||
{!loading && subMenu === 'customInfoRequests' && (
|
||||
<CustomInfoRequests
|
||||
data={enabledCustomInfoRequests}
|
||||
showWizard={wizardType === 'newCustomRequest'}
|
||||
toggleWizard={toggleWizard('newCustomRequest')}
|
||||
/>
|
||||
)}
|
||||
{!loading && !subMenu && (
|
||||
<TriggerView
|
||||
triggers={triggers}
|
||||
showWizard={wizardType === 'newTrigger'}
|
||||
config={data?.config ?? {}}
|
||||
toggleWizard={toggleWizard('newTrigger')}
|
||||
addNewTriger={addNewTriger}
|
||||
emailAuth={emailAuth}
|
||||
complianceServices={complianceServices}
|
||||
customInfoRequests={enabledCustomInfoRequests}
|
||||
/>
|
||||
)}
|
||||
{!loading && subMenu === 'advancedSettings' && (
|
||||
<AdvancedTriggers
|
||||
error={error}
|
||||
save={saveConfig}
|
||||
data={data}></AdvancedTriggers>
|
||||
)}
|
||||
{twilioSetupPopup && (
|
||||
<Modal
|
||||
title={`Configure SMS`}
|
||||
width={478}
|
||||
handleClose={() => setTwilioSetupPopup(false)}
|
||||
open={true}>
|
||||
<P>
|
||||
In order for compliance triggers to work, you'll first need to
|
||||
configure Twilio.
|
||||
</P>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/115001203951-Twilio-for-SMS"
|
||||
label="Lamassu Support Article"
|
||||
/>
|
||||
<FormRenderer
|
||||
save={twilioSave}
|
||||
elements={twilioSchema.elements}
|
||||
validationSchema={twilioSchema.getValidationSchema}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Triggers
|
||||
315
packages/admin-ui/src/pages/Triggers/Wizard.jsx
Normal file
315
packages/admin-ui/src/pages/Triggers/Wizard.jsx
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import { Form, Formik, useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, Fragment, useEffect } from 'react'
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Modal from 'src/components/Modal'
|
||||
import Stepper from 'src/components/Stepper'
|
||||
import { H5, Info3 } from 'src/components/typography'
|
||||
|
||||
import { Button } from 'src/components/buttons'
|
||||
import { singularOrPlural } from 'src/utils/string'
|
||||
|
||||
import { type, requirements } from './helper'
|
||||
|
||||
const LAST_STEP = 2
|
||||
|
||||
const getStep = (
|
||||
{ step, config },
|
||||
currency,
|
||||
customInfoRequests,
|
||||
complianceServices,
|
||||
emailAuth,
|
||||
triggers
|
||||
) => {
|
||||
switch (step) {
|
||||
// case 1:
|
||||
// return txDirection
|
||||
case 1:
|
||||
return type(currency)
|
||||
case 2:
|
||||
return requirements(
|
||||
config,
|
||||
triggers,
|
||||
customInfoRequests,
|
||||
complianceServices,
|
||||
emailAuth
|
||||
)
|
||||
default:
|
||||
return Fragment
|
||||
}
|
||||
}
|
||||
|
||||
const getText = (step, config, currency) => {
|
||||
switch (step) {
|
||||
// case 1:
|
||||
// return `In ${getDirectionText(config)} transactions`
|
||||
case 1:
|
||||
return <>If the user {getTypeText(config, currency)}</>
|
||||
case 2:
|
||||
return <>the user will be {getRequirementText(config)}.</>
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
||||
const orUnderline = value => {
|
||||
const blankSpaceEl = (
|
||||
<span className="py-0 px-7 my-0 mx-1 border-b-1 border-b-comet inline-block"></span>
|
||||
)
|
||||
return R.isEmpty(value) || R.isNil(value) ? blankSpaceEl : value
|
||||
}
|
||||
|
||||
// const getDirectionText = config => {
|
||||
// switch (config.direction) {
|
||||
// case 'both':
|
||||
// return 'both cash-in and cash-out'
|
||||
// case 'cashIn':
|
||||
// return 'cash-in'
|
||||
// case 'cashOut':
|
||||
// return 'cash-out'
|
||||
// default:
|
||||
// return orUnderline(null)
|
||||
// }
|
||||
// }
|
||||
|
||||
const getTypeText = (config, currency) => {
|
||||
switch (config.triggerType) {
|
||||
case 'txAmount':
|
||||
return (
|
||||
<>
|
||||
makes a single transaction over{' '}
|
||||
{orUnderline(config.threshold.threshold)} {currency}
|
||||
</>
|
||||
)
|
||||
case 'txVolume':
|
||||
return (
|
||||
<>
|
||||
makes more than {orUnderline(config.threshold.threshold)} {currency}{' '}
|
||||
worth of transactions within{' '}
|
||||
{orUnderline(config.threshold.thresholdDays)}{' '}
|
||||
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
|
||||
</>
|
||||
)
|
||||
case 'txVelocity':
|
||||
return (
|
||||
<>
|
||||
makes more than {orUnderline(config.threshold.threshold)}{' '}
|
||||
{singularOrPlural(
|
||||
config.threshold.threshold,
|
||||
'transaction',
|
||||
'transactions'
|
||||
)}{' '}
|
||||
in {orUnderline(config.threshold.thresholdDays)}{' '}
|
||||
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
|
||||
</>
|
||||
)
|
||||
case 'consecutiveDays':
|
||||
return (
|
||||
<>
|
||||
at least one transaction every day for{' '}
|
||||
{orUnderline(config.threshold.thresholdDays)}{' '}
|
||||
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
|
||||
</>
|
||||
)
|
||||
default:
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
||||
const getRequirementText = config => {
|
||||
switch (config.requirement?.requirement) {
|
||||
case 'email':
|
||||
return <>asked to enter code provided through email verification</>
|
||||
case 'sms':
|
||||
return <>asked to enter code provided through SMS verification</>
|
||||
case 'idCardPhoto':
|
||||
return <>asked to scan a ID with photo</>
|
||||
case 'idCardData':
|
||||
return <>asked to scan a ID</>
|
||||
case 'facephoto':
|
||||
return <>asked to have a photo taken</>
|
||||
case 'usSsn':
|
||||
return <>asked to input his social security number</>
|
||||
case 'sanctions':
|
||||
return <>matched against the OFAC sanctions list</>
|
||||
case 'superuser':
|
||||
return <></>
|
||||
case 'suspend':
|
||||
return (
|
||||
<>
|
||||
suspended for {orUnderline(config.requirement.suspensionDays)}{' '}
|
||||
{singularOrPlural(config.requirement.suspensionDays, 'day', 'days')}
|
||||
</>
|
||||
)
|
||||
case 'block':
|
||||
return <>blocked</>
|
||||
case 'custom':
|
||||
return <>asked to fulfill a custom requirement</>
|
||||
case 'external':
|
||||
return <>redirected to an external verification process</>
|
||||
default:
|
||||
return orUnderline(null)
|
||||
}
|
||||
}
|
||||
|
||||
const InfoPanel = ({ step, config = {}, liveValues = {}, currency }) => {
|
||||
const oldText = R.range(1, step).map((it, idx) => (
|
||||
<React.Fragment key={idx}>{getText(it, config, currency)}</React.Fragment>
|
||||
))
|
||||
const newText = getText(step, liveValues, currency)
|
||||
const isLastStep = step === LAST_STEP
|
||||
|
||||
return (
|
||||
<>
|
||||
<H5 className="my-5 mx-0">Trigger overview so far</H5>
|
||||
<Info3 noMargin>
|
||||
{oldText}
|
||||
{step !== 1 && ', '}
|
||||
<span className="text-comet">{newText}</span>
|
||||
{!isLastStep && '...'}
|
||||
</Info3>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const GetValues = ({ setValues }) => {
|
||||
const { values } = useFormikContext()
|
||||
useEffect(() => {
|
||||
setValues && values && setValues(values)
|
||||
}, [setValues, values])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const Wizard = ({
|
||||
onClose,
|
||||
save,
|
||||
error,
|
||||
currency,
|
||||
customInfoRequests,
|
||||
complianceServices,
|
||||
emailAuth,
|
||||
triggers
|
||||
}) => {
|
||||
const [liveValues, setLiveValues] = useState({})
|
||||
const [{ step, config }, setState] = useState({
|
||||
step: 1
|
||||
})
|
||||
|
||||
const isLastStep = step === LAST_STEP
|
||||
const stepOptions = getStep(
|
||||
{ step, config },
|
||||
currency,
|
||||
customInfoRequests,
|
||||
complianceServices,
|
||||
emailAuth,
|
||||
triggers
|
||||
)
|
||||
|
||||
const onContinue = async it => {
|
||||
const newConfig = R.merge(config, stepOptions.schema.cast(it))
|
||||
|
||||
if (isLastStep) {
|
||||
return save(newConfig)
|
||||
}
|
||||
|
||||
setState({
|
||||
step: step + 1,
|
||||
config: newConfig
|
||||
})
|
||||
}
|
||||
|
||||
const createErrorMessage = (errors, touched, values) => {
|
||||
const triggerType = values?.triggerType
|
||||
const containsType = R.contains(triggerType)
|
||||
const isSuspend = values?.requirement?.requirement === 'suspend'
|
||||
const isCustom = values?.requirement?.requirement === 'custom'
|
||||
|
||||
const hasRequirementError = requirements().hasRequirementError(
|
||||
errors,
|
||||
touched,
|
||||
values
|
||||
)
|
||||
const hasCustomRequirementError = requirements().hasCustomRequirementError(
|
||||
errors,
|
||||
touched,
|
||||
values
|
||||
)
|
||||
|
||||
const hasAmountError =
|
||||
!!errors.threshold &&
|
||||
!!touched.threshold?.threshold &&
|
||||
!containsType(['consecutiveDays']) &&
|
||||
(!values.threshold?.threshold || values.threshold?.threshold < 0)
|
||||
|
||||
const hasDaysError =
|
||||
!!errors.threshold &&
|
||||
!!touched.threshold?.thresholdDays &&
|
||||
!containsType(['txAmount']) &&
|
||||
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
|
||||
|
||||
if (containsType(['txAmount', 'txVolume', 'txVelocity']) && hasAmountError)
|
||||
return errors.threshold
|
||||
|
||||
if (
|
||||
containsType(['txVolume', 'txVelocity', 'consecutiveDays']) &&
|
||||
hasDaysError
|
||||
)
|
||||
return errors.threshold
|
||||
|
||||
if (
|
||||
(isSuspend && hasRequirementError) ||
|
||||
(isCustom && hasCustomRequirementError)
|
||||
)
|
||||
return errors.requirement
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="New compliance trigger"
|
||||
handleClose={onClose}
|
||||
width={560}
|
||||
height={520}
|
||||
infoPanel={
|
||||
<InfoPanel
|
||||
currency={currency}
|
||||
step={step}
|
||||
config={config}
|
||||
liveValues={liveValues}
|
||||
/>
|
||||
}
|
||||
infoPanelHeight={172}
|
||||
open={true}>
|
||||
<Stepper className="my-4 mx-0" steps={LAST_STEP} currentStep={step} />
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={true}
|
||||
enableReinitialize
|
||||
onSubmit={onContinue}
|
||||
initialValues={stepOptions.initialValues}
|
||||
validationSchema={stepOptions.schema}>
|
||||
{({ errors, touched, values }) => (
|
||||
<Form className="h-full flex flex-col">
|
||||
<GetValues setValues={setLiveValues} />
|
||||
<stepOptions.Component {...stepOptions.props} />
|
||||
<div className="flex flex-row mt-auto mx-0 mb-6">
|
||||
{error && <ErrorMessage>Failed to save</ErrorMessage>}
|
||||
{createErrorMessage(errors, touched, values) && (
|
||||
<ErrorMessage>
|
||||
{createErrorMessage(errors, touched, values)}
|
||||
</ErrorMessage>
|
||||
)}
|
||||
<Button className="ml-auto" type="submit">
|
||||
{isLastStep ? 'Finish' : 'Next'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Wizard
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
import { useMutation, useQuery, gql } from "@apollo/client";
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import Section from 'src/components/layout/Section'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import {
|
||||
defaultSchema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getDefaultSettings,
|
||||
getOverrides
|
||||
} from './helper'
|
||||
|
||||
const SAVE_CONFIG = gql`
|
||||
mutation Save($config: JSONObject) {
|
||||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
|
||||
const GET_INFO = gql`
|
||||
query getData {
|
||||
config
|
||||
}
|
||||
`
|
||||
|
||||
const GET_CUSTOM_REQUESTS = gql`
|
||||
query customInfoRequests {
|
||||
customInfoRequests {
|
||||
id
|
||||
customRequest
|
||||
enabled
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const AdvancedTriggersSettings = memo(() => {
|
||||
const SCREEN_KEY = namespaces.TRIGGERS
|
||||
const [error, setError] = useState(null)
|
||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||
|
||||
const { data, loading: configLoading } = useQuery(GET_INFO)
|
||||
const { data: customInfoReqData, loading: customInfoLoading } =
|
||||
useQuery(GET_CUSTOM_REQUESTS)
|
||||
|
||||
const customInfoRequests =
|
||||
R.path(['customInfoRequests'])(customInfoReqData) ?? []
|
||||
const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))(
|
||||
customInfoRequests
|
||||
)
|
||||
|
||||
const loading = configLoading || customInfoLoading
|
||||
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const saveDefaults = it => {
|
||||
const newConfig = toNamespace(SCREEN_KEY)(it.triggersConfig[0])
|
||||
setError(null)
|
||||
return saveConfig({
|
||||
variables: { config: newConfig }
|
||||
})
|
||||
}
|
||||
|
||||
const saveOverrides = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it)
|
||||
setError(null)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const requirementsData =
|
||||
data?.config && fromNamespace(SCREEN_KEY)(data?.config)
|
||||
const requirementsDefaults =
|
||||
requirementsData && !R.isEmpty(requirementsData)
|
||||
? requirementsData
|
||||
: defaults
|
||||
const requirementsOverrides = requirementsData?.overrides ?? []
|
||||
|
||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||
|
||||
return (
|
||||
!loading && (
|
||||
<>
|
||||
<Section>
|
||||
<EditableTable
|
||||
title="Default requirement settings"
|
||||
error={error?.message}
|
||||
titleLg
|
||||
name="triggersConfig"
|
||||
enableEdit
|
||||
initialValues={requirementsDefaults}
|
||||
save={saveDefaults}
|
||||
validationSchema={defaultSchema}
|
||||
data={R.of(requirementsDefaults)}
|
||||
elements={getDefaultSettings()}
|
||||
setEditing={onEditingDefault}
|
||||
forceDisable={isEditingOverrides}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Overrides"
|
||||
titleLg
|
||||
name="overrides"
|
||||
enableDelete
|
||||
enableEdit
|
||||
enableCreate
|
||||
initialValues={overridesDefaults}
|
||||
save={saveOverrides}
|
||||
validationSchema={getOverridesSchema(
|
||||
requirementsOverrides,
|
||||
enabledCustomInfoRequests
|
||||
)}
|
||||
data={requirementsOverrides}
|
||||
elements={getOverrides(enabledCustomInfoRequests)}
|
||||
setEditing={onEditingOverrides}
|
||||
forceDisable={isEditingDefault}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
export default AdvancedTriggersSettings
|
||||
175
packages/admin-ui/src/pages/Triggers/components/helper.js
Normal file
175
packages/admin-ui/src/pages/Triggers/components/helper.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import * as R from 'ramda'
|
||||
import Autocomplete from 'src/components/inputs/formik/Autocomplete'
|
||||
import { getView } from 'src/pages/Triggers/helper'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
const buildAdvancedRequirementOptions = customInfoRequests => {
|
||||
const base = [
|
||||
{ display: 'Sanctions', code: 'sanctions' },
|
||||
{ display: 'ID card image', code: 'idCardPhoto' },
|
||||
{ display: 'ID data', code: 'idCardData' },
|
||||
{ display: 'Customer camera', code: 'facephoto' },
|
||||
{ display: 'US SSN', code: 'usSsn' }
|
||||
]
|
||||
|
||||
const custom = R.map(it => ({
|
||||
display: it.customRequest.name,
|
||||
code: it.id
|
||||
}))(customInfoRequests)
|
||||
|
||||
return R.concat(base, custom)
|
||||
}
|
||||
|
||||
const displayRequirement = (code, customInfoRequests) => {
|
||||
return R.prop(
|
||||
'display',
|
||||
R.find(R.propEq('code', code))(
|
||||
buildAdvancedRequirementOptions(customInfoRequests)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const defaultSchema = Yup.object().shape({
|
||||
expirationTime: Yup.string().label('Expiration time').required(),
|
||||
automation: Yup.string()
|
||||
.label('Automation')
|
||||
.matches(/(Manual|Automatic)/)
|
||||
.required()
|
||||
})
|
||||
|
||||
const getOverridesSchema = (values, customInfoRequests) => {
|
||||
return Yup.object().shape({
|
||||
id: Yup.string()
|
||||
.label('Requirement')
|
||||
.required()
|
||||
.test({
|
||||
test() {
|
||||
const { id, requirement } = this.parent
|
||||
// If we're editing, filter out the override being edited so that validation schemas don't enter in circular conflicts
|
||||
const _values = R.filter(it => it.id !== id, values)
|
||||
if (R.find(R.propEq('requirement', requirement))(_values)) {
|
||||
return this.createError({
|
||||
message: `Requirement '${displayRequirement(
|
||||
requirement,
|
||||
customInfoRequests
|
||||
)}' already overridden`
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
}),
|
||||
expirationTime: Yup.string().label('Expiration time').required(),
|
||||
automation: Yup.string()
|
||||
.label('Automation')
|
||||
.matches(/(Manual|Automatic)/)
|
||||
.required()
|
||||
});
|
||||
}
|
||||
|
||||
const getDefaultSettings = () => {
|
||||
return [
|
||||
{
|
||||
name: 'expirationTime',
|
||||
header: 'Expiration time',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'automation',
|
||||
header: 'Automation',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: [
|
||||
{ code: 'Automatic', display: 'Automatic' },
|
||||
{ code: 'Manual', display: 'Manual' }
|
||||
],
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'customerAuthentication',
|
||||
header: 'Customer Auth',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: [
|
||||
{ code: 'SMS', display: 'SMS' },
|
||||
{ code: 'EMAIL', display: 'EMAIL' }
|
||||
],
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getOverrides = customInfoRequests => {
|
||||
return [
|
||||
{
|
||||
name: 'requirement',
|
||||
header: 'Requirement',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
view: getView(
|
||||
buildAdvancedRequirementOptions(customInfoRequests),
|
||||
'display'
|
||||
),
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: buildAdvancedRequirementOptions(customInfoRequests),
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'expirationTime',
|
||||
header: 'Expiration time',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
editable: false
|
||||
},
|
||||
{
|
||||
name: 'automation',
|
||||
header: 'Automation',
|
||||
width: 196,
|
||||
size: 'sm',
|
||||
input: Autocomplete,
|
||||
inputProps: {
|
||||
options: [
|
||||
{ code: 'Automatic', display: 'Automatic' },
|
||||
{ code: 'Manual', display: 'Manual' }
|
||||
],
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const defaults = [
|
||||
{
|
||||
expirationTime: 'Forever',
|
||||
automation: 'Automatic',
|
||||
customerAuth: 'SMS'
|
||||
}
|
||||
]
|
||||
|
||||
const overridesDefaults = {
|
||||
requirement: '',
|
||||
expirationTime: 'Forever',
|
||||
automation: 'Automatic'
|
||||
}
|
||||
|
||||
export {
|
||||
defaultSchema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getDefaultSettings,
|
||||
getOverrides
|
||||
}
|
||||
793
packages/admin-ui/src/pages/Triggers/helper.jsx
Normal file
793
packages/admin-ui/src/pages/Triggers/helper.jsx
Normal file
|
|
@ -0,0 +1,793 @@
|
|||
import classnames from 'classnames'
|
||||
import { Field, useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo } from 'react'
|
||||
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik'
|
||||
import { transformNumber } from 'src/utils/number'
|
||||
import { onlyFirstToUpper } from 'src/utils/string'
|
||||
|
||||
const triggerType = Yup.string().required()
|
||||
const threshold = Yup.object().shape({
|
||||
threshold: Yup.number()
|
||||
.nullable()
|
||||
.transform(transformNumber)
|
||||
.label('Invalid threshold'),
|
||||
thresholdDays: Yup.number()
|
||||
.transform(transformNumber)
|
||||
.nullable()
|
||||
.label('Invalid threshold days')
|
||||
})
|
||||
|
||||
const requirement = Yup.object().shape({
|
||||
requirement: Yup.string().required(),
|
||||
suspensionDays: Yup.number().transform(transformNumber).nullable()
|
||||
})
|
||||
|
||||
const Schema = Yup.object()
|
||||
.shape({
|
||||
triggerType,
|
||||
requirement,
|
||||
threshold
|
||||
// direction
|
||||
})
|
||||
.test(({ threshold, triggerType }, context) => {
|
||||
const errorMessages = {
|
||||
txAmount: threshold => 'Amount must be greater than or equal to 0',
|
||||
txVolume: threshold => {
|
||||
const thresholdMessage = 'Volume must be greater than or equal to 0'
|
||||
const thresholdDaysMessage = 'Days must be greater than 0'
|
||||
const message = []
|
||||
if (threshold.threshold < 0) message.push(thresholdMessage)
|
||||
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
|
||||
return message.join(', ')
|
||||
},
|
||||
txVelocity: threshold => {
|
||||
const thresholdMessage = 'Transactions must be greater than 0'
|
||||
const thresholdDaysMessage = 'Days must be greater than 0'
|
||||
const message = []
|
||||
if (threshold.threshold <= 0) message.push(thresholdMessage)
|
||||
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
|
||||
return message.join(', ')
|
||||
},
|
||||
consecutiveDays: threshold => 'Days must be greater than 0'
|
||||
}
|
||||
const thresholdValidator = {
|
||||
txAmount: threshold => threshold.threshold >= 0,
|
||||
txVolume: threshold =>
|
||||
threshold.threshold >= 0 && threshold.thresholdDays > 0,
|
||||
txVelocity: threshold =>
|
||||
threshold.threshold > 0 && threshold.thresholdDays > 0,
|
||||
consecutiveDays: threshold => threshold.thresholdDays > 0
|
||||
}
|
||||
|
||||
if (triggerType && thresholdValidator[triggerType](threshold)) return
|
||||
|
||||
return context.createError({
|
||||
path: 'threshold',
|
||||
message: errorMessages[triggerType](threshold)
|
||||
})
|
||||
})
|
||||
.test(({ requirement }, context) => {
|
||||
const requirementValidator = requirement =>
|
||||
requirement.requirement === 'suspend'
|
||||
? requirement.suspensionDays > 0
|
||||
: true
|
||||
|
||||
if (requirement && requirementValidator(requirement)) return
|
||||
|
||||
return context.createError({
|
||||
path: 'requirement',
|
||||
message: 'Suspension days must be greater than 0'
|
||||
})
|
||||
})
|
||||
|
||||
// TYPE
|
||||
const typeSchema = Yup.object()
|
||||
.shape({
|
||||
triggerType: Yup.string('The trigger type must be a string').required(
|
||||
'The trigger type is required'
|
||||
),
|
||||
threshold: Yup.object({
|
||||
threshold: Yup.number().transform(transformNumber).nullable(),
|
||||
thresholdDays: Yup.number().transform(transformNumber).nullable()
|
||||
})
|
||||
})
|
||||
.test(({ threshold, triggerType }, context) => {
|
||||
const errorMessages = {
|
||||
txAmount: threshold => 'Amount must be greater than or equal to 0',
|
||||
txVolume: threshold => {
|
||||
const thresholdMessage = 'Volume must be greater than or equal to 0'
|
||||
const thresholdDaysMessage = 'Days must be greater than 0'
|
||||
const message = []
|
||||
if (!threshold.threshold || threshold.threshold < 0)
|
||||
message.push(thresholdMessage)
|
||||
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
|
||||
message.push(thresholdDaysMessage)
|
||||
return message.join(', ')
|
||||
},
|
||||
txVelocity: threshold => {
|
||||
const thresholdMessage = 'Transactions must be greater than 0'
|
||||
const thresholdDaysMessage = 'Days must be greater than 0'
|
||||
const message = []
|
||||
if (!threshold.threshold || threshold.threshold <= 0)
|
||||
message.push(thresholdMessage)
|
||||
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
|
||||
message.push(thresholdDaysMessage)
|
||||
return message.join(', ')
|
||||
},
|
||||
consecutiveDays: threshold => 'Days must be greater than 0'
|
||||
}
|
||||
const thresholdValidator = {
|
||||
txAmount: threshold => threshold.threshold >= 0,
|
||||
txVolume: threshold =>
|
||||
threshold.threshold >= 0 && threshold.thresholdDays > 0,
|
||||
txVelocity: threshold =>
|
||||
threshold.threshold > 0 && threshold.thresholdDays > 0,
|
||||
consecutiveDays: threshold => threshold.thresholdDays > 0
|
||||
}
|
||||
|
||||
if (!triggerType) return
|
||||
|
||||
if (triggerType && thresholdValidator[triggerType](threshold)) return
|
||||
|
||||
return context.createError({
|
||||
path: 'threshold',
|
||||
message: errorMessages[triggerType](threshold)
|
||||
})
|
||||
})
|
||||
|
||||
const typeOptions = [
|
||||
{ display: 'Transaction amount', code: 'txAmount' },
|
||||
{
|
||||
display: 'Transaction volume',
|
||||
code: 'txVolume'
|
||||
},
|
||||
{ display: 'Transaction velocity', code: 'txVelocity' },
|
||||
{ display: 'Consecutive days', code: 'consecutiveDays' }
|
||||
]
|
||||
|
||||
const Type = ({ ...props }) => {
|
||||
const { errors, touched, values, setTouched, handleChange } =
|
||||
useFormikContext()
|
||||
|
||||
const typeClass = {
|
||||
'text-tomato': errors.triggerType && touched.triggerType
|
||||
}
|
||||
|
||||
const containsType = R.contains(values?.triggerType)
|
||||
const isThresholdCurrencyEnabled = containsType(['txAmount', 'txVolume'])
|
||||
const isTransactionAmountEnabled = containsType(['txVelocity'])
|
||||
const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity'])
|
||||
const isConsecutiveDaysEnabled = containsType(['consecutiveDays'])
|
||||
|
||||
const hasAmountError =
|
||||
!!errors.threshold &&
|
||||
!!touched.threshold?.threshold &&
|
||||
!isConsecutiveDaysEnabled &&
|
||||
(!values.threshold?.threshold || values.threshold?.threshold < 0)
|
||||
const hasDaysError =
|
||||
!!errors.threshold &&
|
||||
!!touched.threshold?.thresholdDays &&
|
||||
!containsType(['txAmount']) &&
|
||||
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
|
||||
|
||||
const triggerTypeError = !!(hasDaysError || hasAmountError)
|
||||
|
||||
const thresholdClass = {
|
||||
'text-tomato': triggerTypeError
|
||||
}
|
||||
|
||||
const isRadioGroupActive = () => {
|
||||
return (
|
||||
isThresholdCurrencyEnabled ||
|
||||
isTransactionAmountEnabled ||
|
||||
isThresholdDaysEnabled ||
|
||||
isConsecutiveDaysEnabled
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<H4 className={classnames(typeClass)}>Choose trigger type</H4>
|
||||
</div>
|
||||
<Field
|
||||
component={RadioGroup}
|
||||
name="triggerType"
|
||||
options={typeOptions}
|
||||
labelClassName="h-10 py-0 px-3"
|
||||
radioClassName="p-1 m-1"
|
||||
className="flex-row"
|
||||
onChange={e => {
|
||||
handleChange(e)
|
||||
setTouched({
|
||||
threshold: false,
|
||||
thresholdDays: false
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col">
|
||||
{isRadioGroupActive() && (
|
||||
<H4 className={classnames(thresholdClass, 'mt-12')}>Threshold</H4>
|
||||
)}
|
||||
<div className="flex flex-row">
|
||||
{isThresholdCurrencyEnabled && (
|
||||
<>
|
||||
<Field
|
||||
className="mr-2 w-19"
|
||||
component={NumberInput}
|
||||
size="lg"
|
||||
name="threshold.threshold"
|
||||
error={hasAmountError}
|
||||
/>
|
||||
<Info1 className="mt-2">{props.currency}</Info1>
|
||||
</>
|
||||
)}
|
||||
{isTransactionAmountEnabled && (
|
||||
<>
|
||||
<Field
|
||||
className="mr-2 w-19"
|
||||
component={NumberInput}
|
||||
size="lg"
|
||||
name="threshold.threshold"
|
||||
error={hasAmountError}
|
||||
/>
|
||||
<Info1 className="mt-2">transactions</Info1>
|
||||
</>
|
||||
)}
|
||||
{isThresholdDaysEnabled && (
|
||||
<>
|
||||
<Info1 className={classnames(typeClass, 'mx-2 mt-2')}>in</Info1>
|
||||
<Field
|
||||
className="mr-2 w-19"
|
||||
component={NumberInput}
|
||||
size="lg"
|
||||
name="threshold.thresholdDays"
|
||||
error={hasDaysError}
|
||||
/>
|
||||
<Info1 className="mt-2">days</Info1>
|
||||
</>
|
||||
)}
|
||||
{isConsecutiveDaysEnabled && (
|
||||
<>
|
||||
<Field
|
||||
className="mr-2 w-19"
|
||||
component={NumberInput}
|
||||
size="lg"
|
||||
name="threshold.thresholdDays"
|
||||
error={hasDaysError}
|
||||
/>
|
||||
<Info1 className="mt-2">consecutive days</Info1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const type = currency => ({
|
||||
schema: typeSchema,
|
||||
options: typeOptions,
|
||||
Component: Type,
|
||||
props: { currency },
|
||||
initialValues: {
|
||||
triggerType: '',
|
||||
threshold: { threshold: '', thresholdDays: '' }
|
||||
}
|
||||
})
|
||||
|
||||
const requirementSchema = Yup.object()
|
||||
.shape({
|
||||
requirement: Yup.object({
|
||||
requirement: Yup.string().required(),
|
||||
suspensionDays: Yup.number().when('requirement', {
|
||||
is: value => value === 'suspend',
|
||||
then: schema => schema.nullable().transform(transformNumber),
|
||||
otherwise: schema => schema.nullable().transform(() => null)
|
||||
}),
|
||||
customInfoRequestId: Yup.string().when('requirement', {
|
||||
is: value => value !== 'custom',
|
||||
then: schema => schema.nullable().transform(() => '')
|
||||
}),
|
||||
externalService: Yup.string().when('requirement', {
|
||||
is: value => value !== 'external',
|
||||
then: schema => schema.nullable().transform(() => '')
|
||||
})
|
||||
}).required()
|
||||
})
|
||||
.test(({ requirement }, context) => {
|
||||
const requirementValidator = (requirement, type) => {
|
||||
switch (type) {
|
||||
case 'suspend':
|
||||
return requirement.requirement === type
|
||||
? requirement.suspensionDays > 0
|
||||
: true
|
||||
case 'custom':
|
||||
return requirement.requirement === type
|
||||
? !R.isNil(requirement.customInfoRequestId)
|
||||
: true
|
||||
case 'external':
|
||||
return requirement.requirement === type
|
||||
? !R.isNil(requirement.externalService)
|
||||
: true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if (requirement && !requirementValidator(requirement, 'suspend'))
|
||||
return context.createError({
|
||||
path: 'requirement',
|
||||
message: 'Suspension days must be greater than 0'
|
||||
})
|
||||
|
||||
if (requirement && !requirementValidator(requirement, 'custom'))
|
||||
return context.createError({
|
||||
path: 'requirement',
|
||||
message: 'You must select an item'
|
||||
})
|
||||
|
||||
if (requirement && !requirementValidator(requirement, 'external'))
|
||||
return context.createError({
|
||||
path: 'requirement',
|
||||
message: 'You must select an item'
|
||||
})
|
||||
})
|
||||
|
||||
const requirementOptions = [
|
||||
{ display: 'SMS verification', code: 'sms' },
|
||||
{
|
||||
display: 'Email verification',
|
||||
code: 'email'
|
||||
},
|
||||
{ display: 'ID card image', code: 'idCardPhoto' },
|
||||
{
|
||||
display: 'ID data',
|
||||
code: 'idCardData'
|
||||
},
|
||||
{ display: 'Customer camera', code: 'facephoto' },
|
||||
{ display: 'Sanctions', code: 'sanctions' },
|
||||
{
|
||||
display: 'US SSN',
|
||||
code: 'usSsn'
|
||||
}, // { display: 'Super user', code: 'superuser' },
|
||||
{ display: 'Suspend', code: 'suspend' },
|
||||
{ display: 'Block', code: 'block' },
|
||||
{
|
||||
display: 'External verification',
|
||||
code: 'external'
|
||||
}
|
||||
]
|
||||
|
||||
const hasRequirementError = (errors, touched, values) =>
|
||||
!!errors.requirement &&
|
||||
!!touched.requirement?.suspensionDays &&
|
||||
(!values.requirement?.suspensionDays ||
|
||||
values.requirement?.suspensionDays < 0)
|
||||
|
||||
const hasCustomRequirementError = (errors, touched, values) =>
|
||||
!!errors.requirement &&
|
||||
!!touched.requirement?.customInfoRequestId &&
|
||||
(!values.requirement?.customInfoRequestId ||
|
||||
!R.isNil(values.requirement?.customInfoRequestId))
|
||||
|
||||
const hasExternalRequirementError = (errors, touched, values) =>
|
||||
!!errors.requirement &&
|
||||
!!touched.requirement?.externalService &&
|
||||
!values.requirement?.externalService
|
||||
|
||||
const Requirement = ({
|
||||
config = {},
|
||||
triggers,
|
||||
emailAuth,
|
||||
complianceServices,
|
||||
customInfoRequests = []
|
||||
}) => {
|
||||
const { touched, errors, values, handleChange, setTouched } =
|
||||
useFormikContext()
|
||||
|
||||
const isSuspend = values?.requirement?.requirement === 'suspend'
|
||||
const isCustom = values?.requirement?.requirement === 'custom'
|
||||
const isExternal = values?.requirement?.requirement === 'external'
|
||||
|
||||
const customRequirementsInUse = R.reduce(
|
||||
(acc, value) => {
|
||||
if (value.requirement.requirement === 'custom')
|
||||
acc.push({
|
||||
triggerType: value.triggerType,
|
||||
id: value.requirement.customInfoRequestId
|
||||
})
|
||||
return acc
|
||||
},
|
||||
[],
|
||||
triggers
|
||||
)
|
||||
|
||||
const availableCustomRequirements = R.filter(
|
||||
it =>
|
||||
!R.includes(
|
||||
{
|
||||
triggerType: config.triggerType,
|
||||
id: it.id
|
||||
},
|
||||
customRequirementsInUse
|
||||
),
|
||||
customInfoRequests
|
||||
)
|
||||
|
||||
const makeCustomReqOptions = () =>
|
||||
availableCustomRequirements.map(it => ({
|
||||
value: it.id,
|
||||
display: it.customRequest.name
|
||||
}))
|
||||
|
||||
const enableCustomRequirement = !R.isEmpty(availableCustomRequirements)
|
||||
|
||||
const customInfoOption = {
|
||||
display: 'Custom information requirement',
|
||||
code: 'custom'
|
||||
}
|
||||
|
||||
const itemToRemove = emailAuth ? 'sms' : 'email'
|
||||
const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove)
|
||||
const options = R.clone(reqOptions)
|
||||
|
||||
enableCustomRequirement && options.push(customInfoOption)
|
||||
|
||||
const titleClass = {
|
||||
'text-tomato':
|
||||
(!!errors.requirement && !isSuspend && !isCustom && !isExternal) ||
|
||||
(isSuspend && hasRequirementError(errors, touched, values)) ||
|
||||
(isCustom && hasCustomRequirementError(errors, touched, values)) ||
|
||||
(isExternal && hasExternalRequirementError(errors, touched, values))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<H4 className={classnames(titleClass)}>Choose a requirement</H4>
|
||||
</div>
|
||||
<Field
|
||||
component={RadioGroup}
|
||||
name="requirement.requirement"
|
||||
options={options}
|
||||
labelClassName="h-10 p-0"
|
||||
radioClassName="p-1 m-1"
|
||||
className="flex-row grid grid-cols-[182px_162px_181px]"
|
||||
onChange={e => {
|
||||
handleChange(e)
|
||||
setTouched({
|
||||
suspensionDays: false
|
||||
})
|
||||
}}
|
||||
/>
|
||||
{isSuspend && (
|
||||
<Field
|
||||
className="mr-2 w-19"
|
||||
component={NumberInput}
|
||||
label="Days"
|
||||
size="lg"
|
||||
name="requirement.suspensionDays"
|
||||
error={hasRequirementError(errors, touched, values)}
|
||||
/>
|
||||
)}
|
||||
{isCustom && (
|
||||
<div>
|
||||
<Field
|
||||
className="mt-4 min-w-[155px]"
|
||||
component={Dropdown}
|
||||
label="Available requests"
|
||||
name="requirement.customInfoRequestId"
|
||||
options={makeCustomReqOptions()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isExternal && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Field
|
||||
className="mt-4 w-[155px]"
|
||||
component={Dropdown}
|
||||
label="Service"
|
||||
name="requirement.externalService"
|
||||
options={complianceServices.map(it => ({
|
||||
value: it.code,
|
||||
display: it.display
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const requirements = (
|
||||
config,
|
||||
triggers,
|
||||
customInfoRequests,
|
||||
complianceServices,
|
||||
emailAuth
|
||||
) => ({
|
||||
schema: requirementSchema,
|
||||
options: requirementOptions,
|
||||
Component: Requirement,
|
||||
props: {
|
||||
config,
|
||||
triggers,
|
||||
customInfoRequests,
|
||||
emailAuth,
|
||||
complianceServices
|
||||
},
|
||||
hasRequirementError: hasRequirementError,
|
||||
hasCustomRequirementError: hasCustomRequirementError,
|
||||
hasExternalRequirementError: hasExternalRequirementError,
|
||||
initialValues: {
|
||||
requirement: {
|
||||
requirement: '',
|
||||
suspensionDays: '',
|
||||
customInfoRequestId: '',
|
||||
externalService: ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const getView = (data, code, compare) => it => {
|
||||
if (!data) return ''
|
||||
|
||||
return R.compose(R.prop(code), R.find(R.propEq(compare ?? 'code', it)))(data)
|
||||
}
|
||||
|
||||
const customReqIdMatches = customReqId => it => {
|
||||
return it.id === customReqId
|
||||
}
|
||||
|
||||
const RequirementInput = ({ customInfoRequests = [] }) => {
|
||||
const { values } = useFormikContext()
|
||||
const requirement = values?.requirement?.requirement
|
||||
const customRequestId =
|
||||
R.path(['requirement', 'customInfoRequestId'])(values) ?? ''
|
||||
const isSuspend = requirement === 'suspend'
|
||||
const display = customRequestId
|
||||
? (R.path(['customRequest', 'name'])(
|
||||
R.find(customReqIdMatches(customRequestId))(customInfoRequests)
|
||||
) ?? '')
|
||||
: getView(requirementOptions, 'display')(requirement)
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline">
|
||||
{`${display} ${isSuspend ? 'for' : ''}`}
|
||||
{isSuspend && (
|
||||
<Field
|
||||
bold
|
||||
className="w-8"
|
||||
name="requirement.suspensionDays"
|
||||
component={NumberInput}
|
||||
textAlign="center"
|
||||
/>
|
||||
)}
|
||||
{isSuspend && 'days'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RequirementView = ({
|
||||
requirement,
|
||||
suspensionDays,
|
||||
customInfoRequestId,
|
||||
externalService,
|
||||
customInfoRequests = []
|
||||
}) => {
|
||||
const display =
|
||||
requirement === 'custom'
|
||||
? (R.path(['customRequest', 'name'])(
|
||||
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
|
||||
) ?? '')
|
||||
: requirement === 'external'
|
||||
? `External verification (${onlyFirstToUpper(externalService)})`
|
||||
: getView(requirementOptions, 'display')(requirement)
|
||||
const isSuspend = requirement === 'suspend'
|
||||
return (
|
||||
<div className="flex items-baseline">
|
||||
{`${display} ${isSuspend ? 'for' : ''}`}
|
||||
{isSuspend && (
|
||||
<Info2 className="mx-2" noMargin>
|
||||
{suspensionDays}
|
||||
</Info2>
|
||||
)}
|
||||
{isSuspend && 'days'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const DisplayThreshold = ({ config, currency, isEdit }) => {
|
||||
const inputClasses = {
|
||||
'-mt-1': true,
|
||||
'w-13': config?.triggerType === 'txVelocity',
|
||||
'w-15': config?.triggerType === 'consecutiveDays'
|
||||
}
|
||||
|
||||
const threshold = config?.threshold?.threshold
|
||||
const thresholdDays = config?.threshold?.thresholdDays
|
||||
|
||||
const Threshold = isEdit ? (
|
||||
<Field
|
||||
bold
|
||||
className={classnames(inputClasses)}
|
||||
name="threshold.threshold"
|
||||
component={NumberInput}
|
||||
textAlign="right"
|
||||
/>
|
||||
) : (
|
||||
<Info2 noMargin>{threshold}</Info2>
|
||||
)
|
||||
const ThresholdDays = isEdit ? (
|
||||
<Field
|
||||
bold
|
||||
className={classnames(inputClasses)}
|
||||
name="threshold.thresholdDays"
|
||||
component={NumberInput}
|
||||
textAlign="right"
|
||||
/>
|
||||
) : (
|
||||
<Info2 noMargin>{thresholdDays}</Info2>
|
||||
)
|
||||
|
||||
switch (config?.triggerType) {
|
||||
case 'txAmount':
|
||||
return (
|
||||
<div className="flex items-baseline justify-end">
|
||||
{Threshold}
|
||||
<Label2 noMargin className="ml-2">
|
||||
{currency}
|
||||
</Label2>
|
||||
</div>
|
||||
)
|
||||
case 'txVolume':
|
||||
return (
|
||||
<div className="flex items-baseline justify-end">
|
||||
{Threshold}
|
||||
<Label2 noMargin className="ml-2">
|
||||
{currency}
|
||||
</Label2>
|
||||
<Label1 noMargin className="mx-2">
|
||||
in
|
||||
</Label1>
|
||||
{ThresholdDays}
|
||||
<Label1 noMargin className="ml-2">
|
||||
days
|
||||
</Label1>
|
||||
</div>
|
||||
)
|
||||
case 'txVelocity':
|
||||
return (
|
||||
<div className="flex items-baseline justify-end">
|
||||
{Threshold}
|
||||
<Label1 className="mx-2" noMargin>
|
||||
transactions in
|
||||
</Label1>
|
||||
{ThresholdDays}
|
||||
<Label1 className="ml-2" noMargin>
|
||||
days
|
||||
</Label1>
|
||||
</div>
|
||||
)
|
||||
case 'consecutiveDays':
|
||||
return (
|
||||
<div className="flex items-baseline justify-end">
|
||||
{ThresholdDays}
|
||||
<Label1 className="mx-2" noMargin>
|
||||
days
|
||||
</Label1>
|
||||
</div>
|
||||
)
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const ThresholdInput = memo(({ currency }) => {
|
||||
const { values } = useFormikContext()
|
||||
|
||||
return <DisplayThreshold isEdit={true} config={values} currency={currency} />
|
||||
})
|
||||
|
||||
const ThresholdView = ({ config, currency }) => {
|
||||
return <DisplayThreshold config={config} currency={currency} />
|
||||
}
|
||||
|
||||
const getElements = (currency, customInfoRequests) => [
|
||||
{
|
||||
name: 'triggerType',
|
||||
size: 'sm',
|
||||
width: 230,
|
||||
input: ({ field: { value: name } }) => (
|
||||
<>{getView(typeOptions, 'display')(name)}</>
|
||||
),
|
||||
view: getView(typeOptions, 'display'),
|
||||
inputProps: {
|
||||
options: typeOptions,
|
||||
valueProp: 'code',
|
||||
labelProp: 'display',
|
||||
optionsLimit: null
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'requirement',
|
||||
size: 'sm',
|
||||
width: 260,
|
||||
bypassField: true,
|
||||
input: () => <RequirementInput customInfoRequests={customInfoRequests} />,
|
||||
view: it => (
|
||||
<RequirementView {...it} customInfoRequests={customInfoRequests} />
|
||||
)
|
||||
},
|
||||
{
|
||||
name: 'threshold',
|
||||
size: 'sm',
|
||||
width: 254,
|
||||
textAlign: 'right',
|
||||
input: () => <ThresholdInput currency={currency} />,
|
||||
view: (it, config) => <ThresholdView config={config} currency={currency} />
|
||||
}
|
||||
]
|
||||
|
||||
const triggerOrder = R.map(R.prop('code'))(typeOptions)
|
||||
const sortBy = [
|
||||
R.comparator(
|
||||
(a, b) =>
|
||||
triggerOrder.indexOf(a.triggerType) < triggerOrder.indexOf(b.triggerType)
|
||||
)
|
||||
]
|
||||
|
||||
const fromServer = triggers => {
|
||||
return R.map(
|
||||
({
|
||||
requirement,
|
||||
suspensionDays,
|
||||
threshold,
|
||||
thresholdDays,
|
||||
customInfoRequestId,
|
||||
externalService,
|
||||
...rest
|
||||
}) => ({
|
||||
requirement: {
|
||||
requirement,
|
||||
suspensionDays,
|
||||
customInfoRequestId,
|
||||
externalService
|
||||
},
|
||||
threshold: {
|
||||
threshold,
|
||||
thresholdDays
|
||||
},
|
||||
...rest
|
||||
})
|
||||
)(triggers)
|
||||
}
|
||||
|
||||
const toServer = triggers =>
|
||||
R.map(({ requirement, threshold, ...rest }) => ({
|
||||
requirement: requirement.requirement,
|
||||
suspensionDays: requirement.suspensionDays,
|
||||
threshold: threshold.threshold,
|
||||
thresholdDays: threshold.thresholdDays,
|
||||
customInfoRequestId: requirement.customInfoRequestId,
|
||||
externalService: requirement.externalService,
|
||||
...rest
|
||||
}))(triggers)
|
||||
|
||||
export {
|
||||
Schema,
|
||||
getElements, // txDirection,
|
||||
type,
|
||||
requirements,
|
||||
sortBy,
|
||||
fromServer,
|
||||
toServer,
|
||||
getView,
|
||||
requirementOptions
|
||||
}
|
||||
3
packages/admin-ui/src/pages/Triggers/index.js
Normal file
3
packages/admin-ui/src/pages/Triggers/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import Triggers from './Triggers'
|
||||
|
||||
export default Triggers
|
||||
Loading…
Add table
Add a link
Reference in a new issue