btcpayserver/BTCPayServer/Views/UIInvoice/CheckoutV2.cshtml

546 lines
26 KiB
Text
Raw Normal View History

@inject LanguageService LangService
@inject BTCPayServerEnvironment Env
@inject IFileService FileService
@inject ThemeSettings Theme
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@using BTCPayServer.Services
@using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Components.ThemeSwitch
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model PaymentModel
@{
Layout = null;
ViewData["Title"] = Model.HtmlTitle;
var paymentMethodCount = Model.AvailableCryptos.Count;
var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId)
: Model.CustomLogoLink;
}
@functions {
private string PaymentMethodName(PaymentModel.AvailableCrypto pm)
{
return Model.AltcoinsBuild
? $"{pm.PaymentMethodName} {pm.CryptoCode}"
: pm.PaymentMethodName.Replace("Bitcoin (", "").Replace(")", "").Replace("Lightning ", "");
}
}
<!DOCTYPE html>
<html lang="@Model.DefaultLang">
<head>
<partial name="LayoutHead"/>
<meta name="robots" content="noindex,nofollow">
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" />
@if (!string.IsNullOrEmpty(Model.CustomCSSLink))
{
<link href="@Model.CustomCSSLink" rel="stylesheet"/>
}
@if (Model.IsModal)
{
<style>
body { background: rgba(var(--btcpay-black-rgb), 0.85); }
</style>
}
@if (!string.IsNullOrEmpty(Model.BrandColor))
{
<style>
:root {
--btcpay-primary: @Model.BrandColor;
--btcpay-primary-bg-hover: @Model.BrandColor;
--btcpay-primary-bg-active: @Model.BrandColor;
--btcpay-primary-shadow: @Model.BrandColor;
--btcpay-body-link-accent: @Model.BrandColor;
}
</style>
}
</head>
<body>
<div id="Checkout" class="wrap" v-cloak>
<header>
@if (!string.IsNullOrEmpty(logoUrl))
{
<img src="@logoUrl" alt="@Model.StoreName" class="logo @(!string.IsNullOrEmpty(Model.LogoFileId) ? "logo--square" : "")"/>
}
<h1 class="h5 mb-0">@Model.StoreName</h1>
</header>
<main>
<nav v-if="hasNav">
<button type="button" v-if="showBackButton" id="back" v-on:click="back">
<vc:icon symbol="back"/>
</button>
<button type="button" v-if="isModal" id="close" v-on:click="close">
<vc:icon symbol="close"/>
</button>
</nav>
<section id="result" v-if="isPaid || isUnpayable">
<div id="paid" v-if="isPaid">
<div class="top">
<span class="text-success">
<vc:icon symbol="payment-complete"/>
</span>
<h4>{{$t("Invoice paid")}}</h4>
<dl>
<div>
<dt>{{$t("Invoice ID")}}</dt>
<dd>{{srvModel.invoiceId}}</dd>
</div>
<div v-if="srvModel.orderId">
<dt>{{$t("Order ID")}}</dt>
<dd>{{srvModel.orderId}}</dd>
</div>
</dl>
<dl>
<div>
<dt>{{$t("Order Amount")}}</dt>
<dd>{{srvModel.orderAmount}} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="srvModel.orderAmountFiat">
<dt>{{$t("Order Amount")}}</dt>
<dd>{{srvModel.orderAmountFiat}}</dd>
</div>
<div v-if="srvModel.networkFee">
<dt>{{$t("Network Cost")}}</dt>
<dd v-if="srvModel.isMultiCurrency">{{ srvModel.networkFee }} {{ srvModel.cryptoCode }}</dd>
<dd v-else-if="srvModel.txCountForFee > 0">{{$t("txCount", {count: srvModel.txCount})}} x {{ srvModel.networkFee }} {{ srvModel.cryptoCode }}</dd>
</div>
<div>
<dt>{{$t("Amount Paid")}}</dt>
<dd>{{srvModel.btcPaid }} {{ srvModel.cryptoCode }}</dd>
</div>
</dl>
</div>
<div class="buttons">
<a class="btn btn-primary" :href="srvModel.receiptLink" v-if="srvModel.receiptLink" :target="isModal ? '_blank' : '_top'">{{$t('View receipt')}}</a>
<a class="btn btn-secondary" :href="srvModel.merchantRefLink" v-if="srvModel.merchantRefLink">{{$t('Return to StoreName', srvModel)}}</a>
</div>
</div>
<div id="expired" v-if="isUnpayable">
<div class="top">
<span class="text-muted">
<vc:icon symbol="invoice-expired"/>
</span>
<h4>{{$t("Invoice expired")}}</h4>
<dl>
<div>
<dt>{{$t("Invoice ID")}}</dt>
<dd>{{srvModel.invoiceId}}</dd>
</div>
<div v-if="srvModel.orderId">
<dt>{{$t("Order ID")}}</dt>
<dd>{{srvModel.orderId}}</dd>
</div>
</dl>
<p v-html="$t('InvoiceExpired_Body_1', {storeName: srvModel.storeName, maxTimeMinutes: @Model.MaxTimeMinutes})"></p>
<p>{{$t("InvoiceExpired_Body_2")}}</p>
<p>{{$t("InvoiceExpired_Body_3")}}</p>
</div>
<div class="buttons">
<a class="btn btn-primary" :href="srvModel.merchantRefLink" v-if="srvModel.merchantRefLink">{{$t('Return to StoreName', srvModel)}}</a>
</div>
</div>
</section>
<section id="form" v-else-if="step === 'form'">
<form method="post" asp-action="UpdateForm" asp-route-invoiceId="@Model.InvoiceId" v-on:submit.prevent="onFormSubmit">
<div class="top">
<h6>{{$t("Please fill out the following")}}</h6>
<div class="timer" v-if="expiringSoon">
{{$t("Invoice will expire in")}} {{timerText}}
<span class="spinner-border spinner-border-sm ms-2" role="status">
<span class="visually-hidden"></span>
</span>
</div>
<template v-if="srvModel.checkoutFormId && srvModel.checkoutFormId !== 'None'">
<p class="my-5 text-center">TODO: Forms integration -> {{srvModel.checkoutFormId}}</p>
</template>
<template v-else-if="srvModel.requiresRefundEmail">
<p>{{$t("Contact_Body")}}</p>
<div class="form-group">
<label class="form-label" for="Email">{{$t("Contact and Refund Email")}}</label>
<input class="form-control" id="Email" name="Email">
<span class="text-danger" hidden>{{$t("Please enter a valid email address")}}</span>
</div>
</template>
</div>
<div class="buttons">
<button type="submit" class="btn btn-primary" :disabled="formSubmitPending" :class="{ 'loading': formSubmitPending }">
{{$t("Continue")}}
<span class="spinner-border spinner-border-sm ms-1" role="status" v-if="formSubmitPending">
<span class="visually-hidden"></span>
</span>
</button>
</div>
</form>
</section>
<section id="payment" v-else>
<h6 class="text-center mb-3 fw-semibold" v-if="srvModel.itemDesc" v-text="srvModel.itemDesc">@Model.ItemDesc</h6>
@if (Model.IsUnsetTopUp)
{
<h2 class="text-center mb-3">{{$t("Any amount")}}</h2>
}
else
{
<h2 class="text-center" v-text="`${srvModel.btcDue} ${srvModel.cryptoCode}`" :data-clipboard="srvModel.btcDue">@Model.BtcDue @Model.CryptoCode</h2>
<h2 class="text-center" v-else v-text="`${srvModel.btcDue} ${srvModel.cryptoCode}`" :data-clipboard="srvModel.btcDue">@Model.BtcDue @Model.CryptoCode</h2>
<div class="text-muted text-center fw-semibold" v-if="srvModel.orderAmountFiat" v-text="srvModel.orderAmountFiat">@Model.OrderAmountFiat</div>
<div class="timer" v-if="expiringSoon">
{{$t("Invoice will expire in")}} {{timerText}}
<span class="spinner-border spinner-border-sm ms-2 text-muted" role="status">
<span class="visually-hidden"></span>
</span>
</div>
<div class="mt-3 mb-1 text-center" v-if="showPaymentDueInfo">
<span class="text-info"><vc:icon symbol="info"/></span>
<small>{{$t("NotPaid_ExtraTransaction")}}</small>
</div>
<button class="d-flex align-items-center btn btn-link" type="button" id="PaymentDetailsButton" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" aria-controls="PaymentDetails" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<vc:icon symbol="caret-down"/>
<span class="ms-1 fw-semibold">{{$t("View Details")}}</span>
</button>
<div id="PaymentDetails" class="collapse" v-collapsible="displayPaymentDetails">
<dl>
<div>
<dt>{{$t("Total Price")}}</dt>
<dd :data-clipboard="srvModel.orderAmount">{{srvModel.orderAmount}} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="srvModel.orderAmountFiat && srvModel.cryptoCode">
<dt>{{$t("Exchange Rate")}}</dt>
<dd :data-clipboard="srvModel.rate">
<template v-if="srvModel.cryptoCodeSrv === 'Sats'">1 Sat = {{ srvModel.rate }}</template>
<template v-else>1 {{ srvModel.cryptoCodeSrv }} = {{ srvModel.rate }}</template>
</dd>
</div>
<div v-if="srvModel.showRecommendedFee && srvModel.feeRate">
<dt>{{$t("Recommended Fee")}}</dt>
<dd :data-clipboard="srvModel.feeRate">{{$t("Feerate", { feeRate: srvModel.feeRate })}}</dd>
</div>
<div v-if="srvModel.networkFee">
<dt>{{$t("Network Cost")}}</dt>
<dd :data-clipboard="srvModel.networkFee">
<template v-if="srvModel.txCountForFee > 0">{{$t("txCount", {count: srvModel.txCount})}} x</template>
{{ srvModel.networkFee }} {{ srvModel.cryptoCode }}
</dd>
</div>
<div v-if="btcPaid > 0">
<dt>{{$t("Amount Paid")}}</dt>
<dd :data-clipboard="srvModel.btcPaid">{{srvModel.btcPaid }} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="btcDue > 0">
<dt>{{$t("Amount Due")}}</dt>
<dd :data-clipboard="srvModel.btcDue">{{srvModel.btcDue}} {{ srvModel.cryptoCode }}</dd>
</div>
</dl>
</div>
}
<div class="my-3">
@if (paymentMethodCount > 1)
{
<h6 class="text-center mb-3">{{$t("Pay with")}}</h6>
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2 pb-2">
@foreach (var crypto in Model.AvailableCryptos)
{
<a asp-action="Checkout" asp-route-invoiceId="@Model.InvoiceId" asp-route-paymentMethodId="@crypto.PaymentMethodId" class="btcpay-pill m-0@(crypto.PaymentMethodId == Model.PaymentMethodId ? " active" : "")">
@PaymentMethodName(crypto)
</a>
}
</div>
}
else
{
<h6 class="text-center mb-3">
{{$t("Pay with")}}
@PaymentMethodName(Model.AvailableCryptos.First())
</h6>
}
</div>
<component v-if="srvModel.uiSettings && srvModel.activated"
:srv-model="srvModel"
:is="srvModel.uiSettings.checkoutBodyVueComponentName"/>
</section>
</main>
@if (Env.CheatMode)
{
<checkout-cheating v-if="step === 'payment'" invoice-id="@Model.InvoiceId" :btc-due="btcDue" :is-paid="isPaid" />
}
<footer>
<select asp-for="DefaultLang" asp-items="@LangService.GetLanguageSelectListItems()" v-on:change="changeLanguage"></select>
<div class="text-muted my-2">
Powered by <a href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">BTCPay Server</a>
</div>
@if (!Theme.CustomTheme)
{
<vc:theme-switch css-class="text-muted ms-n3" responsive="none"/>
}
</footer>
</div>
<noscript>
<div class="p-5 text-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
<a asp-action="CheckoutNoScript" asp-route-invoiceId="@Model.InvoiceId">HTML-only invoice</a>.
</p>
</div>
</noscript>
<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.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18nextXHRBackend.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script>
<script src="~/js/copy-to-clipboard.js" asp-append-version="true"></script>
<script src="~/main/utils.js" asp-append-version="true"></script>
<script src="~/checkout-v2/checkout.js" asp-append-version="true"></script>
@if (Env.CheatMode)
{
<partial name="Checkout-Cheating" model="@Model" />
}
<script>
const statusUrl = @Safe.Json(Url.Action("GetStatus", new { invoiceId = Model.InvoiceId }));
const statusWsUrl = @Safe.Json(Url.Action("GetStatusWebSocket", new { invoiceId = Model.InvoiceId }));
const initialSrvModel = @Safe.Json(Model);
const availableLanguages = @Safe.Json(LangService.GetLanguages().Select(language => language.Code));
const defaultLang = @Safe.Json(Model.DefaultLang);
const fallbackLanguage = "en";
const startingLanguage = computeStartingLanguage();
const STATUS_PAID = ['complete', 'confirmed', 'paid'];
const STATUS_UNPAID = ['new', 'paidPartial'];
const STATUS_UNPAYABLE = ['expired', 'invalid'];
const qrOptions = { margin: 1, type: 'svg', color: { dark: '#000', light: '#fff' } };
i18next
.use(window.i18nextXHRBackend)
.init({
backend: {
loadPath: @Safe.Json($"{Model.RootPath}locales/{{{{lng}}}}.json")
},
lng: startingLanguage,
fallbackLng: fallbackLanguage,
nsSeparator: false,
keySeparator: false,
load: 'currentOnly'
});
function computeStartingLanguage() {
if (urlParams.lang && isLanguageAvailable(urlParams.lang)) {
return urlParams.lang;
}
else if (isLanguageAvailable(defaultLang)) {
return defaultLang;
} else {
return fallbackLanguage;
}
}
function isLanguageAvailable(languageCode) {
return availableLanguages.indexOf(languageCode) >= 0;
}
const i18n = new VueI18next(i18next);
const eventBus = new Vue();
new Vue({
i18n,
el: '#Checkout',
data () {
const srvModel = initialSrvModel;
let step = 'payment';
if (STATUS_UNPAYABLE.concat(STATUS_PAID).includes(srvModel.status)) {
step = 'result';
} else if (srvModel.requiresRefundEmail || (srvModel.checkoutFormId && srvModel.checkoutFormId !== 'None')) {
step = 'form';
}
return {
srvModel,
step,
displayPaymentDetails: false,
end: new Date(),
expirationPercentage: 0,
timerText: @Safe.Json(Model.TimeLeft),
emailAddressInput: "",
emailAddressInputDirty: false,
emailAddressInputInvalid: false,
formSubmitPending: false,
isModal: srvModel.isModal
}
},
computed: {
expiringSoon () {
return this.isActive && this.expirationPercentage >= 75;
},
showRecommendedFee () {
return this.srvModel.showRecommendedFee && this.srvModel.feeRate !== 0;
},
isUnpayable () {
return STATUS_UNPAYABLE.includes(this.srvModel.status);
},
isPaid () {
return STATUS_PAID.includes(this.srvModel.status);
},
isActive () {
return !this.isUnpayable && !this.isPaid;
},
hasNav () {
return this.isModal || this.showBackButton;
},
hasForm () {
return this.srvModel.requiresRefundEmail || (
this.srvModel.checkoutFormId && this.srvModel.checkoutFormId !== 'None');
},
showBackButton () {
return this.hasForm && this.step === 'payment';
},
showPaymentDueInfo () {
return this.btcPaid > 0 && this.btcDue > 0;
},
btcDue () {
return parseFloat(this.srvModel.btcDue);
},
btcPaid () {
return parseFloat(this.srvModel.btcPaid);
}
},
mounted () {
this.onDataCallback(this.srvModel);
if (this.isActive) {
this.updateProgressTimer();
this.listenIn();
}
window.parent.postMessage('loaded', '*');
},
methods: {
changeLanguage(e) {
const lang = e.target.value;
if (isLanguageAvailable(lang)) {
i18next.changeLanguage(lang);
}
},
back () {
this.step = 'form';
},
close () {
window.parent.postMessage('close', '*');
},
updateProgressTimer () {
const timeLeftS = this.endDate
? Math.floor((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 && STATUS_UNPAID.includes(this.srvModel.status)){
setTimeout(this.updateProgressTimer, 500);
}
},
minutesLeft (timer) {
const val = Math.floor(timer / 60);
return val < 10 ? `0${val}` : val;
},
secondsLeft (timer) {
const val = Math.floor(timer % 60);
return val < 10 ? `0${val}` : val;
},
updateTimerText (timer) {
return timer >= 0
? `${this.minutesLeft(timer)}:${this.secondsLeft(timer)}`
: '00:00';
},
listenIn () {
let socket;
const updateFn = this.fetchData;
const supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
if (supportsWebSockets) {
const protocol = window.location.protocol.replace('http', 'ws');
const wsUri = `${protocol}//${window.location.host}${statusWsUrl}`;
try {
socket = new WebSocket(wsUri);
socket.onmessage = e => {
if (e.data === 'ping') return;
updateFn();
};
socket.onerror = e => {
console.error('Error while connecting to websocket for invoice notifications (callback):', e);
};
}
catch (e) {
console.error('Error while connecting to websocket for invoice notifications', e);
}
}
(function watcher() {
setTimeout(() => {
if (socket === null || socket.readyState !== 1) {
updateFn();
}
watcher();
}, 2000);
})();
},
async onFormSubmit (e) {
const form = e.target;
const url = form.getAttribute('action');
const method = form.getAttribute('method');
const body = new FormData(form);
const headers = { 'Content-Type': 'application/json' };
this.formSubmitPending = true;
const response = await fetch(url, { method, body, headers });
this.formSubmitPending = false;
if (response.ok) {
// TODO
this.step = 'payment';
}
},
async fetchData () {
const url = `${statusUrl}&paymentMethodId=${this.srvModel.paymentMethodId}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
this.onDataCallback(data);
}
},
onDataCallback (jsonData) {
if (this.srvModel.status !== jsonData.status) {
const { invoiceId } = this.srvModel;
const { status } = jsonData;
window.parent.postMessage({ invoiceId, status }, '*');
}
// displaying satoshis for lightning payments
jsonData.cryptoCodeSrv = jsonData.cryptoCode;
const 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.isPaid && jsonData.redirectAutomatically && jsonData.merchantRefLink) {
setTimeout(function () {
if (this.isModal && window.top.location == jsonData.merchantRefLink){
this.close();
} else {
window.top.location = jsonData.merchantRefLink;
}
}.bind(this), 2000);
} else if (!this.isActive) {
this.step = 'result';
}
}
}
});
</script>
@foreach (var paymentMethodHandler in PaymentMethodHandlerDictionary
.Select(handler => handler.GetCheckoutUISettings())
.Where(settings => settings != null)
.DistinctBy(pm => pm.ExtensionPartial))
{
<partial name="@paymentMethodHandler.ExtensionPartial-v2" model="@Model"/>
}
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-end", model = Model })
</body>
</html>