Checkout v2 (#4157)

* Opt-in for new checkout

* Update wording

* Create invoice view update

* Remove jQuery from checkout testing code

* Checkout v2 basics

* WIP

* WIP 2

* Updates and fixes

* Updates

* Design updates

* More design updates

* Cheating and JS fixes

* Use checkout form id whenever invoices get created

* Improve email form handling

* Cleanups

* Payment method exclusion cases for Lightning and LNURL

TODO: Cases and implementation need to be discussed

* Introduce CheckoutType in API and replace UseNewCheckout in backend

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2022-11-02 10:21:33 +01:00 committed by GitHub
parent 63620409a9
commit 3805b7f287
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 1296 additions and 92 deletions

View file

@ -36,6 +36,8 @@ namespace BTCPayServer.Client.Models
public string RedirectUrl { get; set; } = null; public string RedirectUrl { get; set; } = null;
public bool? RedirectAutomatically { get; set; } = null; public bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { get; set; } = null; public bool? RequiresRefundEmail { get; set; } = null;
public string CheckoutFormId { get; set; } = null;
public string EmbeddedCSS { get; set; } = null; public string EmbeddedCSS { get; set; } = null;
public CheckoutType? CheckoutType { get; set; } = null;
} }
} }

View file

@ -85,6 +85,9 @@ namespace BTCPayServer.Client.Models
public bool? RedirectAutomatically { get; set; } public bool? RedirectAutomatically { get; set; }
public bool? RequiresRefundEmail { get; set; } = null; public bool? RequiresRefundEmail { get; set; } = null;
public string DefaultLanguage { get; set; } public string DefaultLanguage { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
public CheckoutType? CheckoutType { get; set; }
} }
} }
public class InvoiceData : InvoiceDataBase public class InvoiceData : InvoiceDataBase

View file

@ -31,6 +31,10 @@ namespace BTCPayServer.Client.Models
public bool AnyoneCanCreateInvoice { get; set; } public bool AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { get; set; } public string DefaultCurrency { get; set; }
public bool RequiresRefundEmail { get; set; } public bool RequiresRefundEmail { get; set; }
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType CheckoutType { get; set; }
public bool LightningAmountInSatoshi { get; set; } public bool LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; } public bool LightningPrivateRouteHints { get; set; }
public bool OnChainWithLnInvoiceFallback { get; set; } public bool OnChainWithLnInvoiceFallback { get; set; }
@ -66,6 +70,12 @@ namespace BTCPayServer.Client.Models
public IDictionary<string, JToken> AdditionalData { get; set; } public IDictionary<string, JToken> AdditionalData { get; set; }
} }
public enum CheckoutType
{
V1,
V2
}
public enum NetworkFeeMode public enum NetworkFeeMode
{ {
MultiplePaymentsOnly, MultiplePaymentsOnly,

View file

@ -19,6 +19,7 @@ using BTCPayServer.Services.Custodian.Client.MockCustodian;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Notifications; using BTCPayServer.Services.Notifications;
using BTCPayServer.Services.Notifications.Blobs; using BTCPayServer.Services.Notifications.Blobs;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -1324,12 +1325,14 @@ namespace BTCPayServer.Tests
Checkout = new CreateInvoiceRequest.CheckoutOptions() Checkout = new CreateInvoiceRequest.CheckoutOptions()
{ {
RedirectAutomatically = true, RedirectAutomatically = true,
RequiresRefundEmail = true RequiresRefundEmail = true,
CheckoutFormId = GenericFormOption.Email.ToString()
}, },
AdditionalSearchTerms = new string[] { "Banana" } AdditionalSearchTerms = new string[] { "Banana" }
}); });
Assert.True(newInvoice.Checkout.RedirectAutomatically); Assert.True(newInvoice.Checkout.RedirectAutomatically);
Assert.True(newInvoice.Checkout.RequiresRefundEmail); Assert.True(newInvoice.Checkout.RequiresRefundEmail);
Assert.Equal(GenericFormOption.Email.ToString(), newInvoice.Checkout.CheckoutFormId);
Assert.Equal(user.StoreId, newInvoice.StoreId); Assert.Equal(user.StoreId, newInvoice.StoreId);
//list //list
var invoices = await viewOnly.GetInvoices(user.StoreId); var invoices = await viewOnly.GetInvoices(user.StoreId);

View file

@ -164,6 +164,8 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS, EmbeddedCSS = request.EmbeddedCSS,
RedirectAutomatically = request.RedirectAutomatically, RedirectAutomatically = request.RedirectAutomatically,
RequiresRefundEmail = BoolToRequiresRefundEmail(request.RequiresRefundEmail) ?? RequiresRefundEmail.InheritFromStore, RequiresRefundEmail = BoolToRequiresRefundEmail(request.RequiresRefundEmail) ?? RequiresRefundEmail.InheritFromStore,
CheckoutFormId = request.CheckoutFormId,
CheckoutType = request.CheckoutType ?? CheckoutType.V1
}; };
} }

View file

@ -437,6 +437,8 @@ namespace BTCPayServer.Controllers.Greenfield
DefaultLanguage = entity.DefaultLanguage, DefaultLanguage = entity.DefaultLanguage,
RedirectAutomatically = entity.RedirectAutomatically, RedirectAutomatically = entity.RedirectAutomatically,
RequiresRefundEmail = entity.RequiresRefundEmail, RequiresRefundEmail = entity.RequiresRefundEmail,
CheckoutFormId = entity.CheckoutFormId,
CheckoutType = entity.CheckoutType,
RedirectURL = entity.RedirectURLTemplate RedirectURL = entity.RedirectURLTemplate
}, },
Receipt = entity.ReceiptOptions Receipt = entity.ReceiptOptions

View file

@ -127,6 +127,8 @@ namespace BTCPayServer.Controllers.Greenfield
//we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572) //we do not include PaymentMethodCriteria because moving the CurrencyValueJsonConverter to the Client csproj is hard and requires a refactor (#1571 & #1572)
NetworkFeeMode = storeBlob.NetworkFeeMode, NetworkFeeMode = storeBlob.NetworkFeeMode,
RequiresRefundEmail = storeBlob.RequiresRefundEmail, RequiresRefundEmail = storeBlob.RequiresRefundEmail,
CheckoutFormId = storeBlob.CheckoutFormId,
CheckoutType = storeBlob.CheckoutType,
Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null), Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null),
LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi, LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi,
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints, LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints,
@ -165,6 +167,8 @@ namespace BTCPayServer.Controllers.Greenfield
blob.NetworkFeeMode = restModel.NetworkFeeMode; blob.NetworkFeeMode = restModel.NetworkFeeMode;
blob.DefaultCurrency = restModel.DefaultCurrency; blob.DefaultCurrency = restModel.DefaultCurrency;
blob.RequiresRefundEmail = restModel.RequiresRefundEmail; blob.RequiresRefundEmail = restModel.RequiresRefundEmail;
blob.CheckoutFormId = restModel.CheckoutFormId;
blob.CheckoutType = restModel.CheckoutType;
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null); blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi; blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints; blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints;

