Checkout v2 finetuning (#4276)

* Indent all JSON files with two spaces

* Upgrade Vue.js

* Cheat mode improvements

* Show payment details in case of expired invoice

* Add logo size recommendation

* Show clipboard copy hint cursor

* Improve info area and wording

* Update BIP21 wording

* Invoice details adjustments

* Remove form; switch payment methods via AJAX

* UI updates

* Decrease paddings to gain space

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

* Add drop-shadow to the containers

* Wording

* Cheating improvements

* Improve footer spacing

* Cheating improvements

* Display addresses

* More improvements

* Expire invoices

* Customize invoice expiry

* Footer improvements

* Remove theme switch

* Remove non-existing sourcemap references

* Move inline JS to checkout.js file

* Plugin compatibility

See Kukks/btcpayserver#8

* Test fix

* Upgrade vue-i18next

* Extract translations into a separate file

* Round QR code borders

* Remove "Pay with Bitcoin" title in BIP21 case

* Add copy hint to payment details

* Cheating: Reduce margins

* Adjust dt color

* Hide addresses for first iteration

* Improve View Details button

* Make info section collapsible

* Revert original en locale file

* Checkout v2 tests

* Result view link fixes

* Fix BIP21 + lazy payment methods case

* More result page link improvements

* minor visual improvements

* Update clipboard code

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

* Transition copy symbol

* Update info text color

* Invert dark neutral colors

Simplifies the dark theme quite a bit.

* copy adjustments

* updates QR border-radius

* Add option to remove logo

* More checkout v2 test cases

* JS improvements

* Remove leftovers

* Update test

* Fix links

* Update tests

* Update plugins integration

* Remove obsolete url code

* Minor view update

* Update JS to not use arrow functions

* Remove FormId from Checkout Appearance settings

* Add English-only hint and feedback link

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

* Clipboard copy full URL instead of just address/BOLT11

* Upgrade JS libs, add content checks

* Add test for BIP21 setting with zero amount invoice

Co-authored-by: dstrukt <gfxdsign@gmail.com>
This commit is contained in:
d11n
2022-11-24 00:53:32 +01:00
committed by GitHub
parent bf0a8c1e62
commit a4ee1e9805
42 changed files with 1714 additions and 12984 deletions

View File

@@ -12,7 +12,7 @@ indent_style = space
indent_size = 4 indent_size = 4
charset = utf-8 charset = utf-8
[launchSettings.json] [*.json]
indent_size = 2 indent_size = 2
# C# files # C# files

View File

@@ -30,7 +30,7 @@ namespace BTCPayServer.Tests
s.AddDerivationScheme(); s.AddDerivationScheme();
s.GoToStore(StoreNavPages.CheckoutAppearance); s.GoToStore(StoreNavPages.CheckoutAppearance);
s.Driver.FindElement(By.Id("RequiresRefundEmail")).Click(); s.Driver.FindElement(By.Id("RequiresRefundEmail")).Click();
s.Driver.FindElement(By.Name("command")).Click(); s.Driver.FindElement(By.Id("Save")).Click();
var emailAlreadyThereInvoiceId = s.CreateInvoice(100, "USD", "a@g.com"); var emailAlreadyThereInvoiceId = s.CreateInvoice(100, "USD", "a@g.com");
s.GoToInvoiceCheckout(emailAlreadyThereInvoiceId); s.GoToInvoiceCheckout(emailAlreadyThereInvoiceId);

View File

@@ -0,0 +1,200 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Payments;
using BTCPayServer.Tests.Logging;
using BTCPayServer.Views.Stores;
using NBitcoin;
using OpenQA.Selenium;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests
{
[Trait("Selenium", "Selenium")]
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class CheckoutV2Tests : UnitTestBase
{
private const int TestTimeout = TestUtils.TestTimeout;
public CheckoutV2Tests(ITestOutputHelper helper) : base(helper)
{
}
[Fact(Timeout = TestTimeout)]
[Trait("Lightning", "Lightning")]
public async Task CanConfigureCheckout()
{
using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser(true);
s.CreateNewStore();
s.EnableCheckoutV2();
s.AddLightningNode();
s.AddDerivationScheme();
// Configure store url
var storeUrl = "https://satoshisteaks.com/";
s.GoToStore();
s.Driver.FindElement(By.Id("StoreWebsite")).SendKeys(storeUrl);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
// Default payment method
var invoiceId = s.CreateInvoice(defaultPaymentMethod: "BTC_LightningLike");
s.GoToInvoiceCheckout(invoiceId);
Assert.Equal(2, s.Driver.FindElements(By.CssSelector(".payment-method")).Count);
Assert.Contains("Lightning", s.Driver.FindElement(By.CssSelector(".payment-method.active")).Text);
Assert.DoesNotContain("LNURL", s.Driver.PageSource);
var payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("lightning:", payUrl);
// Lightning amount in Sats
Assert.Contains("BTC", s.Driver.FindElement(By.Id("AmountDue")).Text);
s.GoToHome();
s.GoToLightningSettings();
s.Driver.SetCheckbox(By.Id("LightningAmountInSatoshi"), true);
s.Driver.FindElement(By.Id("save")).Click();
Assert.Contains("BTC Lightning settings successfully updated", s.FindAlertMessage().Text);
s.GoToInvoiceCheckout(invoiceId);
Assert.Contains("Sats", s.Driver.FindElement(By.Id("AmountDue")).Text);
// Expire
var expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
expirySeconds.Clear();
expirySeconds.SendKeys("3");
s.Driver.FindElement(By.Id("Expire")).Click();
var paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
Assert.Contains("This invoice will expire in", paymentInfo.Text);
TestUtils.Eventually(() =>
{
var expiredSection = s.Driver.FindElement(By.Id("expired"));
Assert.True(expiredSection.Displayed);
Assert.Contains("Invoice Expired", expiredSection.Text);
});
Assert.True(s.Driver.ElementDoesNotExist(By.Id("ReceiptLink")));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// Test payment
s.GoToHome();
invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
// Details
s.Driver.ToggleCollapse("PaymentDetails");
var details = s.Driver.FindElement(By.CssSelector(".payment-details"));
Assert.Contains("Total Price", details.Text);
Assert.Contains("Total Fiat", details.Text);
Assert.Contains("Exchange Rate", details.Text);
Assert.Contains("Amount Due", details.Text);
Assert.Contains("Recommended Fee", details.Text);
// Pay partial amount
await Task.Delay(200);
var address = s.Driver.FindElement(By.CssSelector(".qr-container")).GetAttribute("data-destination");
var amountFraction = "0.00001";
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(address, Network.RegTest),
Money.Parse(amountFraction));
await s.Server.ExplorerNode.GenerateAsync(1);
// Fake Pay
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountFraction);
s.Driver.FindElement(By.Id("FakePay")).Click();
TestUtils.Eventually(() =>
{
Assert.Contains("Created transaction",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
s.Server.ExplorerNode.Generate(1);
Assert.Contains("The invoice hasn't been paid in full",
s.Driver.WaitForElement(By.Id("PaymentInfo")).Text);
});
// Mine
s.Driver.FindElement(By.Id("Mine")).Click();
TestUtils.Eventually(() =>
{
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
});
// Pay full amount
var amountDue = s.Driver.FindElement(By.Id("AmountDue")).GetAttribute("data-amount-due");
s.Driver.FindElement(By.Id("FakePayAmount")).FillIn(amountDue);
s.Driver.FindElement(By.Id("FakePay")).Click();
TestUtils.Eventually(() =>
{
s.Server.ExplorerNode.Generate(1);
var paidSection = s.Driver.WaitForElement(By.Id("paid"));
Assert.True(paidSection.Displayed);
Assert.Contains("Invoice Paid", paidSection.Text);
});
s.Driver.FindElement(By.Id("ReceiptLink"));
Assert.Equal(storeUrl, s.Driver.FindElement(By.Id("StoreLink")).GetAttribute("href"));
// BIP21
s.GoToHome();
s.GoToStore(StoreNavPages.CheckoutAppearance);
s.Driver.SetCheckbox(By.Id("OnChainWithLnInvoiceFallback"), true);
s.Driver.FindElement(By.Id("Save")).Click();
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
invoiceId = s.CreateInvoice();
s.GoToInvoiceCheckout(invoiceId);
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.Contains("&LIGHTNING=", payUrl);
// BIP21 with topup invoice (which is only available with Bitcoin onchain)
s.GoToHome();
invoiceId = s.CreateInvoice(amount: null);
s.GoToInvoiceCheckout(invoiceId);
Assert.Empty(s.Driver.FindElements(By.CssSelector(".payment-method")));
payUrl = s.Driver.FindElement(By.CssSelector(".btn-primary")).GetAttribute("href");
Assert.StartsWith("bitcoin:", payUrl);
Assert.DoesNotContain("&LIGHTNING=", payUrl);
}
[Fact(Timeout = TestTimeout)]
public async Task CanUseCheckoutAsModal()
{
using var s = CreateSeleniumTester();
await s.StartAsync();
s.GoToRegister();
s.RegisterNewUser();
s.CreateNewStore();
s.EnableCheckoutV2();
s.GoToStore();
s.AddDerivationScheme();
var invoiceId = s.CreateInvoice(0.001m, "BTC", "a@x.com");
var invoice = await s.Server.PayTester.InvoiceRepository.GetInvoice(invoiceId);
s.Driver.Navigate()
.GoToUrl(new Uri(s.ServerUri, $"tests/index.html?invoice={invoiceId}"));
TestUtils.Eventually(() =>
{
Assert.True(s.Driver.FindElement(By.Name("btcpay")).Displayed);
});
await s.Server.ExplorerNode.SendToAddressAsync(BitcoinAddress.Create(invoice
.GetPaymentMethod(new PaymentMethodId("BTC", PaymentTypes.BTCLike))
.GetPaymentMethodDetails().GetPaymentDestination(), Network.RegTest),
new Money(0.001m, MoneyUnit.BTC));
IWebElement closebutton = null;
TestUtils.Eventually(() =>
{
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
var iframe = s.Driver.SwitchTo().Frame(frameElement);
closebutton = iframe.FindElement(By.Id("close"));
Assert.True(closebutton.Displayed);
});
closebutton.Click();
s.Driver.AssertElementNotFound(By.Name("btcpay"));
Assert.Equal(s.Driver.Url,
new Uri(s.ServerUri, $"tests/index.html?invoice={invoiceId}").ToString());
}
}
}

View File

