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 bool? RedirectAutomatically { get; set; } = null;
public bool? RequiresRefundEmail { get; set; } = null;
public string CheckoutFormId { 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? RequiresRefundEmail { get; set; } = null;
public string DefaultLanguage { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
public CheckoutType? CheckoutType { get; set; }
}
}
public class InvoiceData : InvoiceDataBase

View file

@ -31,6 +31,10 @@ namespace BTCPayServer.Client.Models
public bool AnyoneCanCreateInvoice { get; set; }
public string DefaultCurrency { 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 LightningPrivateRouteHints { get; set; }
public bool OnChainWithLnInvoiceFallback { get; set; }
@ -66,6 +70,12 @@ namespace BTCPayServer.Client.Models
public IDictionary<string, JToken> AdditionalData { get; set; }
}
public enum CheckoutType
{
V1,
V2
}
public enum NetworkFeeMode
{
MultiplePaymentsOnly,

View file

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

View file

@ -164,6 +164,8 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS,
RedirectAutomatically = request.RedirectAutomatically,
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,
RedirectAutomatically = entity.RedirectAutomatically,
RequiresRefundEmail = entity.RequiresRefundEmail,
CheckoutFormId = entity.CheckoutFormId,
CheckoutType = entity.CheckoutType,
RedirectURL = entity.RedirectURLTemplate
},
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)
NetworkFeeMode = storeBlob.NetworkFeeMode,
RequiresRefundEmail = storeBlob.RequiresRefundEmail,
CheckoutFormId = storeBlob.CheckoutFormId,
CheckoutType = storeBlob.CheckoutType,
Receipt = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, null),
LightningAmountInSatoshi = storeBlob.LightningAmountInSatoshi,
LightningPrivateRouteHints = storeBlob.LightningPrivateRouteHints,
@ -165,6 +167,8 @@ namespace BTCPayServer.Controllers.Greenfield
blob.NetworkFeeMode = restModel.NetworkFeeMode;
blob.DefaultCurrency = restModel.DefaultCurrency;
blob.RequiresRefundEmail = restModel.RequiresRefundEmail;
blob.CheckoutFormId = restModel.CheckoutFormId;
blob.CheckoutType = restModel.CheckoutType;
blob.ReceiptOptions = InvoiceDataBase.ReceiptOptions.Merge(restModel.Receipt, null);
blob.LightningAmountInSatoshi = restModel.LightningAmountInSatoshi;
blob.LightningPrivateRouteHints = restModel.LightningPrivateRouteHints;

View file

