btcpayserver/BTCPayServer/Views/UIStores/PayButton.cshtml
Umar Bolatov 8feb60c30d
Add ability to set default payment method for pay button (#3606)
* Add ability to set default payment method for pay button

close #3604

* Add "#nullable enable" to UIStoresController

* Add PaymentMethodOptionViewModel

* Add explicit "Use the store’s default" option
2022-04-11 17:48:12 +09:00

555 lines
26 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.

@inject BTCPayServer.Security.ContentSecurityPolicies csp
@model PayButtonViewModel
@{
ViewData.SetActivePage(StoreNavPages.PayButton, "Pay Button", Context.GetStoreData().Id);
csp.AllowUnsafeHashes("onBTCPayFormSubmit(event);return false");
csp.AllowUnsafeHashes("handleSliderChange(event);return false");
csp.AllowUnsafeHashes("handleSliderInput(event);return false");
csp.AllowUnsafeHashes("handlePriceSlider(event);return false");
csp.AllowUnsafeHashes("handlePriceInput(event);return false");
csp.AllowUnsafeHashes("handlePlusMinus(event);return false");
}
@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.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs-vee-validate/vee-validate.js" asp-append-version="true"></script>
<script src="~/vendor/clipboard.js/clipboard.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 onBTCPayFormSubmit(event) {
event.preventDefault();
var xhttp = new XMLHttpRequest();
xhttp.onreadystatechange = function() {
if (this.readyState == 4 && this.status == 200 && this.responseText) {
window.btcpay.showInvoice(JSON.parse(this.responseText).invoiceId);
}
};
xhttp.open('POST', event.target.getAttribute('action'), true);
xhttp.send(new FormData(event.target));
}
</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;
}
}
</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;
}
}
</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;
}
</template>
<script>
const srvModel = @Safe.Json(Model);
const payButtonCtrl = new Vue({
el: '#payButtonCtrl',
data: {
srvModel: srvModel,
originalButtonImageUrl: srvModel.payButtonImageUrl,
buttonInlineTextMode: false
},
computed: {
imageUrlRequired() {
return !this.buttonInlineTextMode;
}
},
methods: {
inputChanges(event, buttonSize) {
inputChanges(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/logo.svg`;
}
this.inputChanges();
}
}
});
</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>
<p><strong>Warning:</strong> This feature should not be activated on a BTCPay Server store processing commercial transactions.</p>
<p>By activating this feature, a malicious user can trick you into thinking an order has been processed by creating a new invoice, reusing the same Order Id of another valid order but different amount or currency.</p>
<p>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 name="currency" type="text" class="form-control" id="currency"
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 stores 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 v-show="!srvModel.appIdEndpoint">
<h5 class="mt-4 mb-3">Link</h5>
<span>Alternatively, you can share <a id="preview-link" href="#">this link</a> or encode it in a QR code.</span>
</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>
<p class="form-text text-muted">
The URL to post purchase data.
</p>
</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>
<p class="form-text text-muted">
Receive email notification updates.
</p>
</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>
<p class="form-text text-muted">
Where to redirect the customer after payment is complete.
</p>
</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-primary" data-clipboard-target="#mainCode">
<vc:icon symbol="copy"/>
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>
<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>