mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-17 22:14:26 +01:00
Checkout v2: Configure countdown timer (#4471)
* Checkout v2: Configure countdown timer This addresses feedback by @astupidmoose left [here](https://github.com/btcpayserver/btcpayserver/discussions/4308#discussioncomment-4438926): Make the countdown timer configurable with a minutes setting. This way the merchant has full control over when to display the timer. They could even set it to equal the invoice expiry, so that it is shown right from the beginning. * Rename property and adjust wording * Remove expiration percentage from Checkout v2
This commit is contained in:
@@ -20,6 +20,10 @@ namespace BTCPayServer.Client.Models
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public TimeSpan InvoiceExpiration { get; set; } = TimeSpan.FromMinutes(15);
|
||||
|
||||
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public TimeSpan DisplayExpirationTimer { get; set; } = TimeSpan.FromMinutes(5);
|
||||
|
||||
[JsonConverter(typeof(TimeSpanJsonConverter.Seconds))]
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
public TimeSpan MonitoringExpiration { get; set; } = TimeSpan.FromMinutes(60);
|
||||
|
||||
@@ -180,7 +180,35 @@ namespace BTCPayServer.Tests
|
||||
|
||||
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
|
||||
Assert.Contains("This invoice will expire in", paymentInfo.Text);
|
||||
Assert.Contains("00:0", paymentInfo.Text);
|
||||
Assert.DoesNotContain("Please send", paymentInfo.Text);
|
||||
|
||||
// Configure countdown timer
|
||||
s.GoToHome();
|
||||
invoiceId = s.CreateInvoice();
|
||||
s.GoToHome();
|
||||
s.GoToStore(StoreNavPages.CheckoutAppearance);
|
||||
var displayExpirationTimer = s.Driver.FindElement(By.Id("DisplayExpirationTimer"));
|
||||
Assert.Equal("5", displayExpirationTimer.GetAttribute("value"));
|
||||
displayExpirationTimer.Clear();
|
||||
displayExpirationTimer.SendKeys("10");
|
||||
s.Driver.FindElement(By.Id("Save")).Click();
|
||||
Assert.Contains("Store successfully updated", s.FindAlertMessage().Text);
|
||||
|
||||
s.GoToInvoiceCheckout(invoiceId);
|
||||
paymentInfo = s.Driver.FindElement(By.Id("PaymentInfo"));
|
||||
Assert.False(paymentInfo.Displayed);
|
||||
Assert.DoesNotContain("This invoice will expire in", paymentInfo.Text);
|
||||
|
||||
expirySeconds = s.Driver.FindElement(By.Id("ExpirySeconds"));
|
||||
expirySeconds.Clear();
|
||||
expirySeconds.SendKeys("599");
|
||||
s.Driver.FindElement(By.Id("Expire")).Click();
|
||||
|
||||
paymentInfo = s.Driver.WaitForElement(By.Id("PaymentInfo"));
|
||||
Assert.True(paymentInfo.Displayed);
|
||||
Assert.Contains("This invoice will expire in", paymentInfo.Text);
|
||||
Assert.Contains("09:5", paymentInfo.Text);
|
||||
}
|
||||
|
||||
[Fact(Timeout = TestTimeout)]
|
||||
|
||||
@@ -140,6 +140,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
DefaultLang = storeBlob.DefaultLang,
|
||||
MonitoringExpiration = storeBlob.MonitoringExpiration,
|
||||
InvoiceExpiration = storeBlob.InvoiceExpiration,
|
||||
DisplayExpirationTimer = storeBlob.DisplayExpirationTimer,
|
||||
CustomLogo = storeBlob.CustomLogo,
|
||||
CustomCSS = storeBlob.CustomCSS,
|
||||
HtmlTitle = storeBlob.HtmlTitle,
|
||||
@@ -179,6 +180,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
blob.DefaultLang = restModel.DefaultLang;
|
||||
blob.MonitoringExpiration = restModel.MonitoringExpiration;
|
||||
blob.InvoiceExpiration = restModel.InvoiceExpiration;
|
||||
blob.DisplayExpirationTimer = restModel.DisplayExpirationTimer;
|
||||
blob.CustomLogo = restModel.CustomLogo;
|
||||
blob.CustomCSS = restModel.CustomCSS;
|
||||
blob.HtmlTitle = restModel.HtmlTitle;
|
||||
@@ -213,8 +215,10 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
}
|
||||
if (request.InvoiceExpiration < TimeSpan.FromMinutes(1) && request.InvoiceExpiration > TimeSpan.FromMinutes(60 * 24 * 24))
|
||||
ModelState.AddModelError(nameof(request.InvoiceExpiration), "InvoiceExpiration can only be between 1 and 34560 mins");
|
||||
if (request.DisplayExpirationTimer < TimeSpan.FromMinutes(1) && request.DisplayExpirationTimer > TimeSpan.FromMinutes(60 * 24 * 24))
|
||||
ModelState.AddModelError(nameof(request.DisplayExpirationTimer), "DisplayExpirationTimer can only be between 1 and 34560 mins");
|
||||
if (request.MonitoringExpiration < TimeSpan.FromMinutes(10) && request.MonitoringExpiration > TimeSpan.FromMinutes(60 * 24 * 24))
|
||||
ModelState.AddModelError(nameof(request.MonitoringExpiration), "InvoiceExpiration can only be between 10 and 34560 mins");
|
||||
ModelState.AddModelError(nameof(request.MonitoringExpiration), "MonitoringExpiration can only be between 10 and 34560 mins");
|
||||
if (request.PaymentTolerance < 0 && request.PaymentTolerance > 100)
|
||||
ModelState.AddModelError(nameof(request.PaymentTolerance), "PaymentTolerance can only be between 0 and 100 percent");
|
||||
|
||||
|
||||
@@ -774,6 +774,7 @@ namespace BTCPayServer.Controllers
|
||||
CustomerEmail = invoice.RefundMail,
|
||||
RequiresRefundEmail = invoice.RequiresRefundEmail ?? storeBlob.RequiresRefundEmail,
|
||||
ExpirationSeconds = Math.Max(0, (int)(invoice.ExpirationTime - DateTimeOffset.UtcNow).TotalSeconds),
|
||||
DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalSeconds,
|
||||
MaxTimeSeconds = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalSeconds,
|
||||
MaxTimeMinutes = (int)(invoice.ExpirationTime - invoice.InvoiceTime).TotalMinutes,
|
||||
ItemDesc = invoice.Metadata.ItemDesc,
|
||||
|
||||
@@ -390,6 +390,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.CustomCSS = storeBlob.CustomCSS;
|
||||
vm.CustomLogo = storeBlob.CustomLogo;
|
||||
vm.HtmlTitle = storeBlob.HtmlTitle;
|
||||
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
|
||||
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
|
||||
vm.AutoDetectLanguage = storeBlob.AutoDetectLanguage;
|
||||
vm.SetLanguages(_LangService, storeBlob.DefaultLang);
|
||||
@@ -436,8 +437,7 @@ namespace BTCPayServer.Controllers
|
||||
return defaultChoice is null ? null : choices.FirstOrDefault(c => defaultChoice.ToString().Equals(c.Value, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("{storeId}/checkout")]
|
||||
[HttpPost("{storeId}/checkout")]
|
||||
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model)
|
||||
{
|
||||
bool needUpdate = false;
|
||||
@@ -513,6 +513,7 @@ namespace BTCPayServer.Controllers
|
||||
blob.CustomLogo = model.CustomLogo;
|
||||
blob.CustomCSS = model.CustomCSS;
|
||||
blob.HtmlTitle = string.IsNullOrWhiteSpace(model.HtmlTitle) ? null : model.HtmlTitle;
|
||||
blob.DisplayExpirationTimer = TimeSpan.FromMinutes(model.DisplayExpirationTimer);
|
||||
blob.AutoDetectLanguage = model.AutoDetectLanguage;
|
||||
blob.DefaultLang = model.DefaultLang;
|
||||
blob.NormalizeToRelativeLinks(Request);
|
||||
|
||||
@@ -25,6 +25,7 @@ namespace BTCPayServer.Data
|
||||
public StoreBlob()
|
||||
{
|
||||
InvoiceExpiration = TimeSpan.FromMinutes(15);
|
||||
DisplayExpirationTimer = TimeSpan.FromMinutes(5);
|
||||
RefundBOLT11Expiration = TimeSpan.FromDays(30);
|
||||
MonitoringExpiration = TimeSpan.FromDays(1);
|
||||
PaymentTolerance = 0;
|
||||
@@ -96,6 +97,11 @@ namespace BTCPayServer.Data
|
||||
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
|
||||
public TimeSpan InvoiceExpiration { get; set; }
|
||||
|
||||
[DefaultValue(typeof(TimeSpan), "00:05:00")]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
[JsonConverter(typeof(TimeSpanJsonConverter.Minutes))]
|
||||
public TimeSpan DisplayExpirationTimer { get; set; }
|
||||
|
||||
public decimal Spread { get; set; } = 0.0m;
|
||||
|
||||
public string PreferredExchange { get; set; }
|
||||
|
||||
@@ -40,10 +40,10 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public bool ShowRecommendedFee { get; set; }
|
||||
public decimal FeeRate { get; set; }
|
||||
public int ExpirationSeconds { get; set; }
|
||||
public int DisplayExpirationTimer { get; set; }
|
||||
public string Status { get; set; }
|
||||
public string MerchantRefLink { get; set; }
|
||||
public int MaxTimeSeconds { get; set; }
|
||||
|
||||
public string StoreName { get; set; }
|
||||
public string ItemDesc { get; set; }
|
||||
public string TimeLeft { get; set; }
|
||||
|
||||
@@ -55,6 +55,10 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Custom HTML title to display on Checkout page")]
|
||||
public string HtmlTitle { get; set; }
|
||||
|
||||
[Display(Name = "Show a timer … minutes before invoice expiration")]
|
||||
[Range(1, 60 * 24 * 24)]
|
||||
public int DisplayExpirationTimer { get; set; }
|
||||
|
||||
public class ReceiptOptionsViewModel
|
||||
{
|
||||
public static ReceiptOptionsViewModel Create(Client.Models.InvoiceDataBase.ReceiptOptions opts)
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
<span class="fa fa-question-circle-o text-secondary" title="More information..."></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label asp-for="DisplayExpirationTimer" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:10ch;"/>
|
||||
<span class="input-group-text">minutes</span>
|
||||
</div>
|
||||
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkout-settings collapse @(Model.UseNewCheckout ? "" : "show")" id="OldCheckoutSettings">
|
||||
|
||||
@@ -90,7 +90,6 @@ function initApp() {
|
||||
srvModel,
|
||||
displayPaymentDetails: false,
|
||||
remainingSeconds: srvModel.expirationSeconds,
|
||||
expirationPercentage: 0,
|
||||
emailAddressInput: "",
|
||||
emailAddressInputDirty: false,
|
||||
emailAddressInputInvalid: false,
|
||||
@@ -113,7 +112,7 @@ function initApp() {
|
||||
return this.showTimer || this.showPaymentDueInfo;
|
||||
},
|
||||
showTimer () {
|
||||
return this.isActive && (this.expirationPercentage >= 75 || this.minutesLeft < 5);
|
||||
return this.isActive && this.remainingSeconds < this.srvModel.displayExpirationTimer;
|
||||
},
|
||||
showPaymentDueInfo () {
|
||||
return this.btcPaid > 0 && this.btcDue > 0;
|
||||
@@ -190,7 +189,6 @@ function initApp() {
|
||||
},
|
||||
updateTimer () {
|
||||
this.remainingSeconds = Math.floor((this.endDate.getTime() - new Date().getTime())/1000);
|
||||
this.expirationPercentage = 100 - Math.floor((this.remainingSeconds / this.srvModel.maxTimeSeconds) * 100);
|
||||
if (this.isActive) {
|
||||
setTimeout(this.updateTimer, 500);
|
||||
}
|
||||
|
||||
@@ -308,6 +308,13 @@
|
||||
"description": "The time after which an invoice is considered expired if not paid. The value will be rounded down to a minute.",
|
||||
"allOf": [ { "$ref": "#/components/schemas/TimeSpanSeconds" } ]
|
||||
},
|
||||
"displayExpirationTimer": {
|
||||
"default": 300,
|
||||
"minimum": 60,
|
||||
"maximum": 2073600,
|
||||
"description": "The time left that will trigger the countdown timer on the checkout page to be shown. The value will be rounded down to a minute.",
|
||||
"allOf": [ { "$ref": "#/components/schemas/TimeSpanSeconds" } ]
|
||||
},
|
||||
"monitoringExpiration": {
|
||||
"default": 3600,
|
||||
"minimum": 600,
|
||||
|
||||
Reference in New Issue
Block a user