Merge pull request #1099 from chaotixkilla/fix-custom-sms

Get SMS notices closer to spec
This commit is contained in:
Rafael Taranto 2022-02-16 10:09:18 +00:00 committed by GitHub
commit 26bf036553
19 changed files with 709 additions and 468 deletions

View file

@ -20,6 +20,7 @@ const MANUAL = 'manual'
const CASH_OUT_DISPENSE_READY = 'cash_out_dispense_ready' const CASH_OUT_DISPENSE_READY = 'cash_out_dispense_ready'
const CONFIRMATION_CODE = 'sms_code' const CONFIRMATION_CODE = 'sms_code'
const RECEIPT = 'sms_receipt'
const WALLET_SCORE_THRESHOLD = 9 const WALLET_SCORE_THRESHOLD = 9
@ -37,5 +38,6 @@ module.exports = {
CONFIRMATION_CODE, CONFIRMATION_CODE,
CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES, CASH_OUT_MINIMUM_AMOUNT_OF_CASSETTES,
CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES, CASH_OUT_MAXIMUM_AMOUNT_OF_CASSETTES,
WALLET_SCORE_THRESHOLD WALLET_SCORE_THRESHOLD,
RECEIPT
} }

View file

@ -1,41 +0,0 @@
const _ = require('lodash/fp')
const uuid = require('uuid')
const db = require('./db')
const getCustomMessages = () => {
const sql = `SELECT * FROM custom_messages ORDER BY created`
return db.any(sql).then(res => _.map(
it => ({
id: it.id,
event: _.camelCase(it.event),
message: it.message
}), res))
}
const createCustomMessage = (event, message) => {
const sql = `INSERT INTO custom_messages (id, event, message) VALUES ($1, $2, $3)`
return db.none(sql, [uuid.v4(), _.snakeCase(event), message])
}
const editCustomMessage = (id, event, message) => {
const sql = `UPDATE custom_messages SET event=$2, message=$3 WHERE id=$1`
return db.none(sql, [id, _.snakeCase(event), message])
}
const deleteCustomMessage = id => {
const sql = `DELETE FROM custom_messages WHERE id=$1`
return db.none(sql, [id])
}
const getCustomMessage = event => {
const sql = `SELECT * FROM custom_messages WHERE event=$1 LIMIT 1`
return db.oneOrNone(sql, [event])
}
module.exports = {
getCustomMessages,
createCustomMessage,
editCustomMessage,
deleteCustomMessage,
getCustomMessage
}

View file

@ -1,13 +1,13 @@
const customSms = require('../../../custom-sms') const smsNotices = require('../../../sms-notices')
const resolvers = { const resolvers = {
Query: { Query: {
customMessages: () => customSms.getCustomMessages() SMSNotices: () => smsNotices.getSMSNotices()
}, },
Mutation: { Mutation: {
createCustomMessage: (...[, { event, message }]) => customSms.createCustomMessage(event, message), editSMSNotice: (...[, { id, event, message }]) => smsNotices.editSMSNotice(id, event, message),
editCustomMessage: (...[, { id, event, message }]) => customSms.editCustomMessage(id, event, message), enableSMSNotice: (...[, { id }]) => smsNotices.enableSMSNotice(id),
deleteCustomMessage: (...[, { id }]) => customSms.deleteCustomMessage(id) disableSMSNotice: (...[, { id }]) => smsNotices.disableSMSNotice(id)
} }
} }

View file

@ -1,25 +1,29 @@
const { gql } = require('apollo-server-express') const { gql } = require('apollo-server-express')
const typeDef = gql` const typeDef = gql`
type CustomMessage { type SMSNotice {
id: ID! id: ID!
event: CustomMessageEvent! event: SMSNoticeEvent!
message: String! message: String!
messageName: String!
enabled: Boolean!
allowToggle: Boolean!
} }
enum CustomMessageEvent { enum SMSNoticeEvent {
smsCode smsCode
cashOutDispenseReady cashOutDispenseReady
smsReceipt
} }
type Query { type Query {
customMessages: [CustomMessage] @auth SMSNotices: [SMSNotice] @auth
} }
type Mutation { type Mutation {
createCustomMessage(event: CustomMessageEvent!, message: String!): CustomMessage @auth editSMSNotice(id: ID!, event: SMSNoticeEvent!, message: String!): SMSNotice @auth
editCustomMessage(id: ID!, event: CustomMessageEvent!, message: String!): CustomMessage @auth enableSMSNotice(id: ID!): SMSNotice @auth
deleteCustomMessage(id: ID!): CustomMessage @auth disableSMSNotice(id: ID!): SMSNotice @auth
} }
` `