@ -1,4 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using BTCPayServer.Data;
@ -24,34 +25,35 @@ namespace BTCPayServer.Controllers
public string CryptoCode { get; set; } = "BTC";
}
[HttpPost]
[Route("i/{invoiceId}/test-payment")]
[HttpPost("i/{invoiceId}/test-payment")]
[CheatModeRoute]
public async Task<IActionResult> TestPayment(string invoiceId, FakePaymentRequest request, [FromServices] Cheater cheater)
{
var invoice = await _InvoiceRepository.GetInvoice(invoiceId);
var store = await _StoreRepository.FindStore(invoice.StoreId);
// TODO support altcoins, not just bitcoin
var network = _NetworkProvider.GetNetwork<BTCPayNetwork>(request.CryptoCode);
// TODO support altcoins, not just bitcoin - and make it work for LN-only invoices
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))
.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 bitcoinAddressObj = BitcoinAddress.Create(bitcoinAddressString, network.NBitcoinNetwork);
var BtcAmount = request.Amount;
var amount = new Money(request.Amount, isSats ? MoneyUnit.Satoshi : MoneyUnit.BTC);
try
{
var paymentMethod = invoice.GetPaymentMethod(paymentMethodId);
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.
var totalDue = invoice.Price;
return Ok(new
{
Txid = txid,
AmountRemaining = (totalDue - (BtcAmount * rate)) / rate,
AmountRemaining = (totalDue - (amount.ToUnit(MoneyUnit.BTC) * rate)) / rate,
SuccessMessage = "Created transaction " + txid
});
}
@ -65,8 +67,7 @@ namespace BTCPayServer.Controllers
}
}
[HttpPost]
[Route("i/{invoiceId}/mine-blocks")]
[HttpPost("i/{invoiceId}/mine-blocks")]
[CheatModeRoute]
public IActionResult MineBlock(string invoiceId, MineBlocksRequest request, [FromServices] Cheater cheater)
{
@ -96,8 +97,7 @@ namespace BTCPayServer.Controllers
}
}
[HttpPost]
[Route("i/{invoiceId}/expire")]
[HttpPost("i/{invoiceId}/expire")]
[CheatModeRoute]
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.Export;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -603,23 +604,26 @@ namespace BTCPayServer.Controllers
[HttpGet("i/{invoiceId}/{paymentMethodId}")]
[HttpGet("invoice")]
[AcceptMediaTypeConstraint("application/bitcoin-paymentrequest", false)]
[XFrameOptionsAttribute(null)]
[ReferrerPolicyAttribute("origin")]
[XFrameOptions(null)]
[ReferrerPolicy("origin")]
public async Task<IActionResult> Checkout(string? invoiceId, string? id = null, string? paymentMethodId = null,
[FromQuery] string? view = null, [FromQuery] string? lang = null)
{
//Keep compatibility with Bitpay
invoiceId = invoiceId ?? id;
//
// Keep compatibility with Bitpay
invoiceId ??= id;
if (invoiceId is null)
return NotFound();
var model = await GetInvoiceModel(invoiceId, paymentMethodId == null ? null : PaymentMethodId.Parse(paymentMethodId), lang);
if (model == null)
return NotFound();
if (view == "modal")
model.IsModal = true;
return View(nameof(Checkout), model);
var viewName = model.CheckoutType == CheckoutType.V2 ? "CheckoutV2" : nameof(Checkout);
return View(viewName, model);
}
[HttpGet("invoice-noscript")]
@ -731,15 +735,18 @@ namespace BTCPayServer.Controllers
var receiptEnabled = InvoiceDataBase.ReceiptOptions.Merge(storeBlob.ReceiptOptions, invoice.ReceiptOptions).Enabled is true;
var receiptUrl = receiptEnabled? _linkGenerator.GetUriByAction(
nameof(UIInvoiceController.InvoiceReceipt),
nameof(InvoiceReceipt),
"UIInvoice",
new {invoiceId},
Request.Scheme,
Request.Host,
Request.PathBase) : null;
var model = new PaymentModel
{
#if ALTCOINS
AltcoinsBuild = true,
#endif
Activated = paymentMethodDetails.Activated,
CryptoCode = network.CryptoCode,
RootPath = Request.PathBase.Value.WithTrailingSlash(),
@ -748,6 +755,10 @@ namespace BTCPayServer.Controllers
DefaultLang = lang ?? invoice.DefaultLanguage ?? storeBlob.DefaultLang ?? "en",
CustomCSSLink = storeBlob.CustomCSS,
CustomLogoLink = storeBlob.CustomLogo,
LogoFileId = storeBlob.LogoFileId,
BrandColor = storeBlob.BrandColor,
CheckoutFormId = invoice.CheckoutFormId ?? storeBlob.CheckoutFormId,
CheckoutType = invoice.CheckoutType ?? storeBlob.CheckoutType,
HtmlTitle = storeBlob.HtmlTitle ?? "BTCPay Invoice",
CryptoImage = Request.GetRelativePathOrAbsolute(paymentMethodHandler.GetCryptoImage(paymentMethodId)),
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,
StoreId = store.Id,
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 =>
{
var availableCryptoPaymentMethodId = kv.GetId();
var availableCryptoHandler = _paymentMethodHandlerDictionary[availableCryptoPaymentMethodId];
return new PaymentModel.AvailableCrypto()
return new PaymentModel.AvailableCrypto
{
PaymentMethodId = kv.GetId().ToString(),
CryptoCode = kv.Network?.CryptoCode ?? kv.GetId().CryptoCode,
@ -907,6 +925,14 @@ namespace BTCPayServer.Controllers
return Ok("{}");
}
[HttpPost("i/{invoiceId}/Form")]
[HttpPost("invoice/Form")]
public IActionResult UpdateForm(string invoiceId)
{
// TODO: Forms integration
return Ok();
}
[HttpGet("/stores/{storeId}/invoices")]
[HttpGet("invoices")]
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie, Policy = Policies.CanViewInvoices)]
@ -1053,10 +1079,12 @@ namespace BTCPayServer.Controllers
return RedirectToAction(nameof(UIHomeController.Index), "UIHome");
}
var storeBlob = HttpContext.GetStoreData()?.GetStoreBlob();
var vm = new CreateInvoiceModel
{
StoreId = model.StoreId,
Currency = HttpContext.GetStoreData()?.GetStoreBlob().DefaultCurrency,
Currency = storeBlob?.DefaultCurrency,
UseNewCheckout = storeBlob?.CheckoutType is CheckoutType.V2,
AvailablePaymentMethods = GetPaymentMethodsSelectList()
};
@ -1069,8 +1097,11 @@ namespace BTCPayServer.Controllers
[BitpayAPIConstraint(false)]
public async Task<IActionResult> CreateInvoice(CreateInvoiceModel model, CancellationToken cancellationToken)
{
model.AvailablePaymentMethods = GetPaymentMethodsSelectList();
var store = HttpContext.GetStoreData();
var storeBlob = store.GetStoreBlob();
model.UseNewCheckout = storeBlob.CheckoutType == CheckoutType.V2;
model.AvailablePaymentMethods = GetPaymentMethodsSelectList();
if (!ModelState.IsValid)
{
return View(model);
@ -1089,18 +1120,17 @@ namespace BTCPayServer.Controllers
try
{
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest()
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest
{
Price = model.Amount,
Currency = model.Currency,
PosData = model.PosData,
OrderId = model.OrderId,
//RedirectURL = redirect + "redirect",
NotificationURL = model.NotificationUrl,
ItemDesc = model.ItemDesc,
FullNotifications = true,
BuyerEmail = model.BuyerEmail,
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency()
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency
{
Enabled = true
}),
@ -1108,8 +1138,11 @@ namespace BTCPayServer.Controllers
NotificationEmail = model.NotificationEmail,
ExtendedNotifications = model.NotificationEmail != null,
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On
? storeBlob.RequiresRefundEmail
: model.RequiresRefundEmail == RequiresRefundEmail.On,
CheckoutFormId = model.CheckoutFormId == GenericFormOption.InheritFromStore.ToString()
? storeBlob.CheckoutFormId
: model.CheckoutFormId
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";

View file

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

View file

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

View file

@ -36,6 +36,9 @@ namespace BTCPayServer.Data
[JsonConverter(typeof(Newtonsoft.Json.Converters.StringEnumConverter))]
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 LightningAmountInSatoshi { get; set; }
public bool LightningPrivateRouteHints { get; set; }

View file

@ -81,6 +81,9 @@ namespace BTCPayServer.Models
public bool? RedirectAutomatically { get; set; }
[JsonProperty(PropertyName = "requiresRefundEmail", DefaultValueHandling = DefaultValueHandling.Ignore)]
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
[JsonProperty(PropertyName = "paymentCurrencies", DefaultValueHandling = DefaultValueHandling.Ignore)]

View file

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

View file

@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Services.Apps;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -88,5 +88,10 @@ namespace BTCPayServer.Models.InvoicingModels
{
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 BTCPayServer.Client.Models;
namespace BTCPayServer.Models.InvoicingModels
{
@ -23,9 +24,11 @@ namespace BTCPayServer.Models.InvoicingModels
}
public string CustomCSSLink { get; set; }
public string CustomLogoLink { get; set; }
public string LogoFileId { get; set; }
public string BrandColor { get; set; }
public string HtmlTitle { 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 IsUnsetTopUp { get; set; }
public string CryptoCode { get; set; }
@ -69,5 +72,8 @@ namespace BTCPayServer.Models.InvoicingModels
public bool Activated { get; set; }
public string InvoiceCurrency { 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 Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json.Linq;
using YamlDotNet.Core.Tokens;
namespace BTCPayServer.Models.StoreViewModels
{
@ -20,11 +21,21 @@ namespace BTCPayServer.Models.StoreViewModels
Languages = new SelectList(choices, nameof(chosen.Value), nameof(chosen.Name), chosen);
DefaultLang = chosen.Value;
}
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")]
public string DefaultPaymentMethod { get; set; }
[Display(Name = "Use the new checkout")]
public bool UseNewCheckout { get; set; }
[Display(Name = "Requires a refund email")]
public bool RequiresRefundEmail { get; set; }

View file

@ -221,6 +221,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
PosData = string.IsNullOrEmpty(posData) ? null : posData,
RedirectAutomatically = settings.RedirectAutomatically,
SupportedTransactionCurrencies = paymentMethods,
CheckoutFormId = store.GetStoreBlob().CheckoutFormId,
RequiresRefundEmail = requiresRefundEmail == RequiresRefundEmail.InheritFromStore
? store.GetStoreBlob().RequiresRefundEmail
: requiresRefundEmail == RequiresRefundEmail.On,
@ -252,10 +253,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (app == null)
return NotFound();
var storeBlob = GetCurrentStore().GetStoreBlob();
var settings = app.GetSettings<PointOfSaleSettings>();
settings.DefaultView = settings.EnableShoppingCart ? PosViewType.Cart : settings.DefaultView;
settings.EnableShoppingCart = false;
var vm = new UpdatePointOfSaleViewModel
{
Id = appId,
@ -281,7 +283,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RedirectUrl = settings.RedirectUrl,
SearchTerm = app.TagAllInvoices ? $"storeid:{app.StoreDataId}" : $"orderid:{AppService.GetAppOrderId(app)}",
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)
{
@ -348,8 +352,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return View("PointOfSale/UpdatePointOfSale", vm);
}
app.Name = vm.AppName;
app.SetSettings(new PointOfSaleSettings
var storeBlob = GetCurrentStore().GetStoreBlob();
var settings = new PointOfSaleSettings
{
Title = vm.Title,
DefaultView = vm.DefaultView,
@ -367,9 +371,20 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
RedirectUrl = vm.RedirectUrl,
Description = vm.Description,
EmbeddedCSS = vm.EmbeddedCSS,
RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically) ? (bool?)null : bool.Parse(vm.RedirectAutomatically),
RequiresRefundEmail = vm.RequiresRefundEmail,
});
RedirectAutomatically =
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);
TempData[WellKnownTempData.SuccessMessage] = "App updated";
return RedirectToAction(nameof(UpdatePointOfSale), new { appId });
@ -397,6 +412,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
return currency.Trim().ToUpperInvariant();
}
private StoreData GetCurrentStore() => HttpContext.GetStoreData();
private AppData GetCurrentApp() => HttpContext.GetAppData();
}

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Stores;
using BTCPayServer.Validation;
using Microsoft.AspNetCore.Mvc.Rendering;
@ -97,7 +98,13 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
public string Description { get; set; }
[Display(Name = "Require refund email on checkout")]
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
{
public class PointOfSaleSettings
@ -55,6 +58,8 @@ namespace BTCPayServer.Services.Apps
public bool EnableTips { 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 string ButtonText { get; set; } = BUTTON_TEXT_DEF;
public const string CUSTOM_BUTTON_TEXT_DEF = "Pay";
@ -72,5 +77,6 @@ namespace BTCPayServer.Services.Apps
public string NotificationUrl { get; set; }
public string RedirectUrl { 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)]
public InvoiceDataBase.ReceiptOptions ReceiptOptions { get; set; }
[JsonProperty("checkoutFormId")]
public string CheckoutFormId { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public CheckoutType? CheckoutType { get; set; }
public bool IsExpired()
{
return DateTimeOffset.UtcNow > ExpirationTime;
@ -573,6 +578,8 @@ namespace BTCPayServer.Services.Invoices
dto.TaxIncluded = Metadata.TaxIncluded ?? 0m;
dto.Price = Price;
dto.Currency = Currency;
dto.CheckoutFormId = CheckoutFormId;
dto.CheckoutType = CheckoutType;
dto.Buyer = new JObject();
dto.Buyer.Add(new JProperty("name", Metadata.BuyerName));
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.Views.Apps
@using BTCPayServer.Abstractions.Extensions
@using BTCPayServer.Services.Stores
@model BTCPayServer.Plugins.PointOfSale.Models.UpdatePointOfSaleViewModel
@{
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">
@ -72,9 +76,7 @@
<label asp-for="DefaultView" class="form-label" data-required></label>
<select asp-for="DefaultView" asp-items="@Html.GetEnumSelectList<PosViewType>()" class="form-select" required></select>
<span asp-validation-for="DefaultView" class="text-danger"></span>
<div class="mt-2">
<span class="text-secondary">Choose the point of sale style for your customers.</span>
</div>
<p class="form-text text-muted">Choose the point of sale style for your customers.</p>
</div>
<div class="form-group" id="button-price-text">
<label asp-for="ButtonText" class="form-label" data-required></label>
@ -82,9 +84,18 @@
<span asp-validation-for="ButtonText" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select" required></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
@if (Model.UseNewCheckout)
{
<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>
<section id="discounts" class="p-0">
<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
@using BTCPayServer.Services.Apps
@using BTCPayServer.Services.Stores
@{
ViewData.SetActivePage(InvoiceNavPages.Create, "Create Invoice");
var store = ViewContext.HttpContext.GetStoreData();
var checkoutFormOptions = CheckoutFormSelectList.ForStore(store, Model.CheckoutFormId, true);
}
@section PageFootContent {
@ -90,9 +94,18 @@
<span asp-validation-for="BuyerEmail" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="RequiresRefundEmail" class="form-label"></label>
<select asp-for="RequiresRefundEmail" asp-items="@Html.GetEnumSelectList<RequiresRefundEmail>()" class="form-select"></select>
<span asp-validation-for="RequiresRefundEmail" class="text-danger"></span>
@if (Model.UseNewCheckout)
{
<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>
<h4 class="mt-5 mb-2">Additional Options</h4>

View file

@ -1,8 +1,28 @@
@using BTCPayServer.Payments
@using BTCPayServer.Services.Invoices
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Services.Stores
@model CheckoutAppearanceViewModel
@{
Layout = "../Shared/_NavLayout.cshtml";
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">
@ -43,6 +63,7 @@
</table>
</div>
}
<div class="form-check my-3">
<input asp-for="RequiresRefundEmail" type="checkbox" class="form-check-input" />
<label asp-for="RequiresRefundEmail" class="form-check-label"></label>
@ -56,7 +77,35 @@
<label asp-for="RedirectAutomatically" class="form-check-label"></label>
</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">
<input asp-for="ReceiptOptions.Enabled" type="checkbox" class="form-check-input" />
<label asp-for="ReceiptOptions.Enabled" class="form-check-label"></label>
@ -75,7 +124,7 @@
<div class="form-check">
<input asp-for="AutoDetectLanguage" type="checkbox" class="form-check-input" />
<label asp-for="AutoDetectLanguage" class="form-check-label"></label>
<p class="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 class="form-group">
@ -116,16 +165,3 @@
</form>
</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 () {
var match,
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);
urlParams = {};
while (match = search.exec(query)) {
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="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="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-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>
@ -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="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="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>

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) {
e.preventDefault();
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 ✔';
if (!confirm.dataset.clipboardInitial) {
confirm.dataset.clipboardInitial = confirm.innerHTML;

View file

@ -47,7 +47,17 @@
"Pay with CoinSwitch": "Pay with CoinSwitch",
"Pay with Changelly": "Pay with Changelly",
"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",
"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;
}
[data-bs-toggle="collapse"] svg.icon {
[aria-expanded] > svg.icon-caret-down {
flex-shrink: 0;
width: 24px;
height: 24px;
@ -92,7 +92,7 @@ a.unobtrusive-link {
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);
}

View file

@ -358,6 +358,14 @@
"type": "boolean",
"description": "Whether to redirect user to redirect URL automatically once invoice is paid. Defaults to what is set in the store settings",
"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,
"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": {
"type": "string",
"nullable": true,

View file

@ -79,19 +79,33 @@
"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": {
"type": "number",
"format": "int32",
"example": 90
},
"TimeSpanSeconds": {
"allOf": [ {"$ref": "#/components/schemas/TimeSpan"}],
"allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ],
"format": "seconds",
"description": "A span of times in seconds"
},
"TimeSpanMinutes": {
"allOf": [ {"$ref": "#/components/schemas/TimeSpan"}],
"allOf": [ { "$ref": "#/components/schemas/TimeSpan" } ],
"format": "minutes",
"description": "A span of times in minutes"
}

View file

@ -341,6 +341,14 @@
"default": false,
"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": {
"nullable": true,
"$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}])});
//# sourceMappingURL=vue-i18next.js.map