View file

@ -1,4 +1,5 @@
using System; using System;
using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data; using BTCPayServer.Data;
@ -24,34 +25,35 @@ namespace BTCPayServer.Controllers
public string CryptoCode { get; set; } = "BTC"; public string CryptoCode { get; set; } = "BTC";
} }
[HttpPost] [HttpPost("i/{invoiceId}/test-payment")]
[Route("i/{invoiceId}/test-payment")]
[CheatModeRoute] [CheatModeRoute]
public async Task<IActionResult> TestPayment(string invoiceId, FakePaymentRequest request, [FromServices] Cheater cheater) public async Task<IActionResult> TestPayment(string invoiceId, FakePaymentRequest request, [FromServices] Cheater cheater)
{ {
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 // TODO support altcoins, not just bitcoin - and make it work for LN-only invoices
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(request.CryptoCode); var isSats = request.CryptoCode.ToUpper(CultureInfo.InvariantCulture) == "SATS";
var cryptoCode = isSats ? "BTC" : request.CryptoCode;
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var paymentMethodId = new [] {store.GetDefaultPaymentId()}.Concat(store.GetEnabledPaymentIds(_NetworkProvider)) var paymentMethodId = new [] {store.GetDefaultPaymentId()}.Concat(store.GetEnabledPaymentIds(_NetworkProvider))
.FirstOrDefault(p => p!= null && p.CryptoCode == request.CryptoCode && p.PaymentType == PaymentTypes.BTCLike); .FirstOrDefault(p => p != null && p.CryptoCode == cryptoCode && p.PaymentType == PaymentTypes.BTCLike);
var bitcoinAddressString = invoice.GetPaymentMethod(paymentMethodId).GetPaymentMethodDetails().GetPaymentDestination(); var bitcoinAddressString = invoice.GetPaymentMethod(paymentMethodId).GetPaymentMethodDetails().GetPaymentDestination();
var bitcoinAddressObj = BitcoinAddress.Create(bitcoinAddressString, network.NBitcoinNetwork); var bitcoinAddressObj = BitcoinAddress.Create(bitcoinAddressString, network.NBitcoinNetwork);
var BtcAmount = request.Amount; var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC);
try try
{ {
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId); var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
var rate = paymentMethod.Rate; var rate = paymentMethod.Rate;
var txid = cheater.CashCow.SendToAddress(bitcoinAddressObj, new Money(BtcAmount, MoneyUnit.BTC)).ToString(); var txid = (await cheater.CashCow.SendToAddressAsync(bitcoinAddressObj, amount)).ToString();
// 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. // 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; var totalDue = invoice.Price;
return Ok(new return Ok(new
{ {
Txid = txid, Txid = txid,
AmountRemaining = (totalDue - (BtcAmount * rate)) / rate, AmountRemaining = (totalDue - (amount.ToUnit(MoneyUnit.BTC) * rate)) / rate,
SuccessMessage = "Created transaction " + txid SuccessMessage = "Created transaction " + txid
}); });
} }
@ -65,8 +67,7 @@ namespace BTCPayServer.Controllers
} }
} }
[HttpPost] [HttpPost("i/{invoiceId}/mine-blocks")]
[Route("i/{invoiceId}/mine-blocks")]
[CheatModeRoute] [CheatModeRoute]
public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater) public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater)
{ {
@ -96,8 +97,7 @@ namespace BTCPayServer.Controllers
} }
} }
[HttpPost] [HttpPost("i/{invoiceId}/expire")]
[Route("i/{invoiceId}/expire")]
[CheatModeRoute] [CheatModeRoute]
public async Task<IActionResult> TestExpireNow(string invoiceId, [FromServices] Cheater cheater) public async Task<IActionResult> TestExpireNow(string invoiceId, [FromServices] Cheater cheater)
{ {

View file

@ -24,6 +24,7 @@ using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Invoices.Export; using BTCPayServer.Services.Invoices.Export;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@ -603,23 +604,26 @@ namespace BTCPayServer.Controllers
[HttpGet("i/{invoiceId}/{paymentMethodId}")] [HttpGet("i/{invoiceId}/{paymentMethodId}")]
[HttpGet("invoice")] [HttpGet("invoice")]
[AcceptMediaTypeConstraint("application/bitcoin-paymentrequest", false)] [AcceptMediaTypeConstraint("application/bitcoin-paymentrequest", false)]
[XFrameOptionsAttribute(null)] [XFrameOptions(null)]
[ReferrerPolicyAttribute("origin")] [ReferrerPolicy("origin")]
public async Task<IActionResult> Checkout(string? invoiceId, string? id = null, string? paymentMethodId = null, public async Task<IActionResult> Checkout(string? invoiceId, string? id = null, string? paymentMethodId = null,
[FromQuery] string? view = null, [FromQuery] string? lang = null) [FromQuery] string? view = null, [FromQuery] string? lang = null)
{ {
//Keep compatibility with Bitpay // Keep compatibility with Bitpay
invoiceId = invoiceId ?? id; invoiceId ??= id;
//
if (invoiceId is null) if (invoiceId is null)
return NotFound(); return NotFound();
var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang); var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang);
if (model == null) if (model == null)
return NotFound(); return NotFound();
if (view == "modal") if (view == "modal")
model.IsModal = true; model.IsModal = true;
return View(nameof(Checkout), model);
var viewName = model.CheckoutType == CheckoutType.V2 ? "CheckoutV2" : nameof(Checkout);
return View(viewName, model);
} }
[HttpGet("invoice-noscript")] [HttpGet("invoice-noscript")]
@ -731,7 +735,7 @@ namespace BTCPayServer.Controllers
var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true; var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true;
var receiptUrl = receiptEnabled? _linkGenerator.GetUriByAction( var receiptUrl = receiptEnabled? _linkGenerator.GetUriByAction(
nameof(UIInvoiceController.InvoiceReceipt), nameof(InvoiceReceipt),
"UIInvoice", "UIInvoice",
new {invoiceId}, new {invoiceId},
Request.Scheme, Request.Scheme,
@ -740,6 +744,9 @@ namespace BTCPayServer.Controllers
var model = new PaymentModel var model = new PaymentModel
{ {
#if ALTCOINS
AltcoinsBuild = true,
#endif
Activated = paymentMethodDetails.Activated, Activated = paymentMethodDetails.Activated,
CryptoCode = network.CryptoCode, CryptoCode = network.CryptoCode,
RootPath = Request.PathBase.Value.WithTrailingSlash(), RootPath = Request.PathBase.Value.WithTrailingSlash(),
@ -748,6 +755,10 @@ namespace BTCPayServer.Controllers
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en", DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
CustomCSSLink = storeBlob.CustomCSS, CustomCSSLink = storeBlob.CustomCSS,
CustomLogoLink = storeBlob.CustomLogo, CustomLogoLink = storeBlob.CustomLogo,
LogoFileId = storeBlob.LogoFileId,
BrandColor = storeBlob.BrandColor,
CheckoutFormId = invoice.CheckoutFormId ?? storeBlob.CheckoutFormId,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice", HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)), CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
BtcAddress = paymentMethodDetails.GetPaymentDestination(), BtcAddress = paymentMethodDetails.GetPaymentDestination(),
@ -783,12 +794,19 @@ namespace BTCPayServer.Controllers
IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1, IsMultiCurrency = invoice.GetPayments(false).Select(p => p.GetPaymentMethodId()).Concat(new[] { paymentMethod.GetId() }).Distinct().Count() > 1,
StoreId = store.Id, StoreId = store.Id,
AvailableCryptos = invoice.GetPaymentMethods() AvailableCryptos = invoice.GetPaymentMethods()
.Where(i => i.Network != null) .Where(i => i.Network != null &&
// TODO: These cases and implementation need to be discussed
(storeBlob.CheckoutType == CheckoutType.V1 ||
// Exclude LNURL for non-topup invoices
(invoice.IsUnsetTopUp() || i.GetId().PaymentType is not LNURLPayPaymentType)) &&
// Exclude Lightning if OnChainWithLnInvoiceFallback is active
(!storeBlob.OnChainWithLnInvoiceFallback || i.GetId().PaymentType is not LightningPaymentType)
)
.Select(kv => .Select(kv =>
{ {
var availableCryptoPaymentMethodId = kv.GetId(); var availableCryptoPaymentMethodId = kv.GetId();
var availableCryptoHandler = _paymentMethodHandlerDictionary[availableCryptoPaymentMethodId]; var availableCryptoHandler = _paymentMethodHandlerDictionary[availableCryptoPaymentMethodId];
return new PaymentModel.AvailableCrypto() return new PaymentModel.AvailableCrypto
{ {
PaymentMethodId = kv.GetId().ToString(), PaymentMethodId = kv.GetId().ToString(),
CryptoCode = kv.Network?.CryptoCode ?? kv.GetId().CryptoCode, CryptoCode = kv.Network?.CryptoCode ?? kv.GetId().CryptoCode,
@ -907,6 +925,14 @@ 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)]
@ -1053,10 +1079,12 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIHomeController.Index), "UIHome"); return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
} }
var storeBlob = HttpContext.GetStoreData()?.GetStoreBlob();
var vm = new CreateInvoiceModel var vm = new CreateInvoiceModel
{ {
StoreId = model.StoreId, StoreId = model.StoreId,
Currency = HttpContext.GetStoreData()?.GetStoreBlob().DefaultCurrency, Currency = storeBlob?.DefaultCurrency,
UseNewCheckout = storeBlob?.CheckoutType is CheckoutType.V2,
AvailablePaymentMethods = GetPaymentMethodsSelectList() AvailablePaymentMethods = GetPaymentMethodsSelectList()
}; };
@ -1069,8 +1097,11 @@ namespace BTCPayServer.Controllers
[BitpayAPIConstraint(false)] [BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken) public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken)
{ {
model.AvailablePaymentMethods = GetPaymentMethodsSelectList();
var store = HttpContext.GetStoreData(); var store = HttpContext.GetStoreData();
var storeBlob = store.GetStoreBlob();
model.UseNewCheckout = storeBlob.CheckoutType == CheckoutType.V2;
model.AvailablePaymentMethods = GetPaymentMethodsSelectList();
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
return View(model); return View(model);
@ -1089,18 +1120,17 @@ namespace BTCPayServer.Controllers
try try
{ {
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest() var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest
{ {
Price = model.Amount, Price = model.Amount,
Currency = model.Currency, Currency = model.Currency,
PosData = model.PosData, PosData = model.PosData,
OrderId = model.OrderId, OrderId = model.OrderId,
//RedirectURL = redirect + "redirect",
NotificationURL = model.NotificationUrl, NotificationURL = model.NotificationUrl,
ItemDesc = model.ItemDesc, ItemDesc = model.ItemDesc,
FullNotifications = true, FullNotifications = true,
BuyerEmail = model.BuyerEmail, BuyerEmail = model.BuyerEmail,
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency() SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency
{ {
Enabled = true Enabled = true
}), }),
@ -1108,8 +1138,11 @@ namespace BTCPayServer.Controllers
NotificationEmail = model.NotificationEmail, NotificationEmail = model.NotificationEmail,
ExtendedNotifications = model.NotificationEmail != null, ExtendedNotifications = model.NotificationEmail != null,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail ? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On : model.RequiresRefundEmail == RequiresRefundEmail.On,
CheckoutFormId = model.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: model.CheckoutFormId
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken); }, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!"; TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";

View file

@ -138,6 +138,7 @@ namespace BTCPayServer.Controllers
entity.RedirectAutomatically = entity.RedirectAutomatically =
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically); invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
entity.RequiresRefundEmail = invoice.RequiresRefundEmail; entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
entity.CheckoutFormId = invoice.CheckoutFormId;
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy); entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
IPaymentFilter? excludeFilter = null; IPaymentFilter? excludeFilter = null;
@ -193,6 +194,8 @@ namespace BTCPayServer.Controllers
entity.DefaultLanguage = invoice.Checkout.DefaultLanguage; entity.DefaultLanguage = invoice.Checkout.DefaultLanguage;
entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod; entity.DefaultPaymentMethod = invoice.Checkout.DefaultPaymentMethod;
entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically; entity.RedirectAutomatically = invoice.Checkout.RedirectAutomatically ?? storeBlob.RedirectAutomatically;
entity.CheckoutFormId = invoice.Checkout.CheckoutFormId;
entity.CheckoutType = invoice.Checkout.CheckoutType;
entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail; entity.RequiresRefundEmail = invoice.Checkout.RequiresRefundEmail;
IPaymentFilter? excludeFilter = null; IPaymentFilter? excludeFilter = null;
if (invoice.Checkout.PaymentMethods != null) if (invoice.Checkout.PaymentMethods != null)
@ -278,7 +281,6 @@ namespace BTCPayServer.Controllers
if (!noNeedForMethods) if (!noNeedForMethods)
{ {
// This loop ends with .ToList so we are querying all payment methods at once // This loop ends with .ToList so we are querying all payment methods at once
// instead of sequentially to improve response time // instead of sequentially to improve response time
var x1 = store.GetSupportedPaymentMethods(_NetworkProvider) var x1 = store.GetSupportedPaymentMethods(_NetworkProvider)

View file

@ -365,20 +365,11 @@ namespace BTCPayServer.Controllers
.Where(s => s.PaymentId.PaymentType != PaymentTypes.LNURLPay) .Where(s => s.PaymentId.PaymentType != PaymentTypes.LNURLPay)
.Select(method => .Select(method =>
{ {
var existing = var existing = storeBlob.PaymentMethodCriteria.SingleOrDefault(criteria =>
storeBlob.PaymentMethodCriteria.SingleOrDefault(criteria =>
criteria.PaymentMethod == method.PaymentId); criteria.PaymentMethod == method.PaymentId);
if (existing is null) return existing is null
{ ? new PaymentMethodCriteriaViewModel { PaymentMethod = method.PaymentId.ToString(), Value = "" }
return new PaymentMethodCriteriaViewModel() : new PaymentMethodCriteriaViewModel
{
PaymentMethod = method.PaymentId.ToString(),
Value = ""
};
}
else
{
return new PaymentMethodCriteriaViewModel()
{ {
PaymentMethod = existing.PaymentMethod.ToString(), PaymentMethod = existing.PaymentMethod.ToString(),
Type = existing.Above Type = existing.Above
@ -386,9 +377,11 @@ namespace BTCPayServer.Controllers
: PaymentMethodCriteriaViewModel.CriteriaType.LessThan, : PaymentMethodCriteriaViewModel.CriteriaType.LessThan,
Value = existing.Value?.ToString() ?? "" Value = existing.Value?.ToString() ?? ""
}; };
}
}).ToList(); }).ToList();
vm.UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2;
vm.CheckoutFormId = storeBlob.CheckoutFormId;
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail; vm.RequiresRefundEmail = storeBlob.RequiresRefundEmail;
vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods; vm.LazyPaymentMethods = storeBlob.LazyPaymentMethods;
vm.RedirectAutomatically = storeBlob.RedirectAutomatically; vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
@ -504,6 +497,14 @@ namespace BTCPayServer.Controllers
PaymentMethod = paymentMethodId PaymentMethod = paymentMethodId
}); });
} }
blob.CheckoutType = model.UseNewCheckout ? Client.Models.CheckoutType.V2 : Client.Models.CheckoutType.V1;
if (blob.CheckoutType == Client.Models.CheckoutType.V2)
{
blob.CheckoutFormId = model.CheckoutFormId;
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
}
blob.RequiresRefundEmail = model.RequiresRefundEmail; blob.RequiresRefundEmail = model.RequiresRefundEmail;
blob.LazyPaymentMethods = model.LazyPaymentMethods; blob.LazyPaymentMethods = model.LazyPaymentMethods;
blob.RedirectAutomatically = model.RedirectAutomatically; blob.RedirectAutomatically = model.RedirectAutomatically;