View file

@ -772,7 +772,8 @@ function plugins (settings, deviceId) {
? '123' ? '123'
: randomCode() : randomCode()
return sms.getSms(CONFIRMATION_CODE, phone, { code }) const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z')
return sms.getSms(CONFIRMATION_CODE, phone, { code, timestamp })
.then(smsObj => { .then(smsObj => {
const rec = { const rec = {
sms: smsObj sms: smsObj

View file

@ -9,7 +9,7 @@ const NAME = 'FakeWallet'
const SECONDS = 1000 const SECONDS = 1000
const PUBLISH_TIME = 3 * SECONDS const PUBLISH_TIME = 3 * SECONDS
const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS
const CONFIRM_TIME = AUTHORIZE_TIME + 120 * SECONDS const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS
let t0 let t0

View file

@ -189,6 +189,6 @@ router.patch('/:id/block', triggerBlock)
router.patch('/:id/suspend', triggerSuspend) router.patch('/:id/suspend', triggerSuspend)
router.patch('/:id/photos/idcarddata', updateIdCardData) router.patch('/:id/photos/idcarddata', updateIdCardData)
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto) router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
router.patch('/:id/smsreceipt', sendSmsReceipt) router.post('/:id/smsreceipt', sendSmsReceipt)
module.exports = router module.exports = router

56
lib/sms-notices.js Normal file
View file

@ -0,0 +1,56 @@
const _ = require('lodash/fp')
const uuid = require('uuid')
const db = require('./db')
const getSMSNotices = () => {
const sql = `SELECT * FROM sms_notices ORDER BY created`
return db.any(sql).then(res => _.map(
it => ({
id: it.id,
event: _.camelCase(it.event),
message: it.message,
messageName: it.message_name,
enabled: it.enabled,
allowToggle: it.allow_toggle
}), res))
}
const createSMSNotice = (event, messageName, message, enabled, allowToggle) => {
const sql = `INSERT INTO sms_notices (id, event, message_name, message${enabled ? `, enabled`: ``}${allowToggle ? `, allowToggle`: ``}) VALUES ($1, $2, $3, $4${enabled ? `, $5`: ``}${allowToggle ? `, $6`: ``})`
return db.none(sql, [uuid.v4(), _.snakeCase(event), messageName, message, enabled, allowToggle])
}
const editSMSNotice = (id, event, message) => {
const sql = `UPDATE sms_notices SET event=$2, message=$3 WHERE id=$1`
return db.none(sql, [id, _.snakeCase(event), message])
}
const deleteSMSNotice = id => {
const sql = `DELETE FROM sms_notices WHERE id=$1`
return db.none(sql, [id])
}
const getSMSNotice = event => {
const sql = `SELECT * FROM sms_notices WHERE event=$1 LIMIT 1`
return db.oneOrNone(sql, [event])
}
const enableSMSNotice = id => {
const sql = `UPDATE sms_notices SET enabled = true WHERE id=$1`
return db.oneOrNone(sql, [id])
}
const disableSMSNotice = id => {
const sql = `UPDATE sms_notices SET enabled = false WHERE id=$1`
return db.oneOrNone(sql, [id])
}
module.exports = {
getSMSNotices,
createSMSNotice,
editSMSNotice,
deleteSMSNotice,
getSMSNotice,
enableSMSNotice,
disableSMSNotice
}

View file

@ -1,17 +1,16 @@
const dateFormat = require('dateformat')
const ph = require('./plugin-helper') const ph = require('./plugin-helper')
const argv = require('minimist')(process.argv.slice(2)) const argv = require('minimist')(process.argv.slice(2))
const { utils: coinUtils } = require('lamassu-coins') const { utils: coinUtils } = require('lamassu-coins')
const _ = require('lodash/fp') const _ = require('lodash/fp')
const customSms = require('./custom-sms') const smsNotices = require('./sms-notices')
const { RECEIPT } = require('./constants')
const getDefaultMessageContent = content => ({
smsCode: `Your cryptomat code: ${content.code}`,
cashOutDispenseReady: `Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [${content.timestamp}]`
})
function getSms (event, phone, content) { function getSms (event, phone, content) {
return customSms.getCustomMessage(event) return smsNotices.getSMSNotice(event)
.then(msg => { .then(msg => {
if (!_.isNil(msg)) { if (!_.isNil(msg)) {
var accMsg = msg.message var accMsg = msg.message
@ -22,11 +21,6 @@ function getSms (event, phone, content) {
body: messageContent body: messageContent
} }
} }
return {
toNumber: phone,
body: getDefaultMessageContent(content)[_.camelCase(event)]
}
}) })
} }
@ -110,13 +104,16 @@ function formatSmsReceipt (data, options) {
message = message.concat(`Address: ${data.address}\n`) message = message.concat(`Address: ${data.address}\n`)
} }
const request = { const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z')
sms: { const postReceiptSmsPromise = getSms(RECEIPT, data.customerPhone, { timestamp })
toNumber: data.customerPhone,
body: message return Promise.all([smsNotices.getSMSNotice(RECEIPT), postReceiptSmsPromise])
} .then(([res, postReceiptSms]) => ({
} sms: {
return request toNumber: data.customerPhone,
body: res.enabled ? message.concat('\n\n', postReceiptSms.body) : message
}
}))
} }
module.exports = { module.exports = {

View file

@ -0,0 +1,22 @@
var db = require('./db')
var smsNotices = require('../lib/sms-notices')
exports.up = function (next) {
var sql = [
`ALTER TABLE custom_messages RENAME TO sms_notices`,
`ALTER TYPE custom_message_event RENAME TO sms_notice_event`,
`ALTER TYPE sms_notice_event ADD VALUE 'sms_receipt'`,
`ALTER TABLE sms_notices ADD COLUMN message_name TEXT UNIQUE NOT NULL`,
`ALTER TABLE sms_notices ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT true`
`ALTER TABLE sms_notices ADD COLUMN allow_toggle BOOLEAN NOT NULL DEFAULT true`
]
db.multi(sql, next)
.then(() => smsNotices.createSMSNotice('sms_code', 'SMS confirmation code', 'Your cryptomat code: #code', true, false))
.then(() => smsNotices.createSMSNotice('cash_out_dispense_ready', 'Cash is ready', 'Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [#timestamp]', true, false))
.then(() => smsNotices.createSMSNotice('sms_receipt', 'SMS receipt', '', true, true))
}
exports.down = function (next) {
next()
}

View file

@ -1,188 +0,0 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import { DeleteDialog } from 'src/components/DeleteDialog'
import { Link, IconButton } from 'src/components/buttons'
import DataTable from 'src/components/tables/DataTable'
import { H4 } from 'src/components/typography'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import styles from './CustomSMS.styles'
import CustomSMSModal from './CustomSMSModal'
const useStyles = makeStyles(styles)
const GET_CUSTOM_MESSAGES = gql`
query customMessages {
customMessages {
id
event
message
}
}
`
const CREATE_CUSTOM_MESSAGE = gql`
mutation createCustomMessage($event: CustomMessageEvent!, $message: String!) {
createCustomMessage(event: $event, message: $message) {
id
}
}
`
const EDIT_CUSTOM_MESSAGE = gql`
mutation editCustomMessage(
$id: ID!
$event: CustomMessageEvent!
$message: String!
) {
editCustomMessage(id: $id, event: $event, message: $message) {
id
}
}
`
const DELETE_CUSTOM_MESSAGE = gql`
mutation deleteCustomMessage($id: ID!) {
deleteCustomMessage(id: $id) {
id
}
}
`
const EVENT_OPTIONS = [
{ code: 'smsCode', display: 'On SMS confirmation code' },
{ code: 'cashOutDispenseReady', display: 'Cash out dispense ready' }
]
const CustomSMS = () => {
const classes = useStyles()
const [deleteDialog, setDeleteDialog] = useState(false)
const [showModal, setShowModal] = useState(false)
const [selectedSMS, setSelectedSMS] = useState(null)
const [errorMsg, setErrorMsg] = useState('')
const { data: messagesData, loading: messagesLoading } = useQuery(
GET_CUSTOM_MESSAGES
)
const [createMessage] = useMutation(CREATE_CUSTOM_MESSAGE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['customMessages']
})
const [editMessage] = useMutation(EDIT_CUSTOM_MESSAGE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['customMessages']
})
const [deleteMessage] = useMutation(DELETE_CUSTOM_MESSAGE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['customMessages']
})
const loading = messagesLoading
const handleClose = () => {
setSelectedSMS(null)
setShowModal(false)
setDeleteDialog(false)
}
const handleOpen = () => {
setErrorMsg('')
setShowModal(true)
}
const elements = [
{
header: 'Event',
width: 600,
size: 'sm',
textAlign: 'left',
view: it =>
R.find(ite => R.propEq('event', ite.code, it), EVENT_OPTIONS).display
},
{
header: 'Edit',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<IconButton
onClick={() => {
setSelectedSMS(it)
setShowModal(true)
}}>
<EditIcon />
</IconButton>
)
},
{
header: 'Delete',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<IconButton
onClick={() => {
setSelectedSMS(it)
setDeleteDialog(true)
}}>
<DeleteIcon />
</IconButton>
)
}
]
return (
<>
<div className={classes.header}>
<H4>Custom SMS message</H4>
<Box display="flex" justifyContent="flex-end">
<Link color="primary" onClick={() => handleOpen()}>
Add custom SMS
</Link>
</Box>
</div>
{showModal && (
<CustomSMSModal
showModal={showModal}
onClose={handleClose}
eventOptions={EVENT_OPTIONS}
sms={selectedSMS}
creationError={errorMsg}
submit={selectedSMS ? editMessage : createMessage}
/>
)}
<DeleteDialog
open={deleteDialog}
onDismissed={() => {
handleClose()
}}
onConfirmed={() => {
handleClose()
deleteMessage({
variables: {
id: selectedSMS.id
}
})
}}
errorMessage={errorMsg}
/>
<DataTable
emptyText="No custom SMS so far"
elements={elements}
loading={loading}
data={R.path(['customMessages'])(messagesData)}
/>
</>
)
}
export default CustomSMS

