Merge pull request #806 from josepfo/feat/handle-customer-tc-photos
feat: handle T&C photos and UI photos roll
This commit is contained in:
commit
5570f81802
17 changed files with 604 additions and 74 deletions
|
|
@ -519,7 +519,7 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
|
||||||
*/
|
*/
|
||||||
function getCustomerById (id) {
|
function getCustomerById (id) {
|
||||||
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
|
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
|
||||||
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
|
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
|
||||||
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
|
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
|
||||||
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
|
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
|
||||||
sanctions_override, total_txs, total_spent, created as last_active, fiat as last_tx_fiat,
|
sanctions_override, total_txs, total_spent, created as last_active, fiat as last_tx_fiat,
|
||||||
|
|
@ -528,7 +528,7 @@ function getCustomerById (id) {
|
||||||
select c.id, c.authorized_override,
|
select c.id, c.authorized_override,
|
||||||
greatest(0, date_part('day', c.suspended_until - now())) as days_suspended,
|
greatest(0, date_part('day', c.suspended_until - now())) as days_suspended,
|
||||||
c.suspended_until > now() as is_suspended,
|
c.suspended_until > now() as is_suspended,
|
||||||
c.front_camera_path, c.front_camera_override,
|
c.front_camera_path, c.front_camera_at, c.front_camera_override,
|
||||||
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
|
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
|
||||||
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
|
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
|
||||||
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created,
|
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created,
|
||||||
|
|
@ -654,6 +654,57 @@ function updateIdCardData (patch, id) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {String} imageData customer t&c photo data
|
||||||
|
* @returns {Promise<Object>} new patch to be applied
|
||||||
|
*/
|
||||||
|
function updateTxCustomerPhoto (imageData) {
|
||||||
|
return Promise.resolve(imageData)
|
||||||
|
.then(imageData => {
|
||||||
|
const newPatch = {}
|
||||||
|
const directory = `${operatorDataDir}/customersphotos`
|
||||||
|
|
||||||
|
if (_.isEmpty(imageData)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// decode the base64 string to binary data
|
||||||
|
const decodedImageData = Buffer.from(imageData, 'base64')
|
||||||
|
|
||||||
|
// workout the image hash
|
||||||
|
// i.e. 240e85ff2e4bb931f235985dd0134e459239496d2b5af6c5665168d38ef89b50
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(imageData)
|
||||||
|
.digest('hex')
|
||||||
|
|
||||||
|
// workout the image folder
|
||||||
|
// i.e. 24/0e/85
|
||||||
|
const rpath = _.join(path.sep, _.map(_.wrap(_.join, ''), _.take(3, _.chunk(2, _.split('', hash)))))
|
||||||
|
|
||||||
|
// i.e. ../<lamassu-server-home>/<operator-dir>/customersphotos/24/0e/85
|
||||||
|
const dirname = path.join(directory, rpath)
|
||||||
|
|
||||||
|
// create the directory tree if needed
|
||||||
|
_.attempt(() => makeDir.sync(dirname))
|
||||||
|
|
||||||
|
// i.e. ../<lamassu-server-home>/<operator-dir>/customersphotos/24/0e/85/240e85ff2e4bb931f235985dd01....jpg
|
||||||
|
const filename = path.join(dirname, hash + '.jpg')
|
||||||
|
|
||||||
|
// update db record patch
|
||||||
|
// i.e. {
|
||||||
|
// "idCustomerTxPhoto": "24/0e/85/240e85ff2e4bb931f235985dd01....jpg",
|
||||||
|
// "idCustomerTxPhotoAt": "now()"
|
||||||
|
// }
|
||||||
|
newPatch.txCustomerPhotoPath = path.join(rpath, hash + '.jpg')
|
||||||
|
newPatch.txCustomerPhotoAt = 'now()'
|
||||||
|
|
||||||
|
// write image file
|
||||||
|
return writeFile(filename, decodedImageData)
|
||||||
|
.then(() => newPatch)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function updateFrontCamera (id, patch) {
|
function updateFrontCamera (id, patch) {
|
||||||
return Promise.resolve(patch)
|
return Promise.resolve(patch)
|
||||||
.then(patch => {
|
.then(patch => {
|
||||||
|
|
@ -704,4 +755,4 @@ function updateFrontCamera (id, patch) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera, updateIdCardData }
|
module.exports = { add, get, batch, getCustomersList, getCustomerById, getById, update, updateCustomer, updatePhotoCard, updateFrontCamera, updateIdCardData, updateTxCustomerPhoto }
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ const { typeDefs, resolvers } = require('./graphql/schema')
|
||||||
const devMode = require('minimist')(process.argv.slice(2)).dev
|
const devMode = require('minimist')(process.argv.slice(2)).dev
|
||||||
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
||||||
const frontCameraBasedir = _.get('frontCameraDir', options)
|
const frontCameraBasedir = _.get('frontCameraDir', options)
|
||||||
|
const operatorDataBasedir = _.get('operatorDataDir', options)
|
||||||
|
|
||||||
const hostname = options.hostname
|
const hostname = options.hostname
|
||||||
if (!hostname) {
|
if (!hostname) {
|
||||||
|
|
@ -87,6 +88,7 @@ app.use(cors({ credentials: true, origin: devMode && 'https://localhost:3001' })
|
||||||
|
|
||||||
app.use('/id-card-photo', serveStatic(idPhotoCardBasedir, { index: false }))
|
app.use('/id-card-photo', serveStatic(idPhotoCardBasedir, { index: false }))
|
||||||
app.use('/front-camera-photo', serveStatic(frontCameraBasedir, { index: false }))
|
app.use('/front-camera-photo', serveStatic(frontCameraBasedir, { index: false }))
|
||||||
|
app.use('/operator-data', serveStatic(operatorDataBasedir, { index: false }))
|
||||||
|
|
||||||
// Everything not on graphql or api/register is redirected to the front-end
|
// Everything not on graphql or api/register is redirected to the front-end
|
||||||
app.get('*', (req, res) => res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'index.html')))
|
app.get('*', (req, res) => res.sendFile(path.resolve(__dirname, '..', '..', 'public', 'index.html')))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const typeDef = gql`
|
||||||
daysSuspended: Int
|
daysSuspended: Int
|
||||||
isSuspended: Boolean
|
isSuspended: Boolean
|
||||||
frontCameraPath: String
|
frontCameraPath: String
|
||||||
|
frontCameraAt: Date
|
||||||
frontCameraOverride: String
|
frontCameraOverride: String
|
||||||
phone: String
|
phone: String
|
||||||
isAnonymous: Boolean
|
isAnonymous: Boolean
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,8 @@ const typeDef = gql`
|
||||||
expired: Boolean
|
expired: Boolean
|
||||||
machineName: String
|
machineName: String
|
||||||
discount: Int
|
discount: Int
|
||||||
|
txCustomerPhotoPath: String
|
||||||
|
txCustomerPhotoAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
type Filter {
|
type Filter {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ function batch (
|
||||||
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
||||||
c.front_camera_path AS customer_front_camera_path,
|
c.front_camera_path AS customer_front_camera_path,
|
||||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||||
|
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||||
|
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||||
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired
|
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired
|
||||||
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
|
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
|
||||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||||
|
|
@ -76,6 +78,8 @@ function batch (
|
||||||
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
||||||
c.front_camera_path AS customer_front_camera_path,
|
c.front_camera_path AS customer_front_camera_path,
|
||||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||||
|
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||||
|
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||||
(extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $1 AS expired
|
(extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) * 1000) >= $1 AS expired
|
||||||
FROM (SELECT *, ${CASH_OUT_TRANSACTION_STATES} AS txStatus FROM cash_out_txs) txs
|
FROM (SELECT *, ${CASH_OUT_TRANSACTION_STATES} AS txStatus FROM cash_out_txs) txs
|
||||||
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
||||||
|
|
@ -270,4 +274,23 @@ function getTxAssociatedData (txId, txClass) {
|
||||||
: db.manyOrNone(actionsSql, [txId])
|
: db.manyOrNone(actionsSql, [txId])
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { batch, single, cancel, getCustomerTransactionsBatch, getTx, getTxAssociatedData }
|
function updateTxCustomerPhoto (customerId, txId, direction, data) {
|
||||||
|
const formattedData = _.mapKeys(_.snakeCase, data)
|
||||||
|
const cashInSql = 'UPDATE cash_in_txs SET tx_customer_photo_at = $1, tx_customer_photo_path = $2 WHERE customer_id=$3 AND id=$4'
|
||||||
|
|
||||||
|
const cashOutSql = 'UPDATE cash_out_txs SET tx_customer_photo_at = $1, tx_customer_photo_path = $2 WHERE customer_id=$3 AND id=$4'
|
||||||
|
|
||||||
|
return direction === 'cashIn'
|
||||||
|
? db.oneOrNone(cashInSql, [formattedData.tx_customer_photo_at, formattedData.tx_customer_photo_path, customerId, txId])
|
||||||
|
: db.oneOrNone(cashOutSql, [formattedData.tx_customer_photo_at, formattedData.tx_customer_photo_path, customerId, txId])
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
batch,
|
||||||
|
single,
|
||||||
|
cancel,
|
||||||
|
getCustomerTransactionsBatch,
|
||||||
|
getTx,
|
||||||
|
getTxAssociatedData,
|
||||||
|
updateTxCustomerPhoto
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const compliance = require('../compliance')
|
||||||
const complianceTriggers = require('../compliance-triggers')
|
const complianceTriggers = require('../compliance-triggers')
|
||||||
const configManager = require('../new-config-manager')
|
const configManager = require('../new-config-manager')
|
||||||
const customers = require('../customers')
|
const customers = require('../customers')
|
||||||
|
const txs = require('../new-admin/services/transactions')
|
||||||
const httpError = require('../route-helpers').httpError
|
const httpError = require('../route-helpers').httpError
|
||||||
const notifier = require('../notifier')
|
const notifier = require('../notifier')
|
||||||
const respond = require('../respond')
|
const respond = require('../respond')
|
||||||
|
|
@ -99,10 +100,27 @@ function triggerSuspend (req, res, next) {
|
||||||
.catch(next)
|
.catch(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateTxCustomerPhoto (req, res, next) {
|
||||||
|
const customerId = req.params.id
|
||||||
|
const txId = req.params.txId
|
||||||
|
const tcPhotoData = req.body.tcPhotoData
|
||||||
|
const direction = req.body.direction
|
||||||
|
|
||||||
|
Promise.all([customers.getById(customerId), txs.getTx(txId, direction)])
|
||||||
|
.then(([customer, tx]) => {
|
||||||
|
if (!customer || !tx) return
|
||||||
|
return customers.updateTxCustomerPhoto(tcPhotoData)
|
||||||
|
.then(newPatch => txs.updateTxCustomerPhoto(customerId, txId, direction, newPatch))
|
||||||
|
})
|
||||||
|
.then(() => respond(req, res, {}))
|
||||||
|
.catch(next)
|
||||||
|
}
|
||||||
|
|
||||||
router.patch('/:id', updateCustomer)
|
router.patch('/:id', updateCustomer)
|
||||||
router.patch('/:id/sanctions', triggerSanctions)
|
router.patch('/:id/sanctions', triggerSanctions)
|
||||||
router.patch('/:id/block', triggerBlock)
|
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)
|
||||||
|
|
||||||
module.exports = router
|
module.exports = router
|
||||||
|
|
|
||||||
13
migrations/1627563019030-add-customer-tc-photo-path.js
Normal file
13
migrations/1627563019030-add-customer-tc-photo-path.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
const db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
const sql = [
|
||||||
|
'ALTER TABLE cash_in_txs ADD COLUMN tx_customer_photo_at TIMESTAMPTZ, ADD COLUMN tx_customer_photo_path TEXT',
|
||||||
|
'ALTER TABLE cash_out_txs ADD COLUMN tx_customer_photo_at TIMESTAMPTZ, ADD COLUMN tx_customer_photo_path TEXT'
|
||||||
|
]
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
44
new-lamassu-admin/package-lock.json
generated
44
new-lamassu-admin/package-lock.json
generated
|
|
@ -6057,6 +6057,11 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/scheduler": {
|
||||||
|
"version": "0.16.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
|
||||||
|
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
|
||||||
|
},
|
||||||
"@types/source-list-map": {
|
"@types/source-list-map": {
|
||||||
"version": "0.1.2",
|
"version": "0.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.2.tgz",
|
||||||
|
|
@ -7192,6 +7197,31 @@
|
||||||
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
"integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"auto-bind": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-NUwV1i9D3vxxY1KnfZgSZ716d6ovY7o8LfOwLhGIPFBowIb6Ln6DBW64+jCqPzUznel2hRSkQnYQqvh7/ldw8A==",
|
||||||
|
"requires": {
|
||||||
|
"@types/react": "^16.8.12"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": {
|
||||||
|
"version": "16.14.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-16.14.11.tgz",
|
||||||
|
"integrity": "sha512-Don0MtsZZ3fjwTJ2BsoqkyOy7e176KplEAKOpr/4XDdzinlyJBn9yfsKn5mcSgn4kh1B22+3tBnzBC1z63ybtQ==",
|
||||||
|
"requires": {
|
||||||
|
"@types/prop-types": "*",
|
||||||
|
"@types/scheduler": "*",
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"csstype": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"autoprefixer": {
|
"autoprefixer": {
|
||||||
"version": "9.8.6",
|
"version": "9.8.6",
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.6.tgz",
|
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-9.8.6.tgz",
|
||||||
|
|
@ -21860,6 +21890,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA=="
|
||||||
},
|
},
|
||||||
|
"react-material-ui-carousel": {
|
||||||
|
"version": "2.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-material-ui-carousel/-/react-material-ui-carousel-2.2.7.tgz",
|
||||||
|
"integrity": "sha512-aO42C4oupmIxmJwYaTWrlWaXvVVspKcpEu/5efZ9slteATEsqqPtNAeVaE40Vimw2hZeIh2e8vpRwjq7fSsLxw==",
|
||||||
|
"requires": {
|
||||||
|
"auto-bind": "^2.1.1",
|
||||||
|
"react-swipeable": "^6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"react-number-format": {
|
"react-number-format": {
|
||||||
"version": "4.4.4",
|
"version": "4.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-4.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-number-format/-/react-number-format-4.4.4.tgz",
|
||||||
|
|
@ -23328,6 +23367,11 @@
|
||||||
"throttle-debounce": "^2.1.0"
|
"throttle-debounce": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-swipeable": {
|
||||||
|
"version": "6.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-6.1.2.tgz",
|
||||||
|
"integrity": "sha512-vfZtOZNivwd/aI+ZZH1Grx0eQBdbV1UI3pB9p65jbW5guHHdSIPpKsND6XmaiZXP5REOOc9Ckfr36ChswPqwsA=="
|
||||||
|
},
|
||||||
"react-syntax-highlighter": {
|
"react-syntax-highlighter": {
|
||||||
"version": "12.2.1",
|
"version": "12.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-12.2.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
"react": "^16.12.0",
|
"react": "^16.12.0",
|
||||||
"react-copy-to-clipboard": "^5.0.2",
|
"react-copy-to-clipboard": "^5.0.2",
|
||||||
"react-dom": "^16.10.2",
|
"react-dom": "^16.10.2",
|
||||||
|
"react-material-ui-carousel": "^2.2.7",
|
||||||
"react-number-format": "^4.4.1",
|
"react-number-format": "^4.4.1",
|
||||||
"react-otp-input": "^2.3.0",
|
"react-otp-input": "^2.3.0",
|
||||||
"react-router-dom": "5.1.2",
|
"react-router-dom": "5.1.2",
|
||||||
|
|
|
||||||
60
new-lamassu-admin/src/components/Carousel.js
Normal file
60
new-lamassu-admin/src/components/Carousel.js
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
import ReactCarousel from 'react-material-ui-carousel'
|
||||||
|
|
||||||
|
import { ReactComponent as LeftArrow } from 'src/styling/icons/arrow/carousel-left-arrow.svg'
|
||||||
|
import { ReactComponent as RightArrow } from 'src/styling/icons/arrow/carousel-right-arrow.svg'
|
||||||
|
import { URI } from 'src/utils/apollo'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
imgWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
width: 550
|
||||||
|
},
|
||||||
|
imgInner: {
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
width: 550,
|
||||||
|
marginBottom: 40
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Carousel = memo(({ photosData, slidePhoto }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ReactCarousel
|
||||||
|
PrevIcon={<LeftArrow />}
|
||||||
|
NextIcon={<RightArrow />}
|
||||||
|
navButtonsProps={{
|
||||||
|
style: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderRadius: 0,
|
||||||
|
width: 50,
|
||||||
|
color: 'transparent',
|
||||||
|
opacity: 1
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoPlay={false}
|
||||||
|
indicators={false}
|
||||||
|
navButtonsAlwaysVisible={true}
|
||||||
|
next={activeIndex => slidePhoto(activeIndex)}
|
||||||
|
prev={activeIndex => slidePhoto(activeIndex)}>
|
||||||
|
{photosData.map((item, i) => (
|
||||||
|
<div>
|
||||||
|
<div className={classes.imgWrapper}>
|
||||||
|
<img
|
||||||
|
className={classes.imgInner}
|
||||||
|
src={`${URI}/${item?.photoDir}/${item?.path}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</ReactCarousel>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
64
new-lamassu-admin/src/components/InformativeDialog.js
Normal file
64
new-lamassu-admin/src/components/InformativeDialog.js
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Dialog, DialogContent, makeStyles } from '@material-ui/core'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
import { IconButton } from 'src/components/buttons'
|
||||||
|
import { H1 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||||
|
import { spacer } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const useStyles = makeStyles({
|
||||||
|
closeButton: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: [[spacer * 2, spacer * 2, 0, spacer * 2]],
|
||||||
|
paddingRight: spacer * 1.5,
|
||||||
|
justifyContent: 'end'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
margin: [[0, spacer * 2, spacer, spacer * 2 + 4]]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const DialogTitle = ({ children, onClose }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return (
|
||||||
|
<div className={classes.dialogTitle}>
|
||||||
|
{children}
|
||||||
|
{onClose && (
|
||||||
|
<IconButton size={16} aria-label="close" onClick={onClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InformativeDialog = memo(
|
||||||
|
({ title = '', open, onDissmised, disabled = false, data, ...props }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const innerOnClose = () => {
|
||||||
|
onDissmised()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
PaperProps={{
|
||||||
|
style: {
|
||||||
|
borderRadius: 8
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
open={open}
|
||||||
|
aria-labelledby="form-dialog-title"
|
||||||
|
{...props}>
|
||||||
|
<div className={classes.closeButton}>
|
||||||
|
<IconButton size={16} aria-label="close" onClick={innerOnClose}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<H1 className={classes.title}>{title}</H1>
|
||||||
|
<DialogContent className={classes.dialogContent}>{data}</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
@ -35,6 +35,7 @@ const GET_CUSTOMER = gql`
|
||||||
id
|
id
|
||||||
authorizedOverride
|
authorizedOverride
|
||||||
frontCameraPath
|
frontCameraPath
|
||||||
|
frontCameraAt
|
||||||
frontCameraOverride
|
frontCameraOverride
|
||||||
phone
|
phone
|
||||||
isAnonymous
|
isAnonymous
|
||||||
|
|
@ -67,6 +68,8 @@ const GET_CUSTOMER = gql`
|
||||||
created
|
created
|
||||||
errorMessage: error
|
errorMessage: error
|
||||||
error: errorCode
|
error: errorCode
|
||||||
|
txCustomerPhotoAt
|
||||||
|
txCustomerPhotoPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -168,6 +171,7 @@ const CustomerProfile = memo(() => {
|
||||||
justifyContent="space-between">
|
justifyContent="space-between">
|
||||||
<CustomerDetails
|
<CustomerDetails
|
||||||
customer={customerData}
|
customer={customerData}
|
||||||
|
txData={sortedTransactions}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
setShowCompliance={() => setShowCompliance(!showCompliance)}
|
setShowCompliance={() => setShowCompliance(!showCompliance)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -11,86 +11,99 @@ import { ReactComponent as LawIcon } from 'src/styling/icons/circle buttons/law/
|
||||||
import mainStyles from '../CustomersList.styles'
|
import mainStyles from '../CustomersList.styles'
|
||||||
import { getFormattedPhone, getName } from '../helper'
|
import { getFormattedPhone, getName } from '../helper'
|
||||||
|
|
||||||
import FrontCameraPhoto from './FrontCameraPhoto'
|
import PhotosCard from './PhotosCard'
|
||||||
|
|
||||||
const useStyles = makeStyles(mainStyles)
|
const useStyles = makeStyles(mainStyles)
|
||||||
|
|
||||||
const CustomerDetails = memo(({ customer, locale, setShowCompliance }) => {
|
const CustomerDetails = memo(
|
||||||
const classes = useStyles()
|
({ txData, customer, locale, setShowCompliance }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
|
const idNumber = R.path(['idCardData', 'documentNumber'])(customer)
|
||||||
const usSsn = R.path(['usSsn'])(customer)
|
const usSsn = R.path(['usSsn'])(customer)
|
||||||
|
|
||||||
const elements = [
|
const elements = [
|
||||||
{
|
{
|
||||||
header: 'Phone number',
|
header: 'Phone number',
|
||||||
size: 172,
|
size: 172,
|
||||||
value: getFormattedPhone(customer.phone, locale.country)
|
value: getFormattedPhone(customer.phone, locale.country)
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
if (idNumber)
|
if (idNumber)
|
||||||
elements.push({
|
elements.push({
|
||||||
header: 'ID number',
|
header: 'ID number',
|
||||||
size: 172,
|
size: 172,
|
||||||
value: idNumber
|
value: idNumber
|
||||||
})
|
})
|
||||||
|
|
||||||
if (usSsn)
|
if (usSsn)
|
||||||
elements.push({
|
elements.push({
|
||||||
header: 'US SSN',
|
header: 'US SSN',
|
||||||
size: 127,
|
size: 127,
|
||||||
value: usSsn
|
value: usSsn
|
||||||
})
|
})
|
||||||
|
|
||||||
const name = getName(customer)
|
const name = getName(customer)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box display="flex">
|
<Box display="flex">
|
||||||
<FrontCameraPhoto
|
<PhotosCard
|
||||||
frontCameraPath={R.path(['frontCameraPath'])(customer)}
|
frontCameraData={R.pick(['frontCameraPath', 'frontCameraAt'])(
|
||||||
/>
|
customer
|
||||||
<Box display="flex" flexDirection="column">
|
)}
|
||||||
<div className={classes.name}>
|
txPhotosData={
|
||||||
<IdIcon className={classes.idIcon} />
|
txData &&
|
||||||
<H2 noMargin>
|
R.map(R.pick(['id', 'txCustomerPhotoPath', 'txCustomerPhotoAt']))(
|
||||||
{name.length
|
txData
|
||||||
? name
|
)
|
||||||
: getFormattedPhone(R.path(['phone'])(customer), locale.country)}
|
}
|
||||||
</H2>
|
/>
|
||||||
<SubpageButton
|
<Box display="flex" flexDirection="column">
|
||||||
className={classes.subpageButton}
|
<div className={classes.name}>
|
||||||
Icon={LawIcon}
|
<IdIcon className={classes.idIcon} />
|
||||||
InverseIcon={LawIconInverse}
|
<H2 noMargin>
|
||||||
toggle={setShowCompliance}>
|
{name.length
|
||||||
Compliance details
|
? name
|
||||||
</SubpageButton>
|
: getFormattedPhone(
|
||||||
</div>
|
R.path(['phone'])(customer),
|
||||||
<Box display="flex" mt="auto">
|
locale.country
|
||||||
{elements.map(({ size, header }, idx) => (
|
)}
|
||||||
<Label1
|
</H2>
|
||||||
noMargin
|
<SubpageButton
|
||||||
key={idx}
|
className={classes.subpageButton}
|
||||||
className={classes.label}
|
Icon={LawIcon}
|
||||||
style={{ width: size }}>
|
InverseIcon={LawIconInverse}
|
||||||
{header}
|
toggle={setShowCompliance}>
|
||||||
</Label1>
|
Compliance details
|
||||||
))}
|
</SubpageButton>
|
||||||
</Box>
|
</div>
|
||||||
<Box display="flex">
|
<Box display="flex" mt="auto">
|
||||||
{elements.map(({ size, value }, idx) => (
|
{elements.map(({ size, header }, idx) => (
|
||||||
<P
|
<Label1
|
||||||
noMargin
|
noMargin
|
||||||
key={idx}
|
key={idx}
|
||||||
className={classes.value}
|
className={classes.label}
|
||||||
style={{ width: size }}>
|
style={{ width: size }}>
|
||||||
{value}
|
{header}
|
||||||
</P>
|
</Label1>
|
||||||
))}
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box display="flex">
|
||||||
|
{elements.map(({ size, value }, idx) => (
|
||||||
|
<P
|
||||||
|
noMargin
|
||||||
|
key={idx}
|
||||||
|
className={classes.value}
|
||||||
|
style={{ width: size }}>
|
||||||
|
{value}
|
||||||
|
</P>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
)
|
||||||
)
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
export default CustomerDetails
|
export default CustomerDetails
|
||||||
|
|
|
||||||
137
new-lamassu-admin/src/pages/Customers/components/PhotosCard.js
Normal file
137
new-lamassu-admin/src/pages/Customers/components/PhotosCard.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import ButtonBase from '@material-ui/core/ButtonBase'
|
||||||
|
import Paper from '@material-ui/core/Card'
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import * as R from 'ramda'
|
||||||
|
import React, { memo, useState } from 'react'
|
||||||
|
|
||||||
|
import { Carousel } from 'src/components/Carousel'
|
||||||
|
import { InformativeDialog } from 'src/components/InformativeDialog'
|
||||||
|
import { Info2, Label1 } from 'src/components/typography'
|
||||||
|
import { ReactComponent as CrossedCameraIcon } from 'src/styling/icons/ID/photo/crossed-camera.svg'
|
||||||
|
import { URI } from 'src/utils/apollo'
|
||||||
|
|
||||||
|
import CopyToClipboard from '../../Transactions/CopyToClipboard'
|
||||||
|
|
||||||
|
import styles from './PhotosCard.styles'
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const Label = ({ children }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
return <Label1 className={classes.label}>{children}</Label1>
|
||||||
|
}
|
||||||
|
|
||||||
|
const PhotosCard = memo(({ frontCameraData, txPhotosData }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const [photosDialog, setPhotosDialog] = useState(false)
|
||||||
|
|
||||||
|
const mapKeys = pair => {
|
||||||
|
const [key, value] = pair
|
||||||
|
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
|
||||||
|
return ['path', value]
|
||||||
|
}
|
||||||
|
if (key === 'txCustomerPhotoAt' || key === 'frontCameraAt') {
|
||||||
|
return ['date', value]
|
||||||
|
}
|
||||||
|
return pair
|
||||||
|
}
|
||||||
|
|
||||||
|
const addPhotoDir = R.map(it => {
|
||||||
|
const hasFrontCameraData = R.has('id')(it)
|
||||||
|
return hasFrontCameraData
|
||||||
|
? { ...it, photoDir: 'operator-data/customersphotos' }
|
||||||
|
: { ...it, photoDir: 'front-camera-photo' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const standardizeKeys = R.map(
|
||||||
|
R.compose(R.fromPairs, R.map(mapKeys), R.toPairs)
|
||||||
|
)
|
||||||
|
|
||||||
|
const filterByPhotoAvailable = R.filter(
|
||||||
|
tx => !R.isNil(tx.date) && !R.isNil(tx.path)
|
||||||
|
)
|
||||||
|
|
||||||
|
const photosData = filterByPhotoAvailable(
|
||||||
|
addPhotoDir(standardizeKeys(R.append(frontCameraData, txPhotosData)))
|
||||||
|
)
|
||||||
|
|
||||||
|
const singlePhoto = R.head(photosData)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Paper className={classes.photo} elevation={0}>
|
||||||
|
<ButtonBase
|
||||||
|
disabled={!singlePhoto}
|
||||||
|
className={classes.button}
|
||||||
|
onClick={() => {
|
||||||
|
setPhotosDialog(true)
|
||||||
|
}}>
|
||||||
|
{singlePhoto ? (
|
||||||
|
<div className={classes.container}>
|
||||||
|
<img
|
||||||
|
className={classes.img}
|
||||||
|
src={`${URI}/${singlePhoto.photoDir}/${singlePhoto.path}`}
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<circle className={classes.circle}>
|
||||||
|
<div>
|
||||||
|
<Info2>{photosData.length}</Info2>
|
||||||
|
</div>
|
||||||
|
</circle>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CrossedCameraIcon />
|
||||||
|
)}
|
||||||
|
</ButtonBase>
|
||||||
|
</Paper>
|
||||||
|
<InformativeDialog
|
||||||
|
open={photosDialog}
|
||||||
|
title={`Photo roll`}
|
||||||
|
data={<PhotosCarousel photosData={photosData} />}
|
||||||
|
onDissmised={() => {
|
||||||
|
setPhotosDialog(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const PhotosCarousel = memo(({ photosData }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
|
||||||
|
const isFaceCustomerPhoto = !R.has('id')(photosData[currentIndex])
|
||||||
|
|
||||||
|
const slidePhoto = index => setCurrentIndex(index)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Carousel photosData={photosData} slidePhoto={slidePhoto} />
|
||||||
|
{!isFaceCustomerPhoto && (
|
||||||
|
<div className={classes.firstRow}>
|
||||||
|
<Label>Session ID</Label>
|
||||||
|
<CopyToClipboard>
|
||||||
|
{photosData && photosData[currentIndex]?.id}
|
||||||
|
</CopyToClipboard>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={classes.secondRow}>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<Label>Date</Label>
|
||||||
|
<div>{photosData && photosData[currentIndex]?.date}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Taken by</Label>
|
||||||
|
<div>
|
||||||
|
{!isFaceCustomerPhoto ? 'Acceptance of T&C' : 'Compliance scan'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default PhotosCard
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import { zircon, backgroundColor, offColor } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const { p } = typographyStyles
|
||||||
|
|
||||||
|
export default {
|
||||||
|
photo: {
|
||||||
|
width: 135,
|
||||||
|
height: 135,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: zircon,
|
||||||
|
margin: [[0, 28, 0, 0]],
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
img: {
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
width: 135,
|
||||||
|
height: 135
|
||||||
|
},
|
||||||
|
container: {
|
||||||
|
position: 'relative',
|
||||||
|
'& > img': {
|
||||||
|
display: 'block'
|
||||||
|
},
|
||||||
|
'& > circle': {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '0',
|
||||||
|
right: '0',
|
||||||
|
marginRight: 5,
|
||||||
|
marginTop: 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
circle: {
|
||||||
|
background: backgroundColor,
|
||||||
|
borderRadius: '50%',
|
||||||
|
width: 25,
|
||||||
|
height: 25,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
color: offColor,
|
||||||
|
margin: [[0, 0, 6, 0]]
|
||||||
|
},
|
||||||
|
firstRow: {
|
||||||
|
padding: [[8]],
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column'
|
||||||
|
},
|
||||||
|
secondRow: {
|
||||||
|
extend: p,
|
||||||
|
display: 'flex',
|
||||||
|
padding: [[8]],
|
||||||
|
'& > div': {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
'& > div': {
|
||||||
|
width: 144,
|
||||||
|
height: 37,
|
||||||
|
marginBottom: 15,
|
||||||
|
marginRight: 55
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
imgWrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
display: 'flex',
|
||||||
|
width: 550,
|
||||||
|
height: 550
|
||||||
|
},
|
||||||
|
imgInner: {
|
||||||
|
objectFit: 'cover',
|
||||||
|
objectPosition: 'center',
|
||||||
|
width: 550,
|
||||||
|
height: 550,
|
||||||
|
marginBottom: 40
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="13px" height="33px" viewBox="0 0 13 33" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<polygon id="Simple-Arrow-White" fill="#FFFFFF" fill-rule="nonzero" points="12.1912718 1.56064837 10.8306233 0.395663059 0.196798664 16.2200463 10.8250965 32.3956631 12.1967987 31.2473125 2.33241023 16.233075"></polygon>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 512 B |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="15px" height="34px" viewBox="0 0 15 34" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="Group-2-Copy" transform="translate(1.000000, 1.000000)" stroke="#FFFFFF" stroke-width="2">
|
||||||
|
<polyline id="Path-4-Copy" points="0 0 12 15.8202247 0 32"></polyline>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
Loading…
Add table
Add a link
Reference in a new issue