btcpayserver/BTCPayServer/Views/Shared/PayButton/PayButton.cshtml
Andrew Camilleri fbe31ce64f
Support LNURL in pay button (#5107)
* Support LNURL in pay button

* UI updates

* Cleanups

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
2023-07-06 10:12:31 +02:00

638 lines
30 KiB
Text
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

@using BTCPayServer.Views.Stores
@inject Security.ContentSecurityPolicies Csp
@inject BTCPayNetworkProvider NetworkProvider
@model BTCPayServer.Plugins.PayButton.Models.PayButtonViewModel
@{
ViewData.SetActivePage(StoreNavPages.PayButton, "Pay Button", Context.GetStoreData().Id);
Csp.UnsafeEval();
}
@section PageHeadContent {
<link rel="stylesheet" href="~/vendor/highlightjs/default.min.css" asp-append-version="true">
}
@section PageFootContent {
<script src="~/vendor/highlightjs/highlight.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs-vee-validate/vee-validate.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script>
<script src="~/paybutton/paybutton.js" asp-append-version="true"></script>
<template id="template-modal" csp-allow>
if (!window.btcpay) {
var script = document.createElement('script');
script.src = @(Safe.Json(Model.UrlRoot + "modal/btcpay.js"));
document.getElementsByTagName('head')[0].append(script);
}
function handleFormSubmit(event) {
event.preventDefault();
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
window.btcpay.appendInvoiceFrame(JSON.parse(this.responseText).invoiceId);
}
};
xhttp.open('POST', event.target.getAttribute('action'), true);
xhttp.send(new FormData(event.target));
}
document.querySelectorAll(".btcpay-form").forEach(function(el) {
if (!el.dataset.initialized) {
el.addEventListener('submit', handleFormSubmit);
el.dataset.initialized = true;
}
});
</template>
<template id="template-price-buttons" csp-allow>
function handlePlusMinus(event) {
event.preventDefault();
const root = event.target.closest('.btcpay-form');
const el = root.querySelector('.btcpay-input-price');
const step = parseInt(event.target.dataset.step) || 1;
const min = parseInt(event.target.dataset.min) || 1;
const max = parseInt(event.target.dataset.max);
const type = event.target.dataset.type;
const price = parseInt(el.value) || min;
if (type === '-') {
el.value = price - step < min ? min : price - step;
} else if (type === '+') {
el.value = price + step > max ? max : price + step;
}
}
document.querySelectorAll(".btcpay-form .plus-minus").forEach(function(el) {
if (!el.dataset.initialized) {
el.addEventListener('click', handlePlusMinus);
el.dataset.initialized = true;
}
});
</template>
<template id="template-price-input" csp-allow>
function handlePriceInput(event) {
event.preventDefault();
const root = event.target.closest('.btcpay-form');
const price = parseInt(event.target.dataset.price);
if (isNaN(event.target.value)) root.querySelector('.btcpay-input-price').value = price;
const min = parseInt(event.target.getAttribute('min')) || 1;
const max = parseInt(event.target.getAttribute('max'));
if (event.target.value < min) {
event.target.value = min;
} else if (event.target.value > max) {
event.target.value = max;
}
}
document.querySelectorAll(".btcpay-form .btcpay-input-price").forEach(function(el) {
if (!el.dataset.initialized) {
el.addEventListener('input', handlePriceInput);
el.dataset.initialized = true;
}
});
</template>
<template id="template-price-slider" csp-allow>
function handleSliderChange(event) {
event.preventDefault();
const root = event.target.closest('.btcpay-form');
const el = root.querySelector('.btcpay-input-price');
const price = parseInt(el.value);
const min = parseInt(event.target.getAttribute('min')) || 1;
const max = parseInt(event.target.getAttribute('max'));
if (price < min) {
el.value = min;
} else if (price > max) {
el.value = max;
}
root.querySelector('.btcpay-input-range').value = el.value;
}
function handleSliderInput(event) {
event.target.closest('.btcpay-form').querySelector('.btcpay-input-price').value = event.target.value;
}
document.querySelectorAll(".btcpay-form .btcpay-input-range").forEach(function(el) {
if (!el.dataset.initialized) {
el.addEventListener('input', handleSliderInput);
el.dataset.initialized = true;
}
});
document.querySelectorAll(".btcpay-form .btcpay-input-price").forEach(function(el) {
if (!el.dataset.initialized) {
el.addEventListener('change', handleSliderChange);
el.dataset.initialized = true;
}
});
</template>
<script>
window.lnurlEndpoint = @Safe.Json(Url.Action("GetLNUrlForStore", "UILNURL", new
{
storeId = Model.StoreId,
cryptoCode = NetworkProvider.DefaultNetwork.CryptoCode
}, "lnurlp", Context.Request.Host.ToString()));
const srvModel = @Safe.Json(Model);
const payButtonCtrl = new Vue({
el: '#payButtonCtrl',
components: {
qrcode: VueQrcode
},
data: {
srvModel: srvModel,
originalButtonImageUrl: srvModel.payButtonImageUrl,
buttonInlineTextMode: false,
previewLink: "",
lnurlLink: "",
alternativeMode: 'link',
qrOptions: {
width: 256,
height: 256,
margin: 1,
color: {
dark: '#000',
light: '#f5f5f7'
}
}
},
computed: {
imageUrlRequired() {
return !this.buttonInlineTextMode;
}
},
methods: {
inputChanges(event, buttonSize) {
inputChanges(payButtonCtrl, event, buttonSize, );
}
},
watch: {
buttonInlineTextMode(checked) {
if (!checked) {
this.srvModel.payButtonText = '';
this.srvModel.payButtonImageUrl = this.originalButtonImageUrl;
} else {
this.srvModel.payButtonText = 'Pay with';
this.srvModel.payButtonImageUrl = `${this.srvModel.urlRoot}img/paybutton/logo.svg`;
}
this.inputChanges();
}
},
});
inputChanges(payButtonCtrl);
</script>
}
<partial name="_StatusMessage" />
<h2 class="mt-1 mb-4">@ViewData["Title"]</h2>
<div id="payButtonCtrl">
<div class="row">
<div class="col-xl-8">
<div class="alert alert-warning alert-dismissible mb-4" role="alert">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close">
<vc:icon symbol="close" />
</button>
<h5 class="alert-heading">Warning: Payment button should only be used for tips and donations</h5>
<p>
Using the payment button for e-commerce integrations is not recommended since order relevant information can be modified by the user.
For e-commerce, you should use our
<a href="https://docs.btcpayserver.org/API/Greenfield/v1/" class="alert-link" target="_blank" rel="noreferrer noopener">Greenfield API</a>.
If this store process commercial transactions, we advise you to
<a asp-controller="UIUserStores" asp-action="CreateStore" class="alert-link">create a separate store</a> before using the payment button.
</p>
<form asp-action="DisableAnyoneCanCreateInvoice" asp-route-storeId="@Context.GetRouteValue("storeId")" method="post">
<button name="command" id="disable-pay-button" type="submit" class="btn btn-danger mt-0" value="Save">Disable payment button</button>
</form>
</div>
<p>Configure your Pay Button, and the generated code will be displayed at the bottom of the page to copy into your project.</p>
<h4 class="mt-3 mb-3">General Settings</h4>
<div class="form-group col-md-8">
<label class="form-label" for="price">Price</label>
<input name="price" type="text" class="form-control" id="price" inputmode="decimal"
v-model="srvModel.price" v-on:change="inputChanges"
v-validate="'decimal|min_value:0'" :class="{'is-invalid': errors.has('price') }">
<small class="text-danger">{{ errors.first('price') }}</small>
</div>
<div class="form-group col-md-4" v-if="!srvModel.appIdEndpoint">
<label class="form-label" for="Currency">Currency</label>
<input asp-for="Currency" name="currency" class="form-control w-auto" currency-selection
v-model="srvModel.currency" v-on:change="inputChanges"
:class="{'is-invalid': errors.has('currency') }" />
</div>
<div class="form-group col-md-4" v-if="!srvModel.appIdEndpoint">
<label class="form-label" for="defaultPaymentMethod">Default Payment Method</label>
<select v-model="srvModel.defaultPaymentMethod" v-on:change="inputChanges" class="form-select" id="default-payment-method">
<option value="" selected>Use the store’s default</option>
<option v-for="pm in srvModel.paymentMethods" v-bind:value="pm.value">{{pm.name}}</option>
</select>
</div>
<div class="form-group" v-if="!srvModel.appIdEndpoint">
<label class="form-label" for="description">Checkout Description</label>
<input name="checkoutDesc" type="text" class="form-control" id="description"
v-model="srvModel.checkoutDesc" v-on:change="inputChanges">
</div>
<div class="form-group">
<label class="form-label" for="order-id">Order ID</label>
<input name="orderId" type="text" class="form-control" id="order-id"
v-model="srvModel.orderId" v-on:change="inputChanges">
</div>
</div>
</div>
<h4 class="mt-5 mb-3">Display Options</h4>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<div class="form-check" v-if="!srvModel.appIdEndpoint">
<input id="useModal" type="checkbox" v-model="srvModel.useModal" v-on:change="inputChanges" class="form-check-input"/>
<label for="useModal" class="form-check-label">Use Modal</label>
</div>
<div class="form-check">
<input id="buttonInlineTextMode" type="checkbox" v-model="buttonInlineTextMode" v-on:change="inputChanges" class="form-check-input"/>
<label for="buttonInlineTextMode" class="form-check-label">Customize Pay Button Text</label>
</div>
</div>
<div class="form-group" v-show="buttonInlineTextMode">
<label class="form-label" for="pb-text">Pay Button Text</label>
<input name="payButtonText" type="text" class="form-control" id="pb-text"
v-model="srvModel.payButtonText" v-on:change="inputChanges">
</div>
<div class="form-group mb-4">
<label class="form-label" for="pb-image-url">Pay Button Image Url</label>
<input name="payButtonImageUrl" type="text" class="form-control" id="pb-image-url"
v-model="srvModel.payButtonImageUrl" v-on:change="inputChanges"
v-validate="{ required: this.imageUrlRequired, url: {require_tld:false} }"
:class="{'is-invalid': errors.has('payButtonImageUrl') }">
<small class="text-danger">{{ errors.first('payButtonImageUrl') }}</small>
</div>
<div class="form-group mb-4">
<label class="form-label">Image Size</label>
<div class="btn-group d-flex" role="group">
<button type="button" class="btn btn-outline-secondary"
v-on:click="inputChanges($event, 0)">146 x 40 px</button>
<button type="button" class="btn btn-outline-secondary"
v-on:click="inputChanges($event, 1)">168 x 46 px</button>
<button type="button" class="btn btn-outline-secondary"
v-on:click="inputChanges($event, 2)">209 x 57 px</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Button Type</label>
<div class="form-check">
<input class="form-check-input" type="radio" name="button-type" id="btn-fixed" value="0" v-model="srvModel.buttonType" v-on:change="inputChanges" checked/>
<label for="btn-fixed" class="form-check-label">Fixed amount</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="button-type" id="btn-custom" value="1" v-model="srvModel.buttonType" v-on:change="inputChanges"/>
<label for="btn-custom" class="form-check-label">Custom amount</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="button-type" id="btn-slider" value="2" v-model="srvModel.buttonType" v-on:change="inputChanges"/>
<label for="btn-slider" class="form-check-label">Slider</label>
</div>
</div>
<div class="row" v-if="srvModel.buttonType === '1' ||srvModel.buttonType === '2'">
<div class="form-group col-md-4">
<label class="form-label" for="pb-min">Min</label>
<input name="min" type="text" class="form-control" id="pb-min"
v-model="srvModel.min" v-on:change="inputChanges"
v-validate="'required|decimal|min_value:0'" :class="{'is-invalid': errors.has('min') }">
<small class="text-danger">{{ errors.first('min') }}</small>
</div>
<div class="form-group col-md-4">
<label class="form-label" for="pb-max">Max</label>
<input name="max" type="text" class="form-control" id="pb-max"
v-model="srvModel.max" v-on:change="inputChanges"
v-validate="'required|decimal'" :class="{'is-invalid': errors.has('max') }">
<small class="text-danger">{{ errors.first('max') }}</small>
</div>
<div class="form-group col-md-4">
<label class="form-label" for="pb-step">Step</label>
<input name="step" type="text" class="form-control" id="pb-step"
v-model="srvModel.step" v-on:change="inputChanges"
v-validate="'required'" :class="{'is-invalid': errors.has('step') }">
<small class="text-danger">{{ errors.first('step') }}</small>
</div>
</div>
<template v-if="srvModel.buttonType === '1'">
<div class="form-check">
<input name="simpleInput"
id="simpleInput"
type="checkbox"
class="form-check-input"
v-model="srvModel.simpleInput"
v-on:change="inputChanges"
:class="{'is-invalid': errors.has('simpleInput') }">
<label class="form-check-label" for="simpleInput">Use a simple input style</label>
<small class="text-danger">{{ errors.first('simpleInput') }}</small>
</div>
<div class="form-check">
<input name="fitButtonInline"
id="fitButtonInline"
type="checkbox"
class="form-check-input"
v-model="srvModel.fitButtonInline"
v-on:change="inputChanges"
:class="{'is-invalid': errors.has('fitButtonInline') }">
<label class="form-check-label" for="fitButtonInline">Fit button inline</label>
<small class="text-danger">{{ errors.first('fitButtonInline') }}</small>
</div>
</template>
</div>
<div class="col-xl-4 mt-4 mt-xl-0">
<h5 class="mb-3">Preview</h5>
<div id="preview"></div>
</div>
</div>
<h4 class="mt-5 mb-3">Payment Notifications</h4>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<label class="form-label" for="server-ipn">Server IPN</label>
<input name="serverIpn" type="text" class="form-control" id="server-ipn"
v-model="srvModel.serverIpn" v-on:change="inputChanges"
v-validate="'url'" :class="{'is-invalid': errors.has('serverIpn') }">
<small class="text-danger">{{ errors.first('serverIpn') }}</small>
<div class="form-text">The URL to post purchase data.</div>
</div>
<div class="form-group" v-if="!srvModel.appIdEndpoint">
<label class="form-label" for="email-notifications">Email Notifications</label>
<input name="notifyEmail" type="text" class="form-control" id="email-notifications"
placeholder="name@domain.com"
v-model="srvModel.notifyEmail" v-on:change="inputChanges"
v-validate="'email'" :class="{'is-invalid': errors.has('notifyEmail') }">
<small class="text-danger">{{ errors.first('notifyEmail') }}</small>
<div class="form-text">Receive email notification updates.</div>
</div>
<div class="form-group">
<label class="form-label" for="browser-redirect">Browser Redirect</label>
<input name="browserRedirect" type="text" class="form-control" id="browser-redirect"
v-model="srvModel.browserRedirect" v-on:change="inputChanges"
v-validate="'url'" :class="{'is-invalid': errors.has('browserRedirect') }">
<small class="text-danger">{{ errors.first('browserRedirect') }}</small>
<div class="form-text">Where to redirect the customer after payment is complete</div>
</div>
</div>
</div>
<h4 class="mt-5 mb-3">Advanced Options</h4>
<div class="row" v-if="!srvModel.appIdEndpoint">
<div class="col-xl-8 col-xxl-constrain">
<p>
Specify additional query string parameters that should be appended to the checkout page once the invoice is created.
For example, <code>lang=da-DK</code> would load the checkout page in Danish by default.
</p>
<div class="form-group">
<label class="form-label" for="query-string">Checkout Additional Query String</label>
<input name="checkoutQueryString" type="text" class="form-control" id="query-string"
v-model="srvModel.checkoutQueryString" v-on:change="inputChanges"
:class="{'is-invalid': errors.has('checkoutQueryString') }">
<small class="text-danger">{{ errors.first('checkoutQueryString') }}</small>
</div>
</div>
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<p>Link this Pay Button to an app instead. Some features are disabled due to the different endpoint capabilities. You can set which perk/item this button should be targeting.</p>
<div class="form-group">
<label class="form-label" for="app-as-endpoint">Use App As Endpoint</label>
<select v-model="srvModel.appIdEndpoint" v-on:change="inputChanges" class="form-select" id="app-as-endpoint">
<option value="">Use default pay button endpoint</option>
<option v-for="app in srvModel.apps" v-bind:value="app.id" >{{app.appName}} ({{app.appType}})</option>
</select>
<small class="text-danger">{{ errors.first('appIdEndpoint') }}</small>
</div>
<div class="form-group" v-if="srvModel.appIdEndpoint">
<label class="form-label" for="app-item">App Item/Perk</label>
<input name="appChoiceKey" type="text" class="form-control" id="app-item"
v-model="srvModel.appChoiceKey" v-on:change="inputChanges"
:class="{'is-invalid': errors.has('appChoiceKey') }">
<small class="text-danger">{{ errors.first('appChoiceKey') }}</small>
</div>
</div>
</div>
<h4 class="mt-5 mb-3">Generated Code</h4>
<div class="row" v-show="!errors.any()">
<div class="col-xxl-8">
<pre><code id="mainCode" class="html"></code></pre>
<button class="btn btn-outline-secondary" data-clipboard-target="#mainCode">
<vc:icon symbol="copy"/>&nbsp;Copy Code
</button>
</div>
</div>
<div class="row" v-show="errors.any()">
<div class="col-xl-8 col-xxl-constrain text-danger">
Please fix errors shown in order for code generation to successfully execute.
</div>
</div>
<div v-if="!srvModel.appIdEndpoint && (previewLink || lnurlLink)">
<h4 class="mt-4 mb-3">Alternatives</h4>
<p>You can also share the link/LNURL or encode it in a QR code.</p>
<div class="align-items-center" style="width:256px">
<ul class="nav my-3 btcpay-pills align-items-center gap-2">
<li class="nav-item" v-if="previewLink">
<a class="btcpay-pill" :class="{ active: alternativeMode === 'link' }" data-bs-toggle="tab" data-bs-target="#Alternative-Link" role="tab" href="#Alternative-Link">
Link
</a>
</li>
<li class="nav-item" v-if="previewLink">
<a class="btcpay-pill" :class="{ active: alternativeMode === 'lnurl' }" data-bs-toggle="tab" data-bs-target="#Alternative-LNURL" role="tab" href="#Alternative-LNURL">
LNURL
</a>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane" :class="{ active: alternativeMode === 'link' }" id="Alternative-Link" role="tabpanel">
<a class="qr-container d-inline-block" :class="{ active: true }" :href="previewLink">
<qrcode :value="previewLink" :options="qrOptions" tag="img"></qrcode>
</a>
<div class="input-group mt-3">
<div class="form-floating">
<vc:truncate-center text="previewLink" is-vue="true" padding="15" elastic="true" classes="form-control-plaintext" />
<label>Link URL</label>
</div>
</div>
</div>
<div class="tab-pane" :class="{ active: alternativeMode === 'lnurl' }" id="Alternative-LNURL" role="tabpanel">
<a class="qr-container d-inline-block" :href="lnurlLink">
<qrcode :value="lnurlLink" :options="qrOptions" tag="img"></qrcode>
</a>
<div class="input-group mt-3">
<div class="form-floating">
<vc:truncate-center text="lnurlLink" is-vue="true" padding="15" elastic="true" classes="form-control-plaintext" />
<label>LNURL</label>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script id="template-paybutton-styles" type="text/template">
<style>
.btcpay-form {
display: inline-flex;
align-items: center;
justify-content: center;
}
.btcpay-form--inline {
flex-direction: row;
}
.btcpay-form--block {
flex-direction: column;
}
.btcpay-form--inline .submit {
margin-left: 15px;
}
.btcpay-form--block select {
margin-bottom: 10px;
}
.btcpay-form .btcpay-custom-container{
text-align: center;
}
.btcpay-custom {
display: flex;
align-items: center;
justify-content: center;
}
.btcpay-form .plus-minus {
cursor:pointer;
font-size:25px;
line-height: 25px;
background: #DFE0E1;
height: 30px;
width: 45px;
border:none;
border-radius: 60px;
margin: auto 5px;
display: inline-flex;
justify-content: center;
}
.btcpay-form select {
-moz-appearance: none;
-webkit-appearance: none;
appearance: none;
color: currentColor;
background: transparent;
border:1px solid transparent;
display: block;
padding: 1px;
margin-left: auto;
margin-right: auto;
font-size: 11px;
cursor: pointer;
}
.btcpay-form select:hover {
border-color: #ccc;
}
.btcpay-form option {
color: #000;
background: rgba(0,0,0,.1);
}
.btcpay-input-price {
-moz-appearance: textfield;
border: none;
box-shadow: none;
text-align: center;
font-size: 25px;
margin: auto;
border-radius: 5px;
line-height: 35px;
background: #fff;
}
.btcpay-input-price::-webkit-outer-spin-button,
.btcpay-input-price::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
</style>
</script>
<script id="template-slider-styles" type="text/template">
<style>
input[type=range].btcpay-input-range {
-webkit-appearance:none;
width:100%;
background: transparent;
}
input[type=range].btcpay-input-range:focus {
outline:0;
}
input[type=range].btcpay-input-range::-webkit-slider-runnable-track {
width:100%;
height:3.1px;
cursor:pointer;
box-shadow:0 0 1.7px #020,0 0 0 #003c00;
background:#f3f3f3;
border-radius:1px;
border:0;
}
input[type=range].btcpay-input-range::-webkit-slider-thumb {
box-shadow:none;
border:2.5px solid #cedc21;
height:22px;
width:22px;
border-radius:50%;
background:#0f3723;
cursor:pointer;
-webkit-appearance:none;
margin-top:-9.45px
}
input[type=range].btcpay-input-range:focus::-webkit-slider-runnable-track {
background:#fff;
}
input[type=range].btcpay-input-range::-moz-range-track {
width:100%;
height:3.1px;
cursor:pointer;
box-shadow:0 0 1.7px #020,0 0 0 #003c00;
background:#f3f3f3;
border-radius:1px;
border:0;
}
input[type=range].btcpay-input-range::-moz-range-thumb {
box-shadow:none;
border:2.5px solid #cedc21;
height:22px;
width:22px;
border-radius:50%;
background:#0f3723;
cursor:pointer;
}
input[type=range].btcpay-input-range::-ms-track {
width:100%;
height:3.1px;
cursor:pointer;
background:0 0;
border-color:transparent;
color:transparent;
}
input[type=range].btcpay-input-range::-ms-fill-lower {
background:#e6e6e6;
border:0;
border-radius:2px;
box-shadow:0 0 1.7px #020,0 0 0 #003c00;
}
input[type=range].btcpay-input-range::-ms-fill-upper {
background:#f3f3f3;
border:0;
border-radius:2px;
box-shadow:0 0 1.7px #020,0 0 0 #003c00;
}
input[type=range].btcpay-input-range::-ms-thumb {
box-shadow:none;
border:2.5px solid #cedc21;
height:22px;
width:22px;
border-radius:50%;
background:#0f3723;
cursor:pointer;
height:3.1px;
}
input[type=range].btcpay-input-range:focus::-ms-fill-lower {
background:#f3f3f3;
}
input[type=range].btcpay-input-range:focus::-ms-fill-upper {
background:#fff;
}
</style>
</script>