btcpayserver/BTCPayServer/Views/UIInvoice/Checkout.cshtml
d11n a4ee1e9805
Checkout v2 finetuning (#4276)
* Indent all JSON files with two spaces

* Upgrade Vue.js

* Cheat mode improvements

* Show payment details in case of expired invoice

* Add logo size recommendation

* Show clipboard copy hint cursor

* Improve info area and wording

* Update BIP21 wording

* Invoice details adjustments

* Remove form; switch payment methods via AJAX

* UI updates

* Decrease paddings to gain space

* Tighten up padding between logo mark and the store title text

* Add drop-shadow to the containers

* Wording

* Cheating improvements

* Improve footer spacing

* Cheating improvements

* Display addresses

* More improvements

* Expire invoices

* Customize invoice expiry

* Footer improvements

* Remove theme switch

* Remove non-existing sourcemap references

* Move inline JS to checkout.js file

* Plugin compatibility

See Kukks/btcpayserver#8

* Test fix

* Upgrade vue-i18next

* Extract translations into a separate file

* Round QR code borders

* Remove "Pay with Bitcoin" title in BIP21 case

* Add copy hint to payment details

* Cheating: Reduce margins

* Adjust dt color

* Hide addresses for first iteration

* Improve View Details button

* Make info section collapsible

* Revert original en locale file

* Checkout v2 tests

* Result view link fixes

* Fix BIP21 + lazy payment methods case

* More result page link improvements

* minor visual improvements

* Update clipboard code

Remove fallback for old browsers. https://caniuse.com/?search=navigator.clipboard

* Transition copy symbol

* Update info text color

* Invert dark neutral colors

Simplifies the dark theme quite a bit.

* copy adjustments

* updates QR border-radius

* Add option to remove logo

* More checkout v2 test cases

* JS improvements

* Remove leftovers

* Update test

* Fix links

* Update tests

* Update plugins integration

* Remove obsolete url code

* Minor view update

* Update JS to not use arrow functions

* Remove FormId from Checkout Appearance settings

* Add English-only hint and feedback link

* Checkout Appearance: Make options clearer, remove Custom CSS for v2

* Clipboard copy full URL instead of just address/BOLT11

* Upgrade JS libs, add content checks

* Add test for BIP21 setting with zero amount invoice

Co-authored-by: dstrukt <gfxdsign@gmail.com>
2022-11-24 08:53:32 +09:00

412 lines
18 KiB
Text

@inject BTCPayServer.Services.LanguageService langService
@inject BTCPayServer.Services.BTCPayServerEnvironment env
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@model PaymentModel
@{
Layout = null;
}
<!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" />
}
@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">
<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}locales/{{{{lng}}}}.json")
},
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
},
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;
}
},
mounted: 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;
},
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;
}
// displaying satoshis for lightning payments
jsonData.cryptoCodeSrv = jsonData.cryptoCode;
// 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;
}
}
}
});
</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>