Payment Request: Improve public view (#5413)

* Payment Request: Improve public view

Closes #4450.

* Test fix

* Extract Vue utils

* Improve payment history

* Fix amount display

* Unify receipt and payment request tables

* Re-add text confirmation for copying to clipboard

* Minor print optimizations

* Wording: Rename Description to Memo

* Open view links in new window

* View updates
This commit is contained in:
d11n 2023-11-20 02:45:43 +01:00 committed by GitHub
parent 46f0818765
commit 2fb72d5aa6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 527 additions and 406 deletions

View File

@ -23,7 +23,7 @@
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
<PackageReference Include="Selenium.Support" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="118.0.5993.7000" />
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="119.0.6045.10500" />
<PackageReference Include="xunit" Version="2.4.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
<PrivateAssets>all</PrivateAssets>

View File

@ -92,9 +92,8 @@ namespace BTCPayServer.Tests
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count);
s.Driver.SwitchTo().Window(windows[1]);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("button[type='submit']")).Click();
Assert.Contains("Enter your email", s.Driver.PageSource);
@ -103,6 +102,9 @@ namespace BTCPayServer.Tests
s.PayInvoice(true);
var invoiceId = s.Driver.Url[(s.Driver.Url.LastIndexOf("/", StringComparison.Ordinal) + 1)..];
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToInvoice(invoiceId);
Assert.Contains("aa@aa.com", s.Driver.PageSource);
@ -116,13 +118,19 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.XPath("//a[starts-with(@id, 'Edit-')]")).Click();
var editUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("ViewPaymentRequest")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("[data-test='form-button']")).Click();
Assert.Contains("Enter your email", s.Driver.PageSource);
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
s.Driver.FindElement(By.CssSelector("input[type='submit']")).Click();
invoiceId = s.Driver.Url.Split('/').Last();
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.Driver.Navigate().GoToUrl(editUrl);
Assert.Contains("aa@aa.com", s.Driver.PageSource);
var invoice = await s.Server.PayTester.GetService<InvoiceRepository>().GetInvoice(invoiceId);
@ -1193,13 +1201,13 @@ namespace BTCPayServer.Tests
var editUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("ViewPaymentRequest")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
var viewUrl = s.Driver.Url;
Assert.Equal("Amount due", s.Driver.FindElement(By.CssSelector("[data-test='amount-due-title']")).Text);
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.Id("PayInvoice")).Text.Trim());
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// expire
s.GoToUrl(editUrl);
s.Driver.ExecuteJavaScript("document.getElementById('ExpiryDate').value = '2021-01-21T21:00:00.000Z'");
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.XPath("//a[starts-with(@id, 'Edit-')]")).Click();
@ -1219,12 +1227,28 @@ namespace BTCPayServer.Tests
Assert.True(s.Driver.FindElement(By.Id("Currency")).Enabled);
s.GoToUrl(viewUrl);
s.Driver.AssertElementNotFound(By.CssSelector("[data-test='status']"));
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.CssSelector("[data-test='pay-button']")).Text.Trim());
Assert.Equal("Pay Invoice", s.Driver.FindElement(By.Id("PayInvoice")).Text.Trim());
// test invoice creation, click with JS, because the button is inside a sticky header
s.Driver.ExecuteJavaScript("document.querySelector('[data-test=\"pay-button\"]').click()");
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
// test invoice creation
s.Driver.FindElement(By.Id("PayInvoice")).Click();
TestUtils.Eventually(() =>
{
s.Driver.WaitUntilAvailable(By.Name("btcpay"));
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
Assert.True(frameElement.Displayed);
var iframe = s.Driver.SwitchTo().Frame(frameElement);
iframe.WaitUntilAvailable(By.Id("Checkout-v2"));
IWebElement closebutton = null;
TestUtils.Eventually(() =>
{
closebutton = iframe.FindElement(By.Id("close"));
Assert.True(closebutton.Displayed);
});
closebutton.Click();
s.Driver.AssertElementNotFound(By.Name("btcpay"));
});
// amount and currency should not be editable, because invoice exists
s.GoToUrl(editUrl);
@ -1248,32 +1272,45 @@ namespace BTCPayServer.Tests
// payment
s.GoToUrl(viewUrl);
s.Driver.ExecuteJavaScript("document.querySelector('[data-test=\"pay-button\"]').click()");
// Pay full amount
s.PayInvoice();
// Processing
s.Driver.FindElement(By.Id("PayInvoice")).Click();
TestUtils.Eventually(() =>
{
var processingSection = s.Driver.WaitForElement(By.Id("processing"));
Assert.True(processingSection.Displayed);
Assert.Contains("Payment Received", processingSection.Text);
Assert.Contains("Your payment has been received and is now processing", processingSection.Text);
});
s.Driver.WaitUntilAvailable(By.Name("btcpay"));
var frameElement = s.Driver.FindElement(By.Name("btcpay"));
Assert.True(frameElement.Displayed);
var iframe = s.Driver.SwitchTo().Frame(frameElement);
iframe.WaitUntilAvailable(By.Id("Checkout-v2"));
// Pay full amount
s.PayInvoice();
// Processing
TestUtils.Eventually(() =>
{
var processingSection = s.Driver.WaitForElement(By.Id("processing"));
Assert.True(processingSection.Displayed);
Assert.Contains("Payment Received", processingSection.Text);
Assert.Contains("Your payment has been received and is now processing", processingSection.Text);
});
s.GoToUrl(viewUrl);
Assert.Equal("Processing", s.Driver.WaitForElement(By.CssSelector("[data-test='status']")).Text);
s.Driver.Navigate().Back();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles[0]);
Assert.Equal("Processing", s.Driver.WaitForElement(By.CssSelector("[data-test='status']")).Text);
s.Driver.SwitchTo().Frame(frameElement);
// Mine
s.MineBlockOnInvoiceCheckout();
TestUtils.Eventually(() =>
{
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
// Mine
s.MineBlockOnInvoiceCheckout();
TestUtils.Eventually(() =>
{
Assert.Contains("Mined 1 block",
s.Driver.WaitForElement(By.Id("CheatSuccessMessage")).Text);
});
s.Driver.FindElement(By.Id("close")).Click();
s.Driver.AssertElementNotFound(By.Name("btcpay"));
});
s.GoToUrl(viewUrl);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles[0]);
Assert.Equal("Settled", s.Driver.WaitForElement(By.CssSelector("[data-test='status']")).Text);
}
@ -1769,7 +1806,12 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Contains("PP1", s.Driver.PageSource);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -1780,9 +1822,9 @@ namespace BTCPayServer.Tests
var description = s.Driver.FindElement(By.ClassName("card-block"));
description.SendKeys("Description Edit");
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.LinkText("PP1 Edited")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Contains("Description Edit", s.Driver.PageSource);
Assert.Contains("PP1 Edited", s.Driver.PageSource);
}
@ -1808,7 +1850,12 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
Assert.Contains("PP1", s.Driver.PageSource);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -1820,6 +1867,7 @@ namespace BTCPayServer.Tests
// This should select the first View, ie, the last one PP2
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
var address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
@ -1841,6 +1889,9 @@ namespace BTCPayServer.Tests
Assert.Contains("Awaiting Approval", s.Driver.PageSource);
var viewPullPaymentUrl = s.Driver.Url;
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// This one should have nothing
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
var payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
@ -1918,8 +1969,10 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys(Keys.Enter);
@ -1932,6 +1985,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve")).Click();
s.FindAlertMessage();
var tx = await s.Server.ExplorerNode.SendToAddressAsync(address, Money.FromUnit(0.001m, MoneyUnit.BTC));
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(s.StoreId, StoreNavPages.Payouts);
@ -1970,6 +2025,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC");
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
// Bitcoin-only, SelectedPaymentMethod should not be displayed
s.Driver.ElementDoesNotExist(By.Id("SelectedPaymentMethod"));
@ -1993,6 +2049,8 @@ namespace BTCPayServer.Tests
s.FindAlertMessage();
Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike)}-view")).Click();
@ -2000,10 +2058,10 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.ClassName("mass-action-select-all")).Click();
s.Driver.FindElement(By.Id($"{PayoutState.AwaitingApproval}-approve-pay")).Click();
Assert.Contains(bolt, s.Driver.PageSource);
Assert.Contains($"{payoutAmount.ToString()} BTC", s.Driver.PageSource);
Assert.Contains($"{payoutAmount} BTC", s.Driver.PageSource);
s.Driver.FindElement(By.CssSelector("#pay-invoices-form")).Submit();
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
s.GoToStore(newStore.storeId, StoreNavPages.Payouts);
s.Driver.FindElement(By.Id($"{new PaymentMethodId("BTC", PaymentTypes.LightningLike)}-view")).Click();
@ -2028,16 +2086,21 @@ namespace BTCPayServer.Tests
s.Driver.SetCheckbox(By.Id("AutoApproveClaims"), true);
s.Driver.FindElement(By.Id("Amount")).Clear();
s.Driver.FindElement(By.Id("Amount")).SendKeys("99.0" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
address = await s.Server.ExplorerNode.GetNewAddressAsync();
s.Driver.FindElement(By.Id("Destination")).Clear();
s.Driver.FindElement(By.Id("Destination")).SendKeys(address.ToString());
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("20" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
Assert.Contains(PayoutState.AwaitingPayment.GetStateString(), s.Driver.PageSource);
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// LNURL Withdraw support check with BTC denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -2048,8 +2111,11 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Amount")).SendKeys("0.0000001");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
s.Driver.WaitForElement(By.Id("qr-code-data-input"));
@ -2075,6 +2141,8 @@ namespace BTCPayServer.Tests
Assert.Contains(PayoutState.Completed.GetStateString(), s.Driver.PageSource);
Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
});
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
s.Driver.FindElement(By.Id("NewPullPayment")).Click();
@ -2084,8 +2152,11 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Amount")).SendKeys("0.0000001");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("BTC" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
@ -2109,6 +2180,8 @@ namespace BTCPayServer.Tests
Assert.Contains(PayoutState.AwaitingApproval.GetStateString(), s.Driver.PageSource);
});
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
// LNURL Withdraw support check with SATS denomination
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
@ -2119,8 +2192,11 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Amount")).SendKeys("21021");
s.Driver.FindElement(By.Id("Currency")).Clear();
s.Driver.FindElement(By.Id("Currency")).SendKeys("SATS" + Keys.Enter);
s.FindAlertMessage(StatusMessageModel.StatusSeverity.Success);
s.FindAlertMessage();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
s.Driver.FindElement(By.CssSelector("#lnurlwithdraw-button")).Click();
lnurl = new Uri(LNURL.LNURL.Parse(s.Driver.FindElement(By.Id("qr-code-data-input")).GetAttribute("value"), out _).ToString().Replace("https", "http"));
s.Driver.FindElement(By.CssSelector("button[data-bs-dismiss='modal']")).Click();
@ -2145,6 +2221,7 @@ namespace BTCPayServer.Tests
Assert.Contains(PayoutState.Completed.GetStateString(), s.Driver.PageSource);
Assert.Equal(LightningInvoiceStatus.Paid, (await s.Server.CustomerLightningD.GetInvoice(bolt2.Id)).Status);
});
s.Driver.Close();
}
[Fact]
@ -2534,13 +2611,16 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("Create")).Click();
s.Driver.FindElement(By.LinkText("View")).Click();
s.Driver.FindElement(By.Id("Destination")).SendKeys(lnurl);
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.Last());
var pullPaymentId = s.Driver.Url.Split('/').Last();
s.Driver.FindElement(By.Id("Destination")).SendKeys(lnurl);
s.Driver.FindElement(By.Id("ClaimedAmount")).Clear();
s.Driver.FindElement(By.Id("ClaimedAmount")).SendKeys("0.0000001" + Keys.Enter);
s.FindAlertMessage();
s.Driver.Close();
s.Driver.SwitchTo().Window(s.Driver.WindowHandles.First());
s.GoToStore(s.StoreId, StoreNavPages.PullPayments);
var payouts = s.Driver.FindElements(By.ClassName("pp-payout"));
payouts[0].Click();

View File

@ -13,14 +13,20 @@ namespace BTCPayServer
{
return Regex.Match(color, Pattern).Success;
}
public string TextColor(string bgColor)
public Color TextColor(Color bg)
{
int nThreshold = 105;
var bg = ColorTranslator.FromHtml(bgColor);
int bgDelta = Convert.ToInt32((bg.R * 0.299) + (bg.G * 0.587) + (bg.B * 0.114));
Color color = (255 - bgDelta < nThreshold) ? Color.Black : Color.White;
int bgDelta = Convert.ToInt32(bg.R * 0.299 + bg.G * 0.587 + bg.B * 0.114);
return 255 - bgDelta < nThreshold ? Color.Black : Color.White;
}
public string TextColor(string bg)
{
var color = TextColor(FromHtml(bg));
return ColorTranslator.ToHtml(color).ToLowerInvariant();
}
// Borrowed from https://github.com/ManageIQ/guides/blob/master/labels.md
public static readonly ColorPalette Default = new ColorPalette(new string[] {
"#fbca04",
@ -31,6 +37,7 @@ namespace BTCPayServer
"#cdcdcd",
"#cc317c",
});
private ColorPalette(string[] labels)
{
Labels = labels;
@ -98,5 +105,10 @@ namespace BTCPayServer
var color = AdjustBrightness(ColorTranslator.FromHtml(html), correctionFactor);
return ColorTranslator.ToHtml(color);
}
public Color FromHtml(string html)
{
return ColorTranslator.FromHtml(html);
}
}
}

View File

@ -254,6 +254,7 @@ namespace BTCPayServer.Controllers
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.InvoicePaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency, DisplayFormatter.CurrencyFormat.None),
PaidFormatted = _displayFormatter.Currency(paymentEntity.InvoicePaidAmount.Net, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, i.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),

View File

@ -205,6 +205,7 @@ namespace BTCPayServer.Controllers
var storeBlob = store.GetStoreBlob();
vm.StoreName = store.StoreName;
vm.StoreWebsite = store.StoreWebsite;
vm.BrandColor = storeBlob.BrandColor;
vm.LogoFileId = storeBlob.LogoFileId;
vm.CssFileId = storeBlob.CssFileId;

View File

@ -72,7 +72,11 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
[Display(Name = "Expiration Date")]
public DateTime? ExpiryDate { get; set; }
[Required] public string Title { get; set; }
[Required]
public string Title { get; set; }
[Display(Name = "Memo")]
public string Description { get; set; }
[Display(Name = "Store")]
@ -87,7 +91,8 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
[Display(Name = "Allow payee to create invoices in their own denomination")]
[Display(Name = "Allow payee to create invoices with custom amounts")]
public bool AllowCustomPaymentAmounts { get; set; }
public Dictionary<string, object> FormResponse { get; set; }
@ -151,6 +156,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
public string CssFileId { get; set; }
public string BrandColor { get; set; }
public string StoreName { get; set; }
public string StoreWebsite { get; set; }
public string EmbeddedCSS { get; set; }
public string CustomCSSLink { get; set; }
@ -208,6 +214,7 @@ namespace BTCPayServer.Models.PaymentRequestViewModels
{
public string PaymentMethod { get; set; }
public decimal Amount { get; set; }
public string AmountFormatted { get; set; }
public string RateFormatted { get; set; }
public decimal Paid { get; set; }
public string PaidFormatted { get; set; }

View File

@ -98,9 +98,13 @@ namespace BTCPayServer.Models.WalletViewModels
[MaxLength(30)]
public string Name { get; set; }
[Display(Name = "Memo")]
public string Description { get; set; }
[Display(Name = "Custom CSS URL")]
public string CustomCSSLink { get; set; }
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
}

View File

@ -135,6 +135,7 @@ namespace BTCPayServer.PaymentRequest
Amount = paymentEntity.PaidAmount.Gross,
Paid = paymentEntity.InvoicePaidAmount.Net,
ReceivedDate = paymentEntity.ReceivedTime.DateTime,
AmountFormatted = _displayFormatter.Currency(paymentEntity.PaidAmount.Gross, paymentEntity.PaidAmount.Currency, DisplayFormatter.CurrencyFormat.None),
PaidFormatted = _displayFormatter.Currency(paymentEntity.InvoicePaidAmount.Net, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
RateFormatted = _displayFormatter.Currency(paymentEntity.Rate, blob.Currency, DisplayFormatter.CurrencyFormat.Symbol),
PaymentMethod = paymentMethodId.ToPrettyString(),

View File

@ -10,20 +10,30 @@
}
@if (!string.IsNullOrEmpty(Model.BrandColor))
{
var brandColor = Model.BrandColor;
var accentColor = ColorPalette.Default.AdjustBrightness(brandColor, (float)-0.15);
var complement = ColorPalette.Default.TextColor(brandColor).ToLowerInvariant();
var complementColor = $"var(--btcpay-{(complement == "black" ? "black" : "white")})";
var brand = Model.BrandColor;
var brandColor = ColorPalette.Default.FromHtml(brand);
var brandRgbValues = $"{brandColor.R}, {brandColor.G}, {brandColor.B}";
var accent = ColorPalette.Default.AdjustBrightness(brand, (float)-0.15);
var complement = ColorPalette.Default.TextColor(brand);
var complementVar = $"var(--btcpay-{(complement == "black" ? "black" : "white")})";
<style>
:root {
--btcpay-primary: @brandColor;
--btcpay-primary-shadow: @brandColor;
--btcpay-primary-bg-hover: @accentColor;
--btcpay-primary-bg-active: @accentColor;
--btcpay-body-link-accent: @accentColor;
--btcpay-primary-text: @complementColor;
--btcpay-primary-text-hover: @complementColor;
--btcpay-primary-text-active: @complementColor;
--btcpay-primary: @brand;
--btcpay-primary-rgb: @brandRgbValues;
--btcpay-primary-shadow: @brand;
--btcpay-primary-bg-hover: @accent;
--btcpay-primary-bg-active: @accent;
--btcpay-body-link: @brand;
--btcpay-body-link-accent: @accent;
--btcpay-primary-text: @complementVar;
--btcpay-primary-text-hover: @complementVar;
--btcpay-primary-text-active: @complementVar;
}
a {
color: var(--btcpay-body-link);
}
a:hover {
color: var(--btcpay-body-link-accent);
}
</style>
}

View File

@ -303,6 +303,7 @@
<script src="~/vendor/i18next/i18nextHttpBackend.min.js" asp-append-version="true"></script>
<script src="~/vendor/i18next/vue-i18next.js" asp-append-version="true"></script>
<script src="~/js/copy-to-clipboard.js" asp-append-version="true"></script>
<script src="~/js/vue-utils.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)

View File

@ -108,23 +108,21 @@
<div id="PaymentDetails" class="bg-tile p-3 p-sm-4 rounded">
<h2 class="h4 mb-3">Payment Details</h2>
<div class="table-responsive my-0 d-print-none">
<table class="table table-borderless my-0">
<table class="invoice table table-borderless">
<thead>
<tr>
<th class="fw-normal text-secondary">Date</th>
<th class="fw-normal text-secondary">Payment</th>
<th class="fw-normal text-secondary text-end">Paid</th>
<th class="fw-normal text-secondary text-end">Rate</th>
<th class="fw-normal text-secondary date-col w-125px">Date</th>
<th class="fw-normal text-secondary amount-col">Paid</th>
<th class="fw-normal text-secondary amount-col w-225px">Payment</th>
</tr>
</thead>
<tbody>
@foreach (var payment in Model.Payments)
{
<tbody>
<tr>
<td class="text-nowrap">@payment.ReceivedDate.ToBrowserDate()</td>
<td class="text-nowrap">@payment.Amount @payment.PaymentMethod</td>
<td class="text-end text-nowrap">@payment.PaidFormatted</td>
<td class="text-end text-nowrap">@payment.RateFormatted</td>
<td class="date-col">@payment.ReceivedDate.ToBrowserDate()</td>
<td class="amount-col">@payment.PaidFormatted</td>
<td class="amount-col">@payment.AmountFormatted @payment.PaymentMethod</td>
</tr>
@if (!string.IsNullOrEmpty(payment.Destination))
{
@ -132,7 +130,7 @@
<th class="fw-normal text-nowrap text-secondary">
Destination
</th>
<td class="fw-normal" colspan="3">
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.Destination" classes="truncate-center-id" />
</td>
</tr>
@ -143,20 +141,20 @@
<th class="fw-normal text-nowrap text-secondary">
Payment Proof
</th>
<td class="fw-normal" colspan="3">
<td class="fw-normal" colspan="2">
<vc:truncate-center text="@payment.PaymentProof" link="@payment.Link" classes="truncate-center-id" />
</td>
</tr>
}
</tbody>
}
</tbody>
</table>
</div>
<div class="d-none d-print-block">
@foreach (var payment in Model.Payments)
{
<div class="mb-4">
<strong>@payment.PaidFormatted</strong> = @payment.Amount @payment.PaymentMethod, Rate: @payment.RateFormatted
<strong>@payment.PaidFormatted</strong> = @payment.AmountFormatted @payment.PaymentMethod, Rate: @payment.RateFormatted
@if (!string.IsNullOrEmpty(payment.PaymentProof))
{
<div>Proof: @payment.PaymentProof</div>

View File

@ -45,7 +45,7 @@ else
@foreach (var payment in Model.Payments)
{
<p>&nbsp;</p>
<p class="text-center">@payment.Amount <span class="text-nowrap">@payment.PaymentMethod</span></p>
<p class="text-center">@payment.AmountFormatted <span class="text-nowrap">@payment.PaymentMethod</span></p>
<p class="text-center">Rate: @payment.RateFormatted</p>
<p class="text-center">= @payment.PaidFormatted</p>
}

View File

@ -33,7 +33,7 @@
else
{
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
<a class="btn btn-secondary" asp-action="ViewPaymentRequest" asp-route-payReqId="@Model.Id" id="ViewPaymentRequest">View</a>
<a class="btn btn-secondary" asp-action="ViewPaymentRequest" asp-route-payReqId="@Model.Id" id="ViewPaymentRequest" target="_blank">View</a>
}
</div>
</div>

View File

@ -111,7 +111,7 @@
</td>
<td class="text-end">
<div class="d-inline-flex align-items-center gap-3">
<a asp-action="ViewPaymentRequest" asp-route-payReqId="@item.Id" id="PaymentRequest-@item.Id">View</a>
<a asp-action="ViewPaymentRequest" asp-route-payReqId="@item.Id" id="PaymentRequest-@item.Id" target="_blank">View</a>
<div class="dropdown">
<button class="btn btn-link dropdown-toggle p-0 dropdown-toggle-no-caret text-body" type="button" data-bs-toggle="dropdown" aria-expanded="false" id="ToggleActions-@item.Id">
<vc:icon symbol="dots" />

View File

@ -36,14 +36,13 @@
<partial name="LayoutHead" />
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, Model.CustomCSSLink, Model.EmbeddedCSS)" />
<link href="~/vendor/bootstrap-vue/bootstrap-vue.min.css" asp-append-version="true" rel="stylesheet" />
<script type="text/javascript">
var srvModel = @Safe.Json(Model);
</script>
<script>var srvModel = @Safe.Json(Model);</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-toasted/vue-toasted.min.js" asp-append-version="true"></script>
<script src="~/vendor/bootstrap-vue/bootstrap-vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/signalr/signalr.js" asp-append-version="true"></script>
<script src="~/vendor/animejs/anime.min.js" asp-append-version="true"></script>
<script src="~/js/vue-utils.js" asp-append-version="true"></script>
<script src="~/payment-request/app.js" asp-append-version="true"></script>
<script src="~/payment-request/services/listener.js" asp-append-version="true"></script>
<script src="~/modal/btcpay.js" asp-append-version="true"></script>
@ -51,6 +50,8 @@
.invoice { margin-top: var(--btcpay-space-s); }
.invoice + .invoice { margin-top: var(--btcpay-space-m); }
.invoice .badge { font-size: var(--btcpay-font-size-s); }
#app { --wrap-max-width: 720px; }
#InvoiceDescription > :last-child { margin-bottom: 0; }
@@media print {
@* This is to avoid table header showing up twice: https://github.com/btcpayserver/btcpayserver/issues/4341 *@
@ -59,119 +60,108 @@
</style>
</head>
<body class="min-vh-100">
<div id="app" class="d-flex flex-column min-vh-100 pb-l">
<nav class="btcpay-header navbar sticky-top py-3 py-lg-4 d-print-block">
<div class="container">
<div class="row align-items-center" style="width:calc(100% + 30px)">
<div class="col-12 col-md-8 col-lg-9">
<div class="row">
<div class="col col-12 col-lg-8">
<h1 class="h3" v-text="srvModel.title"></h1>
</div>
<div class="col col-12 col-sm-6 col-lg-8 d-flex align-items-center">
<span class="text-muted text-nowrap">Last Updated</span>
&nbsp;
<span class="text-nowrap d-print-none" v-text="lastUpdated" v-cloak>@Model.LastUpdated.ToString("g")</span>
<span class="text-nowrap d-none d-print-block" v-text="lastUpdatedDate">@Model.LastUpdated.ToString("g")</span>
<button type="button" class="btn btn-link fw-semibold d-none d-lg-inline-block d-print-none border-0 p-0 ms-4 only-for-js" v-on:click="window.print" v-cloak>
Print
</button>
<button type="button" class="btn btn-link fw-semibold d-none d-lg-inline-block d-print-none border-0 p-0 ms-4 only-for-js" v-on:click="window.copyUrlToClipboard" v-cloak>
Copy Link
</button>
</div>
<div class="col col-12 col-sm-6 text-sm-end col-lg-4 mt-lg-n4 pt-lg-1 d-print-none">
@if (Model.IsPending && !Model.Archived && Model.ExpiryDate.HasValue)
{
<noscript>@Model.Status</noscript>
}
<template v-if="srvModel.isPending && !srvModel.archived && endDiff">
<span class="text-muted">Expires in</span> {{endDiff}}
</template>
</div>
</div>
</div>
<div class="col-12 pt-3 pb-2 col-md-4 py-md-0 col-lg-3">
<noscript>
@if (Model.IsPending && !Model.Archived)
<div id="app" class="public-page-wrap">
<main class="flex-grow-1">
<div class="d-flex flex-column justify-content-center gap-4">
<partial name="_StoreHeader" model="(Model.Title, Model.LogoFileId)" />
<div class="text-center mt-n3">
Invoice from
@if (!string.IsNullOrEmpty(Model.StoreWebsite))
{
<a href="@Model.StoreWebsite" target="_blank" rel="noreferrer noopener">@Model.StoreName</a>
}
else
{
@Model.StoreName
}
</div>
<partial name="_StatusMessage" />
<section class="tile">
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between mb-2">
<h2 class="mb-0" v-text="srvModel.amountDue > 0 ? srvModel.amountDueFormatted : srvModel.amountCollectedFormatted">
@if (Model.AmountDue > 0)
{
@if (Model.AllowCustomPaymentAmounts && !Model.AnyPendingInvoice)
{
<form method="get" asp-action="PayPaymentRequest" asp-route-payReqId="@Model.Id" class="d-print-none">
<div class="row">
<div class="col col-12 col-sm-6 col-md-12">
<div class="input-group">
<input type="number" inputmode="decimal" class="form-control text-end hide-number-spin" name="amount" value="@Model.AmountDue" @if (!Model.AllowCustomPaymentAmounts) { @("readonly") } max="@Model.AmountDue" step="any" placeholder="Amount" required />
<span class="input-group-text">@Model.Currency.ToUpper()</span>
</div>
</div>
<div class="col mt-2 col-12 col-sm-6 mt-sm-0 col-md-12 mt-md-2">
<button class="btn btn-primary w-100 text-nowrap" type="submit" data-test="pay-button">Pay Invoice</button>
</div>
</div>
</form>
}
else
{
<a class="btn btn-primary d-inline-block d-print-none w-100 text-nowrap @if (!(Model.AnyPendingInvoice && !Model.PendingInvoiceHasPayments)) { @("btn-lg") }" asp-action="PayPaymentRequest" asp-route-payReqId="@Model.Id" data-test="pay-button">
Pay Invoice
</a>
if (Model.AnyPendingInvoice && !Model.PendingInvoiceHasPayments && Model.AllowCustomPaymentAmounts)
{
<form method="get" asp-action="CancelUnpaidPendingInvoice" asp-route-payReqId="@Model.Id" class="mt-2 d-print-none">
<button class="btn btn-outline-secondary w-100 text-nowrap" type="submit">Cancel Invoice</button>
</form>
}
}
@Model.AmountDueFormatted
}
else
{
<div class="h2 text-md-end">
<span class="badge badge-@Model.Status.ToLowerInvariant()" data-test="status" style="font-size:.75em">
@Model.Status
@if (Model.Archived)
{
<span>(archived)</span>
}
</span>
</div>
@Model.AmountCollectedFormatted
}
</noscript>
<template v-if="srvModel.formId && srvModel.formId != 'None' && !srvModel.formSubmitted">
<a asp-action="ViewPaymentRequestForm" asp-route-payReqId="@Model.Id" class="btn btn-primary w-100 d-flex d-print-none align-items-center justify-content-center text-nowrap btn-lg" data-test="form-button">
</h2>
<span class="badge only-for-js" :class="`badge-${srvModel.status.toLowerCase()}`" data-test="status" style="font-size:.9rem" v-if="srvModel.status.toLowerCase() !== 'pending'">
{{srvModel.status}}
<span v-if="srvModel.archived">(archived)</span>
</span>
@if (Model.Status.ToLowerInvariant() != "pending")
{
<noscript>
<span class="badge badge-@Model.Status.ToLowerInvariant()" data-test="status" style="font-size:.9rem">
@Model.Status
@if (Model.Archived)
{
<span>(archived)</span>
}
</span>
</noscript>
}
</div>
<p>
@if (Model.IsPending && Model.ExpiryDate.HasValue)
{
<span class="text-muted">Due</span>
<span>@Model.ExpiryDate.Value.ToBrowserDate(ViewsRazor.DateDisplayFormat.Relative)</span>
}
else
{
<span class="text-muted">No due date</span>
}
</p>
<dl class="mt-n1 mb-4" v-if="srvModel.amountCollected > 0 && srvModel.amountDue > 0">
<div class="progress bg-light d-flex mb-3 d-print-none" style="height:5px">
<div class="progress-bar bg-primary" role="progressbar" style="width:@(Model.AmountCollected/Model.Amount*100)%" v-bind:style="{ width: (srvModel.amountCollected/srvModel.amount*100) + '%' }"></div>
</div>
<div class="d-flex flex-wrap gap-3 align-items-center justify-content-between">
<div class="d-flex d-print-inline-block flex-column gap-1">
<dd class="text-secondary mb-0">Amount paid</dd>
<dt class="h4 fw-semibold text-nowrap" v-text="srvModel.amountCollectedFormatted">@Model.AmountCollectedFormatted</dt>
</div>
<div class="d-flex d-print-inline-block flex-column gap-1">
<dd class="text-secondary mb-0 text-sm-end">Total requested</dd>
<dt class="h4 fw-semibold text-nowrap text-sm-end" v-text="srvModel.amountFormatted">@Model.AmountFormatted</dt>
</div>
</div>
</dl>
<div class="buttons mt-3">
<template v-if="srvModel.formId && srvModel.formId !== 'None' && !srvModel.formSubmitted">
<a asp-action="ViewPaymentRequestForm" asp-route-payReqId="@Model.Id" class="btn btn-primary btn-lg" data-test="form-button">
Pay Invoice
</a>
</template>
<template v-else-if="srvModel.isPending && !srvModel.archived">
<template v-if="srvModel.allowCustomPaymentAmounts && !srvModel.anyPendingInvoice">
<form v-on:submit="submitCustomAmountForm" class="d-print-none">
<div class="row">
<div class="col col-12 col-sm-6 col-md-12">
<div class="input-group">
<input type="number" inputmode="decimal" class="form-control text-end hide-number-spin" v-model="customAmount" :readonly="!srvModel.allowCustomPaymentAmounts" :max="srvModel.amountDue" placeholder="Amount" step="any" required />
<span class="input-group-text">{{currency}}</span>
</div>
</div>
<div class="col mt-2 col-12 col-sm-6 mt-sm-0 col-md-12 mt-md-2">
<button class="btn btn-primary w-100 d-flex d-print-none align-items-center justify-content-center text-nowrap" v-bind:class="{ 'btn-disabled': loading}" :disabled="loading" type="submit" data-test="pay-button">
<div v-if="loading" class="spinner-grow spinner-grow-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Pay Invoice
</button>
</div>
<form v-on:submit="submitCustomAmountForm">
<div class="input-group mb-3">
<input type="number" class="form-control text-end hide-number-spin" v-model="customAmount" :readonly="!srvModel.allowCustomPaymentAmounts" :max="srvModel.amountDue" placeholder="Amount" step="any" required />
<span class="input-group-text">{{currency}}</span>
</div>
<button class="btn btn-primary btn-lg w-100 d-flex align-items-center justify-content-center text-nowrap" v-bind:class="{ 'btn-disabled': loading }" :disabled="loading" type="submit" id="PayInvoice">
<div v-if="loading" class="spinner-grow spinner-grow-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
Pay Invoice
</button>
</form>
</template>
<template v-else>
<button class="btn btn-primary w-100 d-flex d-print-none align-items-center justify-content-center text-nowrap" :class="{ 'btn-lg': !(srvModel.anyPendingInvoice && !srvModel.pendingInvoiceHasPayments) || !srvModel.allowCustomPaymentAmounts}" v-on:click="pay(null)" :disabled="loading" data-test="pay-button">
<button class="btn btn-primary btn-lg w-100 d-flex align-items-center justify-content-center text-nowrap" v-on:click="pay(null)" :disabled="loading" id="PayInvoice">
<div v-if="loading" class="spinner-grow spinner-grow-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<span>Pay Invoice</span>
</button>
@if (Model.AllowCustomPaymentAmounts) {
<button class="btn btn-outline-secondary mt-2 w-100 d-flex d-print-none align-items-center justify-content-center text-nowrap" v-if="srvModel.anyPendingInvoice && !srvModel.pendingInvoiceHasPayments" v-on:click="cancelPayment()" :disabled="loading">
<button class="btn btn-outline-secondary btn-lg w-100 d-flex align-items-center justify-content-center text-nowrap" v-if="srvModel.anyPendingInvoice && !srvModel.pendingInvoiceHasPayments" v-on:click="cancelPayment()" :disabled="loading">
<span v-if="loading" class="spinner-grow spinner-grow-sm me-2" role="status">
<span class="visually-hidden">Loading...</span>
</span>
@ -180,193 +170,150 @@
}
</template>
</template>
<template v-else>
<div class="h2 text-md-end">
<span class="badge" :class="`badge-${srvModel.status.toLowerCase()}`" data-test="status" style="font-size:.75em">
{{srvModel.status}}
<span v-if="srvModel.archived">(archived)</span>
</span>
</div>
</template>
<div class="d-flex flex-column flex-sm-row gap-3 align-items-center justify-content-between">
<button type="button" class="btn btn-secondary only-for-js w-100" v-on:click="window.print">
Print
</button>
<button type="button" class="btn btn-secondary only-for-js w-100" v-on:click="window.copyUrlToClipboard">
Copy Link
</button>
</div>
</div>
</div>
</div>
</nav>
<main class="flex-grow-1 py-4">
<div class="container">
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData){ { "Margin", "mb-4" } })" />
<div class="row">
<div class="col col-12 col-lg-6 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<h2 class="h4 mb-3">Invoice Summary</h2>
@if (!string.IsNullOrEmpty(Model.Description) && Model.Description != "<br>")
<noscript>
@if (Model.IsPending && !Model.Archived)
{
@if (Model.AllowCustomPaymentAmounts && !Model.AnyPendingInvoice)
{
<div v-html="srvModel.description"></div>
<form method="get" asp-action="PayPaymentRequest" asp-route-payReqId="@Model.Id">
<div class="input-group mb-3">
<input type="number" class="form-control text-end hide-number-spin" name="amount" value="@Model.AmountDue" @if (!Model.AllowCustomPaymentAmounts) { @("readonly") } max="@Model.AmountDue" step="any" placeholder="Amount" required />
<span class="input-group-text">@Model.Currency.ToUpper()</span>
</div>
<button class="btn btn-primary btn-lg w-100 text-nowrap" type="submit" id="PayInvoice">Pay Invoice</button>
</form>
}
else
{
<p class="text-muted mt-3 mb-0">No details provided.</p>
<a class="btn btn-primary btn-lg w-100 text-nowrap" asp-action="PayPaymentRequest" asp-route-payReqId="@Model.Id" id="PayInvoice">
Pay Invoice
</a>
if (Model.AnyPendingInvoice && !Model.PendingInvoiceHasPayments && Model.AllowCustomPaymentAmounts)
{
<form method="get" asp-action="CancelUnpaidPendingInvoice" asp-route-payReqId="@Model.Id" class="mt-2 d-print-none">
<button class="btn btn-outline-secondary btn-lg w-100 text-nowrap" type="submit">Cancel Invoice</button>
</form>
}
}
}
</noscript>
</section>
@if (!string.IsNullOrEmpty(Model.Description) && Model.Description != "<br>")
{
<section class="tile">
<h2 class="h4 mb-3">Memo</h2>
<div id="InvoiceDescription" v-html="srvModel.description">@Safe.Raw(Model.Description)</div>
</section>
}
<section class="tile">
<h2 class="h4 mb-3">Payment History</h2>
<template v-if="!srvModel.invoices || srvModel.invoices.length == 0">
<p class="text-muted mb-0">No payments have been made yet.</p>
</template>
<template v-else>
<div class="table-responsive my-0">
<table v-for="invoice of srvModel.invoices" :key="invoice.id" class="invoice table table-borderless">
<thead>
<tr>
<th class="fw-normal text-secondary" scope="col">Invoice Id</th>
<th class="fw-normal text-secondary amount-col w-125px">Amount</th>
<th class="fw-normal text-secondary text-end w-225px">Status</th>
<th class="w-50px actions-col"></th>
</tr>
</thead>
<tbody>
<tr>
<td class="align-middle"><vc:truncate-center is-vue="true" text="invoice.id" padding="7" classes="truncate-center-id" /></td>
<td class="align-middle amount-col">{{invoice.amountFormatted}}</td>
<td class="align-middle text-end text-print-default">
<span class="badge" :class="`badge-${statusClass(invoice.stateFormatted)}`">{{invoice.stateFormatted}}</span>
</td>
<td class="align-middle actions-col">
<div class="d-inline-flex align-items-center gap-2">
<button class="accordion-button collapsed only-for-js ms-0 d-inline-block" type="button" :aria-controls="`invoice_details_${invoice.id}`" :aria-expanded="showDetails(invoice.id) ? 'true' : 'false'" v-if="invoice.payments && invoice.payments.length > 0" v-on:click="toggleDetails(invoice.id)">
<vc:icon symbol="caret-down" />
</button>
</div>
</td>
</tr>
<tr v-collapsible="showDetails(invoice.id)" :id="`invoice_details_${invoice.id}`" v-if="invoice.payments && invoice.payments.length > 0">
<th class="fw-normal text-secondary">Transaction</th>
<th class="fw-normal text-secondary amount-col">Paid</th>
<th class="fw-normal text-secondary text-end">Payment</th>
</tr>
<tr v-collapsible="showDetails(invoice.id)" v-for="payment of invoice.payments" :key="`invoice_payment_${payment.id}`">
<td class="text-break"><vc:truncate-center is-vue="true" text="payment.id" link="payment.link" padding="7" classes="truncate-center-id" /></td>
<td class="amount-col">{{payment.paidFormatted}}</td>
<td class="amount-col">{{payment.amountFormatted}} {{payment.paymentMethod}}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col col-12 col-lg-6 mb-4">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<h2 class="h4 mb-3">Payment Details</h2>
<dl class="mb-0 mt-md-4">
<div class="d-flex d-print-inline-block flex-column mb-4 me-5">
<dt class="h4 fw-semibold text-nowrap text-primary text-print-default order-2 order-sm-1 mb-1" v-text="srvModel.amountDueFormatted">@Model.AmountDueFormatted</dt>
<dd class="order-1 order-sm-2 mb-1" data-test="amount-due-title">Amount due</dd>
</div>
<div class="progress bg-light d-none d-sm-flex mb-sm-4 d-print-none" style="height:5px">
<div class="progress-bar bg-primary" role="progressbar" style="width:@((Model.AmountCollected/Model.Amount)*100)%" v-bind:style="{ width: (srvModel.amountCollected/srvModel.amount*100) + '%' }"></div>
</div>
<div class="d-flex d-print-inline-block flex-column mb-4 me-5 d-sm-inline-flex mb-sm-0">
<dt class="h4 fw-semibold text-nowrap order-2 order-sm-1 mb-1" v-text="srvModel.amountCollectedFormatted">@Model.AmountCollectedFormatted</dt>
<dd class="order-1 order-sm-2 mb-1">Amount paid</dd>
</div>
<div class="d-flex d-print-inline-block flex-column mb-0 d-sm-inline-flex float-sm-end">
<dt class="h4 text-sm-end fw-semibold text-nowrap order-2 order-sm-1 mb-1" v-text="srvModel.amountFormatted">@Model.AmountFormatted</dt>
<dd class="text-sm-end order-1 order-sm-2 mb-1">Total requested</dd>
</div>
</dl>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="bg-tile h-100 m-0 p-3 p-sm-5 rounded">
<h2 class="h4 mb-0">Payment History</h2>
<noscript>
@if (Model.Invoices == null || !Model.Invoices.Any())
{
<p class="text-muted mt-3 mb-0">No payments made yet.</p>
}
else
{
@foreach (var invoice in Model.Invoices)
{
<div class="table-responsive">
<table class="invoice table">
<thead>
<tr class="table-borderless">
<th class="fw-normal text-secondary w-350px" scope="col">Invoice Id</th>
<th class="fw-normal text-secondary w-175px">Expiry</th>
<th class="fw-normal text-secondary text-end w-125px">Amount</th>
<th class="fw-normal text-secondary text-end w-125px"></th>
<th class="fw-normal text-secondary text-end">Status</th>
</tr>
</thead>
<tbody>
<tr class="table-borderless">
<td>@invoice.Id</td>
<td>@invoice.ExpiryDate.ToString("g")</td>
<td class="text-end">@invoice.AmountFormatted</td>
<td class="text-end"></td>
<td class="text-end text-print-default">
<span class="badge badge-@StatusClass(invoice.State)">@invoice.StateFormatted</span>
</td>
</tr>
@if (invoice.Payments != null && invoice.Payments.Any())
{
<tr class="table-borderless">
<th class="fw-normal text-secondary">Destination</th>
<th class="fw-normal text-secondary">Received</th>
<th class="fw-normal text-secondary text-end">Paid</th>
<th class="fw-normal text-secondary text-end">Rate</th>
<th class="fw-normal text-secondary text-end">Payment</th>
</tr>
@foreach (var payment in invoice.Payments)
{
<tr class="table-borderless">
<td class="text-break"><code>@payment.Destination</code></td>
<td>@payment.ReceivedDate.ToString("g")</td>
<td class="text-end">@payment.PaidFormatted</td>
<td class="text-end">@payment.RateFormatted</td>
<td class="text-end text-nowrap">@payment.Amount @payment.PaymentMethod</td>
</tr>
<tr class="table-borderless">
<td class="fw-normal" colspan="5">
<span class="text-secondary">Transaction Id:</span>
@if (!string.IsNullOrEmpty(payment.Link))
{
<a href="@payment.Link" class="text-print-default text-break" rel="noreferrer noopener" target="_blank">@payment.Id</a>
}
else
{
<span class="text-break">@payment.Id</span>
}
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
}
</noscript>
<template v-if="!srvModel.invoices || srvModel.invoices.length == 0">
<p class="text-muted mt-3 mb-0">No payments made yet.</p>
</template>
<template v-else>
<div class="table-responsive">
<table v-for="invoice of srvModel.invoices" :key="invoice.id" class="invoice table">
</template>
<noscript>
@if (Model.Invoices == null || !Model.Invoices.Any())
{
<p class="text-muted mb-0">No payments have been made yet.</p>
}
else
{
@foreach (var invoice in Model.Invoices)
{
<div class="table-responsive my-0">
<table class="invoice table table-borderless">
<thead>
<tr class="table-borderless">
<th class="fw-normal text-secondary w-350px" scope="col">Invoice Id</th>
<th class="fw-normal text-secondary w-175px">Expiry</th>
<th class="fw-normal text-secondary text-end w-125px">Amount</th>
<th class="fw-normal text-secondary text-end w-125px"></th>
<tr>
<th class="fw-normal text-secondary" scope="col">Invoice Id</th>
<th class="fw-normal text-secondary amount-col w-125px">Amount</th>
<th class="fw-normal text-secondary text-end">Status</th>
</tr>
</thead>
<tbody>
<tr class="table-borderless">
<td>{{invoice.id}}</td>
<td v-text="formatDate(invoice.expiryDate)"></td>
<td class="text-end">{{invoice.amountFormatted}}</td>
<td class="text-end"></td>
<tr>
<td><vc:truncate-center text="@invoice.Id" padding="7" classes="truncate-center-id" /></td>
<td class="amount-col">@invoice.AmountFormatted</td>
<td class="text-end text-print-default">
<span class="badge" :class="`badge-${statusClass(invoice.stateFormatted)}`">{{invoice.stateFormatted}}</span>
<span class="badge badge-@StatusClass(invoice.State)">@invoice.StateFormatted</span>
</td>
</tr>
<template v-if="invoice.payments && invoice.payments.length > 0">
<tr class="table-borderless">
<th class="fw-normal text-secondary">Destination</th>
<th class="fw-normal text-secondary">Received</th>
<th class="fw-normal text-secondary text-end">Paid</th>
<th class="fw-normal text-secondary text-end">Rate</th>
@if (invoice.Payments != null && invoice.Payments.Any())
{
<tr>
<th class="fw-normal text-secondary">Transaction</th>
<th class="fw-normal text-secondary amount-col">Paid</th>
<th class="fw-normal text-secondary text-end">Payment</th>
</tr>
<template v-for="payment of invoice.payments">
<tr class="table-borderless">
<td class="text-break"><code>{{payment.destination}}</code></td>
<td v-text="formatDate(payment.receivedDate)"></td>
<td class="text-end">{{payment.paidFormatted}}</td>
<td class="text-end">{{payment.rateFormatted}}</td>
<td class="text-end text-nowrap">{{payment.amount.noExponents()}} {{payment.paymentMethod}}</td>
@foreach (var payment in invoice.Payments)
{
<tr>
<td class="text-break"><vc:truncate-center text="@payment.Id" link="@payment.Link" padding="7" classes="truncate-center-id" /></td>
<td class="amount-col">@payment.PaidFormatted</td>
<td class="text-end text-nowrap">@payment.AmountFormatted @payment.PaymentMethod</td>
</tr>
<tr class="table-borderless">
<td class="fw-normal" colspan="5">
<span class="text-secondary">Transaction Id:</span>
<a v-if="payment.link" :href="payment.link" class="text-print-default" target="_blank" rel="noreferrer noopener">{{payment.id}}</a>
<span v-else>{{payment.id}}</span>
</td>
</tr>
</template>
</template>
}
}
</tbody>
</table>
</div>
</template>
</div>
</div>
</div>
}
}
</noscript>
</section>
</div>
</main>
<footer class="store-footer">
<p permission="@Policies.CanModifyStoreSettings">
<p permission="@Policies.CanModifyStoreSettings" class="d-print-none">
<a asp-controller="UIPaymentRequest" asp-action="EditPaymentRequest" asp-route-storeId="@Model.StoreId" asp-route-payReqId="@Model.Id">
Edit payment request
</a>

View File

@ -26,7 +26,7 @@
else
{
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
<a class="btn btn-secondary" asp-action="ViewPullPayment" asp-route-pullPaymentId="@Model.Id" id="ViewPullPayment">View</a>
<a class="btn btn-secondary" asp-action="ViewPullPayment" asp-route-pullPaymentId="@Model.Id" id="ViewPullPayment" target="_blank">View</a>
}
</div>
</div>

View File

@ -119,7 +119,7 @@
<th scope="col">Name</th>
<th scope="col">Automatically Approved</th>
<th scope="col">Refunded</th>
<th scope="col"></th>
<th scope="col" class="actions-col"></th>
</tr>
</thead>
<tbody>
@ -146,11 +146,12 @@
</div>
</div>
</td>
<td class="text-end">
<td class="actions-col">
<div class="d-inline-flex align-items-center gap-3">
<a asp-action="ViewPullPayment"
asp-controller="UIPullPayment"
asp-route-pullPaymentId="@pp.Id">
asp-route-pullPaymentId="@pp.Id"
target="_blank">
View
</a>
<a class="pp-payout"

View File

@ -1,4 +1,4 @@
:root {
#Checkout-v2 {
--navbutton-size: .8rem;
--section-padding: 1.5rem;
--border-radius: var(--btcpay-border-radius-l);

View File

@ -1,41 +1,3 @@
Vue.directive('collapsible', {
bind: function (el, binding) {
el.classList.add('collapse');
el.classList[binding.value ? 'add' : 'remove']('show');
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(function () {
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(function () {
el.classList.add('collapse');
el.classList.remove('collapsing');
}, el.transitionDuration)
}
}
}
});
// These are the legacy states, see InvoiceEntity
const STATUS_PAYABLE = ['new'];
const STATUS_PAID = ['paid'];

View File

@ -1,7 +1,12 @@
function confirmCopy(el, message) {
const hasIcon = !!el.innerHTML.match('icon-copy')
const confirmHTML = `<span class="text-success">${message}</span>`;
if (hasIcon) {
el.innerHTML = el.innerHTML.replace('#copy', '#checkmark');
} else {
el.dataset.clipboardInitial = el.innerHTML;
el.style.minWidth = el.getBoundingClientRect().width + 'px';
el.innerHTML = confirmHTML;
}
el.dataset.clipboardConfirming = true;
if (el.dataset.clipboardHandler) {
@ -10,6 +15,8 @@ function confirmCopy(el, message) {
const timeoutId = setTimeout(function () {
if (hasIcon) {
el.innerHTML = el.innerHTML.replace('#checkmark', '#copy');
} else if (el.innerHTML === confirmHTML) {
el.innerHTML = el.dataset.clipboardInitial;
}
delete el.dataset.clipboardConfirming;
el.dataset.clipboardHandler = null;

View File

@ -0,0 +1,37 @@
Vue.directive('collapsible', {
bind: function (el, binding) {
el.classList.add('collapse');
el.classList[binding.value ? 'add' : 'remove']('show');
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(function () {
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(function () {
el.classList.add('collapse');
el.classList.remove('collapsing');
}, el.transitionDuration)
}
}
}
});

View File

@ -219,7 +219,8 @@ h2 svg.icon.icon-info {
/* Print */
@media print {
section {
section,
.tile {
page-break-inside: avoid;
}
.table-responsive {
@ -245,17 +246,21 @@ h2 svg.icon.icon-info {
a {
text-decoration: none !important;
}
.buttons,
.toasted-container {
display: none !important;
}
.truncate-center a,
.truncate-center button,
.truncate-center-truncated {
display: none;
display: none !important;
}
.card {
page-break-inside: avoid;
}
.actions-col {
display: none;
}
}
/* Richtext editor */
@ -638,28 +643,56 @@ input:checked + label.btcpay-list-select-item {
}
/* Public pages */
.public-page-wrap {
display: flex;
flex-direction: column;
gap: 1.5rem;
margin: 0 auto;
padding: var(--btcpay-space-l) var(--btcpay-space-m);
}
/* gradually try to set better but less supported values and units */
.min-vh-100,
.public-page-wrap {
min-height: -webkit-fill-available !important;
min-height: 100dvh !important;
}
@media (max-width: 400px) {
@media screen {
.public-page-wrap {
padding-left: 0;
padding-right: 0;
--wrap-max-width: none;
--wrap-padding-vertical: var(--btcpay-space-l);
--wrap-padding-horizontal: var(--btcpay-space-m);
display: flex;
flex-direction: column;
gap: 1.5rem;
max-width: var(--wrap-max-width);
margin: 0 auto;
padding: var(--wrap-padding-vertical) var(--wrap-padding-horizontal);
}
/* gradually try to set better but less supported values and units */
.min-vh-100,
.public-page-wrap {
min-height: -webkit-fill-available !important;
min-height: 100dvh !important;
}
.tile {
--section-padding: 1.5rem;
--section-border-radius: var(--btcpay-border-radius-l);
padding: var(--section-padding);
background: var(--btcpay-bg-tile);
border-radius: var(--section-border-radius);
box-shadow: var(--btcpay-box-shadow-lg);
}
.tile .buttons {
display: flex;
flex-direction: column;
gap: var(--btcpay-space-m);
}
.tile > :last-child {
margin-bottom: 0;
}
}
@media screen and (max-width: 400px) {
.public-page-wrap {
--wrap-padding-horizontal: 0;
}
.tile {
--section-padding: 1rem;
--section-border-radius: none;
}
}
/* Store Header */
.store-header {
display: flex;
flex-direction: column;
@ -705,6 +738,7 @@ a.store-powered-by svg {
height: 2rem;
width: 4rem;
}
.store-footer a:hover,
a.store-powered-by:hover {
color: var(--btcpay-body-text-hover);
}
@ -952,6 +986,11 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
text-overflow: ellipsis;
}
.truncate-center-end,
.truncate-center-start {
white-space: nowrap;
}
.truncate-center-id {
font-family: var(--btcpay-font-monospace);
font-size: .875em;
@ -1064,6 +1103,9 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
text-align: right;
white-space: nowrap;
}
.actions-col {
text-align: right;
}
/* Mass Actions */
.mass-action-head,

View File

@ -16,7 +16,8 @@ document.addEventListener("DOMContentLoaded",function (ev) {
active: true,
loading: false,
timeoutState: "",
customAmount: null
customAmount: null,
detailsShown: {}
}
},
computed: {
@ -109,6 +110,15 @@ document.addEventListener("DOMContentLoaded",function (ev) {
default:
return status.toLowerCase();
}
},
showDetails(invoiceId) {
return this.detailsShown[invoiceId] === true;
},
toggleDetails(invoiceId) {
if (this.detailsShown[invoiceId])
Vue.delete(this.detailsShown, invoiceId);
else
Vue.set(this.detailsShown, invoiceId, true);
}
},
mounted: function () {