commit
f536bc53ef
3589
components.js
3589
components.js
File diff suppressed because it is too large
Load Diff
2642
css/main.css
2642
css/main.css
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
2
css/main.min.css
vendored
2
css/main.min.css
vendored
File diff suppressed because one or more lines are too long
2745
css/main.scss
2745
css/main.scss
File diff suppressed because it is too large
Load Diff
15077
index.html
15077
index.html
File diff suppressed because it is too large
Load Diff
3079
scripts/components.js
Normal file
3079
scripts/components.js
Normal file
File diff suppressed because it is too large
Load Diff
251
scripts/fn_pay.js
Normal file
251
scripts/fn_pay.js
Normal file
@ -0,0 +1,251 @@
|
||||
/*jshint esversion: 6 */
|
||||
const TYPE_MONEY_REQUEST = "MoneyRequests",
|
||||
TYPE_CASHIER_REQUEST = "CashierRequests",
|
||||
TYPE_CASHIER_UPI = "CashierUPI";
|
||||
|
||||
const cashierUPI = {};
|
||||
|
||||
//For regular users
|
||||
const User = {};
|
||||
const cashierStatus = {};
|
||||
|
||||
User.init = function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
let promises;
|
||||
//Request cashier for token-cash exchange
|
||||
promises = floGlobals.subAdmins.map(cashierID => floCloudAPI.requestGeneralData(TYPE_CASHIER_REQUEST, {
|
||||
senderID: myFloID,
|
||||
receiverID: cashierID,
|
||||
group: "Cashiers",
|
||||
callback: userUI.renderCashierRequests //UI_fn
|
||||
}));
|
||||
//Request received from other Users for token
|
||||
promises.push(floCloudAPI.requestGeneralData(TYPE_MONEY_REQUEST, {
|
||||
receiverID: myFloID,
|
||||
callback: userUI.renderMoneyRequests //UI_fn
|
||||
}));
|
||||
//Check online status of cashiers
|
||||
promises.push(floCloudAPI.requestStatus(Array.from(floGlobals.subAdmins), {
|
||||
callback: (d, e) => {
|
||||
if (e) return console.error(e);
|
||||
for (let i in d)
|
||||
cashierStatus[i] = d[i];
|
||||
//Add any UI_fn if any
|
||||
}
|
||||
}))
|
||||
/*
|
||||
promises.push(floCloudAPI.requestObjectData("UPI", { //Is this needed?
|
||||
callback: UI_RENDER_FN
|
||||
}));
|
||||
*/
|
||||
promises.push(User.getCashierUPI());
|
||||
Promise.all(promises)
|
||||
.then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
User.getCashierUPI = function () {
|
||||
return new Promise((resolve) => {
|
||||
Promise.allSettled(floGlobals.subAdmins.map(cashierID => floCloudAPI.requestApplicationData(TYPE_CASHIER_UPI, {
|
||||
senderID: cashierID,
|
||||
mostRecent: true
|
||||
}))).then(result => {
|
||||
for (let r of result)
|
||||
if (r.status === "fulfilled" && r.value.length)
|
||||
cashierUPI[r.value[0].senderID] = floCloudAPI.util.decodeMessage(r.value[0].message).upi;
|
||||
resolve(cashierUPI);
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(User, 'cashierRequests', {
|
||||
get: function () {
|
||||
let fk = floCloudAPI.util.filterKey(TYPE_CASHIER_REQUEST, {
|
||||
senderID: myFloID,
|
||||
group: "Cashiers",
|
||||
});
|
||||
return floGlobals.generalData[fk];
|
||||
}
|
||||
});
|
||||
|
||||
Object.defineProperty(User, 'moneyRequests', {
|
||||
get: function () {
|
||||
let fk = floCloudAPI.util.filterKey(TYPE_MONEY_REQUEST, {
|
||||
receiverID: myFloID,
|
||||
});
|
||||
return floGlobals.generalData[fk];
|
||||
}
|
||||
});
|
||||
|
||||
User.findCashier = function () {
|
||||
let online = [];
|
||||
for (let c in cashierStatus)
|
||||
if (cashierStatus[c] && cashierUPI[c])
|
||||
online.push(c);
|
||||
if (!online.length)
|
||||
return null;
|
||||
else
|
||||
return online[floCrypto.randInt(0, online.length)];
|
||||
}
|
||||
|
||||
User.cashToToken = function (cashier, amount, upiTxID) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!floGlobals.subAdmins.includes(cashier))
|
||||
return reject("Invalid cashier");
|
||||
floCloudAPI.sendGeneralData({
|
||||
mode: "cash-to-token",
|
||||
amount: amount,
|
||||
upi_txid: upiTxID
|
||||
}, TYPE_CASHIER_REQUEST, {
|
||||
receiverID: cashier
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
User.tokenToCash = function (cashier, amount, blkTxID, upiID) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!floGlobals.subAdmins.includes(cashier))
|
||||
return reject("Invalid cashier");
|
||||
floCloudAPI.sendGeneralData({
|
||||
mode: "token-to-cash",
|
||||
amount: amount,
|
||||
token_txid: blkTxID,
|
||||
upi_id: upiID
|
||||
}, TYPE_CASHIER_REQUEST, {
|
||||
receiverID: cashier
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
User.sendToken = function (receiverID, amount, remark = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
tokenAPI.sendToken(myPrivKey, amount, receiverID, remark)
|
||||
.then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
User.requestToken = function (floID, amount, remark = '') {
|
||||
return new Promise((resolve, reject) => {
|
||||
floCloudAPI.sendGeneralData({
|
||||
amount: amount,
|
||||
remark: remark
|
||||
}, TYPE_MONEY_REQUEST, {
|
||||
receiverID: floID
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
User.decideRequest = function (request, note) {
|
||||
return new Promise((resolve, reject) => {
|
||||
floCloudAPI.noteApplicationData(request.vectorClock, note, {
|
||||
receiverID: myFloID
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
const Cashier = {};
|
||||
|
||||
Cashier.init = function () {
|
||||
return new Promise((resolve, reject) => {
|
||||
let promises = [];
|
||||
//Requests from user to cashier(self) for token-cash exchange
|
||||
promises.push(floCloudAPI.requestGeneralData(TYPE_CASHIER_REQUEST, {
|
||||
receiverID: myFloID,
|
||||
callback: cashierUI.renderRequests //UI_fn
|
||||
}));
|
||||
//Set online status of cashier(self)
|
||||
promises.push(floCloudAPI.setStatus());
|
||||
/*
|
||||
promises.push(floCloudAPI.requestObjectData("UPI", { //Is this needed?
|
||||
callback: UI_RENDER_FN
|
||||
}));
|
||||
*/
|
||||
Promise.all(promises)
|
||||
.then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
Cashier.updateUPI = function (upi_id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
floCloudAPI.sendApplicationData({
|
||||
upi: upi_id
|
||||
}, TYPE_CASHIER_UPI)
|
||||
.then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
Object.defineProperty(Cashier, 'Requests', {
|
||||
get: function () {
|
||||
let fk = floCloudAPI.util.filterKey(TYPE_CASHIER_REQUEST, {
|
||||
receiverID: myFloID
|
||||
});
|
||||
console.debug(fk, floGlobals.generalData[fk]);
|
||||
return floGlobals.generalData[fk];
|
||||
}
|
||||
});
|
||||
|
||||
Cashier.finishRequest = function (request, txid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
floCloudAPI.tagApplicationData(request.vectorClock, 'COMPLETED', {
|
||||
receiverID: myFloID
|
||||
}).then(result => {
|
||||
floCloudAPI.noteApplicationData(request.vectorClock, txid, {
|
||||
receiverID: myFloID
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
}).catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
Cashier.rejectRequest = function (request, reason) {
|
||||
return new Promise((resolve, reject) => {
|
||||
floCloudAPI.noteApplicationData(request.vectorClock, "REJECTED:" + reason, {
|
||||
receiverID: myFloID
|
||||
}).then(result => resolve(result))
|
||||
.catch(error => reject(error))
|
||||
})
|
||||
}
|
||||
|
||||
Cashier.checkIfUpiTxIsValid = function (upiTxID) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let requests = Cashier.Requests;
|
||||
for (let r in requests)
|
||||
if (requests[r].message.mode === "cash-to-token" && requests[r].note)
|
||||
if (requests[r].message.upi_txid === upiTxID)
|
||||
return reject([true, "UPI transaction is already used for another request"]);
|
||||
return resolve(true);
|
||||
})
|
||||
}
|
||||
|
||||
Cashier.checkIfTokenTxIsValid = function (tokenTxID, sender, amount) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let requests = Cashier.Requests;
|
||||
for (let r in requests)
|
||||
if (requests[r].message.mode === "token-to-cash" && requests[r].note)
|
||||
if (requests[r].message.token_txid === tokenTxID)
|
||||
return reject([true, "Token transaction is already used for another request"]);
|
||||
tokenAPI.getTx(tokenTxID).then(tx => {
|
||||
let parsedTxData = tokenAPI.util.parseTxData(tx);
|
||||
console.debug(parsedTxData);
|
||||
if (parsedTxData.type !== "transfer" || parsedTxData.transferType !== "token")
|
||||
reject([true, "Invalid token transfer type"]);
|
||||
else if (parsedTxData.tokenAmount !== amount)
|
||||
reject([true, "Incorrect token amount: " + parsedTxData.tokenAmount]);
|
||||
else if (parsedTxData.tokenIdentification !== floGlobals.currency)
|
||||
reject([true, "Incorrect token: " + parsedTxData.tokenIdentification]);
|
||||
else if (parsedTxData.sender !== sender)
|
||||
reject([true, "Incorrect senderID: " + parsedTxData.sender]);
|
||||
else if (parsedTxData.receiver !== myFloID)
|
||||
reject([true, "Incorrect receiverID: " + parsedTxData.receive])
|
||||
else resolve(true);
|
||||
}).catch(error => reject([null, error]))
|
||||
})
|
||||
}
|
||||
305
scripts/fn_ui.js
Normal file
305
scripts/fn_ui.js
Normal file
@ -0,0 +1,305 @@
|
||||
/*jshint esversion: 6 */
|
||||
const userUI = {};
|
||||
|
||||
userUI.requestTokenFromCashier = function () {
|
||||
let cashier = User.findCashier();
|
||||
if (!cashier)
|
||||
return alert("No cashier online");
|
||||
let amount = parseFloat(getRef('request_cashier_amount').value.trim());
|
||||
//get UPI txid from user
|
||||
let upiTxID = prompt(`Send Rs. ${amount} to ${cashierUPI[cashier]} and enter UPI txid`);
|
||||
if (!upiTxID)
|
||||
return alert("Cancelled");
|
||||
User.cashToToken(cashier, amount, upiTxID).then(result => {
|
||||
console.log(result);
|
||||
alert("Requested cashier. please wait!");
|
||||
}).catch(error => console.error(error))
|
||||
}
|
||||
|
||||
userUI.withdrawCashFromCashier = function () {
|
||||
let cashier = User.findCashier();
|
||||
if (!cashier)
|
||||
return alert("No cashier online");
|
||||
let amount = parseFloat(getRef('request_cashier_amount').value.trim());
|
||||
//get confirmation from user
|
||||
let upiID = prompt(`${amount} ${floGlobals.currency}# will be sent to ${cashier}. Enter UPI ID`);
|
||||
if (!upiID)
|
||||
return alert("Cancelled");
|
||||
User.sendToken(cashier, amount, 'for token-to-cash').then(txid => {
|
||||
console.warn(`Withdraw ${amount} from cashier ${cashier}`, txid);
|
||||
User.tokenToCash(cashier, amount, txid, upiID).then(result => {
|
||||
console.log(result);
|
||||
alert("Requested cashier. please wait!");
|
||||
}).catch(error => console.error(error))
|
||||
}).catch(error => console.error(error))
|
||||
}
|
||||
|
||||
userUI.sendMoneyToUser = function (floID, amount, remark) {
|
||||
getConfirmation('Confirm', { message: `Do you want to SEND ${amount} to ${floID}?` }).then(confirmation => {
|
||||
if (confirmation) {
|
||||
User.sendToken(floID, amount, "|" + remark).then(txid => {
|
||||
console.warn(`Sent ${amount} to ${floID}`, txid);
|
||||
notify(`Sent ${amount} to ${floID}. It may take a few mins to reflect in their wallet`, 'success');
|
||||
hidePopup()
|
||||
}).catch(error => console.error(error));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userUI.requestMoneyFromUser = function (floID, amount, remark) {
|
||||
getConfirmation('Confirm', { message: `Do you want to REQUEST ${amount} from ${floID}?` }).then(confirmation => {
|
||||
if (confirmation) {
|
||||
User.requestToken(floID, amount, remark).then(result => {
|
||||
console.log(`Requested ${amount} from ${floID}`, result);
|
||||
notify(`Requested ${amount} from ${floID}`, 'success');
|
||||
hidePopup()
|
||||
}).catch(error => console.error(error));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userUI.renderCashierRequests = function (requests, error = null) {
|
||||
if (error)
|
||||
return console.error(error);
|
||||
else if (typeof requests !== "object" || requests === null)
|
||||
return;
|
||||
const frag = document.createDocumentFragment()
|
||||
for (let r in requests) {
|
||||
let oldCard = document.getElementById(r);
|
||||
if (oldCard) oldCard.remove();
|
||||
frag.append(render.walletRequestCard(requests[r]))
|
||||
}
|
||||
getRef('user-cashier-requests').append(frag)
|
||||
}
|
||||
|
||||
userUI.renderMoneyRequests = function (requests, error = null) {
|
||||
if (error)
|
||||
return console.error(error);
|
||||
else if (typeof requests !== "object" || requests === null)
|
||||
return;
|
||||
const frag = document.createDocumentFragment()
|
||||
for (let r in requests) {
|
||||
let oldCard = document.getElementById(r);
|
||||
if (oldCard) oldCard.remove();
|
||||
frag.append(render.paymentRequestCard(requests[r]))
|
||||
}
|
||||
getRef('user-money-requests').append(frag)
|
||||
}
|
||||
|
||||
userUI.payRequest = function (reqID) {
|
||||
let request = User.moneyRequests[reqID];
|
||||
getConfirmation('Pay?', { message: `Do you want to pay ${request.message.amount} to ${request.senderID}?` }).then(confirmation => {
|
||||
if (confirmation) {
|
||||
User.sendToken(request.senderID, request.message.amount, "|" + request.message.remark).then(txid => {
|
||||
console.warn(`Sent ${request.message.amount} to ${request.senderID}`, txid);
|
||||
notify(`Sent ${request.message.amount} to ${request.senderID}. It may take a few mins to reflect in their wallet`, 'success');
|
||||
User.decideRequest(request, 'PAID: ' + txid)
|
||||
.then(result => console.log(result))
|
||||
.catch(error => console.error(error))
|
||||
}).catch(error => console.error(error));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
userUI.declineRequest = function (reqID) {
|
||||
let request = User.moneyRequests[reqID];
|
||||
getConfirmation('Decline payment?').then(confirmation => {
|
||||
if (confirmation) {
|
||||
User.decideRequest(request, "DECLINED").then(result => {
|
||||
console.log(result);
|
||||
notify("Request declined", 'success');
|
||||
}).catch(error => console.error(error))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Cashier
|
||||
const cashierUI = {};
|
||||
|
||||
cashierUI.renderRequests = function (requests, error = null) {
|
||||
if (error)
|
||||
return console.error(error);
|
||||
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.append(render.cashierRequestCard(requests[r]));
|
||||
}
|
||||
getRef('cashier_request_list').append(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);
|
||||
}
|
||||
|
||||
function completeCashToTokenRequest(request) {
|
||||
Cashier.checkIfUpiTxIsValid(request.message.upi_txid).then(_ => {
|
||||
let confirmation = confirm(`Check if you have received UPI transfer\ntxid:${request.message.upi_txid}\namount:${request.message.amount}`);
|
||||
if (!confirmation)
|
||||
return alert("Cancelled");
|
||||
User.sendToken(request.senderID, request.message.amount, 'for cash-to-token').then(txid => {
|
||||
console.warn(`${request.message.amount} cash-to-token for ${request.senderID}`, txid);
|
||||
Cashier.finishRequest(request, txid).then(result => {
|
||||
console.log(result);
|
||||
console.info('Completed cash-to-token request:', request.vectorClock);
|
||||
alert("Completed request");
|
||||
}).catch(error => console.error(error))
|
||||
}).catch(error => console.error(error))
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
if (Array.isArray(error) && error[0] === true && typeof error[1] === 'string')
|
||||
Cashier.rejectRequest(request, error[1]).then(result => {
|
||||
console.log(result);
|
||||
console.info('Rejected cash-to-token request:', request.vectorClock);
|
||||
}).catch(error => console.error(error))
|
||||
})
|
||||
}
|
||||
|
||||
function completeTokenToCashRequest(request) {
|
||||
Cashier.checkIfTokenTxIsValid(request.message.token_txid, request.senderID, request.message.amount).then(result => {
|
||||
let upiTxID = prompt(`Token transfer is verified!\n Send ${request.message.amount} to ${request.message.upi_id} and Enter UPI txid`);
|
||||
if (!upiTxID)
|
||||
return alert("Cancelled");
|
||||
Cashier.finishRequest(request, upiTxID).then(result => {
|
||||
console.log(result);
|
||||
console.info('Completed token-to-cash request:', request.vectorClock);
|
||||
alert("Completed request");
|
||||
}).catch(error => console.error(error))
|
||||
}).catch(error => {
|
||||
console.error(error);
|
||||
alert(error);
|
||||
if (Array.isArray(error) && error[0] === true && typeof error[1] === 'string')
|
||||
Cashier.rejectRequest(request, error[1]).then(result => {
|
||||
console.log(result);
|
||||
console.info('Rejected token-to-cash request:', request.vectorClock);
|
||||
}).catch(error => console.error(error))
|
||||
})
|
||||
}
|
||||
|
||||
function renderAllTokenTransactions() {
|
||||
tokenAPI.getAllTxs(myFloID).then(result => {
|
||||
getRef('token_transactions').innerHTML = ''
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let txid in result.transactions) {
|
||||
frag.append(render.transactionCard(txid, tokenAPI.util.parseTxData(result.transactions[txid])))
|
||||
}
|
||||
getRef('token_transactions').append(frag)
|
||||
}).catch(error => console.error(error))
|
||||
}
|
||||
|
||||
const render = {
|
||||
transactionCard(txid, transactionDetails) {
|
||||
const { time, sender, receiver, tokenAmount } = transactionDetails
|
||||
const clone = getRef('transaction_template').content.cloneNode(true).firstElementChild;
|
||||
clone.dataset.txid = txid
|
||||
clone.querySelector('.transaction__time').textContent = getFormattedTime(time * 1000)
|
||||
clone.querySelector('.transaction__amount').textContent = tokenAmount
|
||||
if (sender === myFloID) {
|
||||
clone.querySelector('.transaction__amount').classList.add('sent')
|
||||
clone.querySelector('.transaction__receiver').textContent = `Sent to ${receiver || 'Myself'}`
|
||||
clone.querySelector('.transaction__icon').innerHTML = `<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 d="M9 5v2h6.59L4 18.59 5.41 20 17 8.41V15h2V5z"/></svg>`
|
||||
} else if (receiver === myFloID) {
|
||||
clone.querySelector('.transaction__amount').classList.add('received')
|
||||
clone.querySelector('.transaction__receiver').textContent = `Received from ${sender}`
|
||||
clone.querySelector('.transaction__icon').innerHTML = `<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 d="M20 5.41L18.59 4 7 15.59V9H5v10h10v-2H8.41z"/></svg>`
|
||||
} else { //This should not happen unless API returns transaction that does not involve myFloID
|
||||
row.insertCell().textContent = tx.sender;
|
||||
row.insertCell().textContent = tx.receiver;
|
||||
}
|
||||
return clone
|
||||
},
|
||||
cashierRequestCard(details) {
|
||||
const { time, senderID, message: { mode }, note, tag, vectorClock } = details;
|
||||
const clone = getRef('cashier_request_template').content.cloneNode(true).firstElementChild;
|
||||
clone.id = vectorClock
|
||||
const status = tag || note; //status tag for completed, note for rejected
|
||||
clone.querySelector('.cashier-request__requestor').textContent = senderID
|
||||
clone.querySelector('.cashier-request__time').textContent = getFormattedTime(time)
|
||||
clone.querySelector('.cashier-request__mode').textContent = mode
|
||||
if (status)
|
||||
clone.querySelector('.cashier-request__status').textContent = status
|
||||
else
|
||||
clone.querySelector('.cashier-request__status').innerHTML = `<button class="button" onclick="cashierUI.completeRequest('${vectorClock}')">Process</button>`
|
||||
return clone
|
||||
},
|
||||
walletRequestCard(details) {
|
||||
const { time, receiverID, message: { mode }, note, tag, vectorClock } = details;
|
||||
const clone = getRef('wallet_request_template').content.cloneNode(true).firstElementChild;
|
||||
clone.id = vectorClock
|
||||
clone.querySelector('.wallet-request__requestor').textContent = receiverID
|
||||
clone.querySelector('.wallet-request__time').textContent = getFormattedTime(time)
|
||||
clone.querySelector('.wallet-request__mode').textContent = mode === 'cash-to-token' ? 'Deposit' : 'Withdraw'
|
||||
let status = tag ? (tag + ":" + note) : (note || "PENDING");
|
||||
clone.querySelector('.wallet-request__status').textContent = status
|
||||
return clone
|
||||
},
|
||||
paymentRequestCard(details) {
|
||||
const { time, senderID, message: { amount, remark }, note, vectorClock } = details;
|
||||
const clone = getRef('payment_request_template').content.cloneNode(true).firstElementChild;
|
||||
clone.id = vectorClock
|
||||
clone.querySelector('.payment-request__requestor').textContent = senderID
|
||||
clone.querySelector('.payment-request__time').textContent = getFormattedTime(time)
|
||||
clone.querySelector('.payment-request__amount').textContent = amount.toLocaleString(`en-IN`, { style: 'currency', currency: 'INR' })
|
||||
clone.querySelector('.payment-request__remark').textContent = remark
|
||||
|
||||
let status = note;
|
||||
if (status)
|
||||
clone.querySelector('.payment-request__actions').textContent = note;
|
||||
else
|
||||
clone.querySelector('.payment-request__actions').innerHTML =
|
||||
`<button class="button" onclick="userUI.payRequest('${vectorClock}')">Pay</button>
|
||||
<button class="button" onclick="userUI.declineRequest('${vectorClock}')">Decline</button>`;
|
||||
|
||||
return clone
|
||||
},
|
||||
}
|
||||
|
||||
let currentUserAction
|
||||
function showTokenTransfer(type) {
|
||||
getRef('tt_button').textContent = type;
|
||||
currentUserAction = type
|
||||
if (type === 'send') {
|
||||
getRef('token_transfer__title').textContent = 'Send money to FLO ID';
|
||||
} else {
|
||||
getRef('token_transfer__title').textContent = 'Request money from FLO ID';
|
||||
}
|
||||
showPopup('token_transfer_popup')
|
||||
}
|
||||
|
||||
function executeUserAction() {
|
||||
const floID = getRef('tt_flo_id').value.trim(),
|
||||
amount = parseFloat(getRef('tt_amount').value),
|
||||
remark = getRef('tt_remark').value.trim();
|
||||
if (currentUserAction === 'send') {
|
||||
userUI.sendMoneyToUser(floID, amount, remark)
|
||||
|
||||
} else {
|
||||
userUI.requestMoneyFromUser(floID, amount, remark)
|
||||
}
|
||||
}
|
||||
|
||||
function changeUpi() {
|
||||
const upiID = getRef('upi_id').value.trim()
|
||||
Cashier.updateUPI(upiID).then(() => {
|
||||
notify('UPI ID updated successfully', 'success')
|
||||
}).catch(err => {
|
||||
notify(err, 'error')
|
||||
})
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
getConfirmation('Sign out?', 'You are about to sign out of the app, continue?', 'Stay', 'Leave')
|
||||
.then(async (res) => {
|
||||
if (res) {
|
||||
await floDapps.clearCredentials()
|
||||
location.reload()
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
0
qrcode.min.js → scripts/qrcode.min.js
vendored
0
qrcode.min.js → scripts/qrcode.min.js
vendored
9521
scripts/std_op.js
Normal file
9521
scripts/std_op.js
Normal file
File diff suppressed because it is too large
Load Diff
507
scripts/std_ui.js
Normal file
507
scripts/std_ui.js
Normal file
@ -0,0 +1,507 @@
|
||||
/*jshint esversion: 6 */
|
||||
// Global variables
|
||||
const domRefs = {};
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
//Checks for internet connection status
|
||||
if (!navigator.onLine)
|
||||
notify(
|
||||
"There seems to be a problem connecting to the internet, Please check you internet connection.",
|
||||
"error"
|
||||
);
|
||||
window.addEventListener("offline", () => {
|
||||
notify(
|
||||
"There seems to be a problem connecting to the internet, Please check you internet connection.",
|
||||
"error",
|
||||
{ pinned: true }
|
||||
);
|
||||
});
|
||||
window.addEventListener("online", () => {
|
||||
getRef("notification_drawer").clearAll();
|
||||
notify("We are back online.", "success");
|
||||
});
|
||||
|
||||
// Use instead of document.getElementById
|
||||
function getRef(elementId) {
|
||||
if (!domRefs.hasOwnProperty(elementId)) {
|
||||
domRefs[elementId] = {
|
||||
count: 1,
|
||||
ref: null,
|
||||
};
|
||||
return document.getElementById(elementId);
|
||||
} else {
|
||||
if (domRefs[elementId].count < 3) {
|
||||
domRefs[elementId].count = domRefs[elementId].count + 1;
|
||||
return document.getElementById(elementId);
|
||||
} else {
|
||||
if (!domRefs[elementId].ref)
|
||||
domRefs[elementId].ref = document.getElementById(elementId);
|
||||
return domRefs[elementId].ref;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// returns dom with specified element
|
||||
function createElement(tagName, options = {}) {
|
||||
const { className, textContent, innerHTML, attributes = {} } = options
|
||||
const elem = document.createElement(tagName)
|
||||
for (let attribute in attributes) {
|
||||
elem.setAttribute(attribute, attributes[attribute])
|
||||
}
|
||||
if (className)
|
||||
elem.className = className
|
||||
if (textContent)
|
||||
elem.textContent = textContent
|
||||
if (innerHTML)
|
||||
elem.innerHTML = innerHTML
|
||||
return elem
|
||||
}
|
||||
|
||||
// Use when a function needs to be executed after user finishes changes
|
||||
const debounce = (callback, wait) => {
|
||||
let timeoutId = null;
|
||||
return (...args) => {
|
||||
window.clearTimeout(timeoutId);
|
||||
timeoutId = window.setTimeout(() => {
|
||||
callback.apply(null, args);
|
||||
}, wait);
|
||||
};
|
||||
}
|
||||
|
||||
let zIndex = 10
|
||||
// function required for popups or modals to appear
|
||||
function showPopup(popupId, pinned) {
|
||||
zIndex++
|
||||
getRef(popupId).setAttribute('style', `z-index: ${zIndex}`)
|
||||
getRef(popupId).show({ pinned })
|
||||
return getRef(popupId);
|
||||
}
|
||||
|
||||
// hides the popup or modal
|
||||
function hidePopup() {
|
||||
if (popupStack.peek() === undefined)
|
||||
return;
|
||||
popupStack.peek().popup.hide()
|
||||
}
|
||||
|
||||
document.addEventListener('popupopened', async e => {
|
||||
switch (e.target.id) {
|
||||
case 'saved_ids_popup':
|
||||
const frag = document.createDocumentFragment()
|
||||
const allSavedIds = await getArrayOfSavedIds()
|
||||
allSavedIds.forEach(({ floID, name }) => {
|
||||
frag.append(render.savedIdPickerCard(floID, name))
|
||||
})
|
||||
getRef('saved_ids_picker_list').innerHTML = ''
|
||||
getRef('saved_ids_picker_list').append(frag)
|
||||
getRef('search_saved_ids_picker').focusIn()
|
||||
break;
|
||||
case 'get_private_key_popup':
|
||||
break;
|
||||
}
|
||||
})
|
||||
document.addEventListener('popupclosed', e => {
|
||||
zIndex--
|
||||
switch (e.target.id) {
|
||||
case 'saved_ids_popup':
|
||||
getRef('saved_ids_picker_list').innerHTML = ''
|
||||
getRef('search_saved_ids_picker').value = ''
|
||||
break;
|
||||
case 'get_private_key_popup':
|
||||
getRef('get_private_key').classList.remove('hide')
|
||||
getRef('transaction_result').classList.add('hide')
|
||||
getRef('confirm_transaction_button').classList.remove('hide')
|
||||
getRef('confirm_transaction_button').nextElementSibling.classList.add('hide')
|
||||
break;
|
||||
case 'retrieve_flo_id_popup':
|
||||
getRef('recovered_flo_id_wrapper').classList.add('hide')
|
||||
break;
|
||||
}
|
||||
})
|
||||
|
||||
// displays a popup for asking permission. Use this instead of JS confirm
|
||||
const getConfirmation = (title, options = {}) => {
|
||||
return new Promise(resolve => {
|
||||
const { message, cancelText = 'Cancel', confirmText = 'OK' } = options
|
||||
showPopup('confirmation_popup', true)
|
||||
getRef('confirm_title').textContent = title;
|
||||
getRef('confirm_message').textContent = message;
|
||||
let cancelButton = getRef('confirmation_popup').children[2].children[0],
|
||||
submitButton = getRef('confirmation_popup').children[2].children[1]
|
||||
submitButton.textContent = confirmText
|
||||
cancelButton.textContent = cancelText
|
||||
submitButton.onclick = () => {
|
||||
hidePopup()
|
||||
resolve(true);
|
||||
}
|
||||
cancelButton.onclick = () => {
|
||||
hidePopup()
|
||||
resolve(false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Function for displaying toast notifications. pass in error for mode param if you want to show an error.
|
||||
function notify(message, mode, options = {}) {
|
||||
const { pinned = false, sound = false } = options
|
||||
let icon
|
||||
switch (mode) {
|
||||
case 'success':
|
||||
icon = `<svg class="icon icon--success" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M10 15.172l9.192-9.193 1.415 1.414L10 18l-6.364-6.364 1.414-1.414z"/></svg>`
|
||||
break;
|
||||
case 'error':
|
||||
icon = `<svg class="icon icon--error" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><path fill="none" d="M0 0h24v24H0z"/><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zm-1-7v2h2v-2h-2zm0-8v6h2V7h-2z"/></svg>`
|
||||
break;
|
||||
}
|
||||
getRef("notification_drawer").push(message, { pinned, icon });
|
||||
if (mode === 'error') {
|
||||
console.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
function getFormattedTime(time, format) {
|
||||
try {
|
||||
if (String(time).indexOf('_'))
|
||||
time = String(time).split('_')[0]
|
||||
const intTime = parseInt(time)
|
||||
if (String(intTime).length < 13)
|
||||
time *= 1000
|
||||
let [day, month, date, year] = new Date(intTime).toString().split(' '),
|
||||
minutes = new Date(intTime).getMinutes(),
|
||||
hours = new Date(intTime).getHours(),
|
||||
currentTime = new Date().toString().split(' ')
|
||||
|
||||
minutes = minutes < 10 ? `0${minutes}` : minutes
|
||||
let finalHours = ``;
|
||||
if (hours > 12)
|
||||
finalHours = `${hours - 12}:${minutes}`
|
||||
else if (hours === 0)
|
||||
finalHours = `12:${minutes}`
|
||||
else
|
||||
finalHours = `${hours}:${minutes}`
|
||||
|
||||
finalHours = hours >= 12 ? `${finalHours} PM` : `${finalHours} AM`
|
||||
switch (format) {
|
||||
case 'date-only':
|
||||
return `${month} ${date}, ${year}`;
|
||||
break;
|
||||
default:
|
||||
return `${month} ${date} ${year}, ${finalHours}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return time;
|
||||
}
|
||||
}
|
||||
// implement event delegation
|
||||
function delegate(el, event, selector, fn) {
|
||||
el.addEventListener(event, function (e) {
|
||||
const potentialTarget = e.target.closest(selector)
|
||||
if (potentialTarget) {
|
||||
e.delegateTarget = potentialTarget
|
||||
fn.call(this, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', e => showPage(window.location.hash))
|
||||
window.addEventListener("load", () => {
|
||||
document.body.classList.remove('hide')
|
||||
document.querySelectorAll('sm-input[data-flo-id]').forEach(input => input.customValidation = floCrypto.validateAddr)
|
||||
document.querySelectorAll('sm-input[data-private-key]').forEach(input => input.customValidation = floCrypto.getPubKeyHex)
|
||||
document.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
hidePopup()
|
||||
}
|
||||
})
|
||||
document.addEventListener('copy', () => {
|
||||
notify('copied', 'success')
|
||||
})
|
||||
document.addEventListener("pointerdown", (e) => {
|
||||
if (e.target.closest("button:not([disabled]), sm-button:not([disabled]), .interact")) {
|
||||
createRipple(e, e.target.closest("button, sm-button, .interact"));
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
function createRipple(event, target) {
|
||||
const circle = document.createElement("span");
|
||||
const diameter = Math.max(target.clientWidth, target.clientHeight);
|
||||
const radius = diameter / 2;
|
||||
const targetDimensions = target.getBoundingClientRect();
|
||||
circle.style.width = circle.style.height = `${diameter}px`;
|
||||
circle.style.left = `${event.clientX - (targetDimensions.left + radius)}px`;
|
||||
circle.style.top = `${event.clientY - (targetDimensions.top + radius)}px`;
|
||||
circle.classList.add("ripple");
|
||||
const rippleAnimation = circle.animate(
|
||||
[
|
||||
{
|
||||
transform: "scale(4)",
|
||||
opacity: 0,
|
||||
},
|
||||
],
|
||||
{
|
||||
duration: 600,
|
||||
fill: "forwards",
|
||||
easing: "ease-out",
|
||||
}
|
||||
);
|
||||
target.append(circle);
|
||||
rippleAnimation.onfinish = () => {
|
||||
circle.remove();
|
||||
};
|
||||
}
|
||||
|
||||
const pagesData = {
|
||||
params: {}
|
||||
}
|
||||
|
||||
let tempData
|
||||
async function showPage(targetPage, options = {}) {
|
||||
const { firstLoad, hashChange, isPreview } = options
|
||||
let pageId
|
||||
let params = {}
|
||||
let searchParams
|
||||
if (targetPage === '') {
|
||||
pageId = 'home'
|
||||
} else {
|
||||
if (targetPage.includes('/')) {
|
||||
if (targetPage.includes('?')) {
|
||||
const splitAddress = targetPage.split('?')
|
||||
searchParams = splitAddress.pop()
|
||||
const pages = splitAddress.pop().split('/')
|
||||
pageId = pages[1]
|
||||
subPageId = pages[2]
|
||||
} else {
|
||||
const pages = targetPage.split('/')
|
||||
pageId = pages[1]
|
||||
subPageId = pages[2]
|
||||
}
|
||||
} else {
|
||||
pageId = targetPage
|
||||
}
|
||||
}
|
||||
if (searchParams) {
|
||||
const urlSearchParams = new URLSearchParams('?' + searchParams);
|
||||
params = Object.fromEntries(urlSearchParams.entries());
|
||||
}
|
||||
if (pagesData.lastPage !== pageId) {
|
||||
pagesData.lastPage = pageId
|
||||
}
|
||||
if (params)
|
||||
pagesData.params = params
|
||||
switch (pageId) {
|
||||
case 'transactions':
|
||||
break;
|
||||
default:
|
||||
|
||||
}
|
||||
const animOptions = {
|
||||
duration: 100,
|
||||
fill: 'forwards',
|
||||
}
|
||||
let previousActiveElement = getRef('main_navbar').querySelector('.nav-item--active')
|
||||
const currentActiveElement = document.querySelector(`.nav-item[href="#/${pageId}"]`)
|
||||
if (currentActiveElement) {
|
||||
if (getRef('main_navbar').classList.contains('hide')) {
|
||||
getRef('main_navbar').classList.remove('hide-away')
|
||||
getRef('main_navbar').classList.remove('hide')
|
||||
getRef('main_navbar').animate([
|
||||
{
|
||||
transform: isMobileView ? `translateY(100%)` : `translateX(-100%)`,
|
||||
opacity: 0,
|
||||
},
|
||||
{
|
||||
transform: `none`,
|
||||
opacity: 1,
|
||||
},
|
||||
], {
|
||||
duration: 100,
|
||||
fill: 'forwards',
|
||||
easing: 'ease'
|
||||
})
|
||||
}
|
||||
getRef('main_header').classList.remove('hide')
|
||||
const previousActiveElementIndex = [...getRef('main_navbar').querySelectorAll('.nav-item')].indexOf(previousActiveElement)
|
||||
const currentActiveElementIndex = [...getRef('main_navbar').querySelectorAll('.nav-item')].indexOf(currentActiveElement)
|
||||
const isOnTop = previousActiveElementIndex < currentActiveElementIndex
|
||||
const currentIndicator = createElement('div', { className: 'nav-item__indicator' });
|
||||
let previousIndicator = getRef('main_navbar').querySelector('.nav-item__indicator')
|
||||
if (!previousIndicator) {
|
||||
previousIndicator = currentIndicator.cloneNode(true)
|
||||
previousActiveElement = currentActiveElement
|
||||
previousActiveElement.append(previousIndicator)
|
||||
} else if (currentActiveElementIndex !== previousActiveElementIndex) {
|
||||
const indicatorDimensions = previousIndicator.getBoundingClientRect()
|
||||
const currentActiveElementDimensions = currentActiveElement.getBoundingClientRect()
|
||||
let moveBy
|
||||
if (isMobileView) {
|
||||
moveBy = ((currentActiveElementDimensions.width - indicatorDimensions.width) / 2) + indicatorDimensions.width
|
||||
} else {
|
||||
moveBy = ((currentActiveElementDimensions.height - indicatorDimensions.height) / 2) + indicatorDimensions.height
|
||||
}
|
||||
indicatorObserver.observe(previousIndicator)
|
||||
previousIndicator.animate([
|
||||
{
|
||||
transform: 'none',
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
transform: `translate${isMobileView ? 'X' : 'Y'}(${isOnTop ? `${moveBy}px` : `-${moveBy}px`})`,
|
||||
opacity: 0,
|
||||
},
|
||||
], { ...animOptions, easing: 'ease-in' }).onfinish = () => {
|
||||
previousIndicator.remove()
|
||||
}
|
||||
tempData = {
|
||||
currentActiveElement,
|
||||
currentIndicator,
|
||||
isOnTop,
|
||||
animOptions,
|
||||
moveBy
|
||||
}
|
||||
}
|
||||
previousActiveElement.classList.remove('nav-item--active');
|
||||
currentActiveElement.classList.add('nav-item--active')
|
||||
} else {
|
||||
if (!getRef('main_navbar').classList.contains('hide')) {
|
||||
getRef('main_navbar').classList.add('hide-away')
|
||||
getRef('main_navbar').animate([
|
||||
{
|
||||
transform: `none`,
|
||||
opacity: 1,
|
||||
},
|
||||
{
|
||||
transform: isMobileView ? `translateY(100%)` : `translateX(-100%)`,
|
||||
opacity: 0,
|
||||
},
|
||||
], {
|
||||
duration: 200,
|
||||
fill: 'forwards',
|
||||
easing: 'ease'
|
||||
}).onfinish = () => {
|
||||
getRef('main_navbar').classList.add('hide')
|
||||
}
|
||||
getRef('main_header').classList.add('hide')
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.page').forEach(page => page.classList.add('hide'))
|
||||
getRef(pageId).classList.remove('hide')
|
||||
getRef(pageId).animate([{ opacity: 0 }, { opacity: 1 }], { duration: 300, fill: 'forwards', easing: 'ease' })
|
||||
}
|
||||
|
||||
const indicatorObserver = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (!entry.isIntersecting) {
|
||||
const { currentActiveElement, currentIndicator, isOnTop, animOptions, moveBy } = tempData
|
||||
currentActiveElement.append(currentIndicator)
|
||||
currentIndicator.animate([
|
||||
{
|
||||
transform: `translate${isMobileView ? 'X' : 'Y'}(${isOnTop ? `-${moveBy}px` : `${moveBy}px`})`,
|
||||
opacity: 0,
|
||||
},
|
||||
{
|
||||
transform: 'none',
|
||||
opacity: 1
|
||||
},
|
||||
], { ...animOptions, easing: 'ease-out' })
|
||||
}
|
||||
})
|
||||
}, {
|
||||
threshold: 1
|
||||
})
|
||||
|
||||
// class based lazy loading
|
||||
class LazyLoader {
|
||||
constructor(container, elementsToRender, renderFn, options = {}) {
|
||||
const { batchSize = 10, freshRender } = options
|
||||
|
||||
this.elementsToRender = elementsToRender
|
||||
this.arrayOfElements = (typeof elementsToRender === 'function') ? this.elementsToRender() : elementsToRender || []
|
||||
this.renderFn = renderFn
|
||||
this.intersectionObserver
|
||||
|
||||
this.batchSize = batchSize
|
||||
this.freshRender = freshRender
|
||||
|
||||
this.lazyContainer = document.querySelector(container)
|
||||
|
||||
this.update = this.update.bind(this)
|
||||
this.render = this.render.bind(this)
|
||||
this.init = this.init.bind(this)
|
||||
this.clear = this.clear.bind(this)
|
||||
}
|
||||
init() {
|
||||
this.intersectionObserver = new IntersectionObserver((entries, observer) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
observer.disconnect()
|
||||
this.render({ lazyLoad: true })
|
||||
}
|
||||
})
|
||||
}, {
|
||||
threshold: 0.3
|
||||
})
|
||||
this.mutationObserver = new MutationObserver(mutationList => {
|
||||
mutationList.forEach(mutation => {
|
||||
if (mutation.type === 'childList') {
|
||||
if (mutation.addedNodes.length) {
|
||||
this.intersectionObserver.observe(this.lazyContainer.lastElementChild)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
this.mutationObserver.observe(this.lazyContainer, {
|
||||
childList: true,
|
||||
})
|
||||
this.render()
|
||||
}
|
||||
update(elementsToRender) {
|
||||
this.arrayOfElements = (typeof elementsToRender === 'function') ? this.elementsToRender() : elementsToRender || []
|
||||
this.render()
|
||||
}
|
||||
render(options = {}) {
|
||||
let { lazyLoad = false } = options
|
||||
const frag = document.createDocumentFragment();
|
||||
if (lazyLoad) {
|
||||
this.updateStartIndex = this.updateEndIndex
|
||||
this.updateEndIndex = this.arrayOfElements.length > this.updateEndIndex + this.batchSize ? this.updateEndIndex + this.batchSize : this.arrayOfElements.length
|
||||
} else {
|
||||
this.intersectionObserver.disconnect()
|
||||
this.lazyContainer.innerHTML = ``;
|
||||
this.updateStartIndex = 0
|
||||
this.updateEndIndex = this.arrayOfElements.length > this.batchSize ? this.batchSize : this.arrayOfElements.length
|
||||
}
|
||||
for (let index = this.updateStartIndex; index < this.updateEndIndex; index++) {
|
||||
frag.append(this.renderFn(this.arrayOfElements[index]))
|
||||
}
|
||||
this.lazyContainer.append(frag)
|
||||
// Callback to be called if elements are updated or rendered for first time
|
||||
if (!lazyLoad && this.freshRender)
|
||||
this.freshRender()
|
||||
}
|
||||
clear() {
|
||||
this.intersectionObserver.disconnect()
|
||||
this.mutationObserver.disconnect()
|
||||
this.lazyContainer.innerHTML = ``;
|
||||
}
|
||||
reset() {
|
||||
this.arrayOfElements = (typeof this.elementsToRender === 'function') ? this.elementsToRender() : this.elementsToRender || []
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
function animateTo(element, keyframes, options) {
|
||||
const anime = element.animate(keyframes, { ...options, fill: 'both' })
|
||||
anime.finished.then(() => {
|
||||
anime.commitStyles()
|
||||
anime.cancel()
|
||||
})
|
||||
return anime
|
||||
}
|
||||
let isMobileView = false
|
||||
const mobileQuery = window.matchMedia('(max-width: 40rem)')
|
||||
function handleMobileChange(e) {
|
||||
isMobileView = e.matches
|
||||
}
|
||||
mobileQuery.addEventListener('change', handleMobileChange)
|
||||
handleMobileChange(mobileQuery)
|
||||
Loading…
Reference in New Issue
Block a user