View file

@ -36,6 +36,9 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))] [JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public NetworkFeeMode NetworkFeeMode { get; set; } public NetworkFeeMode NetworkFeeMode { get; set; }
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
public CheckoutType CheckoutType { get; set; }
public string CheckoutFormId { get; set; }
public bool RequiresRefundEmail { get; set; } public bool RequiresRefundEmail { get; set; }
public bool LightningAmountInSatoshi { get; set; } public bool LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; } public bool LightningPrivateRouteHints { get; set; }

View file

@ -82,6 +82,9 @@ namespace BTCPayServer.Models
[JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
public bool? RequiresRefundEmail { get; set; } public bool? RequiresRefundEmail { get; set; }
[JsonProperty(PropertyName = "checkoutFormId", DefaultValueHandling = DefaultValueHandling.Ignore)]
public string CheckoutFormId { get; set; }
//Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies //Bitpay compatibility: create invoice in btcpay uses this instead of supportedTransactionCurrencies
[JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]
public IEnumerable<string> PaymentCurrencies { get; set; } public IEnumerable<string> PaymentCurrencies { get; set; }

View file

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace BTCPayServer.Models namespace BTCPayServer.Models
@ -266,6 +268,11 @@ namespace BTCPayServer.Models
public Dictionary<string, InvoiceCryptoInfo.InvoicePaymentUrls> PaymentCodes { get; set; } public Dictionary<string, InvoiceCryptoInfo.InvoicePaymentUrls> PaymentCodes { get; set; }
[JsonProperty("buyer")] [JsonProperty("buyer")]
public JObject Buyer { get; set; } public JObject Buyer { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; }
} }
public class Flags public class Flags
{ {

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Validation; using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@ -88,5 +88,10 @@ namespace BTCPayServer.Models.InvoicingModels
{ {
get; set; get; set;
} }
[Display(Name = "Request customer data on checkout")]
public string CheckoutFormId { get; set; }
public bool UseNewCheckout { get; set; }
} }
} }

