Files
lnbits/lnbits/extensions/satspay/templates/satspay/index.html
T

798 lines
24 KiB
HTML

{% extends "base.html" %} {% from "macros.jinja" import window_vars with context
%} {% block page %}
<div class="row q-col-gutter-md">
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-card-section>
{% raw %}
<q-btn unelevated color="primary" @click="formDialogCharge.show = true"
>New charge
</q-btn>
<q-btn
unelevated
color="primary"
@click="getSettings();formDialogSettings.show = true"
>SatsPay settings
</q-btn>
</q-card-section>
</q-card>
<q-card>
<q-card-section>
<div class="row items-center no-wrap q-mb-md">
<div class="col">
<h5 class="text-subtitle1 q-my-none">Charges</h5>
</div>
<div class="col q-pr-lg">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
class="float-right"
>
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
<div class="col-auto">
<q-btn outline color="grey" label="...">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item clickable>
<q-item-section @click="exportchargeCSV"
>Export to CSV</q-item-section
>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div>
</div>
<q-table
flat
dense
:data="chargeLinks"
row-key="id"
:columns="chargesTable.columns"
:pagination.sync="chargesTable.pagination"
:filter="filter"
>
<template v-slot:header="props">
<q-tr :props="props">
<q-th auto-width></q-th>
<q-th auto-width>Status </q-th>
<q-th auto-width>Title</q-th>
<q-th auto-width>Time Left (hh:mm)</q-th>
<q-th auto-width>Time To Pay (hh:mm)</q-th>
<q-th auto-width>Amount To Pay</q-th>
<q-th auto-width>Balance</q-th>
<q-th auto-width>Pending Balance</q-th>
<q-th auto-width>Onchain Address</q-th>
<q-th auto-width></q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :props="props">
<q-td auto-width>
<q-btn
size="sm"
color="accent"
round
dense
@click="props.row.expanded= !props.row.expanded"
:icon="props.row.expanded? 'remove' : 'add'"
/>
</q-td>
<q-td auto-width>
<q-badge
v-if="props.row.time_elapsed && props.row.balance < props.row.amount"
color="red"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>expired</a
>
</q-badge>
<q-badge
v-else-if="props.row.balance >= props.row.amount"
color="green"
>
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>paid</a
>
</q-badge>
<q-badge v-else color="blue"
><a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>waiting</a
>
</q-badge>
</q-td>
<q-td key="description" :props="props" :class="">
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.description}}</a
>
</q-td>
<q-td key="timeLeft" :props="props" :class="">
<div>{{props.row.timeLeft}}</div>
<q-linear-progress
v-if="props.row.timeLeft"
:value="props.row.progress"
color="secondary"
>
</q-linear-progress>
</q-td>
<q-td key="time to pay" :props="props" :class="">
<div>{{props.row.time}}</div>
</q-td>
<q-td key="amount" :props="props" :class="">
<div>{{props.row.amount}}</div>
</q-td>
<q-td key="balance" :props="props" :class="">
<div>{{props.row.balance}}</div>
</q-td>
<q-td key="pendingBalance" :props="props" :class="">
<div>
{{props.row.pendingBalance ? props.row.pendingBalance : ''}}
</div>
</q-td>
<q-td key="onchain address" :props="props" :class="">
<a
:href="props.row.displayUrl"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.onchainaddress}}</a
>
</q-td>
</q-tr>
<q-tr v-show="props.row.expanded" :props="props">
<q-td colspan="100%">
<div
v-if="props.row.onchainwallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Onchain Wallet:</div>
<div class="col-4 q-pr-lg">
{{getOnchainWalletName(props.row.onchainwallet)}}
</div>
</div>
<div
v-if="props.row.lnbitswallet"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">LNbits Wallet:</div>
<div class="col-4 q-pr-lg">
{{getLNbitsWalletName(props.row.lnbitswallet)}}
</div>
</div>
<div
v-if="props.row.completelink || props.row.completelinktext"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Completed Link:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.completelink"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.completelinktext ||
props.row.completelink}}</a
>
</div>
</div>
<div
v-if="props.row.webhook"
class="row items-center q-mt-md q-mb-lg"
>
<div class="col-2 q-pr-lg">Webhook:</div>
<div class="col-4 q-pr-lg">
<a
:href="props.row.webhook"
target="_blank"
style="color: unset; text-decoration: none"
>{{props.row.webhook}}</a
>
</div>
<div class="col-4 q-pr-lg">
<q-badge v-if="props.row.webhook_message" color="blue">
{{props.row.webhook_message }}
</q-badge>
</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg">ID:</div>
<div class="col-4 q-pr-lg">{{props.row.id}}</div>
</div>
<div class="row items-center q-mt-md q-mb-lg">
<div class="col-2 q-pr-lg"></div>
<div class="col-6 q-pr-lg">
<q-btn
unelevated
color="gray"
outline
type="a"
:href="props.row.displayUrl"
target="_blank"
class="float-left q-mr-lg"
>Details</q-btn
>
<q-btn
unelevated
color="gray"
outline
type="a"
@click="refreshBalance(props.row)"
target="_blank"
class="float-left"
>Refresh Balance</q-btn
>
</div>
<div class="col-4 q-pr-lg">
<q-btn
unelevated
color="pink"
icon="cancel"
@click="deleteChargeLink(props.row.id)"
>Delete</q-btn
>
</div>
<div class="col-4"></div>
<div class="col-2 q-pr-lg"></div>
</div>
</q-td>
</q-tr>
</template>
{% endraw %}
</q-table>
</q-card-section>
</q-card>
</div>
<div class="col-12 col-md-5 q-gutter-y-md">
<q-card>
<q-card-section>
<h6 class="text-subtitle1 q-my-none">
{{SITE_TITLE}} satspay Extension
</h6>
</q-card-section>
<q-card-section class="q-pa-none">
<q-separator></q-separator>
<q-list> {% include "satspay/_api_docs.html" %} </q-list>
</q-card-section>
</q-card>
</div>
<q-dialog v-model="formDialogCharge.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataCharge" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogCharge.data.description"
type="text"
label="*Description"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.amount"
type="number"
label="*Amount (sats)"
></q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.time"
type="number"
max="1440"
label="*Mins valid for (max 1440)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.webhook"
type="url"
label="Webhook (URL to send transaction data to once paid)"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelink"
type="url"
label="Completed button URL"
>
</q-input>
<q-input
filled
dense
v-model.trim="formDialogCharge.data.completelinktext"
type="text"
label="Completed button text (ie 'Back to merchant')"
>
</q-input>
<div class="row">
<div class="col">
<div v-if="walletLinks.length > 0">
<q-checkbox
v-model="formDialogCharge.data.onchain"
label="Onchain"
/>
</div>
<div v-else>
<q-checkbox :value="false" label="Onchain" disabled>
<q-tooltip>
Onchain Wallet (watch-only) extension MUST be activated and
have a wallet
</q-tooltip>
</q-checkbox>
</div>
</div>
<div class="col">
<div>
<q-checkbox
v-model="formDialogCharge.data.lnbits"
label="LNbits wallet"
/>
</div>
</div>
</div>
<div v-if="formDialogCharge.data.onchain">
<q-select
filled
dense
emit-value
v-model="onchainwallet"
:options="walletLinks"
label="Onchain Wallet"
/>
</div>
<q-select
v-if="formDialogCharge.data.lnbits"
filled
dense
emit-value
v-model="formDialogCharge.data.lnbitswallet"
:options="g.user.walletOptions"
label="Wallet *"
>
</q-select>
<div class="row q-mt-lg">
<q-btn
unelevated
color="primary"
:disable="
formDialogCharge.data.time == null ||
formDialogCharge.data.amount == null"
type="submit"
>Create Charge</q-btn
>
<q-btn @click="cancelCharge" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
<q-dialog v-model="formDialogSettings.show" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="sendFormDataSettings" class="q-gutter-md">
<q-input
filled
dense
v-model.trim="formDialogSettings.data.custom_css"
type="textarea"
label="Custom CSS"
>
<q-tooltip
>Custom CSS to apply styles to your SatsPay invoice</q-tooltip
>
</q-input>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Save Settings</q-btn>
<q-btn @click="cancelSettings" flat color="grey" class="q-ml-auto"
>Cancel</q-btn
>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endblock %} {% block scripts %} {{ window_vars(user) }}
<!-- lnbits/static/vendor
<script src="/vendor/vue-qrcode@1.0.2/vue-qrcode.min.js"></script> -->
<style></style>
<!-- todo: use config mempool -->
<script src="https://mempool.space/mempool.js"></script>
<script src="{{ url_for('satspay_static', path='js/utils.js') }}"></script>
<script>
Vue.component(VueQrcode.name, VueQrcode)
new Vue({
el: '#vue',
mixins: [windowMixin],
data: function () {
return {
settings: {},
filter: '',
balance: null,
walletLinks: [],
chargeLinks: [],
onchainwallet: null,
rescanning: false,
mempool: {
endpoint: '',
network: 'Mainnet'
},
chargesTable: {
columns: [
{
name: 'theId',
align: 'left',
label: 'ID',
field: 'id'
},
{
name: 'description',
align: 'left',
label: 'Title',
field: 'description'
},
{
name: 'timeLeft',
align: 'left',
label: 'Time left',
field: 'timeLeft'
},
{
name: 'time to pay',
align: 'left',
label: 'Time to Pay',
field: 'time'
},
{
name: 'amount',
align: 'left',
label: 'Amount to pay',
field: 'amount'
},
{
name: 'balance',
align: 'left',
label: 'Balance',
field: 'balance'
},
{
name: 'pendingBalance',
align: 'left',
label: 'Pending Balance',
field: 'pendingBalance'
},
{
name: 'onchain address',
align: 'left',
label: 'Onchain Address',
field: 'onchainaddress'
},
{
name: 'LNbits wallet',
align: 'left',
label: 'LNbits wallet',
field: 'lnbitswallet'
},
{
name: 'Webhook link',
align: 'left',
label: 'Webhook link',
field: 'webhook'
},
{
name: 'Paid link',
align: 'left',
label: 'Paid link',
field: 'completelink'
}
],
pagination: {
rowsPerPage: 10
}
},
formDialogCharge: {
show: false,
data: {
onchain: false,
onchainwallet: '',
lnbits: false,
description: '',
time: null,
amount: null
}
},
formDialogSettings: {
show: false,
data: {
custom_css: ''
}
}
}
},
methods: {
cancelSettings: function (data) {
this.formDialogCharge.data.custom_css = ''
this.formDialogSettings.show = false
},
cancelCharge: function (data) {
this.formDialogCharge.data.description = ''
this.formDialogCharge.data.onchain = false
this.formDialogCharge.data.onchainwallet = ''
this.formDialogCharge.data.lnbitswallet = ''
this.formDialogCharge.data.time = null
this.formDialogCharge.data.amount = null
this.formDialogCharge.data.webhook = ''
this.formDialogCharge.data.completelink = ''
this.formDialogCharge.show = false
},
getWalletLinks: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
`/watchonly/api/v1/wallet?network=${this.mempool.network}`,
this.g.user.wallets[0].inkey
)
this.walletLinks = data.map(w => ({
id: w.id,
label: w.title + ' - ' + w.id
}))
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getWalletConfig: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/watchonly/api/v1/config',
this.g.user.wallets[0].inkey
)
this.mempool.endpoint = data.mempool_endpoint
this.mempool.network = data.network || 'Mainnet'
const url = new URL(this.mempool.endpoint)
this.mempool.hostname = url.hostname
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getOnchainWalletName: function (walletId) {
const wallet = this.walletLinks.find(w => w.id === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getLNbitsWalletName: function (walletId) {
const wallet = this.g.user.walletOptions.find(w => w.value === walletId)
if (!wallet) return 'unknown'
return wallet.label
},
getCharges: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charges',
this.g.user.wallets[0].inkey
)
this.chargeLinks = data.map(c =>
mapCharge(
c,
this.chargeLinks.find(old => old.id === c.id)
)
)
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
getSettings: async function () {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/settings',
this.g.user.wallets[0].inkey
)
if(data) {
this.formDialogSettings.data.custom_css = data.custom_css
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
sendFormDataSettings: function () {
const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogSettings.data
data.custom_css = data.custom_css
this.saveSettings(wallet, data)
},
sendFormDataCharge: function () {
const wallet = this.g.user.wallets[0].inkey
const data = this.formDialogCharge.data
data.amount = parseInt(data.amount)
data.time = parseInt(data.time)
data.lnbitswallet = data.lnbits ? data.lnbitswallet : null
data.onchainwallet = data.onchain ? this.onchainwallet?.id : null
this.createCharge(wallet, data)
},
refreshActiveChargesBalance: async function () {
try {
const activeLinkIds = this.chargeLinks
.filter(c => !c.paid && !c.time_elapsed && !c.hasStaleBalance)
.map(c => c.id)
.join(',')
if (activeLinkIds) {
await LNbits.api.request(
'GET',
'/satspay/api/v1/charges/balance/' + activeLinkIds,
'filla'
)
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
await this.getCharges()
}
},
refreshBalance: async function (charge) {
try {
const {data} = await LNbits.api.request(
'GET',
'/satspay/api/v1/charge/balance/' + charge.id,
'filla'
)
charge.balance = data.balance
} catch (error) {}
},
rescanOnchainAddresses: async function () {
if (this.rescanning) return
this.rescanning = true
const {
bitcoin: {addresses: addressesAPI}
} = mempoolJS({hostname: this.mempool.hostname})
try {
const onchainActiveCharges = this.chargeLinks.filter(
c => c.onchainaddress && !c.paid && !c.time_elapsed
)
for (const charge of onchainActiveCharges) {
const fn = async () =>
addressesAPI.getAddressTxsUtxo({
address: charge.onchainaddress
})
const utxos = await retryWithDelay(fn)
const newBalance = utxos.reduce((t, u) => t + u.value, 0)
charge.pendingBalance = utxos
.filter(u => !u.status.confirmed)
.reduce((t, u) => t + u.value, 0)
charge.hasStaleBalance = charge.balance === newBalance
}
} catch (error) {
console.error(error)
} finally {
this.rescanning = false
}
},
saveSettings: async function (wallet, data) {
try {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/settings',
wallet,
data
)
this.formDialogSettings.show = false
this.formDialogSettings.data = {
custom_css: ''
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
createCharge: async function (wallet, data) {
try {
const resp = await LNbits.api.request(
'POST',
'/satspay/api/v1/charge',
wallet,
data
)
this.chargeLinks.unshift(mapCharge(resp.data))
this.formDialogCharge.show = false
this.formDialogCharge.data = {
onchain: false,
lnbits: false,
description: '',
time: null,
amount: null
}
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
deleteChargeLink: function (chargeId) {
const link = _.findWhere(this.chargeLinks, {id: chargeId})
LNbits.utils
.confirmDialog('Are you sure you want to delete this pay link?')
.onOk(async () => {
try {
const response = await LNbits.api.request(
'DELETE',
'/satspay/api/v1/charge/' + chargeId,
this.g.user.wallets[0].adminkey
)
this.chargeLinks = _.reject(this.chargeLinks, function (obj) {
return obj.id === chargeId
})
} catch (error) {
LNbits.utils.notifyApiError(error)
}
})
},
exportchargeCSV: function () {
LNbits.utils.exportCSV(
this.chargesTable.columns,
this.chargeLinks,
'charges'
)
}
},
created: async function () {
await this.getSettings()
await this.getCharges()
await this.getWalletConfig()
await this.getWalletLinks()
setInterval(() => this.refreshActiveChargesBalance(), 10 * 2000)
await this.rescanOnchainAddresses()
setInterval(() => this.rescanOnchainAddresses(), 10 * 1000)
}
})
</script>
{% endblock %}