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:
d11n
2023-01-16 12:45:19 +01:00
committed by GitHub
parent 785cf597ad
commit 068b717a75
11 changed files with 68 additions and 7 deletions

View File

@@ -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);

View File

@@ -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)]

View File

@@ -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");

View File

@@ -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,

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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; }

View File

@@ -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)

View File

@@ -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">

View File

@@ -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);
}

View File

@@ -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,