View file

@ -1,4 +1,5 @@
using System.Collections.Generic; using System.Collections.Generic;
using BTCPayServer.Client.Models;
namespace BTCPayServer.Models.InvoicingModels namespace BTCPayServer.Models.InvoicingModels
{ {
@ -23,9 +24,11 @@ namespace BTCPayServer.Models.InvoicingModels
} }
public string CustomCSSLink { get; set; } public string CustomCSSLink { get; set; }
public string CustomLogoLink { get; set; } public string CustomLogoLink { get; set; }
public string LogoFileId { get; set; }
public string BrandColor { get; set; }
public string HtmlTitle { get; set; } public string HtmlTitle { get; set; }
public string DefaultLang { get; set; } public string DefaultLang { get; set; }
public List<AvailableCrypto> AvailableCryptos { get; set; } = new List<AvailableCrypto>(); public List<AvailableCrypto> AvailableCryptos { get; set; } = new ();
public bool IsModal { get; set; } public bool IsModal { get; set; }
public bool IsUnsetTopUp { get; set; } public bool IsUnsetTopUp { get; set; }
public string CryptoCode { get; set; } public string CryptoCode { get; set; }
@ -69,5 +72,8 @@ namespace BTCPayServer.Models.InvoicingModels
public bool Activated { get; set; } public bool Activated { get; set; }
public string InvoiceCurrency { get; set; } public string InvoiceCurrency { get; set; }
public string ReceiptLink { get; set; } public string ReceiptLink { get; set; }
public string CheckoutFormId { get; set; }
public bool AltcoinsBuild { get; set; }
public CheckoutType CheckoutType { get; set; }
} }
} }

View file

@ -5,6 +5,7 @@ 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
{ {
@ -20,11 +21,21 @@ namespace BTCPayServer.Models.StoreViewModels
Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen); Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
DefaultLang = chosen.Value; DefaultLang = chosen.Value;
} }
public SelectList Languages { get; set; } public SelectList Languages { get; set; }
[Display(Name = "Request customer data on checkout")]
public string CheckoutFormId { get; set; }
[Display(Name = "Include Lightning invoice fallback to on-chain BIP21 payment URL")]
public bool OnChainWithLnInvoiceFallback { get; set; }
[Display(Name = "Default payment method on checkout")] [Display(Name = "Default payment method on checkout")]
public string DefaultPaymentMethod { get; set; } public string DefaultPaymentMethod { get; set; }
[Display(Name = "Use the new checkout")]
public bool UseNewCheckout { get; set; }
[Display(Name = "Requires a refund email")] [Display(Name = "Requires a refund email")]
public bool RequiresRefundEmail { get; set; } public bool RequiresRefundEmail { get; set; }

View file

