add a dialog with payment details for each payment.

for outgoing payments this needs a preimage to be good,
but we don't have it yet because we don't get it from
backends.
This commit is contained in:
fiatjaf 2020-09-02 19:19:18 -03:00
parent 197af922d0
commit ce28db76c9
5 changed files with 172 additions and 105 deletions

View File

@ -14,10 +14,10 @@ function generateChart(canvas, payments) {
} }
_.each( _.each(
payments.slice(0).sort(function(a, b) { payments.slice(0).sort(function (a, b) {
return a.time - b.time return a.time - b.time
}), }),
function(tx) { function (tx) {
txs.push({ txs.push({
hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'), hour: Quasar.utils.date.formatDate(tx.date, 'YYYY-MM-DDTHH:00'),
sat: tx.sat sat: tx.sat
@ -25,17 +25,17 @@ function generateChart(canvas, payments) {
} }
) )
_.each(_.groupBy(txs, 'hour'), function(value, day) { _.each(_.groupBy(txs, 'hour'), function (value, day) {
var income = _.reduce( var income = _.reduce(
value, value,
function(memo, tx) { function (memo, tx) {
return tx.sat >= 0 ? memo + tx.sat : memo return tx.sat >= 0 ? memo + tx.sat : memo
}, },
0 0
) )
var outcome = _.reduce( var outcome = _.reduce(
value, value,
function(memo, tx) { function (memo, tx) {
return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo return tx.sat < 0 ? memo + Math.abs(tx.sat) : memo
}, },
0 0
@ -67,20 +67,14 @@ function generateChart(canvas, payments) {
type: 'bar', type: 'bar',
label: 'in', label: 'in',
barPercentage: 0.75, barPercentage: 0.75,
backgroundColor: window backgroundColor: window.Color('rgb(76,175,80)').alpha(0.5).rgbString() // green
.Color('rgb(76,175,80)')
.alpha(0.5)
.rgbString() // green
}, },
{ {
data: data.outcome, data: data.outcome,
type: 'bar', type: 'bar',
label: 'out', label: 'out',
barPercentage: 0.75, barPercentage: 0.75,
backgroundColor: window backgroundColor: window.Color('rgb(233,30,99)').alpha(0.5).rgbString() // pink
.Color('rgb(233,30,99)')
.alpha(0.5)
.rgbString() // pink
} }
] ]
}, },
@ -121,7 +115,7 @@ function generateChart(canvas, payments) {
new Vue({ new Vue({
el: '#vue', el: '#vue',
mixins: [windowMixin], mixins: [windowMixin],
data: function() { data: function () {
return { return {
user: LNbits.map.user(window.user), user: LNbits.map.user(window.user),
receive: { receive: {
@ -183,49 +177,49 @@ new Vue({
} }
}, },
computed: { computed: {
filteredPayments: function() { filteredPayments: function () {
var q = this.paymentsTable.filter var q = this.paymentsTable.filter
if (!q || q === '') return this.payments if (!q || q === '') return this.payments
return LNbits.utils.search(this.payments, q) return LNbits.utils.search(this.payments, q)
}, },
balance: function() { balance: function () {
if (this.payments.length) { if (this.payments.length) {
return ( return (
_.pluck(this.payments, 'amount').reduce(function(a, b) { _.pluck(this.payments, 'amount').reduce(function (a, b) {
return a + b return a + b
}, 0) / 1000 }, 0) / 1000
) )
} }
return this.g.wallet.sat return this.g.wallet.sat
}, },
fbalance: function() { fbalance: function () {
return LNbits.utils.formatSat(this.balance) return LNbits.utils.formatSat(this.balance)
}, },
canPay: function() { canPay: function () {
if (!this.send.invoice) return false if (!this.send.invoice) return false
return this.send.invoice.sat <= this.balance return this.send.invoice.sat <= this.balance
}, },
pendingPaymentsExist: function() { pendingPaymentsExist: function () {
return this.payments return this.payments
? _.where(this.payments, {pending: 1}).length > 0 ? _.where(this.payments, {pending: 1}).length > 0
: false : false
} }
}, },
methods: { methods: {
closeCamera: function() { closeCamera: function () {
this.sendCamera.show = false this.sendCamera.show = false
}, },
showCamera: function() { showCamera: function () {
this.sendCamera.show = true this.sendCamera.show = true
}, },
showChart: function() { showChart: function () {
this.paymentsChart.show = true this.paymentsChart.show = true
this.$nextTick(function() { this.$nextTick(function () {
generateChart(this.$refs.canvas, this.payments) generateChart(this.$refs.canvas, this.payments)
}) })
}, },
showReceiveDialog: function() { showReceiveDialog: function () {
this.receive = { this.receive = {
show: true, show: true,
status: 'pending', status: 'pending',
@ -237,7 +231,7 @@ new Vue({
paymentChecker: null paymentChecker: null
} }
}, },
showSendDialog: function() { showSendDialog: function () {
this.send = { this.send = {
show: true, show: true,
invoice: null, invoice: null,
@ -247,20 +241,20 @@ new Vue({
paymentChecker: null paymentChecker: null
} }
}, },
closeReceiveDialog: function() { closeReceiveDialog: function () {
var checker = this.receive.paymentChecker var checker = this.receive.paymentChecker
setTimeout(function() { setTimeout(function () {
clearInterval(checker) clearInterval(checker)
}, 10000) }, 10000)
}, },
closeSendDialog: function() { closeSendDialog: function () {
this.sendCamera.show = false this.sendCamera.show = false
var checker = this.send.paymentChecker var checker = this.send.paymentChecker
setTimeout(function() { setTimeout(function () {
clearInterval(checker) clearInterval(checker)
}, 1000) }, 1000)
}, },
createInvoice: function() { createInvoice: function () {
var self = this var self = this
this.receive.status = 'loading' this.receive.status = 'loading'
LNbits.api LNbits.api
@ -269,14 +263,14 @@ new Vue({
this.receive.data.amount, this.receive.data.amount,
this.receive.data.memo this.receive.data.memo
) )
.then(function(response) { .then(function (response) {
self.receive.status = 'success' self.receive.status = 'success'
self.receive.paymentReq = response.data.payment_request self.receive.paymentReq = response.data.payment_request
self.receive.paymentChecker = setInterval(function() { self.receive.paymentChecker = setInterval(function () {
LNbits.api LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash) .getPayment(self.g.wallet, response.data.payment_hash)
.then(function(response) { .then(function (response) {
if (response.data.paid) { if (response.data.paid) {
self.fetchPayments() self.fetchPayments()
self.receive.show = false self.receive.show = false
@ -285,17 +279,17 @@ new Vue({
}) })
}, 2000) }, 2000)
}) })
.catch(function(error) { .catch(function (error) {
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
self.receive.status = 'pending' self.receive.status = 'pending'
}) })
}, },
decodeQR: function(res) { decodeQR: function (res) {
this.send.data.bolt11 = res this.send.data.bolt11 = res
this.decodeInvoice() this.decodeInvoice()
this.sendCamera.show = false this.sendCamera.show = false
}, },
decodeInvoice: function() { decodeInvoice: function () {
if (this.send.data.bolt11.startsWith('lightning:')) { if (this.send.data.bolt11.startsWith('lightning:')) {
this.send.data.bolt11 = this.send.data.bolt11.slice(10) this.send.data.bolt11 = this.send.data.bolt11.slice(10)
} }
@ -320,7 +314,7 @@ new Vue({
fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000) fsat: LNbits.utils.formatSat(invoice.human_readable_part.amount / 1000)
} }
_.each(invoice.data.tags, function(tag) { _.each(invoice.data.tags, function (tag) {
if (_.isObject(tag) && _.has(tag, 'description')) { if (_.isObject(tag) && _.has(tag, 'description')) {
if (tag.description === 'payment_hash') { if (tag.description === 'payment_hash') {
cleanInvoice.hash = tag.value cleanInvoice.hash = tag.value
@ -341,7 +335,7 @@ new Vue({
this.send.invoice = Object.freeze(cleanInvoice) this.send.invoice = Object.freeze(cleanInvoice)
}, },
payInvoice: function() { payInvoice: function () {
var self = this var self = this
let dismissPaymentMsg = this.$q.notify({ let dismissPaymentMsg = this.$q.notify({
@ -352,11 +346,11 @@ new Vue({
LNbits.api LNbits.api
.payInvoice(this.g.wallet, this.send.data.bolt11) .payInvoice(this.g.wallet, this.send.data.bolt11)
.then(function(response) { .then(function (response) {
self.send.paymentChecker = setInterval(function() { self.send.paymentChecker = setInterval(function () {
LNbits.api LNbits.api
.getPayment(self.g.wallet, response.data.payment_hash) .getPayment(self.g.wallet, response.data.payment_hash)
.then(function(res) { .then(function (res) {
if (res.data.paid) { if (res.data.paid) {
self.send.show = false self.send.show = false
clearInterval(self.send.paymentChecker) clearInterval(self.send.paymentChecker)
@ -366,58 +360,58 @@ new Vue({
}) })
}, 2000) }, 2000)
}) })
.catch(function(error) { .catch(function (error) {
dismissPaymentMsg() dismissPaymentMsg()
LNbits.utils.notifyApiError(error) LNbits.utils.notifyApiError(error)
}) })
}, },
deleteWallet: function(walletId, user) { deleteWallet: function (walletId, user) {
LNbits.utils LNbits.utils
.confirmDialog('Are you sure you want to delete this wallet?') .confirmDialog('Are you sure you want to delete this wallet?')
.onOk(function() { .onOk(function () {
LNbits.href.deleteWallet(walletId, user) LNbits.href.deleteWallet(walletId, user)
}) })
}, },
fetchPayments: function(checkPending) { fetchPayments: function (checkPending) {
var self = this var self = this
return LNbits.api return LNbits.api
.getPayments(this.g.wallet, checkPending) .getPayments(this.g.wallet, checkPending)
.then(function(response) { .then(function (response) {
self.payments = response.data self.payments = response.data
.map(function(obj) { .map(function (obj) {
return LNbits.map.payment(obj) return LNbits.map.payment(obj)
}) })
.sort(function(a, b) { .sort(function (a, b) {
return b.time - a.time return b.time - a.time
}) })
}) })
}, },
checkPendingPayments: function() { checkPendingPayments: function () {
var dismissMsg = this.$q.notify({ var dismissMsg = this.$q.notify({
timeout: 0, timeout: 0,
message: 'Checking pending transactions...', message: 'Checking pending transactions...',
icon: null icon: null
}) })
this.fetchPayments(true).then(function() { this.fetchPayments(true).then(function () {
dismissMsg() dismissMsg()
}) })
}, },
exportCSV: function() { exportCSV: function () {
LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments) LNbits.utils.exportCSV(this.paymentsTable.columns, this.payments)
} }
}, },
watch: { watch: {
payments: function() { payments: function () {
EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance]) EventHub.$emit('update-wallet-balance', [this.g.wallet.id, this.balance])
} }
}, },
created: function() { created: function () {
this.fetchPayments() this.fetchPayments()
setTimeout(this.checkPendingPayments(), 1200) setTimeout(this.checkPendingPayments(), 1200)
}, },
mounted: function() { mounted: function () {
if ( if (
this.$refs.disclaimer && this.$refs.disclaimer &&
!this.$q.localStorage.getItem('lnbits.disclaimerShown') !this.$q.localStorage.getItem('lnbits.disclaimerShown')

View File

@ -103,16 +103,25 @@
<q-icon <q-icon
v-if="props.row.isPaid" v-if="props.row.isPaid"
size="14px" size="14px"
:name="(props.row.sat < 0) ? 'call_made' : 'call_received'" :name="props.row.isOut ? 'call_made' : 'call_received'"
:color="(props.row.sat < 0) ? 'pink' : 'green'" :color="props.row.isOut ? 'pink' : 'green'"
@click="props.expand = !props.expand"
></q-icon> ></q-icon>
<q-icon v-else name="settings_ethernet" color="grey"> <q-icon
v-else
name="settings_ethernet"
color="grey"
@click="props.expand = !props.expand"
>
<q-tooltip>Pending</q-tooltip> <q-tooltip>Pending</q-tooltip>
</q-icon> </q-icon>
</q-td> </q-td>
<q-td key="memo" :props="props"> <q-td key="memo" :props="props">
<q-badge v-if="props.row.tag" color="yellow" text-color="black"> <q-badge v-if="props.row.tag" color="yellow" text-color="black">
<a class="inherit" :href="['/', props.row.tag, '?usr=', user.id].join('')"> <a
class="inherit"
:href="['/', props.row.tag, '?usr=', user.id].join('')"
>
#{{ props.row.tag }} #{{ props.row.tag }}
</a> </a>
</q-badge> </q-badge>
@ -125,6 +134,64 @@
{{ props.row.fsat }} {{ props.row.fsat }}
</q-td> </q-td>
</q-tr> </q-tr>
<q-dialog v-model="props.expand" :props="props">
<q-card
v-if="props.row.amount > 0 && props.row.pending"
class="q-pa-lg q-pt-xl lnbits__dialog-card"
>
<div class="text-center q-mb-lg">
<a :href="'lightning:' + receive.paymentReq">
<q-responsive :ratio="1" class="q-mx-xl">
<qrcode
:value="receive.paymentReq"
:options="{width: 340}"
class="rounded-borders"
></qrcode>
</q-responsive>
</a>
</div>
<div class="row q-mt-lg">
<q-btn
outline
color="grey"
@click="copyText(receive.paymentReq)"
>Copy invoice</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
>Close</q-btn
>
</div>
</q-card>
<q-card v-else class="q-pa-lg q-pt-xl lnbits__dialog-card">
<div class="text-center q-mb-lg">
<div v-if="props.row.isPaid && props.row.isIn">
<q-icon
size="18px"
:name="'call_received'"
:color="'green'"
></q-icon>
Payment Received
</div>
<div v-else-if="props.row.isPaid && props.row.isOut">
<q-icon
size="18px"
:name="'call_made'"
:color="'pink'"
></q-icon>
Payment Sent
</div>
<div v-else>
<q-icon name="settings_ethernet" color="grey"></q-icon>
Outgoing payment pending
</div>
<q-tooltip>Payment Hash</q-tooltip>
<div class="text-wrap mono q-pa-md">
{{ props.row.payment_hash }}
</div>
</div>
</q-card>
</q-dialog>
</template> </template>
{% endraw %} {% endraw %}
</q-table> </q-table>

View File

@ -137,15 +137,12 @@
Invoice: function () { Invoice: function () {
var self = this var self = this
axios axios
.post( .post('/lnticket/api/v1/tickets/{{ form_id }}', {
'/lnticket/api/v1/tickets/{{ form_id }}',
{
form: '{{ form_id }}', form: '{{ form_id }}',
name: self.formDialog.data.name, name: self.formDialog.data.name,
email: self.formDialog.data.email, email: self.formDialog.data.email,
ltext: self.formDialog.data.text, ltext: self.formDialog.data.text
} })
)
.then(function (response) { .then(function (response) {
self.paymentReq = response.data.payment_request self.paymentReq = response.data.payment_request
self.paymentCheck = response.data.payment_hash self.paymentCheck = response.data.payment_hash

View File

@ -66,3 +66,12 @@ a.inherit {
direction: ltr; direction: ltr;
-moz-font-feature-settings: 'liga'; -moz-font-feature-settings: 'liga';
-moz-osx-font-smoothing: grayscale; } -moz-osx-font-smoothing: grayscale; }
.text-wrap {
word-wrap: break-word;
word-break: break-all;
}
.mono {
font-family: monospace;
}

View File

@ -6,7 +6,7 @@ var EventHub = new Vue()
var LNbits = { var LNbits = {
api: { api: {
request: function(method, url, apiKey, data) { request: function (method, url, apiKey, data) {
return axios({ return axios({
method: method, method: method,
url: url, url: url,
@ -16,20 +16,20 @@ var LNbits = {
data: data data: data
}) })
}, },
createInvoice: function(wallet, amount, memo) { createInvoice: function (wallet, amount, memo) {
return this.request('post', '/api/v1/payments', wallet.inkey, { return this.request('post', '/api/v1/payments', wallet.inkey, {
out: false, out: false,
amount: amount, amount: amount,
memo: memo memo: memo
}) })
}, },
payInvoice: function(wallet, bolt11) { payInvoice: function (wallet, bolt11) {
return this.request('post', '/api/v1/payments', wallet.adminkey, { return this.request('post', '/api/v1/payments', wallet.adminkey, {
out: true, out: true,
bolt11: bolt11 bolt11: bolt11
}) })
}, },
getPayments: function(wallet, checkPending) { getPayments: function (wallet, checkPending) {
var query_param = checkPending ? '?check_pending' : '' var query_param = checkPending ? '?check_pending' : ''
return this.request( return this.request(
'get', 'get',
@ -37,7 +37,7 @@ var LNbits = {
wallet.inkey wallet.inkey
) )
}, },
getPayment: function(wallet, paymentHash) { getPayment: function (wallet, paymentHash) {
return this.request( return this.request(
'get', 'get',
'/api/v1/payments/' + paymentHash, '/api/v1/payments/' + paymentHash,
@ -46,16 +46,16 @@ var LNbits = {
} }
}, },
href: { href: {
createWallet: function(walletName, userId) { createWallet: function (walletName, userId) {
window.location.href = window.location.href =
'/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName '/wallet?' + (userId ? 'usr=' + userId + '&' : '') + 'nme=' + walletName
}, },
deleteWallet: function(walletId, userId) { deleteWallet: function (walletId, userId) {
window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId window.location.href = '/deletewallet?usr=' + userId + '&wal=' + walletId
} }
}, },
map: { map: {
extension: function(data) { extension: function (data) {
var obj = _.object( var obj = _.object(
['code', 'isValid', 'name', 'shortDescription', 'icon'], ['code', 'isValid', 'name', 'shortDescription', 'icon'],
data data
@ -63,17 +63,17 @@ var LNbits = {
obj.url = ['/', obj.code, '/'].join('') obj.url = ['/', obj.code, '/'].join('')
return obj return obj
}, },
user: function(data) { user: function (data) {
var obj = _.object(['id', 'email', 'extensions', 'wallets'], data) var obj = _.object(['id', 'email', 'extensions', 'wallets'], data)
var mapWallet = this.wallet var mapWallet = this.wallet
obj.wallets = obj.wallets obj.wallets = obj.wallets
.map(function(obj) { .map(function (obj) {
return mapWallet(obj) return mapWallet(obj)
}) })
.sort(function(a, b) { .sort(function (a, b) {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}) })
obj.walletOptions = obj.wallets.map(function(obj) { obj.walletOptions = obj.wallets.map(function (obj) {
return { return {
label: [obj.name, ' - ', obj.id].join(''), label: [obj.name, ' - ', obj.id].join(''),
value: obj.id value: obj.id
@ -81,7 +81,7 @@ var LNbits = {
}) })
return obj return obj
}, },
wallet: function(data) { wallet: function (data) {
var obj = _.object( var obj = _.object(
['id', 'name', 'user', 'adminkey', 'inkey', 'balance'], ['id', 'name', 'user', 'adminkey', 'inkey', 'balance'],
data data
@ -92,7 +92,7 @@ var LNbits = {
obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('') obj.url = ['/wallet?usr=', obj.user, '&wal=', obj.id].join('')
return obj return obj
}, },
payment: function(data) { payment: function (data) {
var obj = _.object( var obj = _.object(
[ [
'checking_id', 'checking_id',
@ -124,7 +124,7 @@ var LNbits = {
} }
}, },
utils: { utils: {
confirmDialog: function(msg) { confirmDialog: function (msg) {
return Quasar.plugins.Dialog.create({ return Quasar.plugins.Dialog.create({
message: msg, message: msg,
ok: { ok: {
@ -137,16 +137,16 @@ var LNbits = {
} }
}) })
}, },
formatCurrency: function(value, currency) { formatCurrency: function (value, currency) {
return new Intl.NumberFormat(LOCALE, { return new Intl.NumberFormat(LOCALE, {
style: 'currency', style: 'currency',
currency: currency currency: currency
}).format(value) }).format(value)
}, },
formatSat: function(value) { formatSat: function (value) {
return new Intl.NumberFormat(LOCALE).format(value) return new Intl.NumberFormat(LOCALE).format(value)
}, },
notifyApiError: function(error) { notifyApiError: function (error) {
var types = { var types = {
400: 'warning', 400: 'warning',
401: 'warning', 401: 'warning',
@ -163,12 +163,12 @@ var LNbits = {
icon: null icon: null
}) })
}, },
search: function(data, q, field, separator) { search: function (data, q, field, separator) {
try { try {
var queries = q.toLowerCase().split(separator || ' ') var queries = q.toLowerCase().split(separator || ' ')
return data.filter(function(obj) { return data.filter(function (obj) {
var matches = 0 var matches = 0
_.each(queries, function(q) { _.each(queries, function (q) {
if (obj[field].indexOf(q) !== -1) matches++ if (obj[field].indexOf(q) !== -1) matches++
}) })
return matches === queries.length return matches === queries.length
@ -177,8 +177,8 @@ var LNbits = {
return data return data
} }
}, },
exportCSV: function(columns, data) { exportCSV: function (columns, data) {
var wrapCsvValue = function(val, formatFn) { var wrapCsvValue = function (val, formatFn) {
var formatted = formatFn !== void 0 ? formatFn(val) : val var formatted = formatFn !== void 0 ? formatFn(val) : val
formatted = formatted =
@ -190,14 +190,14 @@ var LNbits = {
} }
var content = [ var content = [
columns.map(function(col) { columns.map(function (col) {
return wrapCsvValue(col.label) return wrapCsvValue(col.label)
}) })
] ]
.concat( .concat(
data.map(function(row) { data.map(function (row) {
return columns return columns
.map(function(col) { .map(function (col) {
return wrapCsvValue( return wrapCsvValue(
typeof col.field === 'function' typeof col.field === 'function'
? col.field(row) ? col.field(row)
@ -228,7 +228,7 @@ var LNbits = {
} }
var windowMixin = { var windowMixin = {
data: function() { data: function () {
return { return {
g: { g: {
visibleDrawer: false, visibleDrawer: false,
@ -240,13 +240,13 @@ var windowMixin = {
} }
}, },
methods: { methods: {
toggleDarkMode: function() { toggleDarkMode: function () {
this.$q.dark.toggle() this.$q.dark.toggle()
this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive) this.$q.localStorage.set('lnbits.darkMode', this.$q.dark.isActive)
}, },
copyText: function(text, message, position) { copyText: function (text, message, position) {
var notify = this.$q.notify var notify = this.$q.notify
Quasar.utils.copyToClipboard(text).then(function() { Quasar.utils.copyToClipboard(text).then(function () {
notify({ notify({
message: message || 'Copied to clipboard!', message: message || 'Copied to clipboard!',
position: position || 'bottom' position: position || 'bottom'
@ -254,7 +254,7 @@ var windowMixin = {
}) })
} }
}, },
created: function() { created: function () {
this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode')) this.$q.dark.set(this.$q.localStorage.getItem('lnbits.darkMode'))
if (window.user) { if (window.user) {
this.g.user = Object.freeze(LNbits.map.user(window.user)) this.g.user = Object.freeze(LNbits.map.user(window.user))
@ -266,10 +266,10 @@ var windowMixin = {
var user = this.g.user var user = this.g.user
this.g.extensions = Object.freeze( this.g.extensions = Object.freeze(
window.extensions window.extensions
.map(function(data) { .map(function (data) {
return LNbits.map.extension(data) return LNbits.map.extension(data)
}) })
.map(function(obj) { .map(function (obj) {
if (user) { if (user) {
obj.isEnabled = user.extensions.indexOf(obj.code) !== -1 obj.isEnabled = user.extensions.indexOf(obj.code) !== -1
} else { } else {
@ -277,7 +277,7 @@ var windowMixin = {
} }
return obj return obj
}) })
.sort(function(a, b) { .sort(function (a, b) {
return a.name > b.name return a.name > b.name
}) })
) )