btcpayserver/BTCPayServer/Views/UIInvoice/Checkout.cshtml
d11n b9b3860e6b
NFC improvements (#5509)
* Move NFC code on Vue app level

* Update NFC result handling and display

* Save a bit of space

* Scroll NFC error into view
2023-11-30 18:17:23 +09:00

499 lines
22 KiB
Plaintext

@inject BTCPayServer.Services.LanguageService langService
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model PaymentModel
@{
Layout = null;
Csp.UnsafeEval();
}
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<META NAME="robots" CONTENT="noindex,nofollow">
<title>@Model.HtmlTitle</title>
<link href="~/vendor/font-awesome/css/font-awesome.css" asp-append-version="true" rel="stylesheet" />
<link href="~/vendor/vex/css/vex.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/fonts/Roboto.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/fonts/RobotoMono.css" asp-append-version="true" rel="stylesheet" />
<link href="~/checkout/css/default.css" asp-append-version="true" rel="stylesheet" />
<link href="~/checkout/css/vex-extrastyles.css" asp-append-version="true" rel="stylesheet" />
<link href="~/checkout/css/vex-theme-btcpay.css" asp-append-version="true" rel="stylesheet" />
<link href="~/vendor/jquery-prettydropdowns/prettydropdowns.css" asp-append-version="true" rel="stylesheet" />
<script>
var initialSrvModel = @Safe.Json(Model);
</script>
<script src="~/vendor/jquery/jquery.min.js" asp-append-version="true"></script>
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
<script src="~/vendor/clipboard.js/clipboard.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18next.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18nextHttpBackend.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script>
<script src="~/vendor/jquery-prettydropdowns/jquery.prettydropdowns.js" asp-append-version="true"></script>
<script src="~/vendor/vex/js/vex.combined.min.js" asp-append-version="true"></script>
<script src="~/main/utils.js" asp-append-version="true"></script>
<script src="~/checkout/js/checkout.js" asp-append-version="true"></script>
<script src="~/checkout/js/querystring.js" asp-append-version="true"></script>
<script>vex.defaultOptions.className = 'vex-theme-btcpay'</script>
@if (!string.IsNullOrEmpty(Model.CustomCSSLink))
{
<link href="@Model.CustomCSSLink" rel="stylesheet" asp-append-version="true"/>
}
@if (Model.IsModal)
{
<style type="text/css">
body {
background: rgba(25, 25, 25, 0.9);
}
.close-icon {
display: flex;
}
</style>
}
</head>
<body>
<noscript>
<div style="padding:2em;text-align:center;">
<h2>Javascript is currently disabled in your browser.</h2>
<h5>Please enable Javascript and refresh this page for the best experience.</h5>
<p>Alternatively, click below to continue to our HTML-only invoice.</p>
<a asp-action="CheckoutNoScript" asp-route-invoiceId="@Model.InvoiceId" style="text-decoration:underline;color:blue">
Continue to javascript-disabled invoice &gt;
</a>
</div>
</noscript>
<!--[if lte IE 8]>
<div style="padding:2em;text-align:center;">
<form asp-action="CheckoutNoScript" method="GET">
<button style="text-decoration: underline; color: blue">Continue to legacy browser compatible invoice page
</button>
</form>
</div>
<![endif]-->
<invoice>
<div class="no-bounce" id="checkoutCtrl" v-cloak>
<div class="modal page" style="min-height:100vh">
<div class="modal-dialog open opened" role="document" v-bind:class="{ 'expired': invoiceUnpayable, 'paid': invoicePaid, 'enter-purchaser-email': showEmailForm}">
<div class="modal-content long">
<div class="content">
<div class="invoice">
<partial name="Checkout-Body" />
</div>
</div>
</div>
<div style="margin-top: 10px; text-align: center;">
@* Not working because of nsSeparator: false, keySeparator: false,
{{$t("nested.lang")}} >>
*@
<select asp-for="DefaultLang"
class="cmblang reverse invisible"
asp-items="@langService.GetLanguageSelectListItems()"></select>
<script>
$(function () {
// REVIEW: don't use initDropdown method but rather directly initialize select whenever you are using it
$("#DefaultLang").val(startingLanguage);
var languageSelectorPrettyDropdown = initDropdown("#DefaultLang");
languageSelectorPrettyDropdown.change(function () {
changeLanguage(languageSelectorPrettyDropdown.val());
});
languageSelectorPrettyDropdown.keypress(function (event) {
if (event.keyCode == 13) {
languageSelectorPrettyDropdown.click();
}
});
});
function initDropdown(selector) {
return $(selector).prettyDropdown({
classic: true,
height: 32,
reverse: true,
hoverIntent: -1
});
}
</script>
</div>
<div class="powered__by__btcpayserver">
Powered by <a target="_blank" href="https://github.com/btcpayserver/btcpayserver" rel="noreferrer noopener">BTCPay Server</a>
</div>
@if (env.CheatMode)
{
<partial name="Checkout-Testing" />
}
</div>
</div>
</div>
</invoice>
<script>
var availableLanguages = @Safe.Json(langService.GetLanguages().Select((language) => language.Code));
var defaultLang = @Safe.Json(Model.DefaultLang);
var fallbackLanguage = "en";
startingLanguage = computeStartingLanguage();
i18next
.use(window.i18nextHttpBackend)
.init({
backend: {
loadPath: @Safe.Json($"{Model.RootPath}misc/translations/checkout-v1/{{{{lng}}}}?v={env.Version}")
},
lng: startingLanguage,
fallbackLng: fallbackLanguage,
nsSeparator: false,
keySeparator: false
});
function computeStartingLanguage() {
if (urlParams.lang && isLanguageAvailable(urlParams.lang)) {
return urlParams.lang;
}
else if (isLanguageAvailable(defaultLang)) {
return defaultLang;
} else {
return fallbackLanguage;
}
}
function changeLanguage(lang) {
if (isLanguageAvailable(lang)) {
i18next.changeLanguage(lang);
}
}
function isLanguageAvailable(languageCode) {
return availableLanguages.indexOf(languageCode) >= 0;
}
var i18n = new VueI18next(i18next);
Vue.config.ignoredElements = [
'line-items',
'low-fee-timeline',
// Ignoring custom HTML5 elements, eg: bp-spinner
/^bp-/
];
var eventBus = new Vue();
var checkoutCtrl = new Vue({
i18n: i18n,
el: '#checkoutCtrl',
data: {
srvModel: initialSrvModel,
end: new Date(),
expirationPercentage: 0,
timerText: "@Model.TimeLeft",
emailAddressInput: "",
emailAddressInputDirty: false,
emailAddressInputInvalid: false,
emailAddressFormSubmitting: false,
lineItemsExpanded: false,
changingCurrencies: false,
loading: true,
isModal: initialSrvModel.isModal,
nfc: {
supported: 'NDEFReader' in window,
scanning: false,
submitting: false,
errorMessage: null,
permissionGranted: false,
readerAbortController: null
}
},
computed: {
expiringSoon: function(){
return this.expirationPercentage >= 75 && !this.invoiceUnpayable && !this.invoicePaid;
},
showPaymentUI: function(){
return !this.showEmailForm && !this.invoiceUnpayable && !this.invoicePaid;
},
showEmailForm: function(){
return this.srvModel.requiresRefundEmail && (!this.srvModel.customerEmail || !this.validateEmail(this.srvModel.customerEmail)) && !this.invoiceUnpayable;
},
showRecommendedFee: function(){
return this.srvModel.showRecommendedFee && this.srvModel.feeRate != 0;
},
invoiceUnpayable: function(){
return ["expired", "invalid"].indexOf(this.srvModel.status) >= 0;
},
invoicePaid: function(){
return ["complete", "confirmed", "paid"].indexOf(this.srvModel.status) >= 0;
},
storeLink () {
return this.srvModel.merchantRefLink && this.srvModel.merchantRefLink !== this.srvModel.receiptLink
? this.srvModel.merchantRefLink
: null;
}
},
mounted: async function(){
this.startProgressTimer();
this.listenIn();
this.onDataCallback(this.srvModel);
if (this.srvModel.status === "new" && this.srvModel.txCount > 1) {
this.onlyExpandLineItems();
}
window.parent.postMessage("loaded", "*");
jQuery("invoice").fadeOut(0).fadeIn(300);
window.closePaymentMethodDialog = this.closePaymentMethodDialog.bind(this);
this.loading = false;
if (this.nfc.supported) {
this.setupNFC();
}
},
beforeDestroy () {
if (this.nfc.readerAbortController) {
this.nfc.readerAbortController.abort()
}
},
methods: {
onlyExpandLineItems: function() {
if (!this.lineItemsExpanded) {
this.toggleLineItems();
}},
toggleLineItems: function() {
this.lineItemsExpanded ? $("line-items").slideUp() : $("line-items").slideDown();
this.lineItemsExpanded = !this.lineItemsExpanded;
},
openPaymentMethodDialog: function() {
var content = $("#vexPopupDialog").html();
vex.open({
unsafeContent: content
});
},
closePaymentMethodDialog: function(currencyId) {
vex.closeAll();
this.changeCurrency(currencyId);
},
changeCurrency: function (currency) {
if (currency !== null && this.srvModel.paymentMethodId !== currency) {
this.changingCurrencies = true;
this.srvModel.paymentMethodId = currency;
this.fetchData();
this.closePaymentMethodDialog(null);
}
},
close: function(){
$("invoice").fadeOut(300, function () {
window.parent.postMessage("close", "*");
});
},
validateEmail: function (email) {
var re = /^(([^<>()\[\]\\.,;:\s@@"]+(\.[^<>()\[\]\\.,;:\s@@"]+)*)|(".+"))@@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return re.test(email);
},
startProgressTimer: function(){
var timeLeftS = this.endDate? (this.endDate.getTime() - new Date().getTime())/1000 : this.srvModel.expirationSeconds;
this.expirationPercentage = 100 - ((timeLeftS / this.srvModel.maxTimeSeconds) * 100);
this.timerText = this.updateTimerText(timeLeftS);
if( this.expirationPercentage < 100 && (this.srvModel.status === "paidPartial" || this.srvModel.status === "new")){
setTimeout(this.startProgressTimer, 500);
}
},
updateTimerText: function (timer) {
if (timer >= 0) {
var minutes = parseInt(timer / 60, 10);
minutes = minutes < 10 ? "0" + minutes : minutes;
var seconds = parseInt(timer % 60, 10);
seconds = seconds < 10 ? "0" + seconds : seconds;
return minutes + ":" + seconds;
} else {
return "00:00";
}
},
listenIn: function(){
var self = this;
var socket = null;
var supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
if (supportsWebSockets) {
var loc = window.location, ws_uri;
if (loc.protocol === "https:") {
ws_uri = "wss:";
} else {
ws_uri = "ws:";
}
ws_uri += "//" + loc.host;
ws_uri += loc.pathname + "/status/ws?invoiceId=" + this.srvModel.invoiceId;
try {
socket = new WebSocket(ws_uri);
socket.onmessage = function (e) {
if (e.data === "ping")
return;
self.fetchData();
};
socket.onerror = function (e) {
console.error("Error while connecting to websocket for invoice notifications (callback)");
};
}
catch (e) {
console.error("Error while connecting to websocket for invoice notifications");
}
}
var self = this;
function watcher(){
setTimeout(function(){
if (socket === null || socket.readyState !== 1) {
self.fetchData();
}
watcher();
}, 2000);
}
watcher();
},
fetchData: function(){
var self = this;
$.ajax({
url: window.location.pathname + "/status?invoiceId=" + this.srvModel.invoiceId + "&paymentMethodId=" + this.srvModel.paymentMethodId,
type: "GET",
cache: false
})
.done(function (data) {
self.onDataCallback.bind(self)(data);
})
},
onDataCallback : function(jsonData){
if (this.srvModel.status !== jsonData.status) {
window.parent.postMessage({ "invoiceId": this.srvModel.invoiceId, "status": jsonData.status }, "*");
}
if (jsonData.paymentMethodId === this.srvModel.paymentMethodId) {
this.changingCurrencies = false;
}
// expand line items to show details on amount due for multi-transaction payment
if (this.srvModel.txCount === 1 && jsonData.txCount > 1) {
this.onlyExpandLineItems();
}
var newEnd = new Date();
newEnd.setSeconds(newEnd.getSeconds()+ jsonData.expirationSeconds);
this.endDate = newEnd;
// updating ui
this.srvModel = jsonData;
eventBus.$emit("data-fetched", this.srvModel);
if (this.invoicePaid && jsonData.redirectAutomatically && jsonData.merchantRefLink) {
this.loading = true;
setTimeout(function () {
if (this.isModal && window.top.location == jsonData.merchantRefLink){
this.close();
} else {
window.top.location = jsonData.merchantRefLink;
}
}.bind(this), 2000);
}
},
onEmailChange: function(){
this.emailAddressInputDirty = true;
this.emailAddressInputInvalid = false;
},
onEmailSubmit : function(){
var self = this;
if (this.validateEmail(this.emailAddressInput)) {
this.emailAddressFormSubmitting = true;
// Push the email to a server, once the reception is confirmed move on
$.ajax({
url: window.location.pathname + "/UpdateCustomer?invoiceId=" +this.srvModel.invoiceId,
type: "POST",
data: JSON.stringify({ Email: this.emailAddressInput }),
contentType: "application/json; charset=utf-8"
})
.done(function () {
self.srvModel.customerEmail = self.emailAddressInput;
}).always(function () {
self.emailAddressFormSubmitting = false;
});
} else {
this.emailAddressInputInvalid = true;
}
},
async setupNFC () {
try {
this.$set(this.nfc, 'permissionGranted', navigator.permissions && (await navigator.permissions.query({ name: 'nfc' })).state === 'granted');
} catch (e) {}
if (this.nfc.permissionGranted) {
await this.startNFCScan();
}
},
async startNFCScan () {
if (this.nfc.scanning) return;
this.$set(this.nfc, 'scanning', true);
try {
const inModal = window.self !== window.top;
const ndef = inModal ? new NDEFReaderWrapper() : new NDEFReader();
this.nfc.readerAbortController = new AbortController()
this.nfc.readerAbortController.signal.onabort = () => {
this.$set(this.nfc, 'scanning', false);
};
await ndef.scan({ signal: this.nfc.readerAbortController.signal })
ndef.onreadingerror = () => this.reportNfcError('Could not read NFC tag')
ndef.onreading = async ({ message }) => {
const record = message.records[0]
const textDecoder = new TextDecoder('utf-8')
const decoded = textDecoder.decode(record.data)
this.$emit('read-nfc-data', decoded)
}
if (inModal) {
// receive messages from iframe
window.addEventListener('message', async event => {
// deny messages from other origins
if (event.origin !== window.location.origin) return
const { action, data } = event.data
switch (action) {
case 'nfc:data':
this.$emit('read-nfc-data', data)
break;
case 'nfc:error':
this.handleNFCError('Could not read NFC tag')
break;
}
});
}
// we came here, so the user must have allowed NFC access
this.$set(this.nfc, 'permissionGranted', true);
} catch (error) {
this.handleNFCError(`NFC scan failed: ${error}`);
}
},
handleNFCData() { // child component reports it is handling the data
this.$set(this.nfc, 'errorMessage', null);
this.$set(this.nfc, 'successMessage', null);
this.$set(this.nfc, 'submitting', true);
},
handleNFCResult(message) { // child component reports result for handling the data
this.$set(this.nfc, 'submitting', false);
this.$set(this.nfc, 'successMessage', message);
},
handleNFCError(message) { // internal or via child component reporting failure of handling the data
this.$set(this.nfc, 'submitting', false);
this.$set(this.nfc, 'errorMessage', message);
}
}
});
</script>
@foreach (var paymentMethodHandler in PaymentMethodHandlerDictionary.Select(handler => handler.GetCheckoutUISettings()).Where(settings => settings != null))
{
<partial name="@paymentMethodHandler.ExtensionPartial" model="@Model" />
}
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-end", model = Model })
</body>
</html>