View file

@ -1,29 +0,0 @@
import { spacer } from 'src/styling/variables'
const styles = {
header: {
display: 'flex',
position: 'relative',
alignItems: 'center',
justifyContent: 'space-between',
width: 800
},
form: {
'& > *': {
marginTop: 20
},
display: 'flex',
flexDirection: 'column',
height: '100%'
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, spacer * 3, 0]]
},
submit: {
margin: [['auto', 0, 0, 'auto']]
}
}
export default styles

View file

@ -1,165 +0,0 @@
import { makeStyles } from '@material-ui/core'
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React, { useState } from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { Autocomplete, TextInput } from 'src/components/inputs/formik'
import styles from './CustomSMS.styles'
const useStyles = makeStyles(styles)
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Internal server error'
if (formikErrors.event && formikTouched.event) return formikErrors.event
if (formikErrors.message && formikTouched.message) return formikErrors.message
return null
}
const prefill = {
smsCode: {
validator: Yup.string()
.required('The message content is required!')
.trim()
.test({
name: 'has-code-tag',
message: 'A #code tag is missing from the message!',
exclusive: false,
test: value => value?.match(/#code/g || [])?.length > 0
})
.test({
name: 'has-single-code-tag',
message: 'There should be a single #code tag!',
exclusive: false,
test: value => value?.match(/#code/g || [])?.length === 1
})
},
cashOutDispenseReady: {
validator: Yup.string()
.required('The message content is required!')
.trim()
.test({
name: 'has-timestamp-tag',
message: 'A #timestamp tag is missing from the message!',
exclusive: false,
test: value => value?.match(/#timestamp/g || [])?.length > 0
})
.test({
name: 'has-single-timestamp-tag',
message: 'There should be a single #timestamp tag!',
exclusive: false,
test: value => value?.match(/#timestamp/g || [])?.length === 1
})
}
}
const CustomSMSModal = ({
showModal,
onClose,
sms,
eventOptions,
creationError,
submit
}) => {
const classes = useStyles()
const [selectedEvent, setSelectedEvent] = useState(sms?.event)
const initialValues = {
event: !R.isNil(sms) ? sms.event : '',
message: !R.isNil(sms) ? sms.message : ''
}
const validationSchema = Yup.object().shape({
event: Yup.string().required('An event is required!'),
message:
prefill[selectedEvent]?.validator ??
Yup.string()
.required('The message content is required!')
.trim()
})
const handleSubmit = values => {
sms
? submit({
variables: {
id: sms.id,
event: values.event,
message: values.message
}
})
: submit({
variables: {
event: values.event,
message: values.message
}
})
onClose()
}
return (
<>
{showModal && (
<Modal
title={!R.isNil(sms) ? `Edit custom SMS` : `Add custom SMS`}
closeOnBackdropClick={true}
width={600}
height={500}
open={true}
handleClose={onClose}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, errors, touched) =>
handleSubmit(values, errors, touched)
}>
{({ values, errors, touched }) => (
<Form id="custom-sms" className={classes.form}>
<Field
name="event"
label="Event"
fullWidth
onChange={setSelectedEvent(values.event)}
options={eventOptions}
labelProp="display"
valueProp="code"
component={Autocomplete}
/>
<Field
name="message"
label="Message content"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
<div className={classes.footer}>
{getErrorMsg(errors, touched, creationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, creationError)}
</ErrorMessage>
)}
<Button
type="submit"
form="custom-sms"
className={classes.submit}>
{!R.isNil(sms) ? `Confirm` : `Create SMS`}
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)}
</>
)
}
export default CustomSMSModal

View file

@ -0,0 +1,258 @@
import { useQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Paper } from '@material-ui/core'
import { format } from 'date-fns/fp'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
import { HoverableTooltip } from 'src/components/Tooltip'
import { IconButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import DataTable from 'src/components/tables/DataTable'
import { H4, P, Label3 } from 'src/components/typography'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as ExpandIconClosed } from 'src/styling/icons/action/expand/closed.svg'
import { ReactComponent as ExpandIconOpen } from 'src/styling/icons/action/expand/open.svg'
import { ReactComponent as WhiteLogo } from 'src/styling/icons/menu/logo-white.svg'
import styles from './SMSNotices.styles'
import CustomSMSModal from './SMSNoticesModal'
const useStyles = makeStyles(styles)
const GET_SMS_NOTICES = gql`
query SMSNotices {
SMSNotices {
id
event
message
messageName
enabled
allowToggle
}
}
`
const EDIT_SMS_NOTICE = gql`
mutation editSMSNotice($id: ID!, $event: SMSNoticeEvent!, $message: String!) {
editSMSNotice(id: $id, event: $event, message: $message) {
id
}
}
`
const ENABLE_SMS_NOTICE = gql`
mutation enableSMSNotice($id: ID!) {
enableSMSNotice(id: $id) {
id
}
}
`
const DISABLE_SMS_NOTICE = gql`
mutation disableSMSNotice($id: ID!) {
disableSMSNotice(id: $id) {
id
}
}
`
const multiReplace = (str, obj) => {
var re = new RegExp(Object.keys(obj).join('|'), 'gi')
return str.replace(re, function(matched) {
return obj[matched.toLowerCase()]
})
}
const formatContent = content => {
const fragments = R.split(/\n/)(content)
return R.map((it, idx) => {
if (idx === fragments.length) return <>{it}</>
return (
<>
{it}
<br />
</>
)
}, fragments)
}
const TOOLTIPS = {
smsCode: ``,
cashOutDispenseReady: ``,
smsReceipt: formatContent(`The contents of this notice will be appended to the end of the SMS receipt sent, and not replace it.\n
To edit the contents of the SMS receipt, please go to the 'Receipt' tab`)
}
const SMSPreview = ({ sms, coords }) => {
const classes = useStyles(coords)
const matches = {
'#code': 123,
'#timestamp': format('HH:mm', new Date())
}
return (
<div className={classes.smsPreview}>
<div className={classes.smsPreviewContainer}>
<div className={classes.smsPreviewIcon}>
<WhiteLogo width={22} height={22} />
</div>
<Paper className={classes.smsPreviewContent}>
<P noMargin>
{R.isEmpty(sms?.message) ? (
<i>No content available</i>
) : (
formatContent(multiReplace(sms?.message, matches))
)}
</P>
</Paper>
<Label3>{format('HH:mm', new Date())}</Label3>
</div>
</div>
)
}
const SMSNotices = () => {
const classes = useStyles()
const [showModal, setShowModal] = useState(false)
const [selectedSMS, setSelectedSMS] = useState(null)
const [previewOpen, setPreviewOpen] = useState(false)
const [previewCoords, setPreviewCoords] = useState({ x: 0, y: 0 })
const [errorMsg, setErrorMsg] = useState('')
const { data: messagesData, loading: messagesLoading } = useQuery(
GET_SMS_NOTICES
)
const [editMessage] = useMutation(EDIT_SMS_NOTICE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['SMSNotices']
})
const [enableMessage] = useMutation(ENABLE_SMS_NOTICE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['SMSNotices']
})
const [disableMessage] = useMutation(DISABLE_SMS_NOTICE, {
onError: ({ msg }) => setErrorMsg(msg),
refetchQueries: () => ['SMSNotices']
})
const loading = messagesLoading
const handleClose = () => {
setShowModal(false)
setSelectedSMS(null)
}
const elements = [
{
header: 'Message name',
width: 500,
size: 'sm',
textAlign: 'left',
view: it =>
!R.isEmpty(TOOLTIPS[it.event]) ? (
<div className={classes.messageWithTooltip}>
{R.prop('messageName', it)}
<HoverableTooltip width={250}>
<P>{TOOLTIPS[it.event]}</P>
</HoverableTooltip>
</div>
) : (
R.prop('messageName', it)
)
},
{
header: 'Edit',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<IconButton
onClick={() => {
setPreviewOpen(false)
setSelectedSMS(it)
setShowModal(true)
}}>
<EditIcon />
</IconButton>
)
},
{
header: 'Enable',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<Switch
disabled={!it.allowToggle}
onClick={() => {
it.enabled
? disableMessage({ variables: { id: it.id } })
: enableMessage({ variables: { id: it.id } })
}}
checked={it.enabled}
/>
)
},
{
header: '',
width: 100,
size: 'sm',
textAlign: 'center',
view: it => (
<IconButton
onClick={e => {
setSelectedSMS(it)
setPreviewCoords({
x: e.currentTarget.getBoundingClientRect().right + 50,
y:
window.innerHeight -
5 -
e.currentTarget.getBoundingClientRect().bottom
})
R.equals(selectedSMS, it)
? setPreviewOpen(!previewOpen)
: setPreviewOpen(true)
}}>
{R.equals(selectedSMS, it) && previewOpen ? (
<ExpandIconOpen />
) : (
<ExpandIconClosed />
)}
</IconButton>
)
}
]
return (
<>
<div className={classes.header}>
<H4>SMS notices</H4>
</div>
{showModal && (
<CustomSMSModal
showModal={showModal}
onClose={handleClose}
sms={selectedSMS}
creationError={errorMsg}
submit={editMessage}
/>
)}
{previewOpen && <SMSPreview sms={selectedSMS} coords={previewCoords} />}
<DataTable
emptyText="No SMS notices so far"
elements={elements}
loading={loading}
data={R.path(['SMSNotices'])(messagesData)}
/>
</>
)
}
export default SMSNotices

