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 CONFIRMATION_CODE = 'sms_code'
const RECEIPT = 'sms_receipt'
const WALLET_SCORE_THRESHOLD = 9
@ -37,5 +38,6 @@ module.exports = {
CONFIRMATION_CODE,
CASH_OUT_MINIMUM_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 = {
Query: {
customMessages: () => customSms.getCustomMessages()
SMSNotices: () => smsNotices.getSMSNotices()
},
Mutation: {
createCustomMessage: (...[, { event, message }]) => customSms.createCustomMessage(event, message),
editCustomMessage: (...[, { id, event, message }]) => customSms.editCustomMessage(id, event, message),
deleteCustomMessage: (...[, { id }]) => customSms.deleteCustomMessage(id)
editSMSNotice: (...[, { id, event, message }]) => smsNotices.editSMSNotice(id, event, message),
enableSMSNotice: (...[, { id }]) => smsNotices.enableSMSNotice(id),
disableSMSNotice: (...[, { id }]) => smsNotices.disableSMSNotice(id)
}
}

View file

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

View file

@ -772,7 +772,8 @@ function plugins (settings, deviceId) {
? '123'
: 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 => {
const rec = {
sms: smsObj

View file

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

View file

@ -189,6 +189,6 @@ router.patch('/:id/block', triggerBlock)
router.patch('/:id/suspend', triggerSuspend)
router.patch('/:id/photos/idcarddata', updateIdCardData)
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
router.patch('/:id/smsreceipt', sendSmsReceipt)
router.post('/:id/smsreceipt', sendSmsReceipt)
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 argv = require('minimist')(process.argv.slice(2))
const { utils: coinUtils } = require('lamassu-coins')
const _ = require('lodash/fp')
const customSms = require('./custom-sms')
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}]`
})
const smsNotices = require('./sms-notices')
const { RECEIPT } = require('./constants')
function getSms (event, phone, content) {
return customSms.getCustomMessage(event)
return smsNotices.getSMSNotice(event)
.then(msg => {
if (!_.isNil(msg)) {
var accMsg = msg.message
@ -22,11 +21,6 @@ function getSms (event, phone, content) {
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`)
}
const request = {
sms: {
toNumber: data.customerPhone,
body: message
}
}
return request
const timestamp = dateFormat(new Date(), 'UTC:HH:MM Z')
const postReceiptSmsPromise = getSms(RECEIPT, data.customerPhone, { timestamp })
return Promise.all([smsNotices.getSMSNotice(RECEIPT), postReceiptSmsPromise])
.then(([res, postReceiptSms]) => ({
sms: {
toNumber: data.customerPhone,
body: res.enabled ? message.concat('\n\n', postReceiptSms.body) : message
}
}))
}
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 CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
import ContactInfo from 'src/pages/OperatorInfo/ContactInfo'
import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS'
import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import SMSNotices from 'src/pages/OperatorInfo/SMSNotices/SMSNotices'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services'
@ -174,11 +174,11 @@ const getLamassuRoutes = () => [
component: ReceiptPrinting
},
{
key: 'custom-sms',
label: 'Custom SMS',
route: '/settings/operator-info/custom-sms',
key: 'sms-notices',
label: 'SMS notices',
route: '/settings/operator-info/sms-notices',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CustomSMS
component: SMSNotices
},
{
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 CoinAtmRadar from 'src/pages/OperatorInfo/CoinATMRadar'
import ContactInfo from 'src/pages/OperatorInfo/ContactInfo'
import CustomSMS from 'src/pages/OperatorInfo/CustomSMS/CustomSMS'
import ReceiptPrinting from 'src/pages/OperatorInfo/ReceiptPrinting'
import SMSNotices from 'src/pages/OperatorInfo/SMSNotices/SMSNotices'
import TermsConditions from 'src/pages/OperatorInfo/TermsConditions'
import ServerLogs from 'src/pages/ServerLogs'
import Services from 'src/pages/Services/Services'
@ -169,11 +169,11 @@ const getPazuzRoutes = () => [
component: ReceiptPrinting
},
{
key: 'custom-sms',
label: 'Custom SMS',
route: '/settings/operator-info/custom-sms',
key: 'sms-notices',
label: 'SMS notices',
route: '/settings/operator-info/sms-notices',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: CustomSMS
component: SMSNotices
},
{
key: 'coin-atm-radar',

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7 KiB