btcpayserver/BTCPayServer/Views/Shared/NFC/CheckoutEnd.cshtml
Dennis Reimann 1055e61bb4
NFC improvements
Two changes which fix #4807:

- Once permissions are granted we start scanning immediately, no need to ask for permissions or have the user click the button again
- We don't abort the scan, which gets rid of the cases in which the OS took over after the scan, because the user left the card on the device

Also adds feedback for the NFC states scanning and submitting.
2023-03-27 18:28:53 +02:00

184 lines
7.2 KiB
Text

@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
<template id="lnurl-withdraw-template">
<template v-if="display">
<div class="mt-4">
<p id="CheatSuccessMessage" class="alert alert-success text-break" v-if="successMessage" v-text="successMessage"></p>
<p id="CheatErrorMessage" class="alert alert-danger text-break" v-if="errorMessage" v-text="errorMessage"></p>
<template v-if="isV2">
<button class="btn btn-secondary rounded-pill w-100" type="button"
:disabled="scanning || submitting" v-on:click="handleClick" :id="btnId"
:class="{ 'text-secondary': !supported }">{{btnText}}</button>
</template>
<bp-loading-button v-else>
<button class="action-button" style="margin: 0 45px;width:calc(100% - 90px) !important"
:disabled="scanning || submitting" v-on:click="startScan" :id="btnId"
:class="{ 'loading': scanning || submitting, 'action-button': supported, 'btn btn-text w-100': !supported }">
<span class="button-text">{{btnText}}</span>
<div class="loader-wrapper">
@await Html.PartialAsync("~/Views/UIInvoice/Checkout-Spinner.cshtml")
</div>
</button>
</bp-loading-button>
</div>
</template>
</template>
<script type="text/javascript">
// https://developer.chrome.com/articles/nfc/
Vue.component("lnurl-withdraw-checkout", {
template: "#lnurl-withdraw-template",
props: {
model: Object,
isV2: Boolean
},
computed: {
display () {
const {
onChainWithLnInvoiceFallback: isUnified,
paymentMethodId: activePaymentMethodId,
availableCryptos: availablePaymentMethods,
invoiceBitcoinUrl: paymentUrl
} = this.model
const lnurlwAvailable =
// Either we have LN or LNURL available directly
!!availablePaymentMethods.find(pm => ['BTC_LNURLPAY', 'BTC_LightningLike'].includes(pm.paymentMethodId)) ||
// Or the BIP21 payment URL flags Lightning support
!!paymentUrl.match(/lightning=ln/i)
return activePaymentMethodId === 'BTC_LNURLPAY' || (
// Unified QR/BIP21 case
(activePaymentMethodId === 'BTC' && isUnified && lnurlwAvailable) ||
// Lightning with LNURL available
(activePaymentMethodId === 'BTC_LightningLike' && lnurlwAvailable))
},
btnId () {
return this.supported ? 'PayByNFC' : 'PayByLNURL'
},
btnText () {
if (this.supported) {
if (this.submitting) {
return this.isV2 ? this.$t('submitting_nfc') : 'Submitting NFC …'
} else if (this.scanning) {
return this.isV2 ? this.$t('scanning_nfc') : 'Scanning NFC …'
} else {
return this.isV2 ? this.$t('pay_by_nfc') : 'Pay by NFC'
}
} else {
return this.isV2 ? this.$t('pay_by_lnurl') : 'Pay by LNURL-Withdraw'
}
}
},
data () {
return {
url: @Safe.Json(Context.Request.GetAbsoluteUri(Url.Action("SubmitLNURLWithdrawForInvoice", "NFC"))),
supported: 'NDEFReader' in window && window.self === window.top,
scanning: false,
submitting: false,
permissionGranted: false,
readerAbortController: null,
amount: 0,
successMessage: null,
errorMessage: null
}
},
async mounted () {
try {
this.permissionGranted = navigator.permissions &&
(await navigator.permissions.query({ name: 'nfc' })).state === 'granted'
} catch (e) {}
if (this.permissionGranted) {
this.startScan()
}
},
methods: {
async handleClick () {
if (this.supported) {
this.startScan()
} else {
if (this.model.isUnsetTopUp) {
this.handleUnsetTopUp()
if (!this.amount) {
return;
}
}
const lnurl = prompt("Enter LNURL-Withdraw")
if (lnurl) {
await this.sendData(lnurl)
}
}
},
handleUnsetTopUp () {
const amountStr = prompt("How many sats do you want to pay?")
if (amountStr) {
try {
this.amount = parseInt(amountStr)
} catch {
alert("Please provide a valid number amount in sats");
}
}
return false
},
async startScan () {
if (this.scanning || this.submitting) {
return;
}
if (this.model.isUnsetTopUp) {
this.handleUnsetTopUp()
if (!this.amount) {
return;
}
}
this.submitting = false;
this.scanning = true;
try {
const ndef = new NDEFReader()
this.readerAbortController = new AbortController()
this.readerAbortController.signal.onabort = () => {
this.scanning = false;
};
await ndef.scan({ signal: this.readerAbortController.signal })
ndef.onreadingerror = () => {
this.errorMessage = "Could not read NFC tag";
this.readerAbortController.abort()
}
ndef.onreading = async ({ message, serialNumber }) => {
const record = message.records[0]
const textDecoder = new TextDecoder('utf-8')
const lnurl = textDecoder.decode(record.data)
await this.sendData(lnurl)
}
// we came here, so the user must have allowed NFC access
this.permissionGranted = true;
} catch (error) {
this.errorMessage = `NFC scan failed: ${error}`;
}
},
async sendData (lnurl) {
this.submitting = true;
this.successMessage = null;
this.errorMessage = null;
// Post LNURL-Withdraw data to server
const body = JSON.stringify({ lnurl, invoiceId: this.model.invoiceId, amount: this.amount })
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
const response = await fetch(this.url, opts)
// Handle response
try {
const result = await response.text()
if (response.ok) {
this.successMessage = result;
} else {
this.errorMessage = result;
}
} catch (error) {
this.errorMessage = error;
}
this.submitting = false;
}
}
});
</script>