@@ -125,6 +125,12 @@ retry:
return el; return el;
} }
public static void FillIn(this IWebElement el, string text)
{
el.Clear();
el.SendKeys(text);
}
public static void ScrollTo(this IWebDriver driver, IWebElement element) public static void ScrollTo(this IWebDriver driver, IWebElement element)
{ {

View File

@@ -185,6 +185,15 @@ namespace BTCPayServer.Tests
return (name, storeId); return (name, storeId);
} }
public void EnableCheckoutV2(bool bip21 = false)
{
GoToStore(StoreNavPages.CheckoutAppearance);
Driver.SetCheckbox(By.Id("UseNewCheckout"), true);
Driver.WaitForElement(By.Id("OnChainWithLnInvoiceFallback"));
Driver.SetCheckbox(By.Id("OnChainWithLnInvoiceFallback"), bip21);
Driver.FindElement(By.Id("Save")).Click();
}
public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit) public Mnemonic GenerateWallet(string cryptoCode = "BTC", string seed = "", bool? importkeys = null, bool isHotWallet = false, ScriptPubKeyType format = ScriptPubKeyType.Segwit)
{ {
var isImport = !string.IsNullOrEmpty(seed); var isImport = !string.IsNullOrEmpty(seed);

View File

@@ -311,17 +311,39 @@ namespace BTCPayServer.Tests
{ {
// This test verify that no malicious js is added in the minified files. // This test verify that no malicious js is added in the minified files.
// We should extend the tests to other js files, but we can do as we go... // We should extend the tests to other js files, but we can do as we go...
using var client = new HttpClient();
using HttpClient client = new HttpClient(); var actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap", "bootstrap.bundle.min.js").Trim();
var actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap", "bootstrap.bundle.min.js");
var version = Regex.Match(actual, "Bootstrap v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value; var version = Regex.Match(actual, "Bootstrap v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
var expected = await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/js/bootstrap.bundle.min.js")).Content.ReadAsStringAsync(); var expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/js/bootstrap.bundle.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase)); Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "clipboard.js", "clipboard.js"); actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "clipboard.js", "clipboard.js");
expected = await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.js")).Content.ReadAsStringAsync(); expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual.Replace("\r\n", "\n", StringComparison.OrdinalIgnoreCase)); Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vuejs", "vue.min.js").Trim();
version = Regex.Match(actual, "Vue\\.js v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/vue/{version}/vue.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18next.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next/22.0.6/i18next.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18nextHttpBackend.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next-http-backend/2.0.1/i18nextHttpBackend.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "vue-i18next.js").Trim();
expected = (await (await client.GetAsync("https://unpkg.com/@panter/vue-i18next@0.15.2/dist/vue-i18next.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-qrcode", "vue-qrcode.min.js").Trim();
version = Regex.Match(actual, "vue-qrcode v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://unpkg.com/@chenfengyuan/vue-qrcode@{version}/dist/vue-qrcode.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
} }
string GetFileContent(params string[] path) string GetFileContent(params string[] path)
{ {
var l = path.ToList(); var l = path.ToList();

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Filters; using BTCPayServer.Filters;
using BTCPayServer.Lightning;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -17,6 +18,7 @@ namespace BTCPayServer.Controllers
{ {
public Decimal Amount { get; set; } public Decimal Amount { get; set; }
public string CryptoCode { get; set; } = "BTC"; public string CryptoCode { get; set; } = "BTC";
public string PaymentMethodId { get; set; } = "BTC";
} }
public class MineBlocksRequest public class MineBlocksRequest
@@ -31,31 +33,65 @@ namespace BTCPayServer.Controllers
{ {
var invoice = await _InvoiceRepository.GetInvoice(invoiceId); var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
var store = await _StoreRepository.FindStore(invoice.StoreId); var store = await _StoreRepository.FindStore(invoice.StoreId);
// TODO support altcoins, not just bitcoin - and make it work for LN-only invoices
var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS"; var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS";
var cryptoCode = isSats ? "BTC" : request.CryptoCode; var cryptoCode = isSats ? "BTC" : request.CryptoCode;
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var paymentMethodId = new [] {store.GetDefaultPaymentId()}.Concat(store.GetEnabledPaymentIds(_NetworkProvider))
.FirstOrDefault(p => p != null && p.CryptoCode == cryptoCode && p.PaymentType == PaymentTypes.BTCLike);
var bitcoinAddressString = invoice.GetPaymentMethod(paymentMethodId).GetPaymentMethodDetails().GetPaymentDestination();
var bitcoinAddressObj = BitcoinAddress.Create(bitcoinAddressString, network.NBitcoinNetwork);
var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC); var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC);
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode).NBitcoinNetwork;
var paymentMethodId = new [] {store.GetDefaultPaymentId()}
.Concat(store.GetEnabledPaymentIds(_NetworkProvider))
.FirstOrDefault(p => p?.ToString() == request.PaymentMethodId);
try try
{ {
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var rate = paymentMethod.Rate; var destination = paymentMethod?.GetPaymentMethodDetails().GetPaymentDestination();
var txid = (await cheater.CashCow.SendToAddressAsync(bitcoinAddressObj, amount)).ToString();
switch (paymentMethod?.GetId().PaymentType)
// TODO The value of totalDue is wrong. How can we get the real total due? invoice.Price is only correct if this is the 2nd payment, not for a 3rd or 4th payment.
var totalDue = invoice.Price;
return Ok(new
{ {
Txid = txid, case BitcoinPaymentType:
AmountRemaining = (totalDue - (amount.ToUnit(MoneyUnit.BTC) * rate)) / rate, var address = BitcoinAddress.Create(destination, network);
SuccessMessage = "Created transaction " + txid var txid = (await cheater.CashCow.SendToAddressAsync(address, amount)).ToString();
});
return Ok(new
{
Txid = txid,
AmountRemaining = (paymentMethod.Calculate().Due - amount).ToUnit(MoneyUnit.BTC),
SuccessMessage = $"Created transaction {txid}"
});
case LightningPaymentType:
// requires the channels to be set up using the BTCPayServer.Tests/docker-lightning-channel-setup.sh script
LightningConnectionString.TryParse(Environment.GetEnvironmentVariable("BTCPAY_BTCEXTERNALLNDREST"), false, out var lnConnection);
var lnClient = LightningClientFactory.CreateClient(lnConnection, network);
var lnAmount = new LightMoney(amount.Satoshi, LightMoneyUnit.Satoshi);
var response = await lnClient.Pay(destination, new PayInvoiceParams { Amount = lnAmount });
if (response.Result == PayResult.Ok)
{
var bolt11 = BOLT11PaymentRequest.Parse(destination, network);
var paymentHash = bolt11.PaymentHash?.ToString();
var paid = new Money(response.Details.TotalAmount.ToUnit(LightMoneyUnit.Satoshi), MoneyUnit.Satoshi);
return Ok(new
{
Txid = paymentHash,
AmountRemaining = (paymentMethod.Calculate().TotalDue - paid).ToUnit(MoneyUnit.BTC),
SuccessMessage = $"Sent payment {paymentHash}"
});
}
return UnprocessableEntity(new
{
ErrorMessage = response.ErrorDetail,
AmountRemaining = invoice.Price
});
default:
return UnprocessableEntity(new
{
ErrorMessage = $"Payment method {paymentMethodId} is not supported",
AmountRemaining = invoice.Price
});
}
} }
catch (Exception e) catch (Exception e)
{ {
@@ -71,40 +107,30 @@ namespace BTCPayServer.Controllers
[CheatModeRoute] [CheatModeRoute]
public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater) public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater)
{ {
// TODO support altcoins, not just bitcoin
var blockRewardBitcoinAddress = cheater.CashCow.GetNewAddress(); var blockRewardBitcoinAddress = cheater.CashCow.GetNewAddress();
try try
{ {
if (request.BlockCount > 0) if (request.BlockCount > 0)
{ {
cheater.CashCow.GenerateToAddress(request.BlockCount, blockRewardBitcoinAddress); cheater.CashCow.GenerateToAddress(request.BlockCount, blockRewardBitcoinAddress);
return Ok(new return Ok(new { SuccessMessage = $"Mined {request.BlockCount} block{(request.BlockCount == 1 ? "" : "s")} " });
{
SuccessMessage = "Mined " + request.BlockCount + " blocks"
});
} }
return BadRequest(new return BadRequest(new { ErrorMessage = "Number of blocks should be at least 1" });
{
ErrorMessage = "Number of blocks should be > 0"
});
} }
catch (Exception e) catch (Exception e)
{ {
return BadRequest(new return BadRequest(new { ErrorMessage = e.Message });
{
ErrorMessage = e.Message
});
} }
} }
[HttpPost("i/{invoiceId}/expire")] [HttpPost("i/{invoiceId}/expire")]
[CheatModeRoute] [CheatModeRoute]
public async Task<IActionResult> TestExpireNow(string invoiceId, [FromServices] Cheater cheater) public async Task<IActionResult> Expire(string invoiceId, int seconds, [FromServices] Cheater cheater)
{ {
try try
{ {
await cheater.UpdateInvoiceExpiry(invoiceId, DateTimeOffset.Now); await cheater.UpdateInvoiceExpiry(invoiceId, TimeSpan.FromSeconds(seconds));
return Ok(new { SuccessMessage = "Invoice is now expired." }); return Ok(new { SuccessMessage = $"Invoice set to expire in {seconds} seconds." });
} }
catch (Exception e) catch (Exception e)
{ {

View File

@@ -928,14 +928,6 @@ namespace BTCPayServer.Controllers
return Ok("{}"); return Ok("{}");
} }
[HttpPost("i/{invoiceId}/Form")]
[HttpPost("invoice/Form")]
public IActionResult UpdateForm(string invoiceId)
{
// TODO: Forms integration
return Ok();
}
[HttpGet("/stores/{storeId}/invoices")] [HttpGet("/stores/{storeId}/invoices")]
[HttpGet("invoices")] [HttpGet("invoices")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)] [Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]

View File

@@ -417,15 +417,10 @@ namespace BTCPayServer.Controllers
var logPrefix = $"{supportedPaymentMethod.PaymentId.ToPrettyString()}:"; var logPrefix = $"{supportedPaymentMethod.PaymentId.ToPrettyString()}:";
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
object? preparePayment; // Checkout v2 does not show a payment method switch for Bitcoin-only + BIP21, so exclude that case
if (storeBlob.LazyPaymentMethods) var preparePayment = storeBlob.LazyPaymentMethods && !storeBlob.OnChainWithLnInvoiceFallback
{ ? null
preparePayment = null; : handler.PreparePayment(supportedPaymentMethod, store, network);
}
else
{
preparePayment = handler.PreparePayment(supportedPaymentMethod, store, network);
}
var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.Currency)]; var rate = await fetchingByCurrencyPair[new CurrencyPair(network.CryptoCode, entity.Currency)];
if (rate.BidAsk == null) if (rate.BidAsk == null)
{ {

View File

@@ -383,7 +383,6 @@ namespace BTCPayServer.Controllers
}).ToList(); }).ToList();
vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2; vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2;
vm.CheckoutFormId = storeBlob.CheckoutFormId;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback; vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail;
vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods; vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods;
@@ -504,7 +503,6 @@ namespace BTCPayServer.Controllers
blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1; blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1;
if (blob.CheckoutType == Client.Models.CheckoutType.V2) if (blob.CheckoutType == Client.Models.CheckoutType.V2)
{ {
blob.CheckoutFormId = model.CheckoutFormId;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback; blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
} }
@@ -620,7 +618,7 @@ namespace BTCPayServer.Controllers
} }
[HttpPost("{storeId}/settings")] [HttpPost("{storeId}/settings")]
public async Task<IActionResult> GeneralSettings(GeneralSettingsViewModel model, string? command = null) public async Task<IActionResult> GeneralSettings(GeneralSettingsViewModel model, [FromForm] bool RemoveLogoFile = false)
{ {
bool needUpdate = false; bool needUpdate = false;
if (CurrentStore.StoreName != model.StoreName) if (CurrentStore.StoreName != model.StoreName)
@@ -649,14 +647,14 @@ namespace BTCPayServer.Controllers
} }
blob.BrandColor = model.BrandColor; blob.BrandColor = model.BrandColor;
var userId = GetUserId();
if (userId is null)
return NotFound();
if (model.LogoFile != null) if (model.LogoFile != null)
{ {
if (model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture)) if (model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
{ {
var userId = GetUserId();
if (userId is null)
return NotFound();
// delete existing image // delete existing image
if (!string.IsNullOrEmpty(blob.LogoFileId)) if (!string.IsNullOrEmpty(blob.LogoFileId))
{ {
@@ -679,6 +677,12 @@ namespace BTCPayServer.Controllers
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image"; TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
} }
} }
else if (RemoveLogoFile && !string.IsNullOrEmpty(blob.LogoFileId))
{
await _fileService.RemoveFile(blob.LogoFileId, userId);
blob.LogoFileId = null;
needUpdate = true;
}
if (CurrentStore.SetStoreBlob(blob)) if (CurrentStore.SetStoreBlob(blob))
{ {

View File

@@ -5,7 +5,6 @@ using System.Linq;
using BTCPayServer.Services; using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using YamlDotNet.Core.Tokens;
namespace BTCPayServer.Models.StoreViewModels namespace BTCPayServer.Models.StoreViewModels
{ {
@@ -24,10 +23,7 @@ namespace BTCPayServer.Models.StoreViewModels
public SelectList Languages { get; set; } public SelectList Languages { get; set; }
[Display(Name = "Request customer data on checkout")] [Display(Name = "Unify on-chain and lightning payment URL/QR code")]
public string CheckoutFormId { get; set; }
[Display(Name = "Include Lightning invoice fallback to on-chain BIP21 payment URL")]
public bool OnChainWithLnInvoiceFallback { get; set; } public bool OnChainWithLnInvoiceFallback { get; set; }
[Display(Name = "Default payment method on checkout")] [Display(Name = "Default payment method on checkout")]

View File

@@ -11,7 +11,7 @@ namespace BTCPayServer.Models.StoreViewModels
[Display(Name = "Add hop hints for private channels to the Lightning invoice")] [Display(Name = "Add hop hints for private channels to the Lightning invoice")]
public bool LightningPrivateRouteHints { get; set; } public bool LightningPrivateRouteHints { get; set; }
[Display(Name = "Include Lightning invoice fallback to on-chain BIP21 payment URL")] [Display(Name = "Unify on-chain and lightning payment URL/QR code")]
public bool OnChainWithLnInvoiceFallback { get; set; } public bool OnChainWithLnInvoiceFallback { get; set; }
[Display(Name = "Description template of the lightning invoice")] [Display(Name = "Description template of the lightning invoice")]

View File

@@ -1,44 +1,33 @@
using System; using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Configuration; using BTCPayServer.Services.Invoices;
using BTCPayServer.Data;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using NBitcoin;
using NBitcoin.RPC; using NBitcoin.RPC;
namespace BTCPayServer.Services namespace BTCPayServer.Services
{ {
public class Cheater : IHostedService public class Cheater : IHostedService
{ {
private readonly ApplicationDbContextFactory _applicationDbContextFactory; private readonly InvoiceRepository _invoiceRepository;
public RPCClient CashCow { get; set; }
public Cheater(BTCPayServerOptions opts, ExplorerClientProvider prov, ApplicationDbContextFactory applicationDbContextFactory) public Cheater(
ExplorerClientProvider prov,
InvoiceRepository invoiceRepository)
{ {
CashCow = prov.GetExplorerClient("BTC")?.RPCClient; CashCow = prov.GetExplorerClient("BTC")?.RPCClient;
_applicationDbContextFactory = applicationDbContextFactory; _invoiceRepository = invoiceRepository;
}
public RPCClient CashCow
{
get;
set;
} }
public async Task UpdateInvoiceExpiry(string invoiceId, DateTimeOffset dateTimeOffset) public async Task UpdateInvoiceExpiry(string invoiceId, TimeSpan seconds)
{ {
using var ctx = _applicationDbContextFactory.CreateContext(); await _invoiceRepository.UpdateInvoiceExpiry(invoiceId, seconds);
var invoiceData = await ctx.Invoices.FindAsync(invoiceId).ConfigureAwait(false);
if (invoiceData == null)
return;
// TODO change the expiry time. But how?
await ctx.SaveChangesAsync().ConfigureAwait(false);
} }
Task IHostedService.StartAsync(CancellationToken cancellationToken) Task IHostedService.StartAsync(CancellationToken cancellationToken)
{ {
_ = CashCow?.ScanRPCCapabilitiesAsync(); _ = CashCow?.ScanRPCCapabilitiesAsync(cancellationToken);
return Task.CompletedTask; return Task.CompletedTask;
} }

View File

@@ -139,6 +139,28 @@ namespace BTCPayServer.Services.Invoices
await ctx.SaveChangesAsync().ConfigureAwait(false); await ctx.SaveChangesAsync().ConfigureAwait(false);
} }
public async Task UpdateInvoiceExpiry(string invoiceId, TimeSpan seconds)
{
await using var ctx = _applicationDbContextFactory.CreateContext();
var invoiceData = await ctx.Invoices.FindAsync(invoiceId);
var invoice = invoiceData.GetBlob(_btcPayNetworkProvider);
var expiry = DateTimeOffset.Now + seconds;
invoice.ExpirationTime = expiry;
invoice.MonitoringExpiration = expiry.AddHours(1);
invoiceData.Blob = ToBytes(invoice, _btcPayNetworkProvider.DefaultNetwork);
await ctx.SaveChangesAsync();
_eventAggregator.Publish(new InvoiceDataChangedEvent(invoice));
_ = InvoiceNeedUpdateEventLater(invoiceId, seconds);
}
async Task InvoiceNeedUpdateEventLater(string invoiceId, TimeSpan expirationIn)
{
await Task.Delay(expirationIn);
_eventAggregator.Publish(new InvoiceNeedUpdateEvent(invoiceId));
}
public async Task ExtendInvoiceMonitor(string invoiceId) public async Task ExtendInvoiceMonitor(string invoiceId)
{ {
using var ctx = _applicationDbContextFactory.CreateContext(); using var ctx = _applicationDbContextFactory.CreateContext();

View File

@@ -3,26 +3,36 @@
<template id="bitcoin-method-checkout-template"> <template id="bitcoin-method-checkout-template">
<div class="payment-box"> <div class="payment-box">
<small class="qr-text" id="QR_Text_@Model.PaymentMethodId">{{$t("QR_Text")}}</small> <div class="qr-container" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="model.invoiceBitcoinUrl" :data-clipboard-confirm="$t('copy_confirm')" :data-destination="model.btcAddress">
<div class="qr-container my-3" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="srvModel.btcAddress"> <qrcode v-if="model.invoiceBitcoinUrlQR" :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
<qrcode v-if="srvModel.invoiceBitcoinUrlQR" :value="srvModel.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div> </div>
<a v-if="srvModel.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top" <div class="mt-2 mb-4">
:href="srvModel.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')">{{$t("Pay in wallet")}}</a> <small class="qr-text" id="QR_Text_@Model.PaymentMethodId" v-t="'qr_text'"></small>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-bitcoin-post-content", model = Model }) @*
<input type="text" class="form-control form-control-sm" :value="model.btcAddress"
:data-clipboard="model.btcAddress" :data-clipboard-confirm="`$t('copy_confirm')"
data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" readonly>
*@
</div>
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top"
:href="model.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')" v-t="'pay_in_wallet'"></a>
</div> </div>
</template> </template>
<script> <script>
Vue.component('BitcoinLikeMethodCheckout', { Vue.component('BitcoinLikeMethodCheckout', {
props: ["srvModel"], props: ["model"],
template: "#bitcoin-method-checkout-template", template: "#bitcoin-method-checkout-template",
components: { components: {
qrcode: VueQrcode qrcode: VueQrcode
}, },
data () {
// currentTab is needed for backwards-compatibility with old plugin versions
return { currentTab: undefined };
},
computed: { computed: {
hasPayjoin () { hasPayjoin () {
return this.srvModel.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1; return this.model.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1;
} }
} }
}); });

View File

@@ -2,22 +2,32 @@
<template id="lightning-method-checkout-template"> <template id="lightning-method-checkout-template">
<div class="payment-box"> <div class="payment-box">
<small class="qr-text" id="QR_Text_@Model.PaymentMethodId">{{$t("QR_Text")}}</small> <div class="qr-container" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="model.invoiceBitcoinUrl" :data-destination="model.btcAddress">
<div class="qr-container my-3" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="srvModel.btcAddress"> <qrcode v-if="model.invoiceBitcoinUrlQR" :value="model.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
<qrcode v-if="srvModel.invoiceBitcoinUrlQR" :value="srvModel.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div> </div>
<a v-if="srvModel.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top" <div class="mt-1 mb-3">
:href="srvModel.invoiceBitcoinUrl">{{$t("Pay in wallet")}}</a> <small class="qr-text" id="QR_Text_@Model.PaymentMethodId" v-t="'qr_text'"></small>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-lightning-post-content", model = Model }) @*
<input type="text" class="form-control form-control-sm" :value="model.btcAddress"
:data-clipboard="model.invoiceBitcoinUrl" :data-clipboard-confirm="$t('copy_confirm')"
data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" readonly>
*@
</div>
<a v-if="model.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top"
:href="model.invoiceBitcoinUrl" v-t="'pay_in_wallet'"></a>
</div> </div>
</template> </template>
<script> <script>
Vue.component('LightningLikeMethodCheckout', { Vue.component('LightningLikeMethodCheckout', {
props: ["srvModel"], props: ["model"],
template: "#lightning-method-checkout-template", template: "#lightning-method-checkout-template",
components: { components: {
qrcode: VueQrcode qrcode: VueQrcode
},
data () {
// currentTab is needed for backwards-compatibility with old plugin versions
return { currentTab: undefined };
} }
}); });
</script> </script>

View File

@@ -17,7 +17,7 @@
@section PageFootContent { @section PageFootContent {
<script src="~/vendor/highlightjs/highlight.min.js" asp-append-version="true"></script> <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/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/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="~/vendor/clipboard.js/clipboard.js" asp-append-version="true"></script>
<script src="~/paybutton/paybutton.js" asp-append-version="true"></script> <script src="~/paybutton/paybutton.js" asp-append-version="true"></script>

View File

@@ -26,9 +26,9 @@
</a> </a>
@if (!string.IsNullOrEmpty(_env.OnionUrl) && !Context.Request.IsOnion()) @if (!string.IsNullOrEmpty(_env.OnionUrl) && !Context.Request.IsOnion())
{ {
<a href="@_env.OnionUrl" class="d-flex align-items-center" target="_onion" rel="noreferrer noopener" role="button" data-clipboard="@_env.OnionUrl" style="min-width:9em;"> <a href="@_env.OnionUrl" class="d-flex align-items-center" target="_onion" rel="noreferrer noopener" role="button" data-clipboard="@_env.OnionUrl" data-clipboard-confirm-element="CopyTorUrlText" style="min-width:9em;">
<vc:icon symbol="onion"/> <vc:icon symbol="onion"/>
<span style="margin-left:.4rem" data-clipboard-confirm="Copied URL ✔">Copy Tor URL</span> <span style="margin-left:.4rem" id="CopyTorUrlText">Copy Tor URL</span>
</a> </a>
} }
</div> </div>

View File

@@ -1,82 +1,81 @@
@model PaymentModel @model PaymentModel
<div id="Checkout-Cheating" class="mt-5" v-cloak> <main id="checkout-cheating" class="shadow-lg mt-4" v-cloak>
<p class="alert alert-success text-break" v-if="successMessage">{{ successMessage }}</p> <section>
<p class="alert alert-danger text-break" v-if="errorMessage">{{ errorMessage }}</p> <p id="CheatSuccessMessage" class="alert alert-success text-break" v-if="successMessage" v-text="successMessage"></p>
<form id="test-payment" :action="`/i/${invoiceId}/test-payment`" method="post" class="my-5" v-on:submit.prevent="pay" v-if="!isPaid"> <p id="CheatErrorMessage" class="alert alert-danger text-break" v-if="errorMessage" v-text="errorMessage"></p>
<input name="CryptoCode" type="hidden" value="@Model.CryptoCode"> <form id="test-payment" :action="`/i/${invoiceId}/test-payment`" method="post" v-on:submit.prevent="handleFormSubmit($event, 'paying')" v-if="!isPaid">
<label for="test-payment-amount" class="control-label form-label">{{$t("Fake a @Model.CryptoCode payment for testing")}}</label> <input name="CryptoCode" type="hidden" value="@Model.CryptoCode">
<div class="d-flex gap-3 mb-2"> <input name="PaymentMethodId" type="hidden" :value="paymentMethodId">
<div class="input-group"> <label for="FakePayAmount" class="control-label form-label">Fake a @Model.CryptoCode payment for testing</label>
<input id="test-payment-amount" name="Amount" type="number" step="0.00000001" min="0" class="form-control" placeholder="Amount" v-model="amountRemaining" :disabled="paying" /> <div class="d-flex gap-2 mb-2">
<div id="test-payment-crypto-code" class="input-group-addon input-group-text">@Model.CryptoCode</div> <div class="input-group">
<input id="FakePayAmount" name="Amount" type="number" step="0.00000001" min="0" class="form-control" placeholder="Amount" v-model="amountRemaining" :disabled="paying || paymentMethodId === 'BTC_LightningLike'"/>
<div id="test-payment-crypto-code" class="input-group-addon input-group-text">@Model.CryptoCode</div>
</div>
<button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="paying" id="FakePay">Pay</button>
</div> </div>
<button id="FakePayment" class="btn btn-primary flex-shrink-0" type="submit" :disabled="paying">{{$t("Fake Payment")}}</button> </form>
</div> <form id="mine-block" :action="`/i/${invoiceId}/mine-blocks`" method="post" class="mt-4" v-on:submit.prevent="handleFormSubmit($event, 'mining')" v-if="paymentMethodId === 'BTC'">
<small class="text-muted">{{$t("This is the same as running bitcoin-cli.sh sendtoaddress xxx")}}</small> <label for="BlockCount" class="control-label form-label">Mine to test processing and settlement</label>
</form> <div class="d-flex gap-2">
<form id="mine-block" :action="`/i/${invoiceId}/mine-blocks`" method="post" class="my-5" v-on:submit.prevent="mine"> <div class="input-group">
<!-- TODO only show when BTC On-chain --> <input id="BlockCount" name="BlockCount" type="number" step="1" min="1" class="form-control" value="1"/>
<label for="block-count" class="control-label form-label">{{$t("Mine a few blocks to test processing and settlement.")}}</label> <div class="input-group-addon input-group-text">blocks</div>
<div class="d-flex gap-3"> </div>
<div class="input-group"> <button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="mining" id="Mine">Mine</button>
<input id="block-count" name="BlockCount" type="number" step="1" min="1" class="form-control" value="1" /> </div>
<div class="input-group-addon input-group-text">{{$t("Blocks")}}</div> </form>
</div> <form id="expire-invoice" :action="`/i/${invoiceId}/expire`" method="post" class="mt-4" v-on:submit.prevent="handleFormSubmit($event, 'expiring')" v-if="!isPaid">
<button class="btn btn-secondary" type="submit">{{$t("Mine")}}</button> <label for="ExpirySeconds" class="control-label form-label">Expire invoice in …</label>
</div> <div class="d-flex gap-2">
</form> <div class="input-group">
<form id="expire-invoice" :action="`/i/${invoiceId}/expire`" method="post" class="my-5" v-on:submit.prevent="expire" v-if="!isPaid"> <input id="ExpirySeconds" name="Seconds" type="number" step="1" min="0" class="form-control" value="20" />
<button class="btn btn-secondary" type="submit" :disabled="expiring">{{$t("Expire Invoice Now")}}</button> <div class="input-group-addon input-group-text">seconds</div>
</form> </div>
</div> <button class="btn btn-secondary flex-shrink-0 px-3 w-100px" type="submit" :disabled="expiring" id="Expire">Expire</button>
</div>
</form>
</section>
</main>
<script> <script>
Vue.component('checkout-cheating', { Vue.component('checkout-cheating', {
el: '#Checkout-Cheating', el: '#checkout-cheating',
data () { data () {
return { return {
successMessage: null, successMessage: null,
errorMessage: null, errorMessage: null,
paying: false, paying: false,
mining: false,
expiring: false, expiring: false,
amountRemaining: parseFloat(this.btcDue) amountRemaining: parseFloat(this.btcDue)
} }
}, },
props: { props: {
invoiceId: String, invoiceId: String,
paymentMethodId: String,
btcDue: Number, btcDue: Number,
isPaid: Boolean isPaid: Boolean
}, },
methods: { methods: {
async pay (e) { async handleFormSubmit (e, processing) {
const form = e.target; const form = e.target;
const url = form.getAttribute('action'); const url = form.getAttribute('action');
const method = form.getAttribute('method'); const method = form.getAttribute('method');
const body = new FormData(form); const body = new FormData(form);
const headers = { 'Accept': 'application/json' } const headers = { 'Accept': 'application/json' }
this.paying = true; this[processing] = true;
this.successMessage = null;
this.errorMessage = null;
const response = await fetch(url, { method, body, headers }); const response = await fetch(url, { method, body, headers });
const data = await response.json(); const data = await response.json();
this.successMessage = data.successMessage; this.successMessage = data.successMessage;
this.errorMessage = data.errorMessage; this.errorMessage = data.errorMessage;
this.paying = false; if (data.amountRemaining) this.amountRemaining = data.amountRemaining;
}, this[processing] = false;
mine () {
console.log("TODO")
},
async expire (e) {
const form = e.target;
const url = form.getAttribute('action');
const method = form.getAttribute('method');
this.expiring = true;
const response = await fetch(url, { method });
const data = await response.json();
this.successMessage = data.successMessage;
this.errorMessage = data.errorMessage;
this.expiring = false;
} }
} }
}) })

View File

@@ -32,8 +32,8 @@
<script src="~/vendor/clipboard.js/clipboard.js" asp-append-version="true"></script> <script src="~/vendor/clipboard.js/clipboard.js" asp-append-version="true"></script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script> <script src="~/vendor/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/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/i18next.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18nextXHRBackend.js" asp-append-version="true"></script> <script src="~/vendor/i18next/i18nextHttpBackend.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script> <script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script>
<script src="~/vendor/jquery-prettydropdowns/jquery.prettydropdowns.js" asp-append-version="true"></script> <script src="~/vendor/jquery-prettydropdowns/jquery.prettydropdowns.js" asp-append-version="true"></script>
<script src="~/vendor/vex/js/vex.combined.min.js" asp-append-version="true"></script> <script src="~/vendor/vex/js/vex.combined.min.js" asp-append-version="true"></script>
@@ -149,7 +149,7 @@
var fallbackLanguage = "en"; var fallbackLanguage = "en";
startingLanguage = computeStartingLanguage(); startingLanguage = computeStartingLanguage();
i18next i18next
.use(window.i18nextXHRBackend) .use(window.i18nextHttpBackend)
.init({ .init({
backend: { backend: {
loadPath: @Safe.Json($"{Model.RootPath}locales/{{{{lng}}}}.json") loadPath: @Safe.Json($"{Model.RootPath}locales/{{{{lng}}}}.json")

View File

@@ -1,18 +1,17 @@
@inject LanguageService LangService @inject LanguageService LangService
@inject BTCPayServerEnvironment Env @inject BTCPayServerEnvironment Env
@inject IFileService FileService @inject IFileService FileService
@inject ThemeSettings Theme @inject IEnumerable<IUIExtension> UiExtensions
@inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary @inject PaymentMethodHandlerDictionary PaymentMethodHandlerDictionary
@using BTCPayServer.Services @using BTCPayServer.Services
@using BTCPayServer.Abstractions.Contracts @using BTCPayServer.Abstractions.Contracts
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Components.ThemeSwitch
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@model PaymentModel @model PaymentModel
@{ @{
Layout = null; Layout = null;
ViewData["Title"] = Model.HtmlTitle; ViewData["Title"] = Model.HtmlTitle;
var hasPaymentPlugins = UiExtensions.Any(extension => extension.Location == "checkout-payment-method");
var paymentMethodCount = Model.AvailableCryptos.Count; var paymentMethodCount = Model.AvailableCryptos.Count;
var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId) var logoUrl = !string.IsNullOrEmpty(Model.LogoFileId)
? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId) ? await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId)
@@ -25,24 +24,18 @@
? $"{pm.PaymentMethodName} {pm.CryptoCode}" ? $"{pm.PaymentMethodName} {pm.CryptoCode}"
: pm.PaymentMethodName.Replace("Bitcoin (", "").Replace(")", "").Replace("Lightning ", ""); : pm.PaymentMethodName.Replace("Bitcoin (", "").Replace(")", "").Replace("Lightning ", "");
} }
}
private string ToJsValue(object value)
{
return Safe.Json(value).ToString()?.Replace("\"", "'");
}
}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="@Model.DefaultLang"> <html lang="@Model.DefaultLang" class="@(Model.IsModal ? "checkout-modal" : "")"@(Env.IsDeveloping ? " data-devenv" : "")>
<head> <head>
<partial name="LayoutHead"/> <partial name="LayoutHead"/>
<meta name="robots" content="noindex,nofollow"> <meta name="robots" content="noindex,nofollow">
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" /> <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)) @if (!string.IsNullOrEmpty(Model.BrandColor))
{ {
<style> <style>
@@ -56,223 +49,142 @@
</style> </style>
} }
</head> </head>
<body> <body class="min-vh-100">
<div id="Checkout" class="wrap" v-cloak> <div id="Checkout" class="wrap" v-cloak v-waitForT>
<header> <header>
@if (!string.IsNullOrEmpty(logoUrl)) @if (!string.IsNullOrEmpty(logoUrl))
{ {
<img src="@logoUrl" alt="@Model.StoreName" class="logo @(!string.IsNullOrEmpty(Model.LogoFileId) ? "logo--square" : "")"/> <img src="@logoUrl" alt="@Model.StoreName" class="logo @(!string.IsNullOrEmpty(Model.LogoFileId) ? "logo--square" : "")"/>
} }
<h1 class="h5 mb-0">@Model.StoreName</h1> <h1 class="h4 mb-0">@Model.StoreName</h1>
</header> </header>
<main> <main class="shadow-lg">
<nav v-if="hasNav"> <nav v-if="isModal">
<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"> <button type="button" v-if="isModal" id="close" v-on:click="close">
<vc:icon symbol="close"/> <vc:icon symbol="close"/>
</button> </button>
</nav> </nav>
<section id="result" v-if="isPaid || isUnpayable"> <section id="payment" v-if="isActive">
<h5 class="text-center mt-1 mb-3 fw-semibold" v-if="srvModel.itemDesc" v-text="srvModel.itemDesc">@Model.ItemDesc</h5>
@if (Model.IsUnsetTopUp)
{
<h2 id="AmountDue" class="text-center mb-3" v-t="'any_amount'"></h2>
}
else
{
<h2 id="AmountDue" class="text-center" v-text="`${srvModel.btcDue} ${srvModel.cryptoCode}`" :data-clipboard="srvModel.btcDue" :data-clipboard-confirm="$t('copy_confirm')" :data-amount-due="btcDue">@Model.BtcDue @Model.CryptoCode</h2>
}
<div id="PaymentInfo" class="info mt-3 mb-2" v-collapsible="showInfo">
<div>
<div class="timer" v-if="showTimer">
<span class="spinner-border spinner-border-sm" role="status"><span class="visually-hidden"></span></span>
<span v-t="'expiry_info'"></span> <span class="expiryTime">{{timeText}}</span>
</div>
<div class="payment-due" v-if="showPaymentDueInfo">
<vc:icon symbol="info"/>
<span v-t="'partial_payment_info'"></span>
</div>
<div v-html="replaceNewlines($t('still_due', { amount: `${srvModel.btcDue} ${srvModel.cryptoCode}` }))"></div>
</div>
</div>
<button id="DetailsToggle" class="d-flex align-items-center gap-1 btn btn-link payment-details-button mb-2" type="button" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<span class="fw-semibold" v-t="'view_details'"></span>
<vc:icon symbol="caret-down"/>
</button>
<div id="PaymentDetails" class="payment-details" v-collapsible="displayPaymentDetails">
<payment-details :srv-model="srvModel" :is-active="isActive" class="pb-4"></payment-details>
</div>
@if (paymentMethodCount > 1 || hasPaymentPlugins)
{
<div class="mt-3 mb-2">
<h6 class="text-center mb-3" v-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 payment-method"
:class="{ active: pmId === @ToJsValue(crypto.PaymentMethodId) }"
v-on:click.prevent="changePaymentMethod(@ToJsValue(crypto.PaymentMethodId))">
@PaymentMethodName(crypto)
</a>
}
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-payment-method", model = Model })
</div>
</div>
}
<component v-if="paymentMethodComponent" :is="paymentMethodComponent" :model="srvModel" />
</section>
<section id="result" v-else>
<div id="paid" v-if="isPaid"> <div id="paid" v-if="isPaid">
<div class="top"> <div class="top">
<span class="text-success"> <span class="icn">
<vc:icon symbol="payment-complete"/> <vc:icon symbol="payment-complete"/>
</span> </span>
<h4>{{$t("Invoice paid")}}</h4> <h4 v-t="'invoice_paid'"></h4>
<dl> <dl class="mb-3">
<div> <div>
<dt>{{$t("Invoice ID")}}</dt> <dt v-t="'invoice_id'"></dt>
<dd>{{srvModel.invoiceId}}</dd> <dd v-text="srvModel.invoiceId"></dd>
</div> </div>
<div v-if="srvModel.orderId"> <div v-if="srvModel.orderId">
<dt>{{$t("Order ID")}}</dt> <dt v-t="'order_id'"></dt>
<dd>{{srvModel.orderId}}</dd> <dd v-text="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> </div>
</dl> </dl>
<payment-details :srv-model="srvModel" :is-active="isActive" class="mb-5"></payment-details>
</div> </div>
<div class="buttons"> <div class="buttons">
<a class="btn btn-primary" :href="srvModel.receiptLink" v-if="srvModel.receiptLink" :target="isModal ? '_blank' : '_top'">{{$t('View receipt')}}</a> <a v-if="srvModel.receiptLink" class="btn btn-primary" :href="srvModel.receiptLink" :target="isModal ? '_top' : null" v-t="'view_receipt'" id="ReceiptLink"></a>
<a class="btn btn-secondary" :href="srvModel.merchantRefLink" v-if="srvModel.merchantRefLink">{{$t('Return to StoreName', srvModel)}}</a> <a v-if="storeLink" class="btn btn-secondary" :href="storeLink" :target="isModal ? '_top' : null" v-t="{ path: 'return_to_store', args: { storeName: srvModel.storeName }}" id="StoreLink"></a>
<button v-else-if="isModal" class="btn btn-secondary" v-on:click="close" v-t="'Close'"></button>
</div> </div>
</div> </div>
<div id="expired" v-if="isUnpayable"> <div id="expired" v-if="isUnpayable">
<div class="top"> <div class="top">
<span class="text-muted"> <span class="icn">
<vc:icon symbol="invoice-expired"/> <vc:icon symbol="invoice-expired"/>
</span> </span>
<h4>{{$t("Invoice expired")}}</h4> <h4 v-t="'invoice_expired'"></h4>
<dl> <dl class="mb-0">
<div> <div>
<dt>{{$t("Invoice ID")}}</dt> <dt v-t="'invoice_id'"></dt>
<dd>{{srvModel.invoiceId}}</dd> <dd v-text="srvModel.invoiceId"></dd>
</div> </div>
<div v-if="srvModel.orderId"> <div v-if="srvModel.orderId">
<dt>{{$t("Order ID")}}</dt> <dt v-t="'order_id'"></dt>
<dd>{{srvModel.orderId}}</dd> <dd v-text="srvModel.orderId"></dd>
</div> </div>
</dl> </dl>
<p v-html="$t('InvoiceExpired_Body_1', {storeName: srvModel.storeName, maxTimeMinutes: @Model.MaxTimeMinutes})"></p> <div id="PaymentDetails" class="payment-details" v-collapsible="displayPaymentDetails">
<p>{{$t("InvoiceExpired_Body_2")}}</p> <payment-details :srv-model="srvModel" :is-active="isActive"></payment-details>
<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> </div>
<template v-if="srvModel.checkoutFormId && srvModel.checkoutFormId !== 'None'"> <button class="d-flex align-items-center gap-1 btn btn-link payment-details-button" type="button" :aria-expanded="displayPaymentDetails ? 'true' : 'false'" v-on:click="displayPaymentDetails = !displayPaymentDetails">
<p class="my-5 text-center">TODO: Forms integration -> {{srvModel.checkoutFormId}}</p> <span class="fw-semibold" v-t="'view_details'"></span>
</template> <vc:icon symbol="caret-down"/>
<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> </button>
<p class="text-center mt-3" v-html="replaceNewlines($t('invoice_expired_body', { storeName: srvModel.storeName, minutes: @Model.MaxTimeMinutes }))"></p>
</div> </div>
</form> <div class="buttons">
</section> <a v-if="!isModal && storeLink" class="btn btn-primary" :href="storeLink" :target="isModal ? '_top' : null" v-t="{ path: 'return_to_store', args: { storeName: srvModel.storeName }}" id="StoreLink"></a>
<section id="payment" v-else> <button v-else-if="isModal" class="btn btn-primary" v-on:click="close" v-t="'Close'"></button>
<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>
<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> </div>
<component v-if="srvModel.uiSettings && srvModel.activated"
:srv-model="srvModel"
:is="srvModel.uiSettings.checkoutBodyVueComponentName"/>
</section> </section>
</main> </main>
@if (Env.CheatMode) @if (Env.CheatMode)
{ {
<checkout-cheating v-if="step === 'payment'" invoice-id="@Model.InvoiceId" :btc-due="btcDue" :is-paid="isPaid" /> <checkout-cheating v-if="isActive" invoice-id="@Model.InvoiceId" :btc-due="btcDue" :is-paid="isPaid" :payment-method-id="pmId"></checkout-cheating>
} }
<footer> <footer>
<select asp-for="DefaultLang" asp-items="@LangService.GetLanguageSelectListItems()" v-on:change="changeLanguage"></select> <div>
<a href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
<div class="text-muted my-2"> {{$t("powered_by")}}
Powered by <a href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">BTCPay Server</a> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 84" role="img" alt="BTCPay Server" class="ms-1"><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>
</a>
</div> </div>
@if (!Theme.CustomTheme) @* TODO: Re-add this once checkout v2 has been translated
{ <select asp-for="DefaultLang" asp-items="@LangService.GetLanguageSelectListItems()" class="form-select w-auto" v-on:change="changeLanguage"></select>
<vc:theme-switch css-class="text-muted ms-n3" responsive="none"/> *@
}
</footer> </footer>
</div> </div>
<noscript> <noscript>
@@ -285,10 +197,56 @@
</p> </p>
</div> </div>
</noscript> </noscript>
<dl id="payment-details" v-cloak>
<div v-if="orderAmount > 0">
<dt v-t="'total_price'"></dt>
<dd :data-clipboard="srvModel.orderAmount" :data-clipboard-confirm="$t('copy_confirm')">{{srvModel.orderAmount}} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat">
<dt v-t="'total_fiat'"></dt>
<dd :data-clipboard="srvModel.orderAmountFiat" :data-clipboard-confirm="$t('copy_confirm')">{{srvModel.orderAmountFiat}}</dd>
</div>
<div v-if="srvModel.rate && srvModel.cryptoCode">
<dt v-t="'exchange_rate'"></dt>
<dd :data-clipboard="srvModel.rate" :data-clipboard-confirm="$t('copy_confirm')">
<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.networkFee">
<dt v-t="'network_cost'"></dt>
<dd :data-clipboard="srvModel.networkFee" :data-clipboard-confirm="$t('copy_confirm')">
<template v-if="srvModel.txCountForFee > 0">
{{$t('tx_count', { count: srvModel.txCount })}} x
</template>
{{ srvModel.networkFee }} {{ srvModel.cryptoCode }}
</dd>
</div>
<div v-if="btcPaid > 0">
<dt v-t="'amount_paid'"></dt>
<dd :data-clipboard="srvModel.btcPaid" :data-clipboard-confirm="$t('copy_confirm')">{{srvModel.btcPaid }} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="btcDue > 0">
<dt v-t="'amount_due'"></dt>
<dd :data-clipboard="srvModel.btcDue" :data-clipboard-confirm="$t('copy_confirm')">{{srvModel.btcDue}} {{ srvModel.cryptoCode }}</dd>
</div>
<div v-if="showRecommendedFee">
<dt v-t="'recommended_fee'"></dt>
<dd :data-clipboard="srvModel.feeRate" :data-clipboard-confirm="$t('copy_confirm')" v-t="{ path: 'fee_rate', args: { feeRate: srvModel.feeRate } }"></dd>
</div>
</dl>
<script>
const i18nUrl = @Safe.Json($"{Model.RootPath}locales/checkout/{{{{lng}}}}.json");
const statusUrl = @Safe.Json(Url.Action("GetStatus", new { invoiceId = Model.InvoiceId }));
const statusWsUrl = @Safe.Json(Url.Action("GetStatusWebSocket", new { invoiceId = Model.InvoiceId }));
const availableLanguages = ['en']; // @Safe.Json(LangService.GetLanguages().Select(language => language.Code));
const initialSrvModel = @Safe.Json(Model);
const qrOptions = { margin: 1, type: 'svg', color: { dark: '#000', light: '#fff' } };
</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script> <script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-qrcode/vue-qrcode.min.js" asp-append-version="true"></script> <script src="~/vendor/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/i18next.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/i18nextXHRBackend.js" asp-append-version="true"></script> <script src="~/vendor/i18next/i18nextHttpBackend.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script> <script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script>
<script src="~/js/copy-to-clipboard.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="~/main/utils.js" asp-append-version="true"></script>
@@ -297,242 +255,6 @@
{ {
<partial name="Checkout-Cheating" model="@Model" /> <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 @foreach (var paymentMethodHandler in PaymentMethodHandlerDictionary
.Select(handler => handler.GetCheckoutUISettings()) .Select(handler => handler.GetCheckoutUISettings())
.Where(settings => settings != null) .Where(settings => settings != null)
@@ -540,6 +262,6 @@
{ {
<partial name="@paymentMethodHandler.ExtensionPartial-v2" model="@Model"/> <partial name="@paymentMethodHandler.ExtensionPartial-v2" model="@Model"/>
} }
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-end", model = Model }) @await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-payment", model = Model })
</body> </body>
</html> </html>

View File

@@ -35,15 +35,22 @@
<h3 class="mt-5 mb-3">Branding</h3> <h3 class="mt-5 mb-3">Branding</h3>
<div class="form-group"> <div class="form-group">
<label asp-for="LogoFile" class="form-label"></label> <div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="LogoFile" class="form-label"></label>
@if (!string.IsNullOrEmpty(Model.LogoFileId))
{
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveLogoFile" value="true">
<span class="fa fa-times"></span> Remove
</button>
}
</div>
@if (canUpload) @if (canUpload)
{ {
<div class="d-flex flex-wrap gap-3 align-items-center"> <div class="d-flex align-items-center gap-3">
<input asp-for="LogoFile" type="file" class="form-control flex-grow"> <input asp-for="LogoFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.LogoFileId)) @if (!string.IsNullOrEmpty(Model.LogoFileId))
{ {
<img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId))" alt="Logo" style="height:2.1rem;max-width:10.5rem;"/> <img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId))" alt="Logo" style="height:2.1rem;max-width:10.5rem;"/>
<button type="submit" class="btn btn-sm btn-outline-danger" name="RemoveLogoFile" value="true">Remove</button>
} }
</div> </div>
<span asp-validation-for="LogoFile" class="text-danger"></span> <span asp-validation-for="LogoFile" class="text-danger"></span>

View File

@@ -1,7 +1,5 @@
@using BTCPayServer.Payments @using BTCPayServer.Payments
@using BTCPayServer.Services.Invoices
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Services.Stores @using BTCPayServer.Services.Stores
@model CheckoutAppearanceViewModel @model CheckoutAppearanceViewModel
@{ @{
@@ -9,7 +7,6 @@
ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id);
var store = ViewContext.HttpContext.GetStoreData(); var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, false);
} }
@section PageFootContent { @section PageFootContent {
@@ -64,10 +61,79 @@
</div> </div>
} }
<div class="form-check my-3"> <h3 class="mt-5 mb-3">Checkout</h3>
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" /> <div class="d-flex align-items-center mb-3">
<label asp-for="RequiresRefundEmail" class="form-check-label"></label> <input asp-for="UseNewCheckout" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target=".checkout-settings" aria-expanded="@(Model.UseNewCheckout)" aria-controls="NewCheckoutSettings" />
<div>
<label asp-for="UseNewCheckout" class="d-flex align-items-center form-label">
Use the new checkout
<span class="badge bg-warning ms-2">Experimental</span>
</label>
<span asp-validation-for="UseNewCheckout" class="text-danger"></span>
<div class="text-muted">
Since v1.7.0 a new version of the checkout is available. Note: For now, the new version is English-only.<br/>
We are still collecting <a href="https://github.com/btcpayserver/btcpayserver/discussions/4308" target="_blank" rel="noreferrer noopener">feedback</a> and offer this as an opt-in feature.
</div>
</div>
</div> </div>
<div class="checkout-settings collapse @(Model.UseNewCheckout ? "show" : "")" id="NewCheckoutSettings">
<div class="form-check">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input"/>
<label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label>
<a href="https://bitcoinqr.dev/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div>
</div>
<div class="checkout-settings collapse @(Model.UseNewCheckout ? "" : "show")" id="OldCheckoutSettings">
<div class="form-check">
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" />
<label asp-for="RequiresRefundEmail" class="form-check-label"></label>
</div>
<div class="form-group">
<div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input"/>
<label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<p class="text-muted">Detects the language of the customer's browser with 99.9% accuracy.</p>
</div>
</div>
<div class="form-group">
<label asp-for="DefaultLang" class="form-label"></label>
<select asp-for="DefaultLang" asp-items="Model.Languages" class="form-select w-auto"></select>
</div>
<div class="form-group">
<label asp-for="CustomLogo" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
<input asp-for="CustomLogo" class="form-control" />
<span asp-validation-for="CustomLogo" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CustomCSS" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
<input asp-for="CustomCSS" class="form-control" />
<span asp-validation-for="CustomCSS" class="text-danger"></span>
<p class="form-text text-muted">
Bundled Themes:
<a href="#" class="setTheme" data-theme="default">Default</a> |
<a href="#" class="setTheme" data-theme="dark">Dark</a> |
<a href="#" class="setTheme" data-theme="legacy">Legacy</a>
</p>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "invoice-checkout-theme-options", model = Model })
</div>
</div>
<div class="form-group">
<label asp-for="HtmlTitle" class="form-label"></label>
<input asp-for="HtmlTitle" class="form-control" />
<span asp-validation-for="HtmlTitle" class="text-danger"></span>
</div>
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="LazyPaymentMethods" type="checkbox" class="form-check-input" /> <input asp-for="LazyPaymentMethods" type="checkbox" class="form-check-input" />
<label asp-for="LazyPaymentMethods" class="form-check-label"></label> <label asp-for="LazyPaymentMethods" class="form-check-label"></label>
@@ -77,34 +143,6 @@
<label asp-for="RedirectAutomatically" class="form-check-label"></label> <label asp-for="RedirectAutomatically" class="form-check-label"></label>
</div> </div>
<h3 class="mt-5 mb-3 d-flex align-items-center">
New checkout
<span class="badge bg-warning ms-3" style="font-size:10px;">Experimental</span>
</h3>
<div class="d-flex align-items-center mb-3">
<input asp-for="UseNewCheckout" type="checkbox" class="btcpay-toggle me-3" data-bs-toggle="collapse" data-bs-target="#NewCheckoutSettings" aria-expanded="@(Model.UseNewCheckout)" aria-controls="NewCheckoutSettings" />
<div>
<label asp-for="UseNewCheckout" class="form-label mb-0"></label>
<span asp-validation-for="UseNewCheckout" class="text-danger"></span>
<div class="text-muted">
Since v1.7.0 a new version of the checkout is available.<br/>
We are still collecting feedback and offer this as an opt-in feature.
</div>
</div>
</div>
<div class="collapse @(Model.UseNewCheckout ? "show" : "")" id="NewCheckoutSettings">
<div class="form-group pt-2">
<label asp-for="CheckoutFormId" class="form-label"></label>
<select asp-for="CheckoutFormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
<span asp-validation-for="CheckoutFormId" class="text-danger"></span>
</div>
<div class="form-check mb-0">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input"/>
<label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label>
</div>
</div>
<h3 class="mt-5 mb-3">Public receipt</h3> <h3 class="mt-5 mb-3">Public receipt</h3>
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" /> <input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" />
@@ -114,54 +152,12 @@
<input asp-for="ReceiptOptions.ShowPayments" type="checkbox" class="form-check-input" /> <input asp-for="ReceiptOptions.ShowPayments" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.ShowPayments" class="form-check-label"></label> <label asp-for="ReceiptOptions.ShowPayments" class="form-check-label"></label>
</div> </div>
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="form-check-input" /> <input asp-for="ReceiptOptions.ShowQR" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label> <label asp-for="ReceiptOptions.ShowQR" class="form-check-label"></label>
</div> </div>
<h3 class="mt-5 mb-3">Language</h3> <button type="submit" class="btn btn-primary mt-4" id="Save">Save</button>
<div class="form-group">
<div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input" />
<label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<p class="text-muted">Detects the language of the customer's browser with 99.9% accuracy.</p>
</div>
</div>
<div class="form-group">
<label asp-for="DefaultLang" class="form-label"></label>
<select asp-for="DefaultLang" asp-items="Model.Languages" class="form-select w-auto"></select>
</div>
<h3 class="mt-5 mb-3">Appearance</h3>
<div class="form-group">
<label asp-for="HtmlTitle" class="form-label"></label>
<input asp-for="HtmlTitle" class="form-control" />
<span asp-validation-for="HtmlTitle" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CustomLogo" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
<input asp-for="CustomLogo" class="form-control" />
<span asp-validation-for="CustomLogo" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="CustomCSS" class="form-label"></label>
<a href="https://docs.btcpayserver.org/Development/Theme/#checkout-page-themes" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
<input asp-for="CustomCSS" class="form-control" />
<span asp-validation-for="CustomCSS" class="text-danger"></span>
<p class="form-text text-muted">
Bundled Themes:
<a href="#" class="setTheme" data-theme="default">Default</a> |
<a href="#" class="setTheme" data-theme="dark">Dark</a> |
<a href="#" class="setTheme" data-theme="legacy">Legacy</a>
</p>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "invoice-checkout-theme-options", model = Model })
</div>
<button name="command" type="submit" class="btn btn-primary" value="Save">Save</button>
</form> </form>
</div> </div>
</div> </div>

View File

@@ -35,17 +35,29 @@
<h3 class="mt-5 mb-3">Branding</h3> <h3 class="mt-5 mb-3">Branding</h3>
<div class="form-group"> <div class="form-group">
<label asp-for="LogoFile" class="form-label"></label> <div class="d-flex align-items-center justify-content-between gap-2">
<label asp-for="LogoFile" class="form-label"></label>
@if (!string.IsNullOrEmpty(Model.LogoFileId))
{
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveLogoFile" value="true">
<span class="fa fa-times"></span> Remove
</button>
}
</div>
@if (canUpload) @if (canUpload)
{ {
<div class="d-flex align-items-center"> <div class="d-flex align-items-center gap-3">
<input asp-for="LogoFile" type="file" class="form-control flex-grow"> <input asp-for="LogoFile" type="file" class="form-control flex-grow">
@if (!string.IsNullOrEmpty(Model.LogoFileId)) @if (!string.IsNullOrEmpty(Model.LogoFileId))
{ {
<img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId))" alt="@Model.StoreName" class="rounded-circle ms-3" style="width:2.1rem;height:2.1rem;"/> <img src="@(await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.LogoFileId))" alt="@Model.StoreName" class="rounded-circle" style="width:2.1rem;height:2.1rem;"/>
} }
</div> </div>
<p class="form-text text-muted">Please upload an image with square dimension, as it will be displayed in 1:1 format and circular.</p> <span asp-validation-for="LogoFile" class="text-danger"></span>
<p class="form-text text-muted">
Please upload an image with square dimension, as it will be displayed in 1:1 format and circular.
Size should be around 100✕100px.
</p>
} }
else else
{ {
@@ -120,7 +132,7 @@
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span> <span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
</div> </div>
<button name="command" type="submit" class="btn btn-primary mt-2" value="Save" id="Save">Save</button> <button type="submit" class="btn btn-primary mt-2" id="Save">Save</button>
</form> </form>
@if (Model.CanDelete) @if (Model.CanDelete)
{ {

View File

@@ -67,6 +67,9 @@
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input"/> <input asp-for="OnChainWithLnInvoiceFallback" type="checkbox" class="form-check-input"/>
<label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label> <label asp-for="OnChainWithLnInvoiceFallback" class="form-check-label"></label>
<a href="https://bitcoinqr.dev/" target="_blank" rel="noreferrer noopener">
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
</a>
</div> </div>
<div class="form-group mt-3"> <div class="form-group mt-3">
<label asp-for="LightningDescriptionTemplate" class="form-label"></label> <label asp-for="LightningDescriptionTemplate" class="form-label"></label>

View File

@@ -2,22 +2,31 @@
--logo-size: 3rem; --logo-size: 3rem;
--navbutton-size: .8rem; --navbutton-size: .8rem;
--qr-size: 256px; --qr-size: 256px;
--section-padding: var(--btcpay-space-l); --section-padding: 1.5rem;
--border-radius: var(--btcpay-border-radius-l);
--wrap-max-width: 400px;
} }
body { .wrap {
display: flex;
flex-direction: column;
min-height: 100vh;
margin: 0 auto;
padding: var(--btcpay-space-m); padding: var(--btcpay-space-m);
max-width: var(--wrap-max-width);
} }
header, header,
footer { footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding: var(--btcpay-space-l) var(--btcpay-space-s); padding: var(--section-padding);
gap: var(--btcpay-space-m); }
header {
gap: var(--btcpay-space-s);
} }
main { main {
position: relative; position: relative;
border-radius: var(--btcpay-border-radius-l); border-radius: var(--border-radius);
background-color: var(--btcpay-bg-tile); background-color: var(--btcpay-bg-tile);
} }
nav { nav {
@@ -36,9 +45,6 @@ nav button {
nav button:hover { nav button:hover {
color: var(--btcpay-body-text-hover); color: var(--btcpay-body-text-hover);
} }
nav button#back {
left: 0;
}
nav button#close { nav button#close {
right: 0; right: 0;
} }
@@ -68,12 +74,10 @@ section .buttons {
flex-direction: column; flex-direction: column;
gap: var(--btcpay-space-m); gap: var(--btcpay-space-m);
} }
section dl {
margin-bottom: 1.5rem;
}
section dl > div { section dl > div {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
gap: var(--btcpay-space-m);
} }
section dl > div dt, section dl > div dt,
section dl > div dd { section dl > div dd {
@@ -83,9 +87,13 @@ section dl > div dd {
} }
section dl > div dt { section dl > div dt {
text-align: left; text-align: left;
white-space: nowrap;
color: var(--btcpay-body-text-muted);
} }
section dl > div dd { section dl > div dd {
text-align: right; text-align: right;
word-wrap: break-word;
word-break: break-word;
} }
.logo { .logo {
height: var(--logo-size); height: var(--logo-size);
@@ -94,18 +102,38 @@ section dl > div dd {
width: var(--logo-size); width: var(--logo-size);
border-radius: 50%; border-radius: 50%;
} }
.wrap { .info {
max-width: 400px; color: var(--btcpay-neutral-700);
margin: 0 auto; background-color: var(--btcpay-body-bg);
border-radius: var(--border-radius);
} }
.timer { .info .expiryTime {
color: var(--btcpay-body-text);
}
.info > div {
padding: var(--btcpay-space-m) var(--btcpay-space-m);
}
.info > div > div {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: var(--btcpay-space-m) calc(var(--section-padding) * -1) var(--btcpay-space-s);
padding: var(--btcpay-space-s) var(--section-padding);
background-color: var(--btcpay-body-bg-medium);
text-align: center; text-align: center;
gap: var(--btcpay-space-xs);
}
.info > div > div + div {
margin-top: var(--btcpay-space-s);
}
.info .spinner-border {
width: var(--btcpay-font-size-s);
height: var(--btcpay-font-size-s);
color: var(--btcpay-body-text-muted);
margin-right: var(--btcpay-space-xs);
animation-duration: 1s;
}
.info .icon {
width: 1.25rem;
height: 1.25rem;
color: var(--btcpay-info);
} }
.payment-box { .payment-box {
max-width: 300px; max-width: 300px;
@@ -120,12 +148,44 @@ section dl > div dd {
.payment-box .qr-container { .payment-box .qr-container {
min-height: var(--qr-size); min-height: var(--qr-size);
} }
.payment-box .qr-container svg {
border-radius: var(--btcpay-border-radius);
}
.payment-box svg { .payment-box svg {
width: 100%; width: 100%;
} }
.payment-box [data-clipboard] { .payment-details dl {
margin: 0;
}
.payment-details-button {
margin: 0 auto;
padding: var(--btcpay-space-s);
}
.payment-details-button .icon {
margin-left: -1rem; /* Adjust for visual center */
}
[data-clipboard] {
cursor: copy; cursor: copy;
} }
.payment-details [data-clipboard] {
position: relative;
}
.payment-details [data-clipboard]::before {
content: '';
position: absolute;
top: .5rem;
left: -1.5rem;
width: 1rem;
height: 1rem;
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CiAgPGcgY2xpcC1wYXRoPSJ1cmwoI2EpIiBzdHJva2U9IiM4Rjk3OUUiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+CiAgICA8cGF0aCBkPSJNMTMuMzMgNmgtNkM2LjYgNiA2IDYuNiA2IDcuMzN2NmMwIC43NC42IDEuMzQgMS4zMyAxLjM0aDZjLjc0IDAgMS4zNC0uNiAxLjM0LTEuMzR2LTZjMC0uNzMtLjYtMS4zMy0xLjM0LTEuMzNaIi8+CiAgICA8cGF0aCBkPSJNMy4zMyAxMGgtLjY2YTEuMzMgMS4zMyAwIDAgMS0xLjM0LTEuMzN2LTZhMS4zMyAxLjMzIDAgMCAxIDEuMzQtMS4zNGg2QTEuMzMgMS4zMyAwIDAgMSAxMCAyLjY3di42NiIvPgogIDwvZz4KICA8ZGVmcz4KICAgIDxjbGlwUGF0aCBpZD0iYSI+CiAgICAgIDxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0wIDBoMTZ2MTZIMHoiLz4KICAgIDwvY2xpcFBhdGg+CiAgPC9kZWZzPgo8L3N2Zz4K");
pointer-events: none;
opacity: 0;
transition: opacity var(--btcpay-transition-duration-fast);
}
.payment-details [data-clipboard]:hover::before {
opacity: 1;
}
#payment .btcpay-pills .btcpay-pill { #payment .btcpay-pills .btcpay-pill {
padding: var(--btcpay-space-xs) var(--btcpay-space-m); padding: var(--btcpay-space-xs) var(--btcpay-space-m);
} }
@@ -134,19 +194,79 @@ section dl > div dd {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
#result .top .icon {
#result .top .icn .icon {
display: block; display: block;
width: 3rem; width: 3rem;
height: 3rem; height: 3rem;
margin: .5rem auto 1.5rem; margin: .5rem auto 1.5rem;
} }
#PaymentDetails dl {
margin: 0; #result #paid .top .icn .icon {
color: var(--btcpay-primary);
} }
#PaymentDetailsButton { #result #expired .top .icn .icon {
margin: 0 auto; color: var(--btcpay-body-text-muted);
padding: var(--btcpay-space-s);
} }
#PaymentDetailsButton .icon { footer {
margin-left: -1rem; /* Adjust for visual center */ margin-top: auto;
padding-top: 2.5rem;
gap: var(--btcpay-space-m);
}
footer,
footer a,
#DefaultLang {
color: var(--btcpay-body-text-muted);
}
footer a {
transition-duration: unset;
}
footer a svg {
height: 2rem;
width: 4rem;
}
#DefaultLang {
background-color: transparent;
box-shadow: none;
border: none;
text-align: right;
cursor: pointer;
margin-left: -4.5rem; /* Adjust for visual center */
}
footer a:hover,
#DefaultLang:hover {
color: var(--btcpay-body-text-hover);
}
footer a:hover .logo-brand-light {
color: var(--btcpay-brand-secondary);
}
footer a:hover .logo-brand-medium {
color: var(--btcpay-brand-primary);
}
footer a:hover .logo-brand-dark {
color: var(--btcpay-brand-tertiary);
}
@media (max-width: 400px) {
.wrap {
padding: 0;
}
main {
border-radius: 0;
}
}
/* Modal adjustments */
.checkout-modal body {
background: rgba(var(--btcpay-black-rgb), 0.85);
}
.checkout-modal h1,
.checkout-modal footer a:hover,
.checkout-modal #DefaultLang:hover {
color: var(--btcpay-white);
}
/* Plugins */
.payment-box .plugins > .payment {
margin-top: var(--btcpay-space-l);
} }

View File

@@ -1,29 +1,7 @@
const urlParams = {};
(window.onpopstate = function () {
let match,
pl = /\+/g, // Regex for replacing addition symbol with a space
search = /([^&=]+)=?([^&]*)/g,
decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); },
query = window.location.search.substring(1);
while (match = search.exec(query)) {
urlParams[decode(match[1])] = decode(match[2]);
}
})();
document.addEventListener('DOMContentLoaded', () => {
// Theme Switch
delegate('click', '.btcpay-theme-switch', e => {
e.preventDefault()
const current = document.documentElement.getAttribute(THEME_ATTR) || COLOR_MODES[0]
const mode = current === COLOR_MODES[0] ? COLOR_MODES[1] : COLOR_MODES[0]
setColorMode(mode)
e.target.closest('.btcpay-theme-switch').blur()
})
});
Vue.directive('collapsible', { Vue.directive('collapsible', {
bind: function (el) { bind: function (el, binding) {
el.classList.add('collapse');
el.classList[binding.value ? 'add' : 'remove']('show');
el.transitionDuration = 350; el.transitionDuration = 350;
}, },
update: function (el, binding) { update: function (el, binding) {
@@ -35,26 +13,273 @@ Vue.directive('collapsible', {
el.classList.add('collapsing'); el.classList.add('collapsing');
el.offsetHeight; el.offsetHeight;
el.style.height = height; el.style.height = height;
setTimeout(() => { setTimeout(function () {
el.classList.remove('collapsing'); el.classList.remove('collapsing');
el.classList.add('collapse'); el.classList.add('collapse');
el.style.height = null; el.style.height = null;
el.classList.add('show'); el.classList.add('show');
}, el.transitionDuration) }, el.transitionDuration)
}, 0); }, 0);
} } else {
else {
el.style.height = window.getComputedStyle(el).height; el.style.height = window.getComputedStyle(el).height;
el.classList.remove('collapse'); el.classList.remove('collapse');
el.classList.remove('show'); el.classList.remove('show');
el.offsetHeight; el.offsetHeight;
el.style.height = null; el.style.height = null;
el.classList.add('collapsing'); el.classList.add('collapsing');
setTimeout(() => { setTimeout(function () {
el.classList.add('collapse'); el.classList.add('collapse');
el.classList.remove("collapsing"); el.classList.remove('collapsing');
}, el.transitionDuration) }, el.transitionDuration)
} }
} }
} }
}); });
const fallbackLanguage = 'en';
const startingLanguage = computeStartingLanguage();
const STATUS_PAID = ['complete', 'confirmed', 'paid'];
const STATUS_UNPAYABLE = ['expired', 'invalid'];
function computeStartingLanguage() {
const { defaultLang } = initialSrvModel;
return isLanguageAvailable(defaultLang) ? defaultLang : fallbackLanguage;
}
function isLanguageAvailable(languageCode) {
return availableLanguages.indexOf(languageCode) >= 0;
}
Vue.use(VueI18next);
const i18n = new VueI18next(i18next);
const eventBus = new Vue();
const PaymentDetails = Vue.component('payment-details', {
el: '#payment-details',
props: {
srvModel: Object,
isActive: Boolean
},
computed: {
orderAmount () {
return parseFloat(this.srvModel.orderAmount);
},
btcDue () {
return parseFloat(this.srvModel.btcDue);
},
btcPaid () {
return parseFloat(this.srvModel.btcPaid);
},
showRecommendedFee () {
return this.isActive && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
},
}
});
function initApp() {
return new Vue({
i18n,
el: '#Checkout',
components: {
PaymentDetails
},
data () {
const srvModel = initialSrvModel;
return {
srvModel,
displayPaymentDetails: false,
remainingSeconds: srvModel.expirationSeconds,
expirationPercentage: 0,
emailAddressInput: "",
emailAddressInputDirty: false,
emailAddressInputInvalid: false,
paymentMethodId: null,
endData: null,
isModal: srvModel.isModal
}
},
computed: {
isUnpayable () {
return STATUS_UNPAYABLE.includes(this.srvModel.status);
},
isPaid () {
return STATUS_PAID.includes(this.srvModel.status);
},
isActive () {
return !this.isUnpayable && !this.isPaid;
},
showInfo () {
return this.showTimer || this.showPaymentDueInfo;
},
showTimer () {
return this.isActive && (this.expirationPercentage >= 75 || this.minutesLeft < 5);
},
showPaymentDueInfo () {
return this.btcPaid > 0 && this.btcDue > 0;
},
showRecommendedFee () {
return this.isActive() && this.srvModel.showRecommendedFee && this.srvModel.feeRate;
},
orderAmount () {
return parseFloat(this.srvModel.orderAmount);
},
btcDue () {
return parseFloat(this.srvModel.btcDue);
},
btcPaid () {
return parseFloat(this.srvModel.btcPaid);
},
pmId () {
return this.paymentMethodId || this.srvModel.paymentMethodId;
},
minutesLeft () {
return Math.floor(this.remainingSeconds / 60);
},
secondsLeft () {
return Math.floor(this.remainingSeconds % 60);
},
timeText () {
return this.remainingSeconds > 0
? `${this.padTime(this.minutesLeft)}:${this.padTime(this.secondsLeft)}`
: '00:00';
},
storeLink () {
return this.srvModel.merchantRefLink && this.srvModel.merchantRefLink !== this.srvModel.receiptLink
? this.srvModel.merchantRefLink
: null;
},
paymentMethodIds () {
return this.srvModel.availableCryptos.map(function (c) { return c.paymentMethodId });
},
paymentMethodComponent () {
return this.isPluginPaymentMethod
? `${this.pmId}Checkout`
: this.srvModel.activated && this.srvModel.uiSettings.checkoutBodyVueComponentName;
},
isPluginPaymentMethod () {
return !this.paymentMethodIds.includes(this.pmId);
}
},
mounted () {
this.updateData(this.srvModel);
this.updateTimer();
if (this.isActive) {
this.listenIn();
}
window.parent.postMessage('loaded', '*');
},
methods: {
changePaymentMethod (id) { // payment method or plugin id
if (this.pmId !== id) {
this.paymentMethodId = id;
this.fetchData();
}
},
changeLanguage (e) {
const lang = e.target.value;
if (isLanguageAvailable(lang)) {
i18next.changeLanguage(lang);
}
},
padTime (val) {
return val.toString().padStart(2, '0');
},
close () {
window.parent.postMessage('close', '*');
},
updateTimer () {
this.remainingSeconds = Math.floor((this.endDate.getTime() - new Date().getTime())/1000);
this.expirationPercentage = 100 - Math.floor((this.remainingSeconds / this.srvModel.maxTimeSeconds) * 100);
if (this.isActive) {
setTimeout(this.updateTimer, 500);
}
},
listenIn () {
let socket = null;
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 = async function (e) {
if (e.data !== 'ping') await updateFn();
};
socket.onerror = function (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);
}
}
// fallback in case there is no websocket support
(function watcher() {
setTimeout(async function () {
if (socket === null || socket.readyState !== 1) {
await updateFn();
}
watcher();
}, 2000);
})();
},
async fetchData () {
if (this.isPluginPaymentMethod) return;
const url = `${statusUrl}&paymentMethodId=${this.pmId}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
this.updateData(data);
}
},
updateData (data) {
if (this.srvModel.status !== data.status) {
const { invoiceId } = this.srvModel;
const { status } = data;
window.parent.postMessage({ invoiceId, status }, '*');
}
// displaying satoshis for lightning payments
data.cryptoCodeSrv = data.cryptoCode;
const newEnd = new Date();
newEnd.setSeconds(newEnd.getSeconds() + data.expirationSeconds);
this.endDate = newEnd;
// updating ui
this.srvModel = data;
eventBus.$emit('data-fetched', this.srvModel);
const self = this;
if (this.isPaid && data.redirectAutomatically && data.merchantRefLink) {
setTimeout(function () {
if (self.isModal && window.top.location === data.merchantRefLink){
self.close();
} else {
window.top.location = data.merchantRefLink;
}
}, 2000);
}
},
replaceNewlines (value) {
return value ? value.replace(/\n/ig, '<br>') : '';
}
}
});
}
i18next
.use(window.i18nextHttpBackend)
.init({
backend: {
loadPath: i18nUrl
},
lng: startingLanguage,
fallbackLng: fallbackLanguage,
nsSeparator: false,
keySeparator: false,
load: 'currentOnly'
}, initApp);

View File

@@ -4,7 +4,7 @@
<symbol id="back" viewBox="0 0 21 18"><path d="M7.63754 1.10861L0.578503 8.16764C0.119666 8.62648 0.119666 9.37121 0.578503 9.83122L7.63754 16.8902C8.09637 17.3491 8.8411 17.3491 9.30111 16.8902C9.53053 16.6608 9.64583 16.3608 9.64583 16.0585C9.64583 15.7561 9.53053 15.4561 9.30111 15.2267L4.25038 10.1759H19.0579C19.7085 10.1759 20.2344 9.65004 20.2344 8.99943C20.2344 8.34882 19.7085 7.82293 19.0579 7.82293L4.25038 7.82293L9.30111 2.77219C9.53053 2.54277 9.64583 2.24276 9.64583 1.9404C9.64583 1.63804 9.53053 1.33803 9.30111 1.10861C8.84228 0.649771 8.09755 0.649771 7.63754 1.10861Z" fill="currentColor" /></symbol> <symbol id="back" viewBox="0 0 21 18"><path d="M7.63754 1.10861L0.578503 8.16764C0.119666 8.62648 0.119666 9.37121 0.578503 9.83122L7.63754 16.8902C8.09637 17.3491 8.8411 17.3491 9.30111 16.8902C9.53053 16.6608 9.64583 16.3608 9.64583 16.0585C9.64583 15.7561 9.53053 15.4561 9.30111 15.2267L4.25038 10.1759H19.0579C19.7085 10.1759 20.2344 9.65004 20.2344 8.99943C20.2344 8.34882 19.7085 7.82293 19.0579 7.82293L4.25038 7.82293L9.30111 2.77219C9.53053 2.54277 9.64583 2.24276 9.64583 1.9404C9.64583 1.63804 9.53053 1.33803 9.30111 1.10861C8.84228 0.649771 8.09755 0.649771 7.63754 1.10861Z" fill="currentColor" /></symbol>
<symbol id="close" viewBox="0 0 16 16"><path d="M9.38526 8.08753L15.5498 1.85558C15.9653 1.43545 15.9653 0.805252 15.5498 0.385121C15.1342 -0.0350102 14.5108 -0.0350102 14.0952 0.385121L7.93072 6.61707L1.76623 0.315098C1.35065 -0.105033 0.727273 -0.105033 0.311688 0.315098C-0.103896 0.73523 -0.103896 1.36543 0.311688 1.78556L6.47618 8.0175L0.311688 14.2495C-0.103896 14.6696 -0.103896 15.2998 0.311688 15.7199C0.519481 15.93 0.796499 16 1.07355 16C1.35061 16 1.62769 15.93 1.83548 15.7199L7.99997 9.48797L14.1645 15.7199C14.3722 15.93 14.6493 16 14.9264 16C15.2034 16 15.4805 15.93 15.6883 15.7199C16.1039 15.2998 16.1039 14.6696 15.6883 14.2495L9.38526 8.08753Z" fill="currentColor"/></symbol> <symbol id="close" viewBox="0 0 16 16"><path d="M9.38526 8.08753L15.5498 1.85558C15.9653 1.43545 15.9653 0.805252 15.5498 0.385121C15.1342 -0.0350102 14.5108 -0.0350102 14.0952 0.385121L7.93072 6.61707L1.76623 0.315098C1.35065 -0.105033 0.727273 -0.105033 0.311688 0.315098C-0.103896 0.73523 -0.103896 1.36543 0.311688 1.78556L6.47618 8.0175L0.311688 14.2495C-0.103896 14.6696 -0.103896 15.2998 0.311688 15.7199C0.519481 15.93 0.796499 16 1.07355 16C1.35061 16 1.62769 15.93 1.83548 15.7199L7.99997 9.48797L14.1645 15.7199C14.3722 15.93 14.6493 16 14.9264 16C15.2034 16 15.4805 15.93 15.6883 15.7199C16.1039 15.2998 16.1039 14.6696 15.6883 14.2495L9.38526 8.08753Z" fill="currentColor"/></symbol>
<symbol id="copy" viewBox="0 0 24 24" fill="none"><path d="M20 6H8a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2Zm0 13a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v10Z" fill="currentColor"/><path d="M4 5a1 1 0 0 1 1-1h12a1 1 0 1 0 0-2H4a2 2 0 0 0-2 2v13a1 1 0 1 0 2 0V5Z" fill="currentColor"/></symbol> <symbol id="copy" viewBox="0 0 24 24" fill="none"><path d="M20 6H8a2 2 0 0 0-2 2v12c0 1.1.9 2 2 2h12a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2Zm0 13a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V9a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v10Z" fill="currentColor"/><path d="M4 5a1 1 0 0 1 1-1h12a1 1 0 1 0 0-2H4a2 2 0 0 0-2 2v13a1 1 0 1 0 2 0V5Z" fill="currentColor"/></symbol>
<symbol id="info" viewBox="0 0 24 24"><g transform="scale(1.05) translate(0, -1)" transform-origin="50% 50%"><path fill-rule="evenodd" clip-rule="evenodd" d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" fill="currentColor" /><path d="M12 8C11.4477 8 11 8.44772 11 9C11 9.55228 11.4477 10 12 10C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8Z" fill="currentColor" /><path d="M11 12C11 12 11 11 12 11C13 11 13 12 13 12V15C13 15 13 16 12 16C11 16 11 15 11 15V12Z" fill="currentColor" /></g></symbol> <symbol id="info" viewBox="0 0 24 24"><g><path fill-rule="evenodd" clip-rule="evenodd" d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 18C15.3137 18 18 15.3137 18 12C18 8.68629 15.3137 6 12 6C8.68629 6 6 8.68629 6 12C6 15.3137 8.68629 18 12 18Z" fill="currentColor" /><path d="M12 8C11.4477 8 11 8.44772 11 9C11 9.55228 11.4477 10 12 10C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8Z" fill="currentColor" /><path d="M11 12C11 12 11 11 12 11C13 11 13 12 13 12V15C13 15 13 16 12 16C11 16 11 15 11 15V12Z" fill="currentColor" /></g></symbol>
<symbol id="caret-right" viewBox="0 0 24 24"><path d="M9.5 17L14.5 12L9.5 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></symbol> <symbol id="caret-right" viewBox="0 0 24 24"><path d="M9.5 17L14.5 12L9.5 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></symbol>
<symbol id="caret-down" viewBox="0 0 24 24"><path d="M7 9.5L12 14.5L17 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></symbol> <symbol id="caret-down" viewBox="0 0 24 24"><path d="M7 9.5L12 14.5L17 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/></symbol>
<symbol id="new-store" viewBox="0 0 32 32"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol> <symbol id="new-store" viewBox="0 0 32 32"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -1,5 +1,9 @@
const confirmCopy = (el, message) => { function confirmCopy(el, message) {
el.innerText = message; if (!el.dataset.clipboardInitial) {
el.dataset.clipboardInitial = el.innerHTML;
el.style.minWidth = el.getBoundingClientRect().width + 'px';
}
el.innerHTML = `<span class="text-success">${message}</span>`;
setTimeout(function () { setTimeout(function () {
el.innerHTML = el.dataset.clipboardInitial; el.innerHTML = el.dataset.clipboardInitial;
}, 2500); }, 2500);
@@ -11,11 +15,7 @@ window.copyToClipboard = function (e, data) {
const confirm = item.dataset.clipboardConfirmElement const confirm = item.dataset.clipboardConfirmElement
? document.getElementById(item.dataset.clipboardConfirmElement) || item ? document.getElementById(item.dataset.clipboardConfirmElement) || item
: item.querySelector('[data-clipboard-confirm]') || item; : item.querySelector('[data-clipboard-confirm]') || item;
const message = confirm.getAttribute('data-clipboard-confirm') || 'Copied'; const message = confirm.getAttribute('data-clipboard-confirm') || 'Copied';
if (!confirm.dataset.clipboardInitial) {
confirm.dataset.clipboardInitial = confirm.innerHTML;
confirm.style.minWidth = confirm.getBoundingClientRect().width + 'px';
}
if (navigator.clipboard) { if (navigator.clipboard) {
navigator.clipboard.writeText(data).then(function () { navigator.clipboard.writeText(data).then(function () {
confirmCopy(confirm, message); confirmCopy(confirm, message);
@@ -38,12 +38,12 @@ window.copyUrlToClipboard = function (e) {
window.copyToClipboard(e, window.location) window.copyToClipboard(e, window.location)
} }
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", function () {
delegate('click', '[data-clipboard]', e => { delegate('click', '[data-clipboard]', function (e) {
const data = e.target.closest('[data-clipboard]').getAttribute('data-clipboard') const data = e.target.closest('[data-clipboard]').getAttribute('data-clipboard')
window.copyToClipboard(e, data) window.copyToClipboard(e, data)
}) })
delegate('click', '[data-clipboard-target]', e => { delegate('click', '[data-clipboard-target]', function (e) {
const selector = e.target.closest('[data-clipboard-target]').getAttribute('data-clipboard-target') const selector = e.target.closest('[data-clipboard-target]').getAttribute('data-clipboard-target')
const target = document.querySelector(selector) const target = document.querySelector(selector)
const data = target.innerText const data = target.innerText

View File

@@ -0,0 +1,30 @@
{
"any_amount": "Any amount",
"expiry_info": "This invoice will expire in",
"partial_payment_info": "The invoice hasn't been paid in full.",
"still_due": "Please send {{amount}}\nto the address below.",
"view_details": "View Details",
"pay_with": "Pay with",
"pay_in_wallet": "Pay in wallet",
"invoice_id": "Invoice ID",
"order_id": "Order ID",
"total_price": "Total Price",
"total_fiat": "Total Fiat",
"exchange_rate": "Exchange Rate",
"amount_paid": "Amount Paid",
"amount_due": "Amount Due",
"recommended_fee": "Recommended Fee",
"fee_rate": "{{feeRate}} sat/byte",
"network_cost": "Network Cost",
"tx_count": "{{count}} transactions",
"qr_text": "Scan the QR code, or tap to copy the address.",
"invoice_paid": "Invoice Paid",
"invoice_expired": "Invoice Expired",
"invoice_expired_body": "An invoice is only valid for {{minutes}} minutes.\n\nReturn to {{storeName}} if you like to resubmit a payment.",
"view_receipt": "View Receipt",
"return_to_store": "Return to {{storeName}}",
"copy_confirm": "Copied",
"powered_by": "Powered by",
"conversion_body": "You can pay {{btcDue}} {{cryptoCode}} using altcoins other than the ones merchant directly supports.\n\nThis service is provided by 3rd party. Please keep in mind that we have no control over how providers will forward your funds. Invoice will only be marked paid once funds are received on {{cryptoCode}} Blockchain."
}

View File

@@ -176,8 +176,8 @@ h2 small .fa-question-circle-o {
background-color: lightgray; background-color: lightgray;
} }
[v-cloak] { display:none } [v-cloak] { display: none !important; }
[v-cloak-loading] > * { display:none } [v-cloak-loading] > * { display: none !important; }
[v-cloak-loading]::before { content: "loading…" } [v-cloak-loading]::before { content: "loading…" }
.list-group-item a:not(.btn) { .list-group-item a:not(.btn) {

View File

@@ -1,89 +1,47 @@
:root { :root {
--btcpay-neutral-100: var(--btcpay-neutral-dark-100); --btcpay-neutral-50: #0D1117;
--btcpay-neutral-200: var(--btcpay-neutral-dark-200); --btcpay-neutral-100: var(--btcpay-neutral-dark-900);
--btcpay-neutral-300: var(--btcpay-neutral-dark-300); --btcpay-neutral-200: var(--btcpay-neutral-dark-800);
--btcpay-neutral-400: var(--btcpay-neutral-dark-400); --btcpay-neutral-300: var(--btcpay-neutral-dark-700);
--btcpay-neutral-400: var(--btcpay-neutral-dark-600);
--btcpay-neutral-500: var(--btcpay-neutral-dark-500); --btcpay-neutral-500: var(--btcpay-neutral-dark-500);
--btcpay-neutral-600: var(--btcpay-neutral-dark-600); --btcpay-neutral-600: var(--btcpay-neutral-dark-400);
--btcpay-neutral-700: var(--btcpay-neutral-dark-700); --btcpay-neutral-700: var(--btcpay-neutral-dark-300);
--btcpay-neutral-800: var(--btcpay-neutral-dark-800); --btcpay-neutral-800: var(--btcpay-neutral-dark-200);
--btcpay-neutral-900: var(--btcpay-neutral-dark-900); --btcpay-neutral-900: var(--btcpay-neutral-dark-100);
--btcpay-neutral-950: #0D1117; --btcpay-bg-dark: var(--btcpay-neutral-50);
--btcpay-bg-dark: var(--btcpay-neutral-950);
--btcpay-bg-tile: var(--btcpay-bg-dark); --btcpay-bg-tile: var(--btcpay-bg-dark);
--btcpay-body-bg: var(--btcpay-neutral-900); --btcpay-body-bg-light: var(--btcpay-neutral-50);
--btcpay-body-bg-light: var(--btcpay-neutral-950); --btcpay-body-bg-hover: var(--btcpay-neutral-50);
--btcpay-body-bg-medium: var(--btcpay-neutral-800);
--btcpay-body-bg-striped: var(--btcpay-neutral-800);
--btcpay-body-bg-hover: var(--btcpay-neutral-950);
--btcpay-body-bg-rgb: 41, 41, 41; --btcpay-body-bg-rgb: 41, 41, 41;
--btcpay-body-border-light: var(--btcpay-neutral-800);
--btcpay-body-border-medium: var(--btcpay-neutral-700);
--btcpay-body-text: var(--btcpay-white); --btcpay-body-text: var(--btcpay-white);
--btcpay-body-text-muted: var(--btcpay-neutral-600);
--btcpay-body-text-rgb: 255, 255, 255; --btcpay-body-text-rgb: 255, 255, 255;
--btcpay-body-link-accent: var(--btcpay-primary-300); --btcpay-body-link-accent: var(--btcpay-primary-300);
--btcpay-form-bg: var(--btcpay-neutral-950); --btcpay-form-bg: var(--btcpay-bg-dark);
--btcpay-form-bg-addon: var(--btcpay-neutral-700); --btcpay-form-text: var(--btcpay-neutral-800);
--btcpay-form-bg-disabled: var(--btcpay-neutral-800); --btcpay-form-text-label: var(--btcpay-neutral-900);
--btcpay-form-text: var(--btcpay-neutral-200); --btcpay-form-border: var(--btcpay-neutral-200);
--btcpay-form-text-label: var(--btcpay-neutral-100);
--btcpay-form-text-addon: var(--btcpay-neutral-300);
--btcpay-form-border: var(--btcpay-neutral-800);
--btcpay-form-border-check: var(--btcpay-neutral-600);
--btcpay-header-bg: var(--btcpay-bg-dark); --btcpay-header-bg: var(--btcpay-bg-dark);
--btcpay-nav-link: var(--btcpay-neutral-500); --btcpay-nav-link: var(--btcpay-neutral-500);
--btcpay-nav-link-accent: var(--btcpay-neutral-300);
--btcpay-nav-link-active: var(--btcpay-white); --btcpay-nav-link-active: var(--btcpay-white);
--btcpay-footer-text: var(--btcpay-neutral-400); --btcpay-footer-link-accent: var(--btcpay-neutral-800);
--btcpay-footer-link: var(--btcpay-neutral-400);
--btcpay-footer-link-accent: var(--btcpay-neutral-200);
--btcpay-pre-bg: var(--btcpay-bg-dark); --btcpay-pre-bg: var(--btcpay-bg-dark);
--btcpay-secondary: transparent; --btcpay-secondary: transparent;
--btcpay-secondary-text-active: var(--btcpay-primary); --btcpay-secondary-text-active: var(--btcpay-primary);
--btcpay-secondary-border: var(--btcpay-neutral-700);
--btcpay-secondary-rgb: 22, 27, 34; --btcpay-secondary-rgb: 22, 27, 34;
--btcpay-light: var(--btcpay-neutral-800); --btcpay-warning-text: var(--btcpay-neutral-100);
--btcpay-warning-text-hover: var(--btcpay-neutral-100);
--btcpay-warning-text-active: var(--btcpay-neutral-100);
--btcpay-light-accent: var(--btcpay-black); --btcpay-light-accent: var(--btcpay-black);
--btcpay-light-text: var(--btcpay-neutral-200); --btcpay-light-dim-bg: var(--btcpay-neutral-50);
--btcpay-light-text-hover: var(--btcpay-neutral-200);
--btcpay-light-text-active: var(--btcpay-neutral-200);
--btcpay-light-bg-hover: var(--btcpay-light-accent);
--btcpay-light-bg-active: var(--btcpay-light-accent);
--btcpay-light-border: var(--btcpay-light);
--btcpay-light-border-hover: var(--btcpay-light-bg-hover);
--btcpay-light-border-active: var(--btcpay-light-bg-active);
--btcpay-light-dim-bg: var(--btcpay-neutral-950);
--btcpay-light-dim-bg-striped: var(--btcpay-neutral-900);
--btcpay-light-dim-bg-hover: var(--btcpay-neutral-800);
--btcpay-light-dim-bg-active: var(--btcpay-neutral-700);
--btcpay-light-dim-border: var(--btcpay-light-dim-bg);
--btcpay-light-dim-border-active: var(--btcpay-light-dim-bg-active);
--btcpay-light-dim-text: var(--btcpay-neutral-200);
--btcpay-light-dim-text-striped: var(--btcpay-light-dim-text);
--btcpay-light-dim-text-hover: var(--btcpay-light-dim-text);
--btcpay-light-dim-text-active: var(--btcpay-neutral-100);
--btcpay-light-shadow: rgba(66, 70, 73, 0.33); --btcpay-light-shadow: rgba(66, 70, 73, 0.33);
--btcpay-light-rgb: 33, 38, 45; --btcpay-light-rgb: 33, 38, 45;
--btcpay-dark: var(--btcpay-neutral-200);
--btcpay-dark-accent: var(--btcpay-neutral-400); --btcpay-dark-accent: var(--btcpay-neutral-400);
--btcpay-dark-text: var(--btcpay-neutral-800);
--btcpay-dark-text-hover: var(--btcpay-neutral-800);
--btcpay-dark-text-active: var(--btcpay-neutral-800);
--btcpay-dark-bg-hover: var(--btcpay-dark-accent);
--btcpay-dark-bg-active: var(--btcpay-dark-accent);
--btcpay-dark-border: var(--btcpay-dark);
--btcpay-dark-border-hover: var(--btcpay-dark-bg-hover);
--btcpay-dark-border-active: var(--btcpay-dark-bg-active);
--btcpay-dark-dim-bg: var(--btcpay-white); --btcpay-dark-dim-bg: var(--btcpay-white);
--btcpay-dark-dim-bg-striped: var(--btcpay-neutral-200); --btcpay-dark-dim-bg-striped: var(--btcpay-neutral-200);
--btcpay-dark-dim-bg-hover: var(--btcpay-neutral-200); --btcpay-dark-dim-bg-hover: var(--btcpay-neutral-200);
--btcpay-dark-dim-bg-active: var(--btcpay-neutral-300); --btcpay-dark-dim-bg-active: var(--btcpay-neutral-300);
--btcpay-dark-dim-border: var(--btcpay-dark-dim-bg);
--btcpay-dark-dim-border-active: var(--btcpay-dark-dim-bg-active);
--btcpay-dark-dim-text: var(--btcpay-neutral-800);
--btcpay-dark-dim-text-striped: var(--btcpay-dark-dim-text);
--btcpay-dark-dim-text-hover: var(--btcpay-dark-dim-text);
--btcpay-dark-dim-text-active: var(--btcpay-neutral-900);
--btcpay-dark-shadow: rgba(211, 212, 213, 0.33); --btcpay-dark-shadow: rgba(211, 212, 213, 0.33);
--btcpay-dark-rgb: 201, 209, 217; --btcpay-dark-rgb: 201, 209, 217;
} }

View File

@@ -209,8 +209,8 @@
--btcpay-toggle-bg-active: var(--btcpay-primary); --btcpay-toggle-bg-active: var(--btcpay-primary);
--btcpay-toggle-bg-active-hover: var(--btcpay-primary-600); --btcpay-toggle-bg-active-hover: var(--btcpay-primary-600);
--btcpay-footer-bg: var(--btcpay-body-bg); --btcpay-footer-bg: var(--btcpay-body-bg);
--btcpay-footer-text: var(--btcpay-neutral-500); --btcpay-footer-text: var(--btcpay-body-text-muted);
--btcpay-footer-link: var(--btcpay-neutral-500); --btcpay-footer-link: var(--btcpay-body-text-muted);
--btcpay-footer-link-accent: var(--btcpay-neutral-600); --btcpay-footer-link-accent: var(--btcpay-neutral-600);
--btcpay-code-text: var(--btcpay-body-text); --btcpay-code-text: var(--btcpay-body-text);
--btcpay-code-bg: transparent; --btcpay-code-bg: transparent;

View File

@@ -367,7 +367,7 @@
"onChainWithLnInvoiceFallback": { "onChainWithLnInvoiceFallback": {
"type": "boolean", "type": "boolean",
"default": false, "default": false,
"description": "Include lightning invoice fallback to on-chain BIP21 payment url." "description": "Unify on-chain and lightning payment URL."
}, },
"redirectAutomatically": { "redirectAutomatically": {
"type": "boolean", "type": "boolean",

View File

@@ -16699,4 +16699,3 @@
return VuePlugin$F; return VuePlugin$F;
}))); })));
//# sourceMappingURL=bootstrap-vue.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,198 +0,0 @@
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.i18nextXHRBackend = factory());
}(this, (function () { 'use strict';
var arr = [];
var each = arr.forEach;
var slice = arr.slice;
function defaults(obj) {
each.call(slice.call(arguments, 1), function (source) {
if (source) {
for (var prop in source) {
if (obj[prop] === undefined) obj[prop] = source[prop];
}
}
});
return obj;
}
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
function addQueryString(url, params) {
if (params && (typeof params === 'undefined' ? 'undefined' : _typeof(params)) === 'object') {
var queryString = '',
e = encodeURIComponent;
// Must encode data
for (var paramName in params) {
queryString += '&' + e(paramName) + '=' + e(params[paramName]);
}
if (!queryString) {
return url;
}
url = url + (url.indexOf('?') !== -1 ? '&' : '?') + queryString.slice(1);
}
return url;
}
// https://gist.github.com/Xeoncross/7663273
function ajax(url, options, callback, data, cache) {
if (data && (typeof data === 'undefined' ? 'undefined' : _typeof(data)) === 'object') {
if (!cache) {
data['_t'] = new Date();
}
// URL encoded form data must be in querystring format
data = addQueryString('', data).slice(1);
}
if (options.queryStringParams) {
url = addQueryString(url, options.queryStringParams);
}
try {
var x;
if (XMLHttpRequest) {
x = new XMLHttpRequest();
} else {
x = new ActiveXObject('MSXML2.XMLHTTP.3.0');
}
x.open(data ? 'POST' : 'GET', url, 1);
if (!options.crossDomain) {
x.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
}
x.withCredentials = !!options.withCredentials;
if (data) {
x.setRequestHeader('Content-type', 'application/x-www-form-urlencoded');
}
if (x.overrideMimeType) {
x.overrideMimeType("application/json");
}
var h = options.customHeaders;
if (h) {
for (var i in h) {
x.setRequestHeader(i, h[i]);
}
}
x.onreadystatechange = function () {
x.readyState > 3 && callback && callback(x.responseText, x);
};
x.send(data);
} catch (e) {
console && console.log(e);
}
}
var _createClass = function () { function defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } return function (Constructor, protoProps, staticProps) { if (protoProps) defineProperties(Constructor.prototype, protoProps); if (staticProps) defineProperties(Constructor, staticProps); return Constructor; }; }();
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function getDefaults() {
return {
loadPath: '/locales/{{lng}}/{{ns}}.json',
addPath: '/locales/add/{{lng}}/{{ns}}',
allowMultiLoading: false,
parse: JSON.parse,
crossDomain: false,
ajax: ajax
};
}
var Backend = function () {
function Backend(services) {
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
_classCallCheck(this, Backend);
this.init(services, options);
this.type = 'backend';
}
_createClass(Backend, [{
key: 'init',
value: function init(services) {
var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
this.services = services;
this.options = defaults(options, this.options || {}, getDefaults());
}
}, {
key: 'readMulti',
value: function readMulti(languages, namespaces, callback) {
var loadPath = this.options.loadPath;
if (typeof this.options.loadPath === 'function') {
loadPath = this.options.loadPath(languages, namespaces);
}
var url = this.services.interpolator.interpolate(loadPath, { lng: languages.join('+'), ns: namespaces.join('+') });
this.loadUrl(url, callback);
}
}, {
key: 'read',
value: function read(language, namespace, callback) {
var loadPath = this.options.loadPath;
if (typeof this.options.loadPath === 'function') {
loadPath = this.options.loadPath([language], [namespace]);
}
var url = this.services.interpolator.interpolate(loadPath, { lng: language, ns: namespace });
this.loadUrl(url, callback);
}
}, {
key: 'loadUrl',
value: function loadUrl(url, callback) {
var _this = this;
this.options.ajax(url, this.options, function (data, xhr) {
if (xhr.status >= 500 && xhr.status < 600) return callback('failed loading ' + url, true /* retry */);
if (xhr.status >= 400 && xhr.status < 500) return callback('failed loading ' + url, false /* no retry */);
var ret = void 0,
err = void 0;
try {
ret = _this.options.parse(data, url);
} catch (e) {
err = 'failed parsing ' + url + ' to json';
}
if (err) return callback(err, false);
callback(null, ret);
});
}
}, {
key: 'create',
value: function create(languages, namespace, key, fallbackValue) {
var _this2 = this;
if (typeof languages === 'string') languages = [languages];
var payload = {};
payload[key] = fallbackValue || '';
languages.forEach(function (lng) {
var url = _this2.services.interpolator.interpolate(_this2.options.addPath, { lng: lng, ns: namespace });
_this2.options.ajax(url, _this2.options, function (data, xhr) {
//const statusCode = xhr.status.toString();
// TODO: if statusCode === 4xx do log
}, payload);
});
}
}]);
return Backend;
}();
Backend.type = 'backend';
return Backend;
})));

View File

@@ -1 +1,537 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define("VueI18next",[],t):"object"==typeof exports?exports.VueI18next=t():e.VueI18next=t()}(this,function(){return function(e){function t(i){if(n[i])return n[i].exports;var o=n[i]={i:i,l:!1,exports:{}};return e[i].call(o.exports,o,o.exports,t),o.l=!0,o.exports}var n={};return t.m=e,t.c=n,t.i=function(e){return e},t.d=function(e,n,i){t.o(e,n)||Object.defineProperty(e,n,{configurable:!1,enumerable:!0,get:i})},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},t.p="/dist/",t(t.s=2)}([function(e,t,n){"use strict";function i(e){i.installed||(i.installed=!0,t.Vue=u=e,u.mixin({computed:{$t:function(){var e=this;return function(t,n){return e.$i18n.t(t,n,e.$i18n.i18nLoadedAt)}}},beforeCreate:function(){var e=this.$options;e.i18n?this.$i18n=e.i18n:e.parent&&e.parent.$i18n&&(this.$i18n=e.parent.$i18n)}}),u.component(r.default.name,r.default))}Object.defineProperty(t,"__esModule",{value:!0}),t.Vue=void 0,t.install=i;var o=n(1),r=function(e){return e&&e.__esModule?e:{default:e}}(o),u=t.Vue=void 0},function(e,t,n){"use strict";Object.defineProperty(t,"__esModule",{value:!0}),t.default={name:"i18next",functional:!0,props:{tag:{type:String,default:"span"},path:{type:String,required:!0}},render:function(e,t){var n=t.props,i=t.data,o=t.children,r=t.parent,u=r.$i18n;if(!u)return o;var a=n.path,s=u.i18next.services.interpolator.regexp,f=u.t(a,{interpolation:{prefix:"#$?",suffix:"?$#"}}),d=[],c={};return o.forEach(function(e){e.data&&e.data.attrs&&e.data.attrs.tkey&&(c[e.data.attrs.tkey]=e)}),f.split(s).reduce(function(e,t,n){var i=void 0;if(n%2==0){if(0===t.length)return e;i=t}else i=o[parseInt(t,10)];return e.push(i),e},d),e(n.tag,i,d)}},e.exports=t.default},function(e,t,n){"use strict";function i(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}Object.defineProperty(t,"__esModule",{value:!0});var o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},r=function(){function e(e,t){for(var n=0;n<t.length;n++){var i=t[n];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(e,i.key,i)}}return function(t,n,i){return n&&e(t.prototype,n),i&&e(t,i),t}}(),u=n(0),a=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};i(this,e);var o=n.bindI18n,r=void 0===o?"languageChanged loaded":o,u=n.bindStore,a=void 0===u?"added removed":u;this._vm=null,this.i18next=t,this.onI18nChanged=this.onI18nChanged.bind(this),r&&this.i18next.on(r,this.onI18nChanged),a&&this.i18next.store&&this.i18next.store.on(a,this.onI18nChanged),this.resetVM({i18nLoadedAt:new Date})}return r(e,[{key:"resetVM",value:function(e){var t=this._vm,n=u.Vue.config.silent;u.Vue.config.silent=!0,this._vm=new u.Vue({data:e}),u.Vue.config.silent=n,t&&u.Vue.nextTick(function(){return t.$destroy()})}},{key:"t",value:function(e,t){return this.i18next.t(e,t)}},{key:"onI18nChanged",value:function(){this.i18nLoadedAt=new Date}},{key:"i18nLoadedAt",get:function(){return this._vm.$data.i18nLoadedAt},set:function(e){this._vm.$set(this._vm,"i18nLoadedAt",e)}}]),e}();t.default=a,a.install=u.install,a.version="0.4.0",("undefined"==typeof window?"undefined":o(window))&&window.Vue&&window.Vue.use(a),e.exports=t.default}])}); (function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global.VueI18next = factory());
}(this, (function () { 'use strict';
var isMergeableObject = function isMergeableObject(value) {
return isNonNullObject(value)
&& !isSpecial(value)
};
function isNonNullObject(value) {
return !!value && typeof value === 'object'
}
function isSpecial(value) {
var stringValue = Object.prototype.toString.call(value);
return stringValue === '[object RegExp]'
|| stringValue === '[object Date]'
|| isReactElement(value)
}
// see https://github.com/facebook/react/blob/b5ac963fb791d1298e7f396236383bc955f916c1/src/isomorphic/classic/element/ReactElement.js#L21-L25
var canUseSymbol = typeof Symbol === 'function' && Symbol.for;
var REACT_ELEMENT_TYPE = canUseSymbol ? Symbol.for('react.element') : 0xeac7;
function isReactElement(value) {
return value.$$typeof === REACT_ELEMENT_TYPE
}
function emptyTarget(val) {
return Array.isArray(val) ? [] : {}
}
function cloneUnlessOtherwiseSpecified(value, options) {
return (options.clone !== false && options.isMergeableObject(value))
? deepmerge(emptyTarget(value), value, options)
: value
}
function defaultArrayMerge(target, source, options) {
return target.concat(source).map(function(element) {
return cloneUnlessOtherwiseSpecified(element, options)
})
}
function mergeObject(target, source, options) {
var destination = {};
if (options.isMergeableObject(target)) {
Object.keys(target).forEach(function(key) {
destination[key] = cloneUnlessOtherwiseSpecified(target[key], options);
});
}
Object.keys(source).forEach(function(key) {
if (!options.isMergeableObject(source[key]) || !target[key]) {
destination[key] = cloneUnlessOtherwiseSpecified(source[key], options);
} else {
destination[key] = deepmerge(target[key], source[key], options);
}
});
return destination
}
function deepmerge(target, source, options) {
options = options || {};
options.arrayMerge = options.arrayMerge || defaultArrayMerge;
options.isMergeableObject = options.isMergeableObject || isMergeableObject;
var sourceIsArray = Array.isArray(source);
var targetIsArray = Array.isArray(target);
var sourceAndTargetTypesMatch = sourceIsArray === targetIsArray;
if (!sourceAndTargetTypesMatch) {
return cloneUnlessOtherwiseSpecified(source, options)
} else if (sourceIsArray) {
return options.arrayMerge(target, source, options)
} else {
return mergeObject(target, source, options)
}
}
deepmerge.all = function deepmergeAll(array, options) {
if (!Array.isArray(array)) {
throw new Error('first argument should be an array')
}
return array.reduce(function(prev, next) {
return deepmerge(prev, next, options)
}, {})
};
var deepmerge_1 = deepmerge;
var component = {
name: 'i18next',
functional: true,
props: {
tag: {
type: String,
default: 'span'
},
path: {
type: String,
required: true
},
options: {
type: Object
}
},
render: function render(h, ref) {
var props = ref.props;
var data = ref.data;
var children = ref.children;
var parent = ref.parent;
var i18next = parent.$i18n;
var $t = parent.$t.bind(parent);
if (!i18next || !$t) {
return h(props.tag, data, children);
}
var path = props.path;
var options = props.options || {};
var REGEXP = i18next.i18next.services.interpolator.regexp;
var i18nextOptions = Object.assign({}, options,
{interpolation: { prefix: '#$?', suffix: '?$#' }});
var format = $t(path, i18nextOptions);
var tchildren = [];
format.split(REGEXP).reduce(function (memo, match, index) {
var child;
if (index % 2 === 0) {
if (match.length === 0) { return memo; }
child = match;
} else {
var place = match.trim();
// eslint-disable-next-line no-restricted-globals
if (isNaN(parseFloat(place)) || !isFinite(place)) {
children.forEach(function (e) {
if (
!child &&
e.data.attrs &&
e.data.attrs.place &&
e.data.attrs.place === place
) {
child = e;
}
});
} else {
child = children[parseInt(match, 10)];
}
}
memo.push(child);
return memo;
}, tchildren);
return h(props.tag, data, tchildren);
}
};
/* eslint-disable import/prefer-default-export */
function log(message) {
if (typeof console !== 'undefined') {
console.warn(message); // eslint-disable-line no-console
}
}
function warn(message) {
log(("[vue-i18next warn]: " + message));
}
function deprecate(message) {
log(("[vue-i18next deprecated]: " + message));
}
/* eslint-disable no-param-reassign, no-unused-vars */
function equalLanguage(el, vnode) {
var vm = vnode.context;
return el._i18nLanguage === vm.$i18n.i18next.language;
}
function equalValue(value, oldValue) {
if (value === oldValue) {
return true;
}
if (value && oldValue) {
return (
value.path === oldValue.path &&
value.language === oldValue.language &&
value.args === oldValue.args
);
}
}
function assert(vnode) {
var vm = vnode.context;
if (!vm.$i18n) {
warn('No VueI18Next instance found in the Vue instance');
return false;
}
return true;
}
function parseValue(value) {
var assign;
var path;
var language;
var args;
if (typeof value === 'string') {
path = value;
} else if (toString.call(value) === '[object Object]') {
((assign = value, path = assign.path, language = assign.language, args = assign.args));
}
return { path: path, language: language, args: args };
}
function t(el, binding, vnode) {
var value = binding.value;
var ref = parseValue(value);
var path = ref.path;
var language = ref.language;
var args = ref.args;
if (!path && !language && !args) {
warn('v-t: invalid value');
return;
}
if (!path) {
warn('v-t: "path" is required');
return;
}
if (language) {
deprecate("v-t: \"language\" is deprecated.Use the \"lng\" property in args.\n https://www.i18next.com/overview/configuration-options#configuration-options");
}
var vm = vnode.context;
el.textContent = vm.$i18n.i18next.t(path, Object.assign({}, (language ? { lng: language } : {}),
args));
el._i18nLanguage = vm.$i18n.i18next.language;
}
function bind(el, binding, vnode) {
if (!assert(vnode)) {
return;
}
t(el, binding, vnode);
}
function update(el, binding, vnode, oldVNode) {
if (equalLanguage(el, vnode) && equalValue(binding.value, binding.oldValue)) {
return;
}
t(el, binding, vnode);
}
var directive = {
bind: bind,
update: update
};
/* eslint-disable no-param-reassign, no-unused-vars */
function assert$1(vnode) {
var vm = vnode.context;
if (!vm.$i18n) {
warn('No VueI18Next instance found in the Vue instance');
return false;
}
return true;
}
function waitForIt(el, vnode) {
if (vnode.context.$i18n.i18next.isInitialized) {
el.hidden = false;
} else {
el.hidden = true;
var initialized = function () {
vnode.context.$forceUpdate();
// due to emitter removing issue in i18next we need to delay remove
setTimeout(function () {
if (vnode.context && vnode.context.$i18n) {
vnode.context.$i18n.i18next.off('initialized', initialized);
}
}, 1000);
};
vnode.context.$i18n.i18next.on('initialized', initialized);
}
}
function bind$1(el, binding, vnode) {
if (!assert$1(vnode)) {
return;
}
waitForIt(el, vnode);
}
function update$1(el, binding, vnode, oldVNode) {
if (vnode.context.$i18n.i18next.isInitialized) {
el.hidden = false;
}
}
var waitDirective = {
bind: bind$1,
update: update$1
};
/* eslint-disable import/no-mutable-exports */
var Vue;
function install(_Vue) {
if (install.installed) {
return;
}
install.installed = true;
Vue = _Vue;
var getByKey = function (i18nOptions, i18nextOptions) { return function (key) {
if (
i18nOptions &&
i18nOptions.keyPrefix &&
!key.includes(i18nextOptions.nsSeparator)
) {
return ((i18nOptions.keyPrefix) + "." + key);
}
return key;
}; };
var getComponentNamespace = function (vm) {
var namespace = vm.$options.name || vm.$options._componentTag;
if (namespace) {
return {
namespace: namespace,
loadNamespace: true
};
}
return {
namespace: ("" + (Math.random()))
};
};
Vue.mixin({
beforeCreate: function beforeCreate() {
var this$1 = this;
var options = this.$options;
if (options.i18n) {
this._i18n = options.i18n;
} else if (options.parent && options.parent.$i18n) {
this._i18n = options.parent.$i18n;
}
var inlineTranslations = {};
if (this._i18n) {
var getNamespace =
this._i18n.options.getComponentNamespace || getComponentNamespace;
var ref = getNamespace(this);
var namespace = ref.namespace;
var loadNamespace = ref.loadNamespace;
if (options.__i18n) {
options.__i18n.forEach(function (resource) {
inlineTranslations = deepmerge_1(
inlineTranslations,
JSON.parse(resource)
);
});
}
if (options.i18nOptions) {
var ref$1 = this.$options.i18nOptions;
var lng = ref$1.lng; if ( lng === void 0 ) lng = null;
var keyPrefix = ref$1.keyPrefix; if ( keyPrefix === void 0 ) keyPrefix = null;
var messages = ref$1.messages;
var ref$2 = this.$options.i18nOptions;
var namespaces = ref$2.namespaces;
namespaces = namespaces || this._i18n.i18next.options.defaultNS;
if (typeof namespaces === 'string') { namespaces = [namespaces]; }
var namespacesToLoad = namespaces.concat([namespace]);
if (messages) {
inlineTranslations = deepmerge_1(inlineTranslations, messages);
}
this._i18nOptions = { lng: lng, namespaces: namespacesToLoad, keyPrefix: keyPrefix };
this._i18n.i18next.loadNamespaces(namespaces);
} else if (options.parent && options.parent._i18nOptions) {
this._i18nOptions = Object.assign({}, options.parent._i18nOptions);
this._i18nOptions.namespaces = [
namespace ].concat( this._i18nOptions.namespaces
);
} else if (options.__i18n) {
this._i18nOptions = { namespaces: [namespace] };
}
if (loadNamespace && this._i18n.options.loadComponentNamespace) {
this._i18n.i18next.loadNamespaces([namespace]);
}
var languages = Object.keys(inlineTranslations);
languages.forEach(function (lang) {
this$1._i18n.i18next.addResourceBundle(
lang,
namespace,
Object.assign({}, inlineTranslations[lang]),
true,
false
);
});
}
var getKey = getByKey(
this._i18nOptions,
this._i18n ? this._i18n.i18next.options : {}
);
if (this._i18nOptions && this._i18nOptions.namespaces) {
var ref$3 = this._i18nOptions;
var lng$1 = ref$3.lng;
var namespaces$1 = ref$3.namespaces;
var fixedT = this._i18n.i18next.getFixedT(lng$1, namespaces$1);
this._getI18nKey = function (key, i18nextOptions) { return fixedT(getKey(key), i18nextOptions, this$1._i18n.i18nLoadedAt); };
} else {
this._getI18nKey = function (key, i18nextOptions) { return this$1._i18n.t(getKey(key), i18nextOptions, this$1._i18n.i18nLoadedAt); };
}
}
});
// extend Vue.js
if (!Object.prototype.hasOwnProperty.call(Vue.prototype, '$i18n')) {
Object.defineProperty(Vue.prototype, '$i18n', {
get: function get() {
return this._i18n;
}
});
}
Vue.prototype.$t = function t(key, options) {
return this._getI18nKey(key, options);
};
Vue.component(component.name, component);
Vue.directive('t', directive);
Vue.directive('waitForT', waitDirective);
}
var VueI18n = function VueI18n(i18next, opts) {
if ( opts === void 0 ) opts = {};
var options = Object.assign({}, {bindI18n: 'languageChanged loaded',
bindStore: 'added removed',
loadComponentNamespace: false},
opts);
this._vm = null;
this.i18next = i18next;
this.options = options;
this.onI18nChanged = this.onI18nChanged.bind(this);
if (options.bindI18n) {
this.i18next.on(options.bindI18n, this.onI18nChanged);
}
if (options.bindStore && this.i18next.store) {
this.i18next.store.on(options.bindStore, this.onI18nChanged);
}
this.resetVM({ i18nLoadedAt: new Date() });
};
var prototypeAccessors = { i18nLoadedAt: { configurable: true } };
VueI18n.prototype.resetVM = function resetVM (data) {
var oldVM = this._vm;
var ref = Vue.config;
var silent = ref.silent;
Vue.config.silent = true;
this._vm = new Vue({ data: data });
Vue.config.silent = silent;
if (oldVM) {
Vue.nextTick(function () { return oldVM.$destroy(); });
}
};
prototypeAccessors.i18nLoadedAt.get = function () {
return this._vm.$data.i18nLoadedAt;
};
prototypeAccessors.i18nLoadedAt.set = function (date) {
this._vm.$set(this._vm, 'i18nLoadedAt', date);
};
VueI18n.prototype.t = function t (key, options) {
return this.i18next.t(key, options);
};
VueI18n.prototype.onI18nChanged = function onI18nChanged () {
this.i18nLoadedAt = new Date();
};
Object.defineProperties( VueI18n.prototype, prototypeAccessors );
VueI18n.install = install;
VueI18n.version = "0.15.2";
/* istanbul ignore if */
if (typeof window !== 'undefined' && window.Vue) {
window.Vue.use(VueI18n);
}
return VueI18n;
})));

View File

@@ -1357,7 +1357,6 @@ return Promise$2;
//# sourceMappingURL=es6-promise.auto.map
/* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(2))) /* WEBPACK VAR INJECTION */}.call(this, __webpack_require__(2)))
@@ -4085,4 +4084,3 @@ var JsonHubProtocol = /** @class */ (function () {
/***/ }) /***/ })
/******/ ]); /******/ ]);
}); });
//# sourceMappingURL=signalr.js.map

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long