Cashier side feature and UI update

-- added option to decline top-up requests by cashier with option to specify reason

-- separated pending and processed requests
This commit is contained in:
sairaj mote 2022-05-22 18:16:34 +05:30
parent 357c7d35f8
commit a465104276
8 changed files with 367 additions and 38 deletions

View File

@ -229,6 +229,11 @@ sm-form {
sm-select {
--padding: 0.8rem;
font-size: 0.9rem;
}
sm-option {
font-size: 0.9rem;
}
strip-select {
@ -308,6 +313,10 @@ ul {
grid-auto-flow: column;
}
.gap-0-3 {
gap: 0.3rem;
}
.gap-0-5 {
gap: 0.5rem;
}

2
css/main.min.css vendored

File diff suppressed because one or more lines are too long

View File

@ -210,6 +210,10 @@ sm-form {
}
sm-select {
--padding: 0.8rem;
font-size: 0.9rem;
}
sm-option {
font-size: 0.9rem;
}
strip-select {
--gap: 0;
@ -275,6 +279,9 @@ ul {
grid-auto-flow: column;
}
.gap-0-3 {
gap: 0.3rem;
}
.gap-0-5 {
gap: 0.5rem;
}

View File

@ -301,10 +301,26 @@
</div>
</div>
<section id="cashier" class=" grid gap-1 hide admin-element">
<h4>Requests</h4>
<ul id="cashier_request_list" class="observe-empty-state"></ul>
<div class="empty-state">
<p>No requests to process</p>
<div class="flex align-center space-between">
<h4>Requests</h4>
<strip-select id="cashier_requests_selector">
<strip-option value="pending" selected>Pending</strip-option>
<strip-option value="processed">Processed</strip-option>
</strip-select>
</div>
<div id="cashier_requests_wrapper">
<div>
<ul id="cashier_pending_request_list" class="observe-empty-state"></ul>
<div class="empty-state">
<p>No requests to process</p>
</div>
</div>
<div class="hide">
<ul id="cashier_processed_request_list" class="observe-empty-state"></ul>
<div class="empty-state">
<p>No requests to process</p>
</div>
</div>
</div>
</section>
</section>
@ -350,10 +366,11 @@
</div>
<div id="wallet_history_wrapper" class="grid gap-1-5">
<div class="hide grid gap-1">
<p>Pending transactions</p>
<h4>Pending</h4>
<ul id="pending_wallet_transactions" class="grid gap-0-5"></ul>
</div>
<div class="grid gap-1">
<h4>Processed</h4>
<ul id="wallet_history" class="observe-empty-state grid gap-0-5"></ul>
<div class=" empty-state gap-1 justify-center text-center">
<p>No transactions</p>
@ -493,7 +510,7 @@
</div>
</div>
<a id="transaction__link" target="_blank" rel="noopener noreferrer">See transaction on blockchain</a>
<div id="transaction__note" class="hide flex align-center"></div>
<p id="transaction__note" class="hide flex flex-direction-column gap-0-5"></p>
</div>
<section id="settings" class="inner-page hide">
<div class="page__header">
@ -834,7 +851,10 @@
</div>
<sm-copy id="topup_wallet__upi_id" style="font-weight: 700;"></sm-copy>
</div>
<p>After sending money, please enter the transaction ID of completed transaction.</p>
<p>
After sending money, please enter the transaction ID of completed transaction. <br>
* <strong>PhonePe</strong> users please enter <strong>UTR ID</strong> instead of transaction ID
</p>
<sm-input id="topup_wallet__txid" minlength="12" maxlength="12"
error-text="Please enter UPI transaction ID of money you sent to continue."
placeholder="UPI transaction ID" autofocus animate required></sm-input>
@ -1039,6 +1059,51 @@
</div>
</sm-popup>
<!-- Cashier popups -->
<sm-popup id="confirm_topup_popup">
<header slot="header" class="popup__header">
<button class="popup__header__close justify-self-start" onclick="hidePopup()">
<svg class="icon" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px"
fill="#000000">
<path d="M0 0h24v24H0V0z" fill="none" />
<path
d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
</button>
<h4>Confirm top-up</h4>
</header>
<div id="confirm_topup_wrapper">
<div class="grid gap-1-5">
<div class="grid gap-0-5">
<p>
Check if you have received UPI payment
</p>
<b id="top_up_amount" style="font-size: 1.5rem;"></b>
<sm-copy id="top_up_txid"></sm-copy>
</div>
<div class="flex justify-right gap-0-3">
<button class="button" onclick="showChildElement('confirm_topup_wrapper', 1)">Decline</button>
<button class="button" onclick="confirmTopUp()">Confirm</button>
</div>
</div>
<div class="flex flex-direction-column gap-1-5 hide" style="height: 24rem;">
<div class="grid gap-0-3">
<p>Select reason</p>
<sm-select id="top_up__reason_selector">
<sm-option value="1001">Invalid transaction ID</sm-option>
<sm-option value="1002">Amount doesn't match</sm-option>
<sm-option value="other">Other</sm-option>
</sm-select>
</div>
<div class="grid gap-0-3 hide">
<p>Describe reason</p>
<sm-textarea id="top_up__specified_reason" rows="4"></sm-textarea>
</div>
<button class="button justify-right" style="margin-top: auto;" onclick="declineTopUp()">Decline</button>
</div>
</div>
</sm-popup>
<!-- templates -->
<template id="saved_id_template">
<li class="saved-id grid interact" tabindex="0">

View File

@ -687,6 +687,204 @@ customElements.define('sm-input',
this.clearBtn.removeEventListener('click', this.clear);
}
})
const smTextarea = document.createElement('template')
smTextarea.innerHTML = `
<style>
*,
*::before,
*::after {
padding: 0;
margin: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
::-moz-focus-inner{
border: none;
}
.hide{
opacity: 0 !important;
}
:host{
display: grid;
--danger-color: red;
--border-radius: 0.3rem;
--background: rgba(var(--text-color,(17,17,17)), 0.06);
--padding: initial;
--max-height: 8rem;
}
:host([variant="outlined"]) .textarea {
box-shadow: 0 0 0 0.1rem rgba(var(--text-color,(17,17,17)), 0.4) inset;
background: rgba(var(--background-color,(255,255,255)), 1);
}
.textarea{
display: grid;
position: relative;
cursor: text;
min-width: 0;
text-align: left;
overflow: hidden auto;
grid-template-columns: 1fr;
align-items: stretch;
max-height: var(--max-height);
background: var(--background);
border-radius: var(--border-radius);
padding: var(--padding);
}
.textarea::after,
textarea{
padding: 0.7rem 1rem;
width: 100%;
min-width: 1em;
font: inherit;
color: inherit;
resize: none;
grid-area: 2/1;
justify-self: stretch;
background: none;
appearance: none;
border: none;
outline: none;
line-height: 1.5;
overflow: hidden;
}
.textarea::after{
content: attr(data-value) ' ';
visibility: hidden;
white-space: pre-wrap;
overflow-wrap: break-word;
word-wrap: break-word;
hyphens: auto;
}
.readonly{
pointer-events: none;
}
.textarea:focus-within:not(.readonly){
box-shadow: 0 0 0 0.1rem var(--accent-color,teal) inset;
}
.placeholder{
position: absolute;
margin: 0.7rem 1rem;
opacity: .7;
font-weight: inherit;
font-size: inherit;
line-height: 1.5;
pointer-events: none;
user-select: none;
}
:host([disabled]) .textarea{
cursor: not-allowed;
opacity: 0.6;
}
@media (any-hover: hover){
::-webkit-scrollbar{
width: 0.5rem;
height: 0.5rem;
}
::-webkit-scrollbar-thumb{
background: rgba(var(--text-color,(17,17,17)), 0.3);
border-radius: 1rem;
&:hover{
background: rgba(var(--text-color,(17,17,17)), 0.5);
}
}
}
</style>
<label class="textarea" part="textarea">
<span class="placeholder"></span>
<textarea rows="1"></textarea>
</label>
`;
customElements.define('sm-textarea',
class extends HTMLElement {
constructor() {
super()
this.attachShadow({
mode: 'open'
}).append(smTextarea.content.cloneNode(true))
this.textarea = this.shadowRoot.querySelector('textarea')
this.textareaBox = this.shadowRoot.querySelector('.textarea')
this.placeholder = this.shadowRoot.querySelector('.placeholder')
this.reflectedAttributes = ['disabled', 'required', 'readonly', 'rows', 'minlength', 'maxlength']
this.reset = this.reset.bind(this)
this.focusIn = this.focusIn.bind(this)
this.fireEvent = this.fireEvent.bind(this)
this.checkInput = this.checkInput.bind(this)
}
static get observedAttributes() {
return ['disabled', 'value', 'placeholder', 'required', 'readonly', 'rows', 'minlength', 'maxlength']
}
get value() {
return this.textarea.value
}
set value(val) {
this.setAttribute('value', val)
this.fireEvent()
}
get disabled() {
return this.hasAttribute('disabled')
}
set disabled(val) {
if (val) {
this.setAttribute('disabled', '')
} else {
this.removeAttribute('disabled')
}
}
get isValid() {
return this.textarea.checkValidity()
}
reset() {
this.setAttribute('value', '')
}
focusIn() {
this.textarea.focus()
}
fireEvent() {
let event = new Event('input', {
bubbles: true,
cancelable: true,
composed: true
});
this.dispatchEvent(event);
}
checkInput() {
if (!this.hasAttribute('placeholder') || this.getAttribute('placeholder') === '')
return;
if (this.textarea.value !== '') {
this.placeholder.classList.add('hide')
} else {
this.placeholder.classList.remove('hide')
}
}
connectedCallback() {
this.textarea.addEventListener('input', e => {
this.textareaBox.dataset.value = this.textarea.value
this.checkInput()
})
}
attributeChangedCallback(name, oldValue, newValue) {
if (this.reflectedAttributes.includes(name)) {
if (this.hasAttribute(name)) {
this.textarea.setAttribute(name, this.getAttribute(name) ? this.getAttribute(name) : '')
}
else {
this.textContent.removeAttribute(name)
}
}
else if (name === 'placeholder') {
this.placeholder.textContent = this.getAttribute('placeholder')
}
else if (name === 'value') {
this.textarea.value = newValue;
this.textareaBox.dataset.value = newValue
this.checkInput()
}
}
})
const smNotifications = document.createElement('template')
smNotifications.innerHTML = `
<style>

View File

@ -164,10 +164,13 @@ User.decideRequest = function (request, note) {
const Cashier = {};
Cashier.init = function () {
delegate(getRef('cashier_request_list'), 'click', '.process-cashier-request', e => {
delegate(getRef('cashier_pending_request_list'), 'click', '.process-cashier-request', e => {
const requestID = e.delegateTarget.closest('.cashier-request').id;
cashierUI.completeRequest(requestID)
})
getRef('cashier_requests_selector').addEventListener('change', e => {
showChildElement('cashier_requests_wrapper', e.target.value === 'pending' ? 0 : 1)
})
return new Promise((resolve, reject) => {
let promises = [];
//Requests from user to cashier(self) for token-cash exchange

View File

@ -336,40 +336,29 @@ cashierUI.renderRequests = function (requests, error = null) {
else if (typeof requests !== "object" || requests === null)
return;
const frag = document.createDocumentFragment();
for (let r in requests) {
const oldCard = document.getElementById(r);
if (oldCard) oldCard.remove();
frag.prepend(render.cashierRequestCard(requests[r]));
for (let transactionID in requests) {
const { note, tag } = requests[transactionID];
let status = tag ? 'done' : (note ? 'failed' : "pending");
getRef('cashier_pending_request_list').querySelectorAll(`[data-vc="${transactionID}"]`).forEach(card => card.remove());
getRef(status === 'pending' ? 'cashier_pending_request_list' : 'cashier_processed_request_list').prepend(render.cashierRequestCard(requests[transactionID]))
}
getRef('cashier_request_list').prepend(frag)
}
cashierUI.completeRequest = function (reqID) {
let request = Cashier.Requests[reqID];
if (request.message.mode === "cash-to-token")
completeCashToTokenRequest(request);
else if (request.message.mode === "token-to-cash")
completeTokenToCashRequest(request);
floGlobals.cashierProcessingRequest = Cashier.Requests[reqID];
const { message: { mode } } = floGlobals.cashierProcessingRequest;
if (mode === "cash-to-token")
completeCashToTokenRequest(floGlobals.cashierProcessingRequest);
else if (mode === "token-to-cash")
completeTokenToCashRequest(floGlobals.cashierProcessingRequest);
}
function completeCashToTokenRequest(request) {
const { message: { upi_txid, amount }, vectorClock, senderID } = request;
Cashier.checkIfUpiTxIsValid(upi_txid).then(_ => {
getConfirmation('Confirm', {
message: `Check if you have received UPI transfer\nTxID: ${upi_txid}\nAmount: ${formatAmount(amount)}`,
confirmText: 'Confirm'
}).then(confirmed => {
if (confirmed) {
User.sendToken(senderID, amount, 'for cash-to-token').then(txid => {
console.warn(`${amount} cash-to-token for ${senderID}`, txid);
Cashier.finishRequest(request, txid).then(result => {
console.log(result);
console.info('Completed cash-to-token request:', vectorClock);
notify("Completed request", 'success');
}).catch(error => console.error(error))
}).catch(error => console.error(error))
}
})
getRef('top_up_amount').textContent = formatAmount(amount);
getRef('top_up_txid').value = upi_txid;
showPopup('confirm_topup_popup');
}).catch(error => {
notify(error, 'error');
if (Array.isArray(error) && error[0] === true && typeof error[1] === 'string')
@ -380,6 +369,41 @@ function completeCashToTokenRequest(request) {
})
}
function confirmTopUp() {
const { message: { amount }, vectorClock, senderID } = floGlobals.cashierProcessingRequest;
User.sendToken(senderID, amount, 'for cash-to-token').then(txid => {
console.warn(`${amount} cash-to-token for ${senderID}`, txid);
Cashier.finishRequest(floGlobals.cashierProcessingRequest, txid).then(result => {
console.log(result);
console.info('Completed cash-to-token request:', vectorClock);
notify("Completed request", 'success');
}).catch(error => console.error(error))
}).catch(error => console.error(error))
}
getRef('top_up__reason_selector').addEventListener('change', e => {
console.log(e.target.value);
if (e.target.value === 'other') {
getRef('top_up__specified_reason').parentNode.classList.remove('hide');
} else {
getRef('top_up__specified_reason').parentNode.classList.add('hide');
}
})
function declineTopUp() {
const { vectorClock } = floGlobals.cashierProcessingRequest;
let reason = getRef('top_up__reason_selector').value;
if (reason === 'other') {
reason = getRef('top_up__specified_reason').value
}
Cashier.rejectRequest(floGlobals.cashierProcessingRequest, reason).then(result => {
console.log(result);
console.info('Rejected cash-to-token request:', vectorClock);
notify('Request top-up request', 'success');
hidePopup()
}).catch(error => console.error(error))
}
function completeTokenToCashRequest(request) {
const { vectorClock, senderID, message: { token_txid, amount, upi_id } } = request
Cashier.checkIfTokenTxIsValid(token_txid, senderID, amount).then(result => {
@ -427,6 +451,11 @@ function getStatusIcon(status) {
}
}
const cashierRejectionErrors = {
1001: `Your request was reject because of wrong transaction ID. If you have sent money, it'll be returned within 24 hrs.`,
1002: `Amount requested and amount sent via UPI doesn't match. your transferred money will be returned within 24hrs.`
}
const render = {
savedId(floID, details) {
const { title } = details;
@ -469,7 +498,7 @@ const render = {
:
`<svg class="icon" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"></path><path d="M21 18v1c0 1.1-.9 2-2 2H5c-1.11 0-2-.9-2-2V5c0-1.1.89-2 2-2h14c1.1 0 2 .9 2 2v1h-9c-1.11 0-2 .9-2 2v8c0 1.1.89 2 2 2h9zm-9-2h10V8H12v8zm4-2.5c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5z"></path></svg>`;
if (status)
clone.querySelector('.cashier-request__status').textContent = status;
clone.querySelector('.cashier-request__status').textContent = status.includes(':') ? status.split(':')[0] : status;
else
clone.querySelector('.cashier-request__status').innerHTML = `<button class="button process-cashier-request">Process</button>`;
return clone;

View File

@ -72,7 +72,7 @@ const debounce = (callback, wait) => {
};
}
let zIndex = 10
let zIndex = 100
// function required for popups or modals to appear
function showPopup(popupId, pinned) {
zIndex++
@ -135,6 +135,9 @@ document.addEventListener('popupclosed', e => {
case 'transfer_to_exchange_popup':
showChildElement('exchange_transfer_process', 0);
break;
case 'confirm_topup_popup':
showChildElement('confirm_topup_wrapper', 0);
break;
}
})
@ -448,6 +451,7 @@ async function showPage(targetPage, options = {}) {
}
} else if (params.type === 'wallet') {
transactionDetails = User.cashierRequests[params.transactionId]
console.log(transactionDetails)
const { message: { amount, mode, upi_id, upi_txid }, note, tag } = transactionDetails
status = tag ? tag : (note ? 'REJECTED' : "PENDING");
getRef('transaction__type').textContent = mode === 'cash-to-token' ? 'Wallet top-up' : 'Withdraw';
@ -459,7 +463,15 @@ async function showPage(targetPage, options = {}) {
getRef('transaction__note').classList.remove('hide')
}
if (mode === 'cash-to-token') {
getRef('transaction__note').textContent = `UPI transaction ID: ${upi_txid}`
if (status === 'COMPLETED') {
getRef('transaction__note').textContent = `UPI transaction ID: ${upi_txid}`
} else if (status === 'REJECTED') {
const reason = ['1001', '1002'].includes(note.split(':')[1]) ? cashierRejectionErrors[note.split(':')[1]] : note.split(':')[1]
getRef('transaction__note').innerHTML = `
<svg class="icon failed" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"></path><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"></path></svg>
${reason}
`
}
getRef('transaction__note').classList.remove('hide')
} else {
@ -806,7 +818,13 @@ function createState(defaultValue, key, callback) {
return reactiveState.createState(defaultValue, key, callback)
}
const smState = document.createElement('template')
smState.innerHTML = ``
smState.innerHTML = `
<style>
font-size: inherit;
font-weight: inherit;
font-family: inherit;
</style>
`
class SmState extends HTMLElement {
constructor() {
super();