Compare commits

...

10 Commits

Author SHA1 Message Date
d7644c0fe7 Adding ability to print vouchers for Pull Payments if it's supported 2023-11-26 22:26:24 -06:00
bf9e2f47a3 Destination and Pay Proof use space on receipt more efficiently
Allowing more space for the break, that is useful especially on Lightning invoices (Destination)
2023-11-26 22:26:11 -06:00
a32257e5a2 Scroll NFC error into view 2023-11-26 22:06:15 -06:00
f56ea60317 Save a bit of space 2023-11-26 22:05:50 -06:00
5bb7f158db Update NFC result handling and display 2023-11-26 22:05:43 -06:00
4f49b5f1a0 Move NFC code on Vue app level 2023-11-26 22:03:15 -06:00
25c30512ec Receipt fixes and improvements (#5505)
* Fix additional div

* Don't show payment number if there is only one

* Bump max-width to prevent wrapping in top container

* Fix colspan

* Re-add POS data

Closes #5498.

* Right-align amounts

* Re-order

* Don't show redundant receive date if there is only one payment

* Table improvements

* Unify crypto amount display

* More formatting improvements

* Only show Subtotal if there are calculations applicable to it

* Making margin on the bottom smaller to reduce expansion on Bitcoinize machines

---------

Co-authored-by: rockstardev <5191402+rockstardev@users.noreply.github.com>
2023-11-23 12:58:57 -06:00
52df7a5b89 Print button 2023-11-21 08:39:31 -06:00
1a85da27db Cleanup receipt print template 2023-11-21 08:39:25 -06:00
10326a822e Optimizing receipt printing, now works on POS terminal 2023-11-21 08:39:16 -06:00
24 changed files with 731 additions and 325 deletions

View File

@ -137,6 +137,7 @@
<ItemGroup>
<Watch Include="Views\**\*.*"></Watch>
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
<Watch Remove="Views\UIPullPayment\ViewPullPaymentPrint.cshtml" />
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
<Content Update="Views\UIApps\_ViewImports.cshtml">
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>

View File

@ -11,13 +11,14 @@ namespace BTCPayServer.Components.QRCode
{
private static QRCodeGenerator _qrGenerator = new();
public IViewComponentResult Invoke(string data)
public IViewComponentResult Invoke(string data, int size=256)
{
var qrCodeData = _qrGenerator.CreateQrCode(data, QRCodeGenerator.ECCLevel.Q);
var qrCode = new PngByteQRCode(qrCodeData);
var bytes = qrCode.GetGraphic(5, new byte[] { 0, 0, 0, 255 }, new byte[] { 0xf5, 0xf5, 0xf7, 255 });
var b64 = Convert.ToBase64String(bytes);
return new HtmlContentViewComponentResult(new HtmlString($"<img style=\"image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:256px;min-height:256px\" src=\"data:image/png;base64,{b64}\" class=\"qr-code\" />"));
return new HtmlContentViewComponentResult(new HtmlString(
$"<img style=\"image-rendering:pixelated;image-rendering:-moz-crisp-edges;min-width:{size}px;min-height:{size}px\" src=\"data:image/png;base64,{b64}\" class=\"qr-code\" />"));
}
}
}

View File

@ -254,7 +254,7 @@ namespace BTCPayServer.Controllers
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.InvoicePaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency, DisplayFormatter.CurrencyFormat.None),
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency),
PaidFormatted = _displayFormatter.Currency(paymentEntity.InvoicePaidAmount.Net, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),

View File

@ -55,7 +55,7 @@ namespace BTCPayServer.Controllers
[AllowAnonymous]
[HttpGet("pull-payments/{pullPaymentId}")]
public async Task<IActionResult> ViewPullPayment(string pullPaymentId)
public async Task<IActionResult> ViewPullPayment(string pullPaymentId, [FromQuery] bool print = false)
{
using var ctx = _dbContextFactory.CreateContext();
var pp = await ctx.PullPayments.FindAsync(pullPaymentId);
@ -111,8 +111,9 @@ namespace BTCPayServer.Controllers
var url = Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = _networkProvider.DefaultNetwork.CryptoCode, pullPaymentId = vm.Id }, Request.Scheme, Request.Host.ToString());
vm.LnurlEndpoint = url != null ? new Uri(url) : null;
}
return View(nameof(ViewPullPayment), vm);
return View(print ? "ViewPullPaymentPrint" : "ViewPullPayment", vm);
}
[HttpGet("stores/{storeId}/pull-payments/edit/{pullPaymentId}")]

View File

@ -135,7 +135,7 @@ namespace BTCPayServer.PaymentRequest
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.InvoicePaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency, DisplayFormatter.CurrencyFormat.None),
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency),
PaidFormatted = _displayFormatter.Currency(paymentEntity.InvoicePaidAmount.Net, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),

View File

@ -32,7 +32,7 @@
<script>
Vue.component('BitcoinLikeMethodCheckout', {
props: ["model"],
props: ['model', 'nfcSupported', 'nfcScanning', 'nfcErrorMessage'],
template: "#bitcoin-method-checkout-template",
components: {
qrcode: VueQrcode

View File

@ -79,7 +79,7 @@
<script type="text/javascript">
Vue.component('BitcoinLikeMethodCheckout',
{
props: ["srvModel"],
props: ['srvModel', 'nfcSupported', 'nfcScanning', 'nfcErrorMessage'],
template: "#bitcoin-method-checkout-template",
components: {
qrcode: VueQrcode
@ -107,7 +107,7 @@
});
Vue.component('BitcoinLikeMethodCheckoutHeader', {
props: ["srvModel"],
props: ['srvModel', 'nfcSupported', 'nfcScanning', 'nfcErrorMessage'],
template: "#bitcoin-method-checkout-header-template",
data: function() {
return {

View File

@ -24,7 +24,7 @@
<script>
Vue.component('LightningLikeMethodCheckout', {
props: ["model"],
props: ['model', 'nfcSupported', 'nfcScanning', 'nfcErrorMessage'],
template: "#lightning-method-checkout-template",
components: {
qrcode: VueQrcode

View File

@ -80,7 +80,7 @@
<script type="text/javascript">
Vue.component('LightningLikeMethodCheckout',
{
props: ["srvModel"],
props: ['srvModel', 'nfcSupported', 'nfcScanning', 'nfcErrorMessage'],
template: "#lightning-method-checkout-template",
components: {
qrcode: VueQrcode

View File

@ -1,48 +1,32 @@
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Abstractions.TagHelpers
<template id="lnurl-withdraw-template">
<template v-if="display">
<div class="mt-4">
<p id="CheatSuccessMessage" class="alert alert-success text-break" v-if="successMessage" v-text="successMessage"></p>
<p id="CheatErrorMessage" class="alert alert-danger text-break" v-if="errorMessage" v-text="errorMessage"></p>
<template v-if="isV2">
<button class="btn btn-secondary rounded-pill w-100" type="button" id="PayByNFC"
:disabled="scanning || submitting" v-on:click="handleClick">{{btnText}}</button>
</template>
<bp-loading-button v-else>
<button class="action-button" style="margin: 0 45px;width:calc(100% - 90px) !important" :disabled="scanning || submitting" v-on:click="handleClick" id="PayByNFC"
:class="{ 'loading': scanning || submitting, 'action-button': supported, 'btn btn-text w-100': !supported }">
<div v-if="display" class="mt-4" id="NFC">
<div v-if="nfcErrorMessage" class="alert alert-danger" v-text="nfcErrorMessage"></div>
<template v-if="isV2">
<button class="btn btn-secondary rounded-pill w-100" type="button" id="PayByNFC"
:disabled="nfcScanning || submitting" v-on:click="handleClick">{{btnText}}</button>
</template>
<template v-else>
<bp-loading-button>
<button class="action-button" style="margin: 0 45px;width:calc(100% - 90px) !important" :disabled="nfcScanning || submitting" v-on:click="handleClick" id="PayByNFC"
:class="{ 'action-button': nfcSupported, 'btn btn-text w-100': !nfcSupported }">
<span class="button-text">{{btnText}}</span>
<div class="loader-wrapper">
@await Html.PartialAsync("~/Views/UIInvoice/Checkout-Spinner.cshtml")
</div>
</button>
</bp-loading-button>
</div>
</template>
</template>
</div>
</template>
<script>
class NDEFReaderWrapper {
constructor() {
this.onreading = null;
this.onreadingerror = null;
}
async scan(opts) {
if (opts && opts.signal){
opts.signal.addEventListener('abort', () => {
window.parent.postMessage('nfc:abort', '*');
});
}
window.parent.postMessage('nfc:startScan', '*');
}
}
Vue.component("lnurl-withdraw-checkout", {
template: "#lnurl-withdraw-template",
props: {
model: Object,
isV2: Boolean
isV2: Boolean,
nfcSupported: Boolean,
nfcScanning: Boolean,
nfcErrorMessage: String
},
computed: {
display () {
@ -62,16 +46,16 @@ Vue.component("lnurl-withdraw-checkout", {
(activePaymentMethodId === 'BTC' && isUnified && lnurlwAvailable) ||
// Lightning with LNURL available
(activePaymentMethodId === 'BTC_LightningLike' && lnurlwAvailable))
return isAvailable && (this.supported || this.testFallback)
return isAvailable && (this.nfcSupported || this.testFallback)
},
testFallback () {
return !this.supported && window.location.search.match('lnurlwtest=(1|true)')
return !this.nfcSupported && window.location.search.match('lnurlwtest=(1|true)')
},
btnText () {
if (this.supported) {
if (this.nfcSupported) {
if (this.submitting) {
return this.isV2 ? this.$t('submitting_nfc') : 'Submitting NFC …'
} else if (this.scanning) {
} else if (this.nfcScanning) {
return this.isV2 ? this.$t('scanning_nfc') : 'Scanning NFC …'
} else {
return this.isV2 ? this.$t('pay_by_nfc') : 'Pay by NFC'
@ -84,35 +68,20 @@ Vue.component("lnurl-withdraw-checkout", {
data () {
return {
url: @Safe.Json(Context.Request.GetAbsoluteUri(Url.Action("SubmitLNURLWithdrawForInvoice", "NFC"))),
supported: 'NDEFReader' in window,
scanning: false,
submitting: false,
permissionGranted: false,
readerAbortController: null,
amount: 0,
successMessage: null,
errorMessage: null
submitting: false
}
},
async mounted () {
if (!this.supported) return;
try {
this.permissionGranted = navigator.permissions &&
(await navigator.permissions.query({ name: 'nfc' })).state === 'granted'
} catch (e) {}
if (this.permissionGranted) {
this.startScan()
}
beforeMount () {
this.$root.$on('read-nfc-data', this.sendData)
},
beforeDestroy () {
if (this.readerAbortController) {
this.readerAbortController.abort()
}
this.$root.$off('read-nfc-data')
},
methods: {
async handleClick () {
if (this.supported) {
this.startScan()
if (this.nfcSupported) {
this.$emit('start-nfc-scan')
} else {
if (this.model.isUnsetTopUp) {
this.handleUnsetTopUp()
@ -132,94 +101,29 @@ Vue.component("lnurl-withdraw-checkout", {
try {
this.amount = parseInt(amountStr)
} catch {
alert("Please provide a valid number amount in sats");
alert("Please provide a valid number amount in sats")
}
}
return false
},
async startScan () {
if (this.scanning || this.submitting) {
return;
}
if (this.model.isUnsetTopUp) {
this.handleUnsetTopUp()
if (!this.amount) {
return;
}
}
this.submitting = false;
this.scanning = true;
try {
const inModal = window.self !== window.top;
const ndef = inModal ? new NDEFReaderWrapper() : new NDEFReader();
this.readerAbortController = new AbortController()
this.readerAbortController.signal.onabort = () => {
this.scanning = false;
};
await ndef.scan({ signal: this.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 lnurl = textDecoder.decode(record.data)
await this.sendData(lnurl)
}
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':
await this.sendData(data)
break;
case 'nfc:error':
this.reportNfcError('Could not read NFC tag')
break;
}
});
}
// we came here, so the user must have allowed NFC access
this.permissionGranted = true;
} catch (error) {
this.reportNfcError(`NFC scan failed: ${error}`);
}
},
async sendData (lnurl) {
this.submitting = true;
this.successMessage = null;
this.errorMessage = null;
if (this.isV2) this.$root.playSound('nfcRead');
async sendData (data) {
this.submitting = true
this.$emit('handle-nfc-data')
// Post LNURL-Withdraw data to server
const body = JSON.stringify({ lnurl, invoiceId: this.model.invoiceId, amount: this.amount })
const body = JSON.stringify({ lnurl: data, invoiceId: this.model.invoiceId, amount: this.amount })
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
const response = await fetch(this.url, opts)
// Handle response
try {
const result = await response.text()
if (response.ok) {
this.successMessage = result;
} else {
this.reportNfcError(result);
}
const action = response.ok ? 'handle-nfc-result' : 'handle-nfc-error'
this.$emit(action, result)
} catch (error) {
this.reportNfcError(error);
this.$emit('handle-nfc-error', error)
}
this.submitting = false;
},
reportNfcError(message) {
this.errorMessage = message;
if (this.isV2) this.$root.playSound('error');
this.submitting = false
}
}
});

View File

@ -1 +1 @@
<lnurl-withdraw-checkout :model="model" :is-v2="true" />
<lnurl-withdraw-checkout :model="model" :is-v2="true" :nfc-supported="nfcSupported" :nfc-scanning="nfcScanning" :nfc-error-message="nfcErrorMessage" v-on="$listeners" />

View File

@ -1 +1 @@
<lnurl-withdraw-checkout :model="srvModel" />
<lnurl-withdraw-checkout :model="srvModel" :nfc-supported="nfcSupported" :nfc-scanning="nfcScanning" :nfc-error-message="nfcErrorMessage" v-on="$listeners" />

View File

@ -1,2 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 84" role="img" alt="BTCPay Server" class="d-print-none"><path d="M5.206 83.433a4.86 4.86 0 01-4.859-4.861V5.431a4.86 4.86 0 119.719 0v73.141a4.861 4.861 0 01-4.86 4.861" fill="currentColor" class="logo-brand-light"/><path d="M5.209 83.433a4.862 4.862 0 01-2.086-9.253L32.43 60.274 2.323 38.093a4.861 4.861 0 015.766-7.826l36.647 26.999a4.864 4.864 0 01-.799 8.306L7.289 82.964a4.866 4.866 0 01-2.08.469" fill="currentColor" class="logo-brand-medium"/><path d="M5.211 54.684a4.86 4.86 0 01-2.887-8.774L32.43 23.73 3.123 9.821a4.861 4.861 0 014.166-8.784l36.648 17.394a4.86 4.86 0 01.799 8.305l-36.647 27a4.844 4.844 0 01-2.878.948" fill="currentColor" class="logo-brand-light"/><path d="M10.066 31.725v20.553L24.01 42.006z" fill="currentColor" class="logo-brand-dark"/><path d="M10.066 5.431A4.861 4.861 0 005.206.57 4.86 4.86 0 00.347 5.431v61.165h9.72V5.431h-.001z" fill="currentColor" class="logo-brand-light"/><path d="M74.355 41.412c3.114.884 4.84 3.704 4.84 7.238 0 5.513-3.368 8.082-7.955 8.082H60.761V27.271h9.259c4.504 0 7.997 2.146 7.997 7.743 0 2.821-1.179 5.43-3.662 6.398m-4.293-.716c3.324 0 6.018-1.179 6.018-5.724 0-4.586-2.776-5.808-6.145-5.808h-7.197v11.531h7.324v.001zm1.052 14.099c3.366 0 6.06-1.768 6.06-6.145 0-4.713-3.072-6.144-6.901-6.144h-7.534v12.288h8.375v.001zM98.893 27.271v1.81h-8.122v27.651h-1.979V29.081h-8.123v-1.81zM112.738 26.85c5.01 0 9.554 2.524 10.987 8.543h-1.895c-1.348-4.923-5.303-6.732-9.134-6.732-6.944 0-10.605 5.681-10.605 13.341 0 8.08 3.661 13.256 10.646 13.256 4.125 0 7.828-1.85 9.26-7.279h1.895c-1.264 6.271-6.229 9.174-11.154 9.174-7.87 0-12.583-5.808-12.583-15.15 0-8.966 4.969-15.153 12.583-15.153M138.709 27.271c5.091 0 8.795 3.326 8.795 9.764 0 6.06-3.704 9.722-8.795 9.722h-7.746v9.976h-1.935V27.271h9.681zm0 17.549c3.745 0 6.816-2.397 6.816-7.827 0-5.429-2.947-7.869-6.816-7.869h-7.746V44.82h7.746zM147.841 56.732v-.255l11.741-29.29h.885l11.615 29.29v.255h-2.062l-3.322-8.501H153.27l-3.324 8.501h-2.105zm12.164-26.052l-6.059 15.697h12.078l-6.019-15.697zM189.551 27.271h2.104v.293l-9.176 16.92v12.248h-2.02V44.484l-9.216-16.961v-.252h2.147l3.997 7.492 4.043 7.786h.04l4.081-7.786z" fill="currentColor" class="logo-brand-text"/></svg>
<span class="d-none d-print-inline">BTCPay Server</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 84" role="img" alt="BTCPay Server"><path d="M5.206 83.433a4.86 4.86 0 01-4.859-4.861V5.431a4.86 4.86 0 119.719 0v73.141a4.861 4.861 0 01-4.86 4.861" fill="currentColor" class="logo-brand-light"/><path d="M5.209 83.433a4.862 4.862 0 01-2.086-9.253L32.43 60.274 2.323 38.093a4.861 4.861 0 015.766-7.826l36.647 26.999a4.864 4.864 0 01-.799 8.306L7.289 82.964a4.866 4.866 0 01-2.08.469" fill="currentColor" class="logo-brand-medium"/><path d="M5.211 54.684a4.86 4.86 0 01-2.887-8.774L32.43 23.73 3.123 9.821a4.861 4.861 0 014.166-8.784l36.648 17.394a4.86 4.86 0 01.799 8.305l-36.647 27a4.844 4.844 0 01-2.878.948" fill="currentColor" class="logo-brand-light"/><path d="M10.066 31.725v20.553L24.01 42.006z" fill="currentColor" class="logo-brand-dark"/><path d="M10.066 5.431A4.861 4.861 0 005.206.57 4.86 4.86 0 00.347 5.431v61.165h9.72V5.431h-.001z" fill="currentColor" class="logo-brand-light"/><path d="M74.355 41.412c3.114.884 4.84 3.704 4.84 7.238 0 5.513-3.368 8.082-7.955 8.082H60.761V27.271h9.259c4.504 0 7.997 2.146 7.997 7.743 0 2.821-1.179 5.43-3.662 6.398m-4.293-.716c3.324 0 6.018-1.179 6.018-5.724 0-4.586-2.776-5.808-6.145-5.808h-7.197v11.531h7.324v.001zm1.052 14.099c3.366 0 6.06-1.768 6.06-6.145 0-4.713-3.072-6.144-6.901-6.144h-7.534v12.288h8.375v.001zM98.893 27.271v1.81h-8.122v27.651h-1.979V29.081h-8.123v-1.81zM112.738 26.85c5.01 0 9.554 2.524 10.987 8.543h-1.895c-1.348-4.923-5.303-6.732-9.134-6.732-6.944 0-10.605 5.681-10.605 13.341 0 8.08 3.661 13.256 10.646 13.256 4.125 0 7.828-1.85 9.26-7.279h1.895c-1.264 6.271-6.229 9.174-11.154 9.174-7.87 0-12.583-5.808-12.583-15.15 0-8.966 4.969-15.153 12.583-15.153M138.709 27.271c5.091 0 8.795 3.326 8.795 9.764 0 6.06-3.704 9.722-8.795 9.722h-7.746v9.976h-1.935V27.271h9.681zm0 17.549c3.745 0 6.816-2.397 6.816-7.827 0-5.429-2.947-7.869-6.816-7.869h-7.746V44.82h7.746zM147.841 56.732v-.255l11.741-29.29h.885l11.615 29.29v.255h-2.062l-3.322-8.501H153.27l-3.324 8.501h-2.105zm12.164-26.052l-6.059 15.697h12.078l-6.019-15.697zM189.551 27.271h2.104v.293l-9.176 16.92v12.248h-2.02V44.484l-9.216-16.961v-.252h2.147l3.997 7.492 4.043 7.786h.04l4.081-7.786z" fill="currentColor" class="logo-brand-text"/></svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@ -184,11 +184,16 @@
</div>
<div v-if="showPaymentUI">
<component v-if="srvModel.uiSettings && srvModel.uiSettings.checkoutBodyVueComponentName && srvModel.activated"
v-bind:srv-model="srvModel"
v-bind:is="srvModel.uiSettings.checkoutBodyVueComponentName">
</component>
:is="srvModel.uiSettings.checkoutBodyVueComponentName"
:srv-model="srvModel"
:nfc-scanning="nfc.scanning"
:nfc-supported="nfc.supported"
:nfc-error-message="nfc.errorMessage"
v-on:start-nfc-scan="startNFCScan"
v-on:handle-nfc-data="handleNFCData"
v-on:handle-nfc-error="handleNFCError"
v-on:handle-nfc-result="handleNFCResult" />
</div>
<div class="bp-view" id="paid" v-bind:class="{ 'active': invoicePaid && !showEmailForm}">
<div class="status-block">
<div class="success-block">

View File

@ -206,7 +206,15 @@
lineItemsExpanded: false,
changingCurrencies: false,
loading: true,
isModal: initialSrvModel.isModal
isModal: initialSrvModel.isModal,
nfc: {
supported: 'NDEFReader' in window,
scanning: false,
submitting: false,
errorMessage: null,
permissionGranted: false,
readerAbortController: null
}
},
computed: {
expiringSoon: function(){
@ -233,7 +241,7 @@
: null;
}
},
mounted: function(){
mounted: async function(){
this.startProgressTimer();
this.listenIn();
this.onDataCallback(this.srvModel);
@ -244,6 +252,14 @@
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() {
@ -403,6 +419,71 @@
} 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);
}
}
});

View File

@ -109,7 +109,15 @@
</div>
</div>
}
<component v-if="paymentMethodComponent" :is="paymentMethodComponent" :model="srvModel" />
<component v-if="paymentMethodComponent" :is="paymentMethodComponent"
:model="srvModel"
:nfc-scanning="nfc.scanning"
:nfc-supported="nfc.supported"
:nfc-error-message="nfc.errorMessage"
v-on:start-nfc-scan="startNFCScan"
v-on:handle-nfc-data="handleNFCData"
v-on:handle-nfc-error="handleNFCError"
v-on:handle-nfc-result="handleNFCResult" />
</section>
<section id="result" v-else>
<div v-if="isProcessing" id="processing" key="processing">

View File

@ -31,6 +31,7 @@
</script>
}
<style>
#InvoiceReceipt { --wrap-max-width: 768px; }
#InvoiceSummary { gap: var(--btcpay-space-l); }
#PaymentDetails table tbody tr:first-child td { padding-top: 1rem; }
#PaymentDetails table tbody:not(:last-child) tr:last-child > th,td { padding-bottom: 1rem; }
@ -39,38 +40,34 @@
</style>
</head>
<body class="min-vh-100">
<div class="public-page-wrap">
<div id="InvoiceReceipt" class="public-page-wrap">
<main class="flex-grow-1">
<div class="container" style="max-width:720px;">
<div class="d-flex flex-column justify-content-center gap-4">
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })"/>
<div class="d-flex flex-column justify-content-center gap-4">
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
<div id="InvoiceSummary" class="bg-tile p-3 p-sm-4 rounded d-flex flex-wrap align-items-center justify-content-center">
@if (isProcessing)
<div id="InvoiceSummary" class="tile d-flex flex-wrap align-items-center justify-content-center">
@if (isProcessing)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-processing">
The invoice has detected a payment but is still waiting to be settled.
</div>
}
else if (!isSettled)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
else
{
if (Model.ReceiptOptions.ShowQR is true)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-processing">
The invoice has detected a payment but is still waiting to be settled.
</div>
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
}
else if (!isSettled)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
else
{
if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
}
<div class="d-flex gap-4 mb-0 flex-fill">
<dl class="d-flex flex-column gap-4 mb-0 flex-fill">
<div class="d-flex flex-column">
<div class="d-flex align-items-center justify-content-between">
<a href="?print=true" class="btn btn-link p-0 d-print-none fw-semibold order-1" target="_blank">Print</a>
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
</div>
<dd class="text-muted mb-0 fw-semibold">Amount Paid</dd>
<dt class="fs-2 mb-0 text-nowrap fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</dt>
</div>
<div class="d-flex flex-column">
@ -85,91 +82,92 @@
</div>
}
</dl>
}
</div>
@if (isProcessing)
{
<small class="d-block text-muted text-center px-4">This page will refresh periodically until the invoice is settled.</small>
}
else if (isSettled)
{
if (Model.AdditionalData?.Any() is true)
{
<div id="AdditionalData" class="bg-tile p-3 p-sm-4 rounded">
<h2 class="h4 mb-3">Additional Data</h2>
<div class="table-responsive my-0">
<partial name="PosData" model="(Model.AdditionalData, 1)"/>
</div>
</div>
}
if (Model.Payments?.Any() is true)
{
<div id="PaymentDetails" class="bg-tile p-3 p-sm-4 rounded">
<h2 class="h4 mb-3">Payment Details</h2>
<div class="table-responsive my-0 d-print-none">
<table class="invoice table table-borderless">
<thead>
<tr>
<th class="fw-normal text-secondary date-col w-125px">Date</th>
<th class="fw-normal text-secondary amount-col">Paid</th>
<th class="fw-normal text-secondary amount-col w-225px">Payment</th>
</tr>
</thead>
<tbody>
@foreach (var payment in Model.Payments)
{
<tr>
<td class="date-col">@payment.ReceivedDate.ToBrowserDate()</td>
<td class="amount-col">@payment.PaidFormatted</td>
<td class="amount-col">@payment.AmountFormatted @payment.PaymentMethod</td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
<tr>
<th class="fw-normal text-nowrap text-secondary">
Destination
</th>
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.Destination" classes="truncate-center-id" />
</td>
</tr>
}
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<tr>
<th class="fw-normal text-nowrap text-secondary">
Payment Proof
</th>
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.PaymentProof" link="@payment.Link" classes="truncate-center-id" />
</td>
</tr>
}
}
</tbody>
</table>
</div>
<div class="d-none d-print-block">
@foreach (var payment in Model.Payments)
{
<div class="mb-4">
<strong>@payment.PaidFormatted</strong> = @payment.AmountFormatted @payment.PaymentMethod, Rate: @payment.RateFormatted
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<div>Proof: @payment.PaymentProof</div>
}
</div>
}
</div>
</div>
}
}
@if (!string.IsNullOrEmpty(Model.OrderUrl))
{
<a href="@Model.OrderUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
<a href="?print=true" class="flex-grow-0 align-self-start btn btn-secondary d-print-none fs-4" target="_blank">Print</a>
</div>
}
</div>
@if (isProcessing)
{
<small class="d-block text-muted text-center px-4">This page will refresh periodically until the invoice is settled.</small>
}
else if (isSettled)
{
if (Model.AdditionalData?.Any() is true)
{
<div id="AdditionalData" class="tile">
<h2 class="h4 mb-3">Additional Data</h2>
<div class="table-responsive my-0">
<partial name="PosData" model="(Model.AdditionalData, 1)"/>
</div>
</div>
}
if (Model.Payments?.Any() is true)
{
<div id="PaymentDetails" class="tile">
<h2 class="h4 mb-3">Payment Details</h2>
<div class="table-responsive my-0 d-print-none">
<table class="invoice table table-borderless">
<thead>
<tr>
<th class="fw-normal text-secondary date-col w-125px">Date</th>
<th class="fw-normal text-secondary amount-col">Paid</th>
<th class="fw-normal text-secondary amount-col w-225px">Payment</th>
</tr>
</thead>
@foreach (var payment in Model.Payments)
{
<tbody>
<tr>
<td class="date-col">@payment.ReceivedDate.ToBrowserDate()</td>
<td class="amount-col">@payment.PaidFormatted</td>
<td class="amount-col">@payment.AmountFormatted</td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
<tr>
<th class="fw-normal text-nowrap text-secondary">
Destination
</th>
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.Destination" classes="truncate-center-id" />
</td>
</tr>
}
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<tr>
<th class="fw-normal text-nowrap text-secondary">
Payment Proof
</th>
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.PaymentProof" link="@payment.Link" classes="truncate-center-id" />
</td>
</tr>
}
</tbody>
}
</table>
</div>
<div class="d-none d-print-block">
@foreach (var payment in Model.Payments)
{
<div class="mb-4">
<strong>@payment.PaidFormatted</strong> = @payment.Amount @payment.PaymentMethod, Rate: @payment.RateFormatted
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<div>Proof: @payment.PaymentProof</div>
}
</div>
}
</div>
</div>
}
}
@if (!string.IsNullOrEmpty(Model.OrderUrl))
{
<a href="@Model.OrderUrl" class="btn btn-secondary rounded-pill mx-auto mt-3" rel="noreferrer noopener" target="_blank">Return to @(string.IsNullOrEmpty(Model.StoreName) ? "store" : Model.StoreName)</a>
}
</div>
</main>
<footer class="store-footer">

View File

@ -2,71 +2,213 @@
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@inject DisplayFormatter DisplayFormatter
@{
Layout = null;
ViewData["Title"] = $"Receipt from {Model.StoreName}";
var isProcessing = Model.Status == InvoiceStatus.Processing;
var isFreeInvoice = (Model.Status == InvoiceStatus.New && Model.Amount == 0);
var isSettled = Model.Status == InvoiceStatus.Settled;
}
<link href="~/main/bootstrap/bootstrap.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<p class="text-center">@Model.StoreName</p>
<p class="text-center">@Model.Timestamp.ToBrowserDate()</p>
<p>&nbsp;</p>
@if (isProcessing)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-processing">
The invoice has detected a payment but is still waiting to be settled.
</div>
}
else if (!isSettled)
{
<div class="lead text-center p-4 fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
else
{
<h3 class="text-center">
<strong>@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</strong>
</h3>
@if (Model.Payments?.Any() is true)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="~/favicon.ico" type="image/x-icon">
<meta name="robots" content="noindex">
<title>@ViewData["Title"]</title>
@* CSS *@
<link href="~/main/bootstrap/bootstrap.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/fonts/OpenSans.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/layout.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/themes/default.css" asp-append-version="true" rel="stylesheet" />
<meta name="robots" content="noindex,nofollow">
@if (isProcessing)
{
<p>&nbsp;</p>
<p class="text-center"><strong>Payments</strong></p>
@foreach (var payment in Model.Payments)
{
<p>&nbsp;</p>
<p class="text-center">@payment.AmountFormatted <span class="text-nowrap">@payment.PaymentMethod</span></p>
<p class="text-center">Rate: @payment.RateFormatted</p>
<p class="text-center">= @payment.PaidFormatted</p>
<script type="text/javascript">
setTimeout(() => { window.location.reload(); }, 10000);
</script>
}
else if (isFreeInvoice)
{
<script type="text/javascript">
setTimeout(() => { window.location.reload(); }, 2000);
</script>
}
<style>
h1 {
margin: 0;
}
}
if (Model.AdditionalData?.Any() is true)
{
<p>&nbsp;</p>
<p class="text-center"><strong>Additional Data</strong></p>
<partial name="PosData" model="(Model.AdditionalData, 1)"/>
}
@if (!string.IsNullOrEmpty(Model.OrderId))
{
<p>&nbsp;</p>
<p class="text-break">Order ID: @Model.OrderId</p>
}
}
@if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()"></vc:qr-code>
}
.qr-code {
width: 128px;
}
<script>window.print();</script>
/* change height as you like */
@@media print {
body {
width: 58mm;
margin: 0;
padding: 0;
}
.p-1 {
padding: 1mm !important;
}
.m-1 {
margin: 1mm !important;
}
}
/* this line is needed for fixing Chrome's bug */
@@page {
margin-left: 0px;
margin-right: 0px;
margin-top: 0px;
margin-bottom: 0px;
}
</style>
</head>
<body class="m-0 p-0 bg-white">
<center>
<partial name="_StoreHeader" model="(Model.StoreName, Model.LogoFileId)" />
<div id="InvoiceSummary" style="max-width:600px">
@if (isProcessing)
{
<div class="lead text-center fw-semibold" id="invoice-processing">
The invoice has detected a payment but is still waiting to be settled.
</div>
}
else if (!isSettled)
{
<div class="lead text-center fw-semibold" id="invoice-unsettled">
The invoice is not settled.
</div>
}
else
{
<div id="PaymentDetails">
<div class="my-2 text-center small">
@if (!string.IsNullOrEmpty(Model.OrderId))
{
<div>Order ID: @Model.OrderId</div>
}
@Model.Timestamp.ToBrowserDate()
</div>
<table class="table table-borderless table-sm small my-0">
<tr>
<td class="text-nowrap text-secondary">Total</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@if (Model.AdditionalData?.Any() is true &&
(Model.AdditionalData.ContainsKey("Cart") || Model.AdditionalData.ContainsKey("Discount") || Model.AdditionalData.ContainsKey("Tip")))
{
@if (Model.AdditionalData.ContainsKey("Cart"))
{
@foreach (var (key, value) in (Dictionary<string, object>)Model.AdditionalData["Cart"])
{
<tr>
<td class="text-secondary">@key</td>
<td class="text-end">@value</td>
</tr>
}
}
@if (Model.AdditionalData.ContainsKey("Subtotal"))
{
<tr>
<td class="text-secondary">Subtotal</td>
<td class="text-end">@Model.AdditionalData["Subtotal"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Discount"))
{
<tr>
<td class="text-secondary">Discount</td>
<td class="text-end">@Model.AdditionalData["Discount"]</td>
</tr>
}
@if (Model.AdditionalData.ContainsKey("Tip"))
{
<tr>
<td class="text-secondary">Tip</td>
<td class="text-end">@Model.AdditionalData["Tip"]</td>
</tr>
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
@if (Model.Payments?.Any() is true)
{
@for (var i = 0; i < Model.Payments.Count; i++)
{
var payment = Model.Payments[i];
@if (Model.Payments.Count > 1)
{
<tr>
<td colspan="2" class="text-nowrap text-secondary">Payment @(i + 1)</td>
</tr>
<tr>
<td class="text-nowrap">Received</td>
<td>@payment.ReceivedDate.ToBrowserDate()</td>
</tr>
}
<tr>
<td class="text-nowrap text-secondary">@(Model.Payments.Count == 1 ? "Paid" : "")</td>
<td class="text-end">@payment.AmountFormatted</td>
</tr>
<tr>
<td colspan="2" class="text-end">@payment.PaidFormatted</td>
</tr>
<tr>
<td class="text-nowrap text-secondary">Rate</td>
<td class="text-end">@payment.RateFormatted</td>
</tr>
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
<tr>
<td class="text-break" colspan="2">
<span class="text-secondary">Destination:</span>&nbsp;@payment.Destination
</td>
</tr>
}
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<tr>
<td class="text-break" colspan="2">
<span class="text-secondary">Pay&nbsp;Proof:</span>&nbsp;@payment.PaymentProof
</td>
</tr>
}
}
<tr>
<td colspan="2"><hr class="w-100 my-0"/></td>
</tr>
}
</table>
</div>
if (Model.ReceiptOptions.ShowQR is true)
{
<vc:qr-code data="@Context.Request.GetCurrentUrl()" size="128" />
}
}
</div>
<div class="store-footer p-3">
<a class="store-powered-by" style="color:#000;">Powered by <partial name="_StoreFooterLogo" /></a>
</div>
<hr class="w-100 my-0 bg-none"/>
</center>
</body>
<script>
window.print();
</script>
</html>

View File

@ -299,7 +299,7 @@
<tr>
<td class="text-break"><vc:truncate-center text="@payment.Id" link="@payment.Link" padding="7" classes="truncate-center-id" /></td>
<td class="amount-col">@payment.PaidFormatted</td>
<td class="text-end text-nowrap">@payment.AmountFormatted @payment.PaymentMethod</td>
<td class="text-end text-nowrap">@payment.AmountFormatted</td>
</tr>
}
}

View File

@ -92,6 +92,11 @@
<div class="row">
<div class="col col-12 col-lg-6 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
@if (Model.LnurlEndpoint != null)
{
<a href="?print=true" class="float-end btn btn-secondary d-print-none fs-4" target="_blank">Print</a>
}
@if (!string.IsNullOrWhiteSpace(Model.Title))
{
<h2 class="h4 mb-3">@Model.Title</h2>

View File

@ -0,0 +1,138 @@
@model BTCPayServer.Models.ViewPullPaymentModel
@using BTCPayServer.Client
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@inject BTCPayServerEnvironment Env
@inject DisplayFormatter DisplayFormatter
@using BTCPayServer.Components.QRCode
@{
Layout = null;
ViewData["Title"] = Model.Title;
ViewData.SetBlazorAllowed(false);
string lnurl = null;
if (Model.LnurlEndpoint != null)
{
lnurl = LNURL.LNURL.EncodeBech32(new Uri(Url.Action("GetLNURLForPullPayment", "UILNURL", new { cryptoCode = "BTC", pullPaymentId = Model.Id }, Context.Request.Scheme, Context.Request.Host.ToString())));
}
var fullView = Url.Action("ViewPullPayment", "UIPullPayment", new { pullPaymentId = Model.Id }, Context.Request.Scheme, Context.Request.Host.ToString());
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="~/favicon.ico" type="image/x-icon">
<meta name="robots" content="noindex">
<title>@ViewData["Title"]</title>
@* CSS *@
<link href="~/main/bootstrap/bootstrap.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/fonts/OpenSans.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/layout.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/themes/default.css" asp-append-version="true" rel="stylesheet" />
<meta name="robots" content="noindex,nofollow">
<style>
h1 {
margin: 0;
}
.qr-code {
width: 128px;
}
/* change height as you like */
@@media print {
body {
width: 58mm;
margin: 0;
padding: 0;
}
.p-1 {
padding: 1mm !important;
}
.m-1 {
margin: 1mm !important;
}
}
/* this line is needed for fixing Chrome's bug */
@@page {
margin-left: 0px;
margin-right: 0px;
margin-top: 0px;
margin-bottom: 0px;
}
</style>
</head>
<body class="m-0 p-0 bg-white">
<center>
<partial name="_StoreHeader" model="(Model.Title, String.Empty)" />
<div id="InvoiceSummary" style="max-width:600px">
@if (!@Model.IsPending || Model.SelectedPaymentMethod != "BTC")
{
<div class="lead text-center fw-semibold" id="invoice-processing">
This Pull Payment is not Printable
</div>
}
else
{
<div id="PaymentDetails">
<hr class="w-100 my-0 bg-none" />
<table class="table table-borderless table-sm small my-0">
<tr>
<td class="text-nowrap text-secondary">Amount</td>
<td class="text-end fw-semibold">@DisplayFormatter.Currency(Model.Amount, Model.Currency, DisplayFormatter.CurrencyFormat.Symbol)</td>
</tr>
<tr>
<td colspan="2">
@if (lnurl != null)
{
<div class="m-2 text-center">
<vc:qr-code data="@lnurl" size="178"></vc:qr-code>
<div>
@if (!string.IsNullOrEmpty(Model.Description))
{
<span>@Model.Description</span><br />
}
Scan with LNURLw compatible Bitcoin Wallet to redeem
</div>
</div>
}
</td>
</tr>
</table>
<hr class="w-100 my-0 p-1 bg-none" />
<div class="my-2 text-center small">
<a href="@fullView">
<vc:qr-code data="@fullView" size="128"></vc:qr-code>
</a>
<div>Scan to open this link in browser for the full redemption option list</div>
</div>
</div>
}
</div>
<hr class="w-100 my-0 p-1 bg-none" />
<div class="store-footer p-2">
<a class="store-powered-by" style="color:#000;">Powered by <partial name="_StoreFooterLogo" /></a>
</div>
</center>
</body>
<script>
window.print();
</script>
</html>

View File

@ -189,3 +189,10 @@ section dl > div dd {
.payment-box .plugins > .payment {
margin-top: var(--btcpay-space-l);
}
@media (max-width: 400px) {
/* Pull it up if there's no store header */
#Checkout-v2 > main.tile:first-child {
margin-top: calc(var(--wrap-padding-vertical) * -1);
}
}

View File

@ -5,6 +5,22 @@ const STATUS_SETTLED = ['complete', 'confirmed'];
const STATUS_INVALID = ['expired', 'invalid'];
const urlParams = new URLSearchParams(window.location.search);
class NDEFReaderWrapper {
constructor() {
this.onreading = null;
this.onreadingerror = null;
}
async scan(opts) {
if (opts && opts.signal){
opts.signal.addEventListener('abort', () => {
window.parent.postMessage('nfc:abort', '*');
});
}
window.parent.postMessage('nfc:startScan', '*');
}
}
function computeStartingLanguage() {
const lang = urlParams.get('lang')
if (lang && isLanguageAvailable(lang)) return lang;
@ -45,7 +61,6 @@ Vue.use(VueI18next);
const fallbackLanguage = 'en';
const startingLanguage = computeStartingLanguage();
const i18n = new VueI18next(i18next);
const eventBus = new Vue();
const PaymentDetails = {
template: '#payment-details',
@ -82,6 +97,14 @@ function initApp() {
paymentSound: null,
nfcReadSound: null,
errorSound: null,
nfc: {
supported: 'NDEFReader' in window,
scanning: false,
submitting: false,
errorMessage: null,
permissionGranted: false,
readerAbortController: null
}
}
},
computed: {
@ -192,7 +215,7 @@ function initApp() {
}
}
},
mounted () {
async mounted () {
this.updateData(this.srvModel);
this.updateTimer();
if (this.isActive || this.isProcessing) {
@ -206,9 +229,18 @@ function initApp() {
this.prepareSound(this.srvModel.nfcReadSoundUrl).then(sound => this.nfcReadSound = sound);
this.prepareSound(this.srvModel.errorSoundUrl).then(sound => this.errorSound = sound);
}
if (this.nfc.supported) {
await this.setupNFC();
}
updateLanguageSelect();
window.parent.postMessage('loaded', '*');
},
beforeDestroy () {
if (this.nfc.readerAbortController) {
this.nfc.readerAbortController.abort()
}
},
methods: {
changePaymentMethod (id) { // payment method or plugin id
if (this.pmId !== id) {
@ -303,7 +335,6 @@ function initApp() {
// updating ui
this.srvModel = data;
eventBus.$emit('data-fetched', this.srvModel);
},
replaceNewlines (value) {
return value ? value.replace(/\n/ig, '<br>') : '';
@ -345,6 +376,75 @@ function initApp() {
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
return { audioContext, audioBuffer, playing: false };
},
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.playSound('nfcRead');
this.$set(this.nfc, 'submitting', true);
this.$set(this.nfc, 'errorMessage', null);
},
handleNFCResult() { // child component reports result for handling the data
this.$set(this.nfc, 'submitting', false);
},
handleNFCError(message) { // internal or via child component reporting failure of handling the data
this.playSound('error');
this.$set(this.nfc, 'submitting', false);
this.$set(this.nfc, 'errorMessage', message);
const $nfc = document.getElementById('NFC');
if ($nfc) {
$nfc.scrollIntoView({ block: 'end', inline: 'center', behavior: 'smooth' });
}
}
}
});

View File

@ -1,5 +1,21 @@
class NDEFReaderWrapper {
constructor() {
this.onreading = null;
this.onreadingerror = null;
}
async scan(opts) {
if (opts && opts.signal){
opts.signal.addEventListener('abort', () => {
window.parent.postMessage('nfc:abort', '*');
});
}
window.parent.postMessage('nfc:startScan', '*');
}
}
delegate('click', '.payment-method', e => {
const el = e.target.closest('.payment-method')
closePaymentMethodDialog(el.dataset.paymentMethod);
return false;
})
})