View file

@ -0,0 +1,95 @@
import {
spacer,
fontMonospaced,
fontSize5,
fontColor
} from 'src/styling/variables'
const styles = {
header: {
display: 'flex',
position: 'relative',
alignItems: 'center',
justifyContent: 'space-between',
width: 800
},
form: {
'& > *': {
marginTop: 20
},
display: 'flex',
flexDirection: 'column',
height: '100%'
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, spacer * 3, 0]]
},
submit: {
margin: [['auto', 0, 0, 'auto']]
},
smsPreview: {
position: 'absolute',
left: ({ x }) => x,
bottom: ({ y }) => y,
width: 350,
overflow: 'visible'
},
smsPreviewContainer: {
display: 'flex',
flexDirection: 'row',
alignItems: 'flex-end',
'& > *': {
marginRight: 10
}
},
smsPreviewIcon: {
display: 'flex',
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#16D6D3',
alignItems: 'center',
justifyContent: 'center'
},
smsPreviewContent: {
width: 225,
padding: 15,
borderRadius: '15px 15px 15px 0px'
},
chipButtons: {
width: 480,
display: 'flex',
flexDirection: 'column',
alignItems: 'space-between',
'& > div': {
marginTop: 15
},
'& > div:first-child': {
marginTop: 0
},
'& > div > div': {
margin: [[0, 5, 0, 5]]
},
'& > div > div > span': {
lineHeight: '120%',
color: fontColor,
fontSize: fontSize5,
fontFamily: fontMonospaced,
fontWeight: 500
},
marginLeft: 'auto',
marginRight: 'auto'
},
resetToDefault: {
width: 145
},
messageWithTooltip: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
}
}
export default styles

View file

@ -0,0 +1,209 @@
import { makeStyles, Chip } from '@material-ui/core'
import { Form, Formik, Field } from 'formik'
import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { ActionButton, Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { Info2 } from 'src/components/typography'
import { ReactComponent as DefaultIconReverse } from 'src/styling/icons/button/retry/white.svg'
import { ReactComponent as DefaultIcon } from 'src/styling/icons/button/retry/zodiac.svg'
import { zircon } from 'src/styling/variables'
import styles from './SMSNotices.styles'
const useStyles = makeStyles(styles)
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Internal server error'
if (formikErrors.event && formikTouched.event) return formikErrors.event
if (formikErrors.message && formikTouched.message) return formikErrors.message
return null
}
const PREFILL = {
smsCode: {
validator: Yup.string()
.required('The message content is required!')
.trim()
.test({
name: 'has-code',
message: 'The confirmation code is missing from the message!',
exclusive: false,
test: value => value?.match(/#code/g || [])?.length > 0
})
.test({
name: 'has-single-code',
message: 'There should be a single confirmation code!',
exclusive: false,
test: value => value?.match(/#code/g || [])?.length === 1
})
},
cashOutDispenseReady: {
validator: Yup.string()
.required('The message content is required!')
.trim()
},
smsReceipt: {
validator: Yup.string().trim()
}
}
const CHIPS = {
smsCode: [
{ code: '#code', display: 'Confirmation code', obligatory: true },
{ code: '#timestamp', display: 'Timestamp', obligatory: false }
],
cashOutDispenseReady: [
{ code: '#timestamp', display: 'Timestamp', obligatory: false }
],
smsReceipt: [{ code: '#timestamp', display: 'Timestamp', obligatory: false }]
}
const DEFAULT_MESSAGES = {
smsCode: 'Your cryptomat code: #code',
cashOutDispenseReady:
'Your cash is waiting! Go to the Cryptomat and press Redeem within 24 hours. [#timestamp]',
smsReceipt: ''
}
const SMSNoticesModal = ({
showModal,
onClose,
sms,
creationError,
submit
}) => {
const classes = useStyles()
const initialValues = {
event: !R.isNil(sms) ? sms.event : '',
message: !R.isNil(sms) ? sms.message : ''
}
const validationSchema = Yup.object().shape({
event: Yup.string().required('An event is required!'),
message:
PREFILL[sms?.event]?.validator ??
Yup.string()
.required('The message content is required!')
.trim()
})
const handleSubmit = values => {
sms
? submit({
variables: {
id: sms.id,
event: values.event,
message: values.message
}
})
: submit({
variables: {
event: values.event,
message: values.message
}
})
onClose()
}
return (
<>
{showModal && (
<Modal
title={`SMS notice - ${sms?.messageName}`}
closeOnBackdropClick={true}
width={600}
height={500}
open={true}
handleClose={onClose}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={(values, errors, touched) =>
handleSubmit(values, errors, touched)
}>
{({ values, errors, touched, setFieldValue }) => (
<Form id="sms-notice" className={classes.form}>
<ActionButton
color="primary"
Icon={DefaultIcon}
InverseIcon={DefaultIconReverse}
className={classes.resetToDefault}
type="button"
onClick={() =>
setFieldValue('message', DEFAULT_MESSAGES[sms?.event])
}>
Reset to default
</ActionButton>
<Field
name="message"
label="Message content"
fullWidth
multiline={true}
rows={6}
component={TextInput}
/>
{R.length(CHIPS[sms?.event]) > 0 && (
<Info2 noMargin>Values to attach</Info2>
)}
<div className={classes.chipButtons}>
{R.map(
it => (
<div>
{R.map(
ite => (
<Chip
label={ite.display}
size="small"
style={{ backgroundColor: zircon }}
disabled={R.includes(ite.code, values.message)}
className={classes.chip}
onClick={() => {
setFieldValue(
'message',
values.message.concat(
R.last(values.message) === ' ' ? '' : ' ',
ite.code
)
)
}}
/>
),
it
)}
</div>
),
R.splitEvery(3, CHIPS[sms?.event])
)}
</div>
<div className={classes.footer}>
{getErrorMsg(errors, touched, creationError) && (
<ErrorMessage>
{getErrorMsg(errors, touched, creationError)}
</ErrorMessage>
)}
<Button
type="submit"
form="sms-notice"
className={classes.submit}>
Confirm
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)}
</>
)
}
export default SMSNoticesModal

View file

@ -16,8 +16,8 @@ import MachineStatus from 'src/pages/Maintenance/MachineStatus'
import Notifications from 'src/pages/Notifications/Notifications' import Notifications from 'src/pages/Notifications/Notifications'
import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
import ContactInfo from 'src/pages/OperatorInfo/ContactInfo' import ContactInfo from 'src/pages/OperatorInfo/ContactInfo'
import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS'
import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting' import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import SMSNotices from 'src/pages/OperatorInfo/SMSNotices/SMSNotices'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions' import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs' import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services' import Services from 'src/pages/Services/Services'
@ -174,11 +174,11 @@ const getLamassuRoutes = () => [
component: ReceiptPrinting component: ReceiptPrinting
}, },
{ {
key: 'custom-sms', key: 'sms-notices',
label: 'Custom SMS', label: 'SMS notices',
route: '/settings/operator-info/custom-sms', route: '/settings/operator-info/sms-notices',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CustomSMS component: SMSNotices
}, },
{ {
key: 'coin-atm-radar', key: 'coin-atm-radar',

View file

@ -19,8 +19,8 @@ import MachineStatus from 'src/pages/Maintenance/MachineStatus'
import Notifications from 'src/pages/Notifications/Notifications' import Notifications from 'src/pages/Notifications/Notifications'
import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar' import CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
import ContactInfo from 'src/pages/OperatorInfo/ContactInfo' import ContactInfo from 'src/pages/OperatorInfo/ContactInfo'
import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS'
import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting' import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import SMSNotices from 'src/pages/OperatorInfo/SMSNotices/SMSNotices'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions' import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs' import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services' import Services from 'src/pages/Services/Services'
@ -169,11 +169,11 @@ const getPazuzRoutes = () => [
component: ReceiptPrinting component: ReceiptPrinting
}, },
{ {
key: 'custom-sms', key: 'sms-notices',
label: 'Custom SMS', label: 'SMS notices',
route: '/settings/operator-info/custom-sms', route: '/settings/operator-info/sms-notices',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER], allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CustomSMS component: SMSNotices
}, },
{ {
key: 'coin-atm-radar', key: 'coin-atm-radar',

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7 KiB