@ -221,6 +221,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
PosData = string.IsNullOrEmpty(posData) ? null : posData, PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically, RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods, SupportedTransactionCurrencies = paymentMethods,
CheckoutFormId = store.GetStoreBlob().CheckoutFormId,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail ? store.GetStoreBlob().RequiresRefundEmail
: requiresRefundEmail == RequiresRefundEmail.On, : requiresRefundEmail == RequiresRefundEmail.On,
@ -252,6 +253,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (app == null) if (app == null)
return NotFound(); return NotFound();
var storeBlob = GetCurrentStore().GetStoreBlob();
var settings = app.GetSettings<PointOfSaleSettings>(); var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView; settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
settings.EnableShoppingCart = false; settings.EnableShoppingCart = false;
@ -281,7 +283,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RedirectUrl = settings.RedirectUrl, RedirectUrl = settings.RedirectUrl,
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}", SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "", RedirectAutomatically = settings.RedirectAutomatically.HasValue ? settings.RedirectAutomatically.Value ? "true" : "false" : "",
RequiresRefundEmail = settings.RequiresRefundEmail RequiresRefundEmail = settings.RequiresRefundEmail,
CheckoutFormId = settings.CheckoutFormId,
UseNewCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V2
}; };
if (HttpContext?.Request != null) if (HttpContext?.Request != null)
{ {
@ -348,8 +352,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return View("PointOfSale/UpdatePointOfSale", vm); return View("PointOfSale/UpdatePointOfSale", vm);
} }
app.Name = vm.AppName; var storeBlob = GetCurrentStore().GetStoreBlob();
app.SetSettings(new PointOfSaleSettings var settings = new PointOfSaleSettings
{ {
Title = vm.Title, Title = vm.Title,
DefaultView = vm.DefaultView, DefaultView = vm.DefaultView,
@ -367,9 +371,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RedirectUrl = vm.RedirectUrl, RedirectUrl = vm.RedirectUrl,
Description = vm.Description, Description = vm.Description,
EmbeddedCSS = vm.EmbeddedCSS, EmbeddedCSS = vm.EmbeddedCSS,
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically), RedirectAutomatically =
RequiresRefundEmail = vm.RequiresRefundEmail, string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically),
}); RequiresRefundEmail = vm.RequiresRefundEmail
};
if (storeBlob.CheckoutType == Client.Models.CheckoutType.V2)
{
settings.CheckoutFormId = vm.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: vm.CheckoutFormId;
}
app.Name = vm.AppName;
app.SetSettings(settings);
await _appService.UpdateOrCreateApp(app); await _appService.UpdateOrCreateApp(app);
TempData[WellKnownTempData.SuccessMessage] = "App updated"; TempData[WellKnownTempData.SuccessMessage] = "App updated";
return RedirectToAction(nameof(UpdatePointOfSale), new { appId }); return RedirectToAction(nameof(UpdatePointOfSale), new { appId });
@ -398,6 +413,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return currency.Trim().ToUpperInvariant(); return currency.Trim().ToUpperInvariant();
} }
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData(); private AppData GetCurrentApp() => HttpContext.GetAppData();
} }
} }

View file

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using BTCPayServer.Validation; using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@ -97,7 +98,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
[Display(Name = "Custom CSS Code")] [Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; } public string EmbeddedCSS { get; set; }
public string Description { get; set; } public string Description { get; set; }
[Display(Name = "Require refund email on checkout")] [Display(Name = "Require refund email on checkout")]
public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore; public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore;
[Display(Name = "Request customer data on checkout")]
public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString();
public bool UseNewCheckout { get; set; }
} }
} }

View file

@ -1,3 +1,6 @@
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
namespace BTCPayServer.Services.Apps namespace BTCPayServer.Services.Apps
{ {
public class PointOfSaleSettings public class PointOfSaleSettings
@ -55,6 +58,8 @@ namespace BTCPayServer.Services.Apps
public bool EnableTips { get; set; } public bool EnableTips { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; } public RequiresRefundEmail RequiresRefundEmail { get; set; }
public string CheckoutFormId { get; set; } = GenericFormOption.InheritFromStore.ToString();
public const string BUTTON_TEXT_DEF = "Buy for {0}"; public const string BUTTON_TEXT_DEF = "Buy for {0}";
public string ButtonText { get; set; } = BUTTON_TEXT_DEF; public string ButtonText { get; set; } = BUTTON_TEXT_DEF;
public const string CUSTOM_BUTTON_TEXT_DEF = "Pay"; public const string CUSTOM_BUTTON_TEXT_DEF = "Pay";
@ -72,5 +77,6 @@ namespace BTCPayServer.Services.Apps
public string NotificationUrl { get; set; } public string NotificationUrl { get; set; }
public string RedirectUrl { get; set; } public string RedirectUrl { get; set; }
public bool? RedirectAutomatically { get; set; } public bool? RedirectAutomatically { get; set; }
} public CheckoutType CheckoutType { get; internal set; }
}
} }

View file

@ -446,6 +446,11 @@ namespace BTCPayServer.Services.Invoices
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)] [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]
public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; } public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; }
public bool IsExpired() public bool IsExpired()
{ {
return DateTimeOffset.UtcNow > ExpirationTime; return DateTimeOffset.UtcNow > ExpirationTime;
@ -573,6 +578,8 @@ namespace BTCPayServer.Services.Invoices
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m; dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price; dto.Price = Price;
dto.Currency = Currency; dto.Currency = Currency;
dto.CheckoutFormId = CheckoutFormId;
dto.CheckoutType = CheckoutType;
dto.Buyer = new JObject(); dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName)); dto.Buyer.Add(new JProperty("name", Metadata.BuyerName));
dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1)); dto.Buyer.Add(new JProperty("address1", Metadata.BuyerAddress1));

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Services.Stores;
public enum GenericFormOption
{
[Display(Name = "Inherit from store settings")]
InheritFromStore,
[Display(Name = "Do not request any information")]
None,
[Display(Name = "Request email address only")]
Email,
[Display(Name = "Request shipping address")]
Address
}
public static class CheckoutFormSelectList
{
public static SelectList ForStore(StoreData store, string selectedFormId, bool isStoreEntity)
{
var choices = new List<SelectListItem>();
if (isStoreEntity)
{
var blob = store.GetStoreBlob();
var inherit = GenericOptionItem(GenericFormOption.InheritFromStore);
inherit.Text += Enum.TryParse<GenericFormOption>(blob.CheckoutFormId, out var item)
? $" ({DisplayName(item)})"
: $" ({blob.CheckoutFormId})";
choices.Add(inherit);
}
choices.Add(GenericOptionItem(GenericFormOption.None));
choices.Add(GenericOptionItem(GenericFormOption.Email));
choices.Add(GenericOptionItem(GenericFormOption.Address));
var chosen = choices.FirstOrDefault(t => t.Value == selectedFormId);
return new SelectList(choices, nameof(SelectListItem.Value), nameof(SelectListItem.Text), chosen?.Value);
}
private static string DisplayName(GenericFormOption opt) =>
typeof(GenericFormOption).DisplayName(opt.ToString());
private static SelectListItem GenericOptionItem(GenericFormOption opt) =>
new() { Text = DisplayName(opt), Value = opt.ToString() };
}

View file

@ -0,0 +1,29 @@
@using BTCPayServer.BIP78.Sender
@model BTCPayServer.Models.InvoicingModels.PaymentModel
<template id="bitcoin-method-checkout-template">
<div class="payment-box">
<small class="qr-text" id="QR_Text_@Model.PaymentMethodId">{{$t("QR_Text")}}</small>
<div class="qr-container my-3" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="srvModel.btcAddress">
<qrcode v-if="srvModel.invoiceBitcoinUrlQR" :value="srvModel.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div>
<a v-if="srvModel.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top"
:href="srvModel.invoiceBitcoinUrl" :title="$t(hasPayjoin ? 'BIP21 payment link with PayJoin support' : 'BIP21 payment link')">{{$t("Pay in wallet")}}</a>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-bitcoin-post-content", model = Model })
</div>
</template>
<script>
Vue.component('BitcoinLikeMethodCheckout', {
props: ["srvModel"],
template: "#bitcoin-method-checkout-template",
components: {
qrcode: VueQrcode
},
computed: {
hasPayjoin () {
return this.srvModel.invoiceBitcoinUrl.indexOf('@PayjoinClient.BIP21EndpointKey=') !== -1;
}
}
});
</script>

View file

@ -0,0 +1,23 @@
@model BTCPayServer.Models.InvoicingModels.PaymentModel
<template id="lightning-method-checkout-template">
<div class="payment-box">
<small class="qr-text" id="QR_Text_@Model.PaymentMethodId">{{$t("QR_Text")}}</small>
<div class="qr-container my-3" data-clipboard-confirm-element="QR_Text_@Model.PaymentMethodId" :data-clipboard="srvModel.btcAddress">
<qrcode v-if="srvModel.invoiceBitcoinUrlQR" :value="srvModel.invoiceBitcoinUrlQR" tag="div" :options="qrOptions" />
</div>
<a v-if="srvModel.invoiceBitcoinUrl" class="btn btn-primary rounded-pill w-100" target="_top"
:href="srvModel.invoiceBitcoinUrl">{{$t("Pay in wallet")}}</a>
@await Component.InvokeAsync("UiExtensionPoint", new { location = "checkout-lightning-post-content", model = Model })
</div>
</template>
<script>
Vue.component('LightningLikeMethodCheckout', {
props: ["srvModel"],
template: "#lightning-method-checkout-template",
components: {
qrcode: VueQrcode
}
});
</script>

View file

@ -2,9 +2,13 @@
@using BTCPayServer.Abstractions.Models @using BTCPayServer.Abstractions.Models
@using BTCPayServer.Views.Apps @using BTCPayServer.Views.Apps
@using BTCPayServer.Abstractions.Extensions @using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Services.Stores
@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
@{ @{
ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id); ViewData.SetActivePage(AppsNavPages.Update, "Update Point of Sale", Model.Id);
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
} }
<form method="post"> <form method="post">
@ -72,9 +76,7 @@
<label asp-for="DefaultView" class="form-label" data-required></label> <label asp-for="DefaultView" class="form-label" data-required></label>
<select asp-for="DefaultView" asp-items="@Html.GetEnumSelectList<PosViewType>()" class="form-select" required></select> <select asp-for="DefaultView" asp-items="@Html.GetEnumSelectList<PosViewType>()" class="form-select" required></select>
<span asp-validation-for="DefaultView" class="text-danger"></span> <span asp-validation-for="DefaultView" class="text-danger"></span>
<div class="mt-2"> <p class="form-text text-muted">Choose the point of sale style for your customers.</p>
<span class="text-secondary">Choose the point of sale style for your customers.</span>
</div>
</div> </div>
<div class="form-group" id="button-price-text"> <div class="form-group" id="button-price-text">
<label asp-for="ButtonText" class="form-label" data-required></label> <label asp-for="ButtonText" class="form-label" data-required></label>
@ -82,9 +84,18 @@
<span asp-validation-for="ButtonText" class="text-danger"></span> <span asp-validation-for="ButtonText" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-label"></label> @if (Model.UseNewCheckout)
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select" required></select> {
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span> <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>
}
else
{
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
</div> </div>
<section id="discounts" class="p-0"> <section id="discounts" class="p-0">
<h3 class="mt-5 mb-4">Discounts</h3> <h3 class="mt-5 mb-4">Discounts</h3>

View file

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

View file

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

View file

@ -1,7 +1,11 @@
@model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel @model BTCPayServer.Models.InvoicingModels.CreateInvoiceModel
@using BTCPayServer.Services.Apps @using BTCPayServer.Services.Apps
@using BTCPayServer.Services.Stores
@{ @{
ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice"); ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice");
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
} }
@section PageFootContent { @section PageFootContent {
@ -90,9 +94,18 @@
<span asp-validation-for="BuyerEmail" class="text-danger"></span> <span asp-validation-for="BuyerEmail" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-label"></label> @if (Model.UseNewCheckout)
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select"></select> {
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span> <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>
}
else
{
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select w-auto"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
}
</div> </div>
<h4 class="mt-5 mb-2">Additional Options</h4> <h4 class="mt-5 mb-2">Additional Options</h4>

View file

@ -1,8 +1,28 @@
@using BTCPayServer.Payments @using BTCPayServer.Payments
@using BTCPayServer.Services.Invoices
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Services.Stores
@model CheckoutAppearanceViewModel @model CheckoutAppearanceViewModel
@{ @{
Layout = "../Shared/_NavLayout.cshtml"; Layout = "../Shared/_NavLayout.cshtml";
ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id); ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id);
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, false);
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script>
delegate('click', '.setTheme', e => {
e.preventDefault();
const { theme } = e.target.dataset;
document.getElementById('CustomCSS').value = ['dark', 'legacy'].includes(theme)
? `/checkout/css/themes/${theme}.css`
: ''
});
</script>
} }
<div class="row"> <div class="row">
@ -43,6 +63,7 @@
</table> </table>
</div> </div>
} }
<div class="form-check my-3"> <div class="form-check my-3">
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" /> <input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" />
<label asp-for="RequiresRefundEmail" class="form-check-label"></label> <label asp-for="RequiresRefundEmail" class="form-check-label"></label>
@ -56,7 +77,35 @@
<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">Public receipt</h3> <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>
<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" />
<label asp-for="ReceiptOptions.Enabled" class="form-check-label"></label> <label asp-for="ReceiptOptions.Enabled" class="form-check-label"></label>
@ -75,7 +124,7 @@
<div class="form-check"> <div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input" /> <input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input" />
<label asp-for="AutoDetectLanguage" class="form-check-label"></label> <label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<p class="form-text text-muted">Detects the language of the customer's browser with 99.9% accuracy.</p> <p class="text-muted">Detects the language of the customer's browser with 99.9% accuracy.</p>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -116,16 +165,3 @@
</form> </form>
</div> </div>
</div> </div>
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script>
delegate('click', '.setTheme', e => {
e.preventDefault();
const { theme } = e.target.dataset;
document.getElementById('CustomCSS').value = ['dark', 'legacy'].includes(theme)
? `/checkout/css/themes/${theme}.css`
: ''
});
</script>
}

View file

@ -0,0 +1,152 @@
:root {
--logo-size: 3rem;
--navbutton-size: .8rem;
--qr-size: 256px;
--section-padding: var(--btcpay-space-l);
}
body {
padding: var(--btcpay-space-m);
}
header,
footer {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--btcpay-space-l) var(--btcpay-space-s);
gap: var(--btcpay-space-m);
}
main {
position: relative;
border-radius: var(--btcpay-border-radius-l);
background-color: var(--btcpay-bg-tile);
}
nav {
position: absolute;
top: var(--btcpay-space-m);
left: var(--btcpay-space-m);
right: var(--btcpay-space-m);
}
nav button {
position: absolute;
top: 0;
border: none;
background: none;
color: var(--btcpay-body-text-muted);
}
nav button:hover {
color: var(--btcpay-body-text-hover);
}
nav button#back {
left: 0;
}
nav button#close {
right: 0;
}
nav button .icon {
width: var(--navbutton-size);
height: var(--navbutton-size);
}
section {
padding: var(--section-padding);
}
section h4 {
margin-bottom: var(--btcpay-space-l);
font-weight: var(--btcpay-font-weight-semibold);
text-align: center;
}
section h5,
section h6 {
margin-bottom: 1.5rem;
font-weight: var(--btcpay-font-weight-semibold);
text-align: center;
}
section .top {
flex: 1;
}
section .buttons {
display: flex;
flex-direction: column;
gap: var(--btcpay-space-m);
}
section dl {
margin-bottom: 1.5rem;
}
section dl > div {
display: flex;
justify-content: space-between;
}
section dl > div dt,
section dl > div dd {
margin: 0;
padding: var(--btcpay-space-xs) 0;
font-weight: var(--btcpay-font-weight-normal);
}
section dl > div dt {
text-align: left;
}
section dl > div dd {
text-align: right;
}
.logo {
height: var(--logo-size);
}
.logo--square {
width: var(--logo-size);
border-radius: 50%;
}
.wrap {
max-width: 400px;
margin: 0 auto;
}
.timer {
display: flex;
align-items: 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;
}
.payment-box {
max-width: 300px;
min-width: var(--qr-size);
margin: 0 auto;
text-align: center;
}
.payment-box .qr-text {
display: block;
color: var(--btcpay-light-text);
}
.payment-box .qr-container {
min-height: var(--qr-size);
}
.payment-box svg {
width: 100%;
}
.payment-box [data-clipboard] {
cursor: copy;
}
#payment .btcpay-pills .btcpay-pill {
padding: var(--btcpay-space-xs) var(--btcpay-space-m);
}
#form > form,
#result > div {
display: flex;
flex-direction: column;
}
#result .top .icon {
display: block;
width: 3rem;
height: 3rem;
margin: .5rem auto 1.5rem;
}
#PaymentDetails dl {
margin: 0;
}
#PaymentDetailsButton {
margin: 0 auto;
padding: var(--btcpay-space-s);
}
#PaymentDetailsButton .icon {
margin-left: -1rem; /* Adjust for visual center */
}

View file

@ -0,0 +1,60 @@
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', {
bind: function (el) {
el.transitionDuration = 350;
},
update: function (el, binding) {
if (binding.oldValue !== binding.value){
if (binding.value) {
setTimeout(function () {
el.classList.remove('collapse');
const height = window.getComputedStyle(el).height;
el.classList.add('collapsing');
el.offsetHeight;
el.style.height = height;
setTimeout(() => {
el.classList.remove('collapsing');
el.classList.add('collapse');
el.style.height = null;
el.classList.add('show');
}, el.transitionDuration)
}, 0);
}
else {
el.style.height = window.getComputedStyle(el).height;
el.classList.remove('collapse');
el.classList.remove('show');
el.offsetHeight;
el.style.height = null;
el.classList.add('collapsing');
setTimeout(() => {
el.classList.add('collapse');
el.classList.remove("collapsing");
}, el.transitionDuration)
}
}
}
});

View file

@ -1,12 +1,11 @@
var urlParams; const urlParams = {};
(window.onpopstate = function () { (window.onpopstate = function () {
var match, let match,
pl = /\+/g, // Regex for replacing addition symbol with a space pl = /\+/g, // Regex for replacing addition symbol with a space
search = /([^&=]+)=?([^&]*)/g, search = /([^&=]+)=?([^&]*)/g,
decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); }, decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); },
query = window.location.search.substring(1); query = window.location.search.substring(1);
urlParams = {};
while (match = search.exec(query)) { while (match = search.exec(query)) {
urlParams[decode(match[1])] = decode(match[2]); urlParams[decode(match[1])] = decode(match[2]);
} }

View file

@ -4,6 +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="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>
@ -44,4 +45,7 @@
<symbol id="thunderhub" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(4, 4)"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><path d="M9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></g></symbol> <symbol id="thunderhub" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(4, 4)"><rect x="4" y="4" width="16" height="16" rx="2" ry="2"/><path d="M9 9h6v6H9zM9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/></g></symbol>
<symbol id="lightningterminal" viewBox="0 0 28 55"><g fill="currentColor"><path d="m27.25 30.5-15.9 23.2a.84.84 0 1 1-1.38-.96l15.9-23.19a.84.84 0 1 1 1.38.96zm-2.09-4.13L9.63 49.08a.84.84 0 0 1-1.39-.95l15.54-22.71a.84.84 0 0 1 1.38.95zm-4.72-24.8L2.43 27.9h16.9l-1.14 1.68H.36a.84.84 0 0 1-.22-1.15L19 .62A.84.84 0 0 1 20.16.4c.4.26.52.78.28 1.19z"/><path d="M22.12 6.62 10.24 23.99H22l-1.15 1.68H7.05l1.14-1.68 12.53-18.3a.84.84 0 0 1 1.39.93z"/></g></symbol> <symbol id="lightningterminal" viewBox="0 0 28 55"><g fill="currentColor"><path d="m27.25 30.5-15.9 23.2a.84.84 0 1 1-1.38-.96l15.9-23.19a.84.84 0 1 1 1.38.96zm-2.09-4.13L9.63 49.08a.84.84 0 0 1-1.39-.95l15.54-22.71a.84.84 0 0 1 1.38.95zm-4.72-24.8L2.43 27.9h16.9l-1.14 1.68H.36a.84.84 0 0 1-.22-1.15L19 .62A.84.84 0 0 1 20.16.4c.4.26.52.78.28 1.19z"/><path d="M22.12 6.62 10.24 23.99H22l-1.15 1.68H7.05l1.14-1.68 12.53-18.3a.84.84 0 0 1 1.39.93z"/></g></symbol>
<symbol id="donate" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.65 14.91a.75.75 0 0 0 .7 0L8 14.26l.35.66h.02c1.33-.74 2.59-1.6 3.75-2.6C13.96 10.74 16 8.36 16 5.5 16 2.84 13.91 1 11.75 1 10.2 1 8.85 1.8 8 3.02A4.57 4.57 0 0 0 4.25 1 4.38 4.38 0 0 0 0 5.5c0 2.85 2.04 5.23 3.88 6.82a22.08 22.08 0 0 0 3.75 2.58l.02.01Z" fill="currentColor"/></symbol> <symbol id="donate" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.65 14.91a.75.75 0 0 0 .7 0L8 14.26l.35.66h.02c1.33-.74 2.59-1.6 3.75-2.6C13.96 10.74 16 8.36 16 5.5 16 2.84 13.91 1 11.75 1 10.2 1 8.85 1.8 8 3.02A4.57 4.57 0 0 0 4.25 1 4.38 4.38 0 0 0 0 5.5c0 2.85 2.04 5.23 3.88 6.82a22.08 22.08 0 0 0 3.75 2.58l.02.01Z" fill="currentColor"/></symbol>
<symbol id="invoice-expired" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="m17 31 14-14m-14 0 14 14" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payment-sent" viewBox="0 0 48 48" fill="none"><circle cx="24" cy="24" r="22.5" stroke="currentColor" stroke-width="3"/><path d="M24 16v16m5.71-10.29L24 16l-6.29 6.29" stroke="currentColor" stroke-width="3" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="payment-complete" viewBox="0 0 48 48" fill="none"><path d="M47 24a22.5 22.5 0 1 1-45 0 22.5 22.5 0 0 1 45 0Z" stroke="currentColor" stroke-width="3"/><path d="m15.23 24.8 7.1 7.1 12.8-12.8" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View file

@ -8,7 +8,9 @@ const confirmCopy = (el, message) => {
window.copyToClipboard = function (e, data) { window.copyToClipboard = function (e, data) {
e.preventDefault(); e.preventDefault();
const item = e.target.closest('[data-clipboard]') || e.target.closest('[data-clipboard-target]') || e.target; const item = e.target.closest('[data-clipboard]') || e.target.closest('[data-clipboard-target]') || e.target;
const confirm = item.querySelector('[data-clipboard-confirm]') || item; const confirm = item.dataset.clipboardConfirmElement
? document.getElementById(item.dataset.clipboardConfirmElement) || 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) { if (!confirm.dataset.clipboardInitial) {
confirm.dataset.clipboardInitial = confirm.innerHTML; confirm.dataset.clipboardInitial = confirm.innerHTML;

View file

@ -47,7 +47,17 @@
"Pay with CoinSwitch": "Pay with CoinSwitch", "Pay with CoinSwitch": "Pay with CoinSwitch",
"Pay with Changelly": "Pay with Changelly", "Pay with Changelly": "Pay with Changelly",
"Close": "Close", "Close": "Close",
"NotPaid_ExtraTransaction": "The invoice hasn't been paid in full. Please send another transaction to cover amount Due.", "NotPaid_ExtraTransaction": "The invoice hasn't been paid in full. Please send another transaction to cover amount due.",
"Recommended_Fee": "Recommended fee: {{feeRate}} sat/byte", "Recommended_Fee": "Recommended fee: {{feeRate}} sat/byte",
"View receipt": "View receipt" "View receipt": "View receipt",
"View Details": "View Details",
"QR_Text": "Scan the QR code, or tap to copy the address.",
"Total Price": "Total Price",
"Exchange Rate": "Exchange Rate",
"Recommended Fee": "Recommended Fee",
"Feerate": "{{feeRate}} sat/byte",
"Amount Paid": "Amount Paid",
"Amount Due": "Amount Due",
"Pay in wallet": "Pay in wallet",
"Invoice paid": "Invoice paid"
} }

View file

@ -84,7 +84,7 @@ a.unobtrusive-link {
box-shadow: none; box-shadow: none;
} }
[data-bs-toggle="collapse"] svg.icon { [aria-expanded] > svg.icon-caret-down {
flex-shrink: 0; flex-shrink: 0;
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -92,7 +92,7 @@ a.unobtrusive-link {
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
[data-bs-toggle="collapse"][aria-expanded="true"] svg.icon { [aria-expanded="true"] > svg.icon-caret-down {
transform: rotate(-180deg); transform: rotate(-180deg);
} }

View file

@ -358,6 +358,14 @@
"type": "boolean", "type": "boolean",
"description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings", "description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings",
"nullable": true "nullable": true
},
"checkoutType": {
"$ref": "#/components/schemas/CheckoutType"
},
"checkoutFormId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"nullable": true
} }
} }
} }

View file

@ -1074,6 +1074,25 @@
"nullable": true, "nullable": true,
"description": "Invoice will require user to provide a refund email if this option is set to `true`. Has no effect if `buyerEmail` metadata is set as there is no email to collect in this case." "description": "Invoice will require user to provide a refund email if this option is set to `true`. Has no effect if `buyerEmail` metadata is set as there is no email to collect in this case."
}, },
"checkoutType": {
"type": "string",
"description": "`\"V1\"`: The original checkout form \n`\"V2\"`: The new experimental checkout form. \nIf `null` or unspecified, the store's settings will be used.",
"nullable": true,
"default": null,
"x-enumNames": [
"V1",
"V2"
],
"enum": [
"V1",
"V2"
]
},
"checkoutFormId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"nullable": true
},
"defaultLanguage": { "defaultLanguage": {
"type": "string", "type": "string",
"nullable": true, "nullable": true,

View file

@ -79,19 +79,33 @@
"LowMediumSpeed" "LowMediumSpeed"
] ]
}, },
"CheckoutType": {
"type": "string",
"description": "`\"V1\"`: The original checkout form \n`\"V2\"`: The new experimental checkout form",
"nullable": true,
"default": "V1",
"x-enumNames": [
"V1",
"V2"
],
"enum": [
"V1",
"V2"
]
},
"TimeSpan": { "TimeSpan": {
"type": "number", "type": "number",
"format": "int32", "format": "int32",
"example": 90 "example": 90
}, },
"TimeSpanSeconds": { "TimeSpanSeconds": {
"allOf": [ {"$ref": "#/components/schemas/TimeSpan"}], "allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ],
"format": "seconds", "format": "seconds",
"description": "A span of times in seconds" "description": "A span of times in seconds"
}, },
"TimeSpanMinutes": { "TimeSpanMinutes": {
"allOf": [ {"$ref": "#/components/schemas/TimeSpan"}], "allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ],
"format": "minutes", "format": "minutes",
"description": "A span of times in minutes" "description": "A span of times in minutes"
} }

View file

@ -341,6 +341,14 @@
"default": false, "default": false,
"description": "If true, the checkout page will ask to enter an email address before accessing payment information." "description": "If true, the checkout page will ask to enter an email address before accessing payment information."
}, },
"checkoutType": {
"$ref": "#/components/schemas/CheckoutType"
},
"checkoutFormId": {
"type": "string",
"description": "Form ID to request customer data, in case the new checkout is used",
"nullable": true
},
"receipt": { "receipt": {
"nullable": true, "nullable": true,
"$ref": "#/components/schemas/ReceiptOptions", "$ref": "#/components/schemas/ReceiptOptions",

View file

@ -1,2 +1 @@
!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(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}])});
//# sourceMappingURL=vue-i18next.js.map