Compare commits
10 Commits
fwoinq
...
v1.11.0-rc
Author | SHA1 | Date | |
---|---|---|---|
08e21c1a5d | |||
4d5245605d | |||
453548d614 | |||
95a0614ae1 | |||
36ea17a6b7 | |||
dc986959fd | |||
845e2881fa | |||
2e4be9310c | |||
a2faa6fd59 | |||
0a78846e8d |
BTCPayServer.Abstractions/TagHelpers
BTCPayServer.Client/Models
BTCPayServer.Data
BTCPayServer.Tests
Extensions.csFastTests.csGreenfieldAPITests.csSeleniumTests.csTestAccount.csThirdPartyTests.csUnitTest1.cs
BTCPayServer
BTCPayServer.csproj
Components/MainNav
Controllers
BitpayInvoiceController.cs
GreenField
UIInvoiceController.UI.csUIInvoiceController.csUIPublicController.csUIPullPaymentController.csUIReportsController.CheatMode.csUIReportsController.csUIServerController.csUIStorePullPaymentsController.PullPayments.csUIStoresController.csData
Extensions.csExtensions
FileTypeDetector.csHostedServices
Hosting
Models
InvoicingModels
StoreReportsViewModels
StoreViewModels
WalletViewModels
PayoutProcessors
Plugins/PointOfSale/Models
Services
TagHelpers
Views
Shared
UIForms
UIInvoice
UILightningAutomatedPayoutProcessors
UIOnChainAutomatedPayoutProcessors
UIReports
UIStorePullPayments
UIStores
wwwroot
cart
checkout-v2
img
js
light-pos
main
pos
swagger/v1
vendor
Build
btcpayserver.sln.DotSettings@ -12,13 +12,11 @@ public class PermissionTagHelper : TagHelper
|
||||
{
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly ILogger<PermissionTagHelper> _logger;
|
||||
|
||||
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor, ILogger<PermissionTagHelper> logger)
|
||||
public PermissionTagHelper(IAuthorizationService authorizationService, IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
_authorizationService = authorizationService;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string Permission { get; set; }
|
||||
|
@ -1,8 +1,11 @@
|
||||
#nullable enable
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models;
|
||||
|
||||
public class CreatePayoutThroughStoreRequest : CreatePayoutRequest
|
||||
{
|
||||
public string? PullPaymentId { get; set; }
|
||||
public bool? Approved { get; set; }
|
||||
public JObject? Metadata { get; set; }
|
||||
}
|
||||
|
@ -31,5 +31,6 @@ namespace BTCPayServer.Client.Models
|
||||
public PayoutState State { get; set; }
|
||||
public int Revision { get; set; }
|
||||
public JObject PaymentProof { get; set; }
|
||||
public JObject Metadata { get; set; }
|
||||
}
|
||||
}
|
||||
|
62
BTCPayServer.Client/Models/StoreReportRequest.cs
Normal file
62
BTCPayServer.Client/Models/StoreReportRequest.cs
Normal file
@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Client.Models;
|
||||
|
||||
public class StoreReportRequest
|
||||
{
|
||||
public string ViewName { get; set; }
|
||||
public TimePeriod TimePeriod { get; set; }
|
||||
}
|
||||
public class StoreReportResponse
|
||||
{
|
||||
public class Field
|
||||
{
|
||||
public Field()
|
||||
{
|
||||
|
||||
}
|
||||
public Field(string name, string type)
|
||||
{
|
||||
Name = name;
|
||||
Type = type;
|
||||
}
|
||||
public string Name { get; set; }
|
||||
public string Type { get; set; }
|
||||
}
|
||||
public IList<Field> Fields { get; set; } = new List<Field>();
|
||||
public List<JArray> Data { get; set; }
|
||||
public DateTimeOffset From { get; set; }
|
||||
public DateTimeOffset To { get; set; }
|
||||
public List<ChartDefinition> Charts { get; set; }
|
||||
|
||||
public int GetIndex(string fieldName)
|
||||
{
|
||||
return Fields.ToList().FindIndex(f => f.Name == fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
public class ChartDefinition
|
||||
{
|
||||
public string Name { get; set; }
|
||||
|
||||
public List<string> Groups { get; set; } = new List<string>();
|
||||
public List<string> Totals { get; set; } = new List<string>();
|
||||
public bool HasGrandTotal { get; set; }
|
||||
public List<string> Aggregates { get; set; } = new List<string>();
|
||||
public List<string> Filters { get; set; } = new List<string>();
|
||||
}
|
||||
|
||||
public class TimePeriod
|
||||
{
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||
public DateTimeOffset? From { get; set; }
|
||||
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||
public DateTimeOffset? To { get; set; }
|
||||
}
|
16
BTCPayServer.Client/Models/StoreReportsResponse.cs
Normal file
16
BTCPayServer.Client/Models/StoreReportsResponse.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public class StoreReportsResponse
|
||||
{
|
||||
public string ViewName { get; set; }
|
||||
public StoreReportResponse.Field[] Fields
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,3 +1,6 @@
|
||||
using System;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
@ -196,6 +197,7 @@ retry:
|
||||
driver.FindElement(selector).Click();
|
||||
}
|
||||
|
||||
[DebuggerHidden]
|
||||
public static bool ElementDoesNotExist(this IWebDriver driver, By selector)
|
||||
{
|
||||
Assert.Throws<NoSuchElementException>(() =>
|
||||
|
@ -659,7 +659,7 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CanDetectImage()
|
||||
public void CanDetectFileType()
|
||||
{
|
||||
Assert.True(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, "test.bmp"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x42, 0x4D }, ".bmp"));
|
||||
@ -672,6 +672,15 @@ namespace BTCPayServer.Tests
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x3C, 0x73, 0x76, 0x67 }, "test.jpg"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0xFF }, "e.jpg"));
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { }, "empty.jpg"));
|
||||
|
||||
Assert.False(FileTypeDetector.IsPicture(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x49, 0x44, 0x33, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x23 }, "music.mp3"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x52, 0x49, 0x46, 0x46, 0x24, 0x9A, 0x08, 0x00, 0x57, 0x41 }, "music.wav"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF1, 0x50, 0x80, 0x1C, 0x3F, 0xFC, 0xDA, 0x00, 0x4C }, "music.aac"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x66, 0x4C, 0x61, 0x43, 0x00, 0x00, 0x00, 0x22, 0x04, 0x80 }, "music.flac"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00 }, "music.ogg"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00 }, "music.weba"));
|
||||
Assert.True(FileTypeDetector.IsAudio(new byte[] { 0xFF, 0xF3, 0xE4, 0x64, 0x00, 0x20, 0xAD, 0xBD, 0x04, 0x00 }, "music.mp3"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -1152,7 +1152,8 @@ namespace BTCPayServer.Tests
|
||||
Approved = false,
|
||||
PaymentMethod = "BTC",
|
||||
Amount = 0.0001m,
|
||||
Destination = address.ToString()
|
||||
Destination = address.ToString(),
|
||||
|
||||
});
|
||||
await AssertAPIError("invalid-state", async () =>
|
||||
{
|
||||
@ -3545,6 +3546,7 @@ namespace BTCPayServer.Tests
|
||||
PaymentMethod = "BTC_LightningNetwork",
|
||||
Destination = customerInvoice.BOLT11
|
||||
});
|
||||
Assert.Equal(payout.Metadata.ToString(), new JObject().ToString()); //empty
|
||||
Assert.Empty(await adminClient.GetStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork"));
|
||||
await adminClient.UpdateStoreLightningAutomatedPayoutProcessors(admin.StoreId, "BTC_LightningNetwork",
|
||||
new LightningAutomatedPayoutSettings() { IntervalSeconds = TimeSpan.FromSeconds(600) });
|
||||
@ -3555,6 +3557,46 @@ namespace BTCPayServer.Tests
|
||||
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
|
||||
Assert.Equal(PayoutState.Completed, payoutC.State);
|
||||
});
|
||||
|
||||
payout = await adminClient.CreatePayout(admin.StoreId,
|
||||
new CreatePayoutThroughStoreRequest()
|
||||
{
|
||||
Approved = true,
|
||||
PaymentMethod = "BTC",
|
||||
Destination = (await tester.ExplorerNode.GetNewAddressAsync()).ToString(),
|
||||
Amount = 0.0001m,
|
||||
Metadata = JObject.FromObject(new
|
||||
{
|
||||
source ="apitest",
|
||||
sourceLink = "https://chocolate.com"
|
||||
})
|
||||
});
|
||||
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
|
||||
{
|
||||
source = "apitest",
|
||||
sourceLink = "https://chocolate.com"
|
||||
}).ToString());
|
||||
|
||||
payout =
|
||||
(await adminClient.GetStorePayouts(admin.StoreId, false)).Single(data => data.Id == payout.Id);
|
||||
|
||||
Assert.Equal(payout.Metadata.ToString(), JObject.FromObject(new
|
||||
{
|
||||
source = "apitest",
|
||||
sourceLink = "https://chocolate.com"
|
||||
}).ToString());
|
||||
|
||||
customerInvoice = await tester.CustomerLightningD.CreateInvoice(LightMoney.FromUnit(10, LightMoneyUnit.Satoshi),
|
||||
Guid.NewGuid().ToString(), TimeSpan.FromDays(40));
|
||||
var payout2 = await adminClient.CreatePayout(admin.StoreId,
|
||||
new CreatePayoutThroughStoreRequest()
|
||||
{
|
||||
Approved = true,
|
||||
Amount = new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC),
|
||||
PaymentMethod = "BTC_LightningNetwork",
|
||||
Destination = customerInvoice.BOLT11
|
||||
});
|
||||
Assert.Equal(payout2.Amount, new Money(100, MoneyUnit.Satoshi).ToDecimal(MoneyUnit.BTC));
|
||||
}
|
||||
|
||||
[Fact(Timeout = 60 * 2 * 1000)]
|
||||
|
@ -988,14 +988,14 @@ namespace BTCPayServer.Tests
|
||||
Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS");
|
||||
Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view");
|
||||
Assert.True(s.Driver.PageSource.Contains("Take my money"), "PoS not showing correct default view");
|
||||
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
|
||||
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
|
||||
|
||||
var drinks = s.Driver.FindElement(By.CssSelector("label[for='Category-Drinks']"));
|
||||
Assert.Equal("Drinks", drinks.Text);
|
||||
drinks.Click();
|
||||
Assert.Single(s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")));
|
||||
Assert.Single(s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")));
|
||||
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
|
||||
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
|
||||
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".posItem:not(.d-none)")).Count);
|
||||
|
||||
s.Driver.Url = posBaseUrl + "/static";
|
||||
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");
|
||||
@ -2189,47 +2189,52 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
||||
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
|
||||
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
|
||||
s.Driver.FindElement(By.Id("ShowCustomAmount")).Click();
|
||||
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
|
||||
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
|
||||
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
||||
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.WaitForElement(By.Id("js-cart-list"));
|
||||
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
|
||||
Assert.Equal("0,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
Assert.False(s.Driver.FindElement(By.Id("CartClear")).Displayed);
|
||||
s.Driver.WaitForElement(By.Id("PosItems"));
|
||||
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
|
||||
|
||||
// Select and clear
|
||||
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
|
||||
Assert.Single(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
|
||||
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
|
||||
Assert.Single(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
|
||||
s.Driver.FindElement(By.Id("CartClear")).Click();
|
||||
Assert.Empty(s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")));
|
||||
Thread.Sleep(250);
|
||||
Assert.Empty(s.Driver.FindElements(By.CssSelector("#CartItems tr")));
|
||||
|
||||
// Select items
|
||||
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(2)")).Click();
|
||||
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
|
||||
Thread.Sleep(250);
|
||||
s.Driver.FindElement(By.CssSelector(".card.js-add-cart:nth-child(1)")).Click();
|
||||
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(1) .btn-primary")).Click();
|
||||
Thread.Sleep(250);
|
||||
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#js-cart-list tbody tr")).Count);
|
||||
Assert.Equal("2,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
s.Driver.FindElement(By.CssSelector(".posItem:nth-child(2) .btn-primary")).Click();
|
||||
Thread.Sleep(250);
|
||||
Assert.Equal(2, s.Driver.FindElements(By.CssSelector("#CartItems tr")).Count);
|
||||
Assert.Equal("3,00 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
|
||||
// Custom amount
|
||||
s.Driver.FindElement(By.Id("CartCustomAmount")).SendKeys("1.5");
|
||||
s.Driver.FindElement(By.Id("CartTotal")).Click();
|
||||
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
s.Driver.FindElement(By.Id("js-cart-confirm")).Click();
|
||||
// Discount: 10%
|
||||
s.Driver.ElementDoesNotExist(By.Id("CartDiscount"));
|
||||
s.Driver.FindElement(By.Id("Discount")).SendKeys("10");
|
||||
Assert.Contains("10% = 0,30 €", s.Driver.FindElement(By.Id("CartDiscount")).Text);
|
||||
Assert.Equal("2,70 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
|
||||
// Tip: 10%
|
||||
s.Driver.ElementDoesNotExist(By.Id("CartTip"));
|
||||
s.Driver.FindElement(By.Id("Tip-10")).Click();
|
||||
Assert.Contains("10% = 0,27 €", s.Driver.FindElement(By.Id("CartTip")).Text);
|
||||
Assert.Equal("2,97 €", s.Driver.FindElement(By.Id("CartTotal")).Text);
|
||||
|
||||
// Pay
|
||||
Assert.Equal("3,50 €", s.Driver.FindElement(By.Id("CartSummaryTotal")).Text);
|
||||
s.Driver.FindElement(By.Id("js-cart-pay")).Click();
|
||||
|
||||
s.Driver.FindElement(By.Id("CartSubmit")).Click();
|
||||
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
|
||||
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
|
||||
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
|
||||
Assert.Contains("3,50 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
|
||||
Assert.Contains("2,97 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
@ -40,6 +40,7 @@ namespace BTCPayServer.Tests
|
||||
public class TestAccount
|
||||
{
|
||||
readonly ServerTester parent;
|
||||
public string LNAddress;
|
||||
|
||||
public TestAccount(ServerTester parent)
|
||||
{
|
||||
@ -242,7 +243,7 @@ namespace BTCPayServer.Tests
|
||||
policies.LockSubscription = false;
|
||||
await account.Register(RegisterDetails);
|
||||
}
|
||||
|
||||
TestLogs.LogInformation($"UserId: {account.RegisteredUserId} Password: {Password}");
|
||||
UserId = account.RegisteredUserId;
|
||||
Email = RegisterDetails.Email;
|
||||
IsAdmin = account.RegisteredAdmin;
|
||||
@ -309,8 +310,9 @@ namespace BTCPayServer.Tests
|
||||
Assert.False(true, storeController.ModelState.FirstOrDefault().Value.Errors[0].ErrorMessage);
|
||||
}
|
||||
|
||||
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network)
|
||||
public async Task<Coin> ReceiveUTXO(Money value, BTCPayNetwork network = null)
|
||||
{
|
||||
network ??= SupportedNetwork;
|
||||
var cashCow = parent.ExplorerNode;
|
||||
var btcPayWallet = parent.PayTester.GetService<BTCPayWalletProvider>().GetWallet(network);
|
||||
var address = (await btcPayWallet.ReserveAddressAsync(this.DerivationScheme)).Address;
|
||||
@ -553,5 +555,94 @@ retry:
|
||||
var repo = this.parent.PayTester.GetService<StoreRepository>();
|
||||
await repo.AddStoreUser(StoreId, userId, StoreRoleId.Owner);
|
||||
}
|
||||
|
||||
public async Task<uint256> PayOnChain(string invoiceId)
|
||||
{
|
||||
var cryptoCode = "BTC";
|
||||
var client = await CreateClient();
|
||||
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
|
||||
var method = methods.First(m => m.PaymentMethod == cryptoCode);
|
||||
var address = method.Destination;
|
||||
var tx = await client.CreateOnChainTransaction(StoreId, cryptoCode, new CreateOnChainTransactionRequest()
|
||||
{
|
||||
Destinations = new List<CreateOnChainTransactionRequest.CreateOnChainTransactionRequestDestination>()
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Destination = address,
|
||||
Amount = method.Due
|
||||
}
|
||||
},
|
||||
FeeRate = new FeeRate(1.0m)
|
||||
});
|
||||
await WaitInvoicePaid(invoiceId);
|
||||
return tx.TransactionHash;
|
||||
}
|
||||
|
||||
public async Task PayOnBOLT11(string invoiceId)
|
||||
{
|
||||
var cryptoCode = "BTC";
|
||||
var client = await CreateClient();
|
||||
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
|
||||
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LightningNetwork");
|
||||
var bolt11 = method.Destination;
|
||||
TestLogs.LogInformation("PAYING");
|
||||
await parent.CustomerLightningD.Pay(bolt11);
|
||||
TestLogs.LogInformation("PAID");
|
||||
await WaitInvoicePaid(invoiceId);
|
||||
}
|
||||
|
||||
public async Task PayOnLNUrl(string invoiceId)
|
||||
{
|
||||
var cryptoCode = "BTC";
|
||||
var network = SupportedNetwork.NBitcoinNetwork;
|
||||
var client = await CreateClient();
|
||||
var methods = await client.GetInvoicePaymentMethods(StoreId, invoiceId);
|
||||
var method = methods.First(m => m.PaymentMethod == $"{cryptoCode}-LNURLPAY");
|
||||
var lnurL = LNURL.LNURL.Parse(method.PaymentLink, out var tag);
|
||||
var http = new HttpClient();
|
||||
var payreq = (LNURL.LNURLPayRequest)await LNURL.LNURL.FetchInformation(lnurL, tag, http);
|
||||
var resp = await payreq.SendRequest(payreq.MinSendable, network, http);
|
||||
var bolt11 = resp.Pr;
|
||||
await parent.CustomerLightningD.Pay(bolt11);
|
||||
await WaitInvoicePaid(invoiceId);
|
||||
}
|
||||
|
||||
public Task WaitInvoicePaid(string invoiceId)
|
||||
{
|
||||
return TestUtils.EventuallyAsync(async () =>
|
||||
{
|
||||
var client = await CreateClient();
|
||||
var invoice = await client.GetInvoice(StoreId, invoiceId);
|
||||
if (invoice.Status == InvoiceStatus.Settled)
|
||||
return;
|
||||
Assert.Equal(InvoiceStatus.Processing, invoice.Status);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task PayOnLNAddress(string lnAddrUser = null)
|
||||
{
|
||||
lnAddrUser ??= LNAddress;
|
||||
var network = SupportedNetwork.NBitcoinNetwork;
|
||||
var payReqStr = await (await parent.PayTester.HttpClient.GetAsync($".well-known/lnurlp/{lnAddrUser}")).Content.ReadAsStringAsync();
|
||||
var payreq = JsonConvert.DeserializeObject<LNURL.LNURLPayRequest>(payReqStr);
|
||||
var resp = await payreq.SendRequest(payreq.MinSendable, network, parent.PayTester.HttpClient);
|
||||
var bolt11 = resp.Pr;
|
||||
await parent.CustomerLightningD.Pay(bolt11);
|
||||
}
|
||||
|
||||
public async Task<string> CreateLNAddress()
|
||||
{
|
||||
var lnAddrUser = Guid.NewGuid().ToString();
|
||||
var ctx = parent.PayTester.GetService<ApplicationDbContextFactory>().CreateContext();
|
||||
ctx.LightningAddresses.Add(new()
|
||||
{
|
||||
StoreDataId = StoreId,
|
||||
Username = lnAddrUser
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
LNAddress = lnAddrUser;
|
||||
return lnAddrUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -391,6 +391,14 @@ retry:
|
||||
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-vue/{version}/bootstrap-vue.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
EqualJsContent(expected, actual);
|
||||
|
||||
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "FileSaver", "FileSaver.min.js").Trim();
|
||||
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/eligrey/FileSaver.js/43bbd2f0ae6794f8d452cd360e9d33aef6071234/dist/FileSaver.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
EqualJsContent(expected, actual);
|
||||
|
||||
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "papaparse", "papaparse.min.js").Trim();
|
||||
expected = (await (await client.GetAsync($"https://raw.githubusercontent.com/mholt/PapaParse/5.4.1/papaparse.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
EqualJsContent(expected, actual);
|
||||
|
||||
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sanitize-directive", "vue-sanitize-directive.umd.min.js").Trim();
|
||||
version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
|
||||
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim();
|
||||
|
@ -2936,5 +2936,124 @@ namespace BTCPayServer.Tests
|
||||
Assert.IsType<ViewFilesViewModel>(Assert.IsType<ViewResult>(await controller.Files(new string[] { fileId })).Model);
|
||||
Assert.Null(viewFilesViewModel.DirectUrlByFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
public async Task CanCreateReports()
|
||||
{
|
||||
using var tester = CreateServerTester();
|
||||
tester.ActivateLightning();
|
||||
tester.DeleteStore = false;
|
||||
await tester.StartAsync();
|
||||
await tester.EnsureChannelsSetup();
|
||||
var acc = tester.NewAccount();
|
||||
await acc.GrantAccessAsync();
|
||||
await acc.MakeAdmin();
|
||||
acc.RegisterDerivationScheme("BTC", importKeysToNBX: true);
|
||||
acc.RegisterLightningNode("BTC");
|
||||
await acc.ReceiveUTXO(Money.Coins(1.0m));
|
||||
|
||||
var client = await acc.CreateClient();
|
||||
var posController = acc.GetController<UIPointOfSaleController>();
|
||||
|
||||
var app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
||||
{
|
||||
AppName = "Static",
|
||||
DefaultView = Client.Models.PosViewType.Static,
|
||||
Template = new PointOfSaleSettings().Template
|
||||
});
|
||||
var resp = await posController.ViewPointOfSale(app.Id, choiceKey: "green-tea");
|
||||
var invoiceId = GetInvoiceId(resp);
|
||||
await acc.PayOnChain(invoiceId);
|
||||
|
||||
app = await client.CreatePointOfSaleApp(acc.StoreId, new CreatePointOfSaleAppRequest()
|
||||
{
|
||||
AppName = "Cart",
|
||||
DefaultView = Client.Models.PosViewType.Cart,
|
||||
Template = new PointOfSaleSettings().Template
|
||||
});
|
||||
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
|
||||
{
|
||||
["cart"] = new JArray()
|
||||
{
|
||||
new JObject()
|
||||
{
|
||||
["id"] = "green-tea",
|
||||
["count"] = 2
|
||||
},
|
||||
new JObject()
|
||||
{
|
||||
["id"] = "black-tea",
|
||||
["count"] = 1
|
||||
},
|
||||
}
|
||||
}.ToString());
|
||||
invoiceId = GetInvoiceId(resp);
|
||||
await acc.PayOnBOLT11(invoiceId);
|
||||
|
||||
resp = await posController.ViewPointOfSale(app.Id, posData: new JObject()
|
||||
{
|
||||
["cart"] = new JArray()
|
||||
{
|
||||
new JObject()
|
||||
{
|
||||
["id"] = "green-tea",
|
||||
["count"] = 5
|
||||
}
|
||||
}
|
||||
}.ToString());
|
||||
invoiceId = GetInvoiceId(resp);
|
||||
await acc.PayOnLNUrl(invoiceId);
|
||||
|
||||
await acc.CreateLNAddress();
|
||||
await acc.PayOnLNAddress();
|
||||
|
||||
var report = await GetReport(acc, new() { ViewName = "Payments" });
|
||||
// 1 payment on LN Address
|
||||
// 1 payment on LNURL
|
||||
// 1 payment on BOLT11
|
||||
// 1 payment on chain
|
||||
Assert.Equal(4, report.Data.Count);
|
||||
var lnAddressIndex = report.GetIndex("LightningAddress");
|
||||
var paymentTypeIndex = report.GetIndex("PaymentType");
|
||||
Assert.Contains(report.Data, d => d[lnAddressIndex]?.Value<string>()?.Contains(acc.LNAddress) is true);
|
||||
var paymentTypes = report.Data
|
||||
.GroupBy(d => d[paymentTypeIndex].Value<string>())
|
||||
.ToDictionary(d => d.Key);
|
||||
Assert.Equal(3, paymentTypes["Lightning"].Count());
|
||||
Assert.Single(paymentTypes["On-Chain"]);
|
||||
|
||||
// 2 on-chain transactions: It received from the cashcow, then paid its own invoice
|
||||
report = await GetReport(acc, new() { ViewName = "On-Chain Wallets" });
|
||||
var txIdIndex = report.GetIndex("TransactionId");
|
||||
var balanceIndex = report.GetIndex("BalanceChange");
|
||||
Assert.Equal(2, report.Data.Count);
|
||||
Assert.Equal(64, report.Data[0][txIdIndex].Value<string>().Length);
|
||||
Assert.Contains(report.Data, d => d[balanceIndex].Value<decimal>() == 1.0m);
|
||||
|
||||
// Items sold
|
||||
report = await GetReport(acc, new() { ViewName = "Products sold" });
|
||||
var itemIndex = report.GetIndex("Product");
|
||||
var countIndex = report.GetIndex("Quantity");
|
||||
var itemsCount = report.Data.GroupBy(d => d[itemIndex].Value<string>())
|
||||
.ToDictionary(d => d.Key, r => r.Sum(d => d[countIndex].Value<int>()));
|
||||
Assert.Equal(8, itemsCount["green-tea"]);
|
||||
Assert.Equal(1, itemsCount["black-tea"]);
|
||||
}
|
||||
|
||||
private async Task<StoreReportResponse> GetReport(TestAccount acc, StoreReportRequest req)
|
||||
{
|
||||
var controller = acc.GetController<UIReportsController>();
|
||||
return (await controller.StoreReportsJson(acc.StoreId, req)).AssertType<JsonResult>()
|
||||
.Value
|
||||
.AssertType<StoreReportResponse>();
|
||||
}
|
||||
|
||||
private static string GetInvoiceId(IActionResult resp)
|
||||
{
|
||||
var redirect = resp.AssertType<RedirectToActionResult>();
|
||||
Assert.Equal("Checkout", redirect.ActionName);
|
||||
return (string)redirect.RouteValues["invoiceId"];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -48,7 +48,7 @@
|
||||
<PackageReference Include="YamlDotNet" Version="8.0.0" />
|
||||
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
|
||||
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.28" />
|
||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.29" />
|
||||
<PackageReference Include="CsvHelper" Version="15.0.5" />
|
||||
<PackageReference Include="Dapper" Version="2.0.123" />
|
||||
<PackageReference Include="Fido2" Version="2.0.2" />
|
||||
@ -81,6 +81,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="Views\UIReports\StoreReports.cshtml" />
|
||||
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg" />
|
||||
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2" />
|
||||
<None Include="wwwroot\vendor\font-awesome\less\animated.less" />
|
||||
@ -119,6 +120,7 @@
|
||||
<Folder Include="wwwroot\vendor\bootstrap" />
|
||||
<Folder Include="wwwroot\vendor\clipboard.js\" />
|
||||
<Folder Include="wwwroot\vendor\highlightjs\" />
|
||||
<Folder Include="wwwroot\vendor\pivottable\" />
|
||||
<Folder Include="wwwroot\vendor\summernote" />
|
||||
<Folder Include="wwwroot\vendor\tom-select" />
|
||||
<Folder Include="wwwroot\vendor\ur-registry" />
|
||||
@ -136,6 +138,7 @@
|
||||
<ItemGroup>
|
||||
<Watch Include="Views\**\*.*"></Watch>
|
||||
<Watch Remove="Views\UIAccount\CheatPermissions.cshtml" />
|
||||
<Watch Remove="Views\UIReports\StoreReports.cshtml" />
|
||||
<Content Update="Views\UIApps\_ViewImports.cshtml">
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
<Pack>$(IncludeRazorContentInPack)</Pack>
|
||||
|
@ -131,6 +131,12 @@
|
||||
<span>Invoices</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" permission="@Policies.CanViewInvoices">
|
||||
<a asp-area="" asp-controller="UIReports" asp-action="StoreReports" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActivePage(StoreNavPages.Reporting)" id="SectionNav-Reporting">
|
||||
<vc:icon symbol="invoice" />
|
||||
<span>Reporting</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
|
||||
<a asp-area="" asp-controller="UIPaymentRequest" asp-action="GetPaymentRequests" asp-route-storeId="@Model.Store.Id" class="nav-link @ViewData.IsActiveCategory(typeof(PaymentRequestsNavPages))" id="StoreNav-PaymentRequests">
|
||||
<vc:icon symbol="payment-requests"/>
|
||||
|
@ -1,17 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.ModelBinders;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Security.Greenfield;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NBitpayClient;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
{
|
||||
@ -36,7 +42,7 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (invoice == null)
|
||||
throw new BitpayHttpException(400, "Invalid invoice");
|
||||
return await _InvoiceController.CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
|
||||
return await CreateInvoiceCore(invoice, HttpContext.GetStoreData(), HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -66,7 +72,7 @@ namespace BTCPayServer.Controllers
|
||||
int? limit = null,
|
||||
int? offset = null)
|
||||
{
|
||||
if (User.Identity.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
|
||||
if (User.Identity?.AuthenticationType == Security.Bitpay.BitpayAuthenticationTypes.Anonymous)
|
||||
return Forbid(Security.Bitpay.BitpayAuthenticationTypes.Anonymous);
|
||||
if (dateEnd != null)
|
||||
dateEnd = dateEnd.Value + TimeSpan.FromDays(1); //Should include the end day
|
||||
@ -88,5 +94,133 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
return Json(DataWrapper.Create(entities));
|
||||
}
|
||||
|
||||
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
|
||||
StoreData store, string serverUrl, List<string> additionalTags = null,
|
||||
CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
|
||||
{
|
||||
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
|
||||
var resp = entity.EntityToDTO();
|
||||
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
|
||||
}
|
||||
|
||||
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string> additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity> entityManipulator = null)
|
||||
{
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
|
||||
entity.ExpirationTime = invoice.ExpirationTime is { } v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
|
||||
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
|
||||
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
|
||||
{
|
||||
throw new BitpayHttpException(400, "The expirationTime is set too soon");
|
||||
}
|
||||
if (entity.Price < 0.0m)
|
||||
{
|
||||
throw new BitpayHttpException(400, "The price should be 0 or more.");
|
||||
}
|
||||
if (entity.Price > GreenfieldConstants.MaxAmount)
|
||||
{
|
||||
throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
|
||||
}
|
||||
entity.Metadata.OrderId = invoice.OrderId;
|
||||
entity.Metadata.PosDataLegacy = invoice.PosData;
|
||||
entity.ServerUrl = serverUrl;
|
||||
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
|
||||
entity.ExtendedNotifications = invoice.ExtendedNotifications;
|
||||
entity.NotificationURLTemplate = invoice.NotificationURL;
|
||||
entity.NotificationEmail = invoice.NotificationEmail;
|
||||
if (additionalTags != null)
|
||||
entity.InternalTags.AddRange(additionalTags);
|
||||
FillBuyerInfo(invoice, entity);
|
||||
|
||||
var price = invoice.Price;
|
||||
entity.Metadata.ItemCode = invoice.ItemCode;
|
||||
entity.Metadata.ItemDesc = invoice.ItemDesc;
|
||||
entity.Metadata.Physical = invoice.Physical;
|
||||
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
|
||||
entity.Currency = invoice.Currency;
|
||||
if (price is { } vv)
|
||||
{
|
||||
entity.Price = vv;
|
||||
entity.Type = InvoiceType.Standard;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Price = 0m;
|
||||
entity.Type = InvoiceType.TopUp;
|
||||
}
|
||||
|
||||
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
|
||||
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
|
||||
entity.RedirectAutomatically =
|
||||
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
|
||||
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
|
||||
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
|
||||
|
||||
IPaymentFilter excludeFilter = null;
|
||||
if (invoice.PaymentCurrencies?.Any() is true)
|
||||
{
|
||||
invoice.SupportedTransactionCurrencies ??=
|
||||
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
|
||||
foreach (string paymentCurrency in invoice.PaymentCurrencies)
|
||||
{
|
||||
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
|
||||
new InvoiceSupportedTransactionCurrency() { Enabled = true });
|
||||
}
|
||||
}
|
||||
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
|
||||
{
|
||||
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
|
||||
.Where(c => c.Value.Enabled)
|
||||
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
|
||||
.Where(c => c != null)
|
||||
.ToHashSet();
|
||||
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
|
||||
}
|
||||
entity.PaymentTolerance = storeBlob.PaymentTolerance;
|
||||
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
|
||||
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
|
||||
|
||||
return await _InvoiceController.CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
|
||||
}
|
||||
|
||||
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
|
||||
{
|
||||
var buyerInformation = invoiceEntity.Metadata;
|
||||
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
|
||||
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
|
||||
buyerInformation.BuyerCity = req.BuyerCity;
|
||||
buyerInformation.BuyerCountry = req.BuyerCountry;
|
||||
buyerInformation.BuyerEmail = req.BuyerEmail;
|
||||
buyerInformation.BuyerName = req.BuyerName;
|
||||
buyerInformation.BuyerPhone = req.BuyerPhone;
|
||||
buyerInformation.BuyerState = req.BuyerState;
|
||||
buyerInformation.BuyerZip = req.BuyerZip;
|
||||
var buyer = req.Buyer;
|
||||
if (buyer == null)
|
||||
return;
|
||||
buyerInformation.BuyerAddress1 ??= buyer.Address1;
|
||||
buyerInformation.BuyerAddress2 ??= buyer.Address2;
|
||||
buyerInformation.BuyerCity ??= buyer.City;
|
||||
buyerInformation.BuyerCountry ??= buyer.country;
|
||||
buyerInformation.BuyerEmail ??= buyer.email;
|
||||
buyerInformation.BuyerName ??= buyer.Name;
|
||||
buyerInformation.BuyerPhone ??= buyer.phone;
|
||||
buyerInformation.BuyerState ??= buyer.State;
|
||||
buyerInformation.BuyerZip ??= buyer.zip;
|
||||
}
|
||||
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
|
||||
{
|
||||
if (transactionSpeed == null)
|
||||
return defaultPolicy;
|
||||
var mappings = new Dictionary<string, SpeedPolicy>();
|
||||
mappings.Add("low", SpeedPolicy.LowSpeed);
|
||||
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
|
||||
mappings.Add("medium", SpeedPolicy.MediumSpeed);
|
||||
mappings.Add("high", SpeedPolicy.HighSpeed);
|
||||
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
|
||||
policy = defaultPolicy;
|
||||
return policy;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
|
||||
namespace BTCPayServer.Controllers.Greenfield
|
||||
@ -284,7 +285,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
Amount = blob.Amount,
|
||||
PaymentMethodAmount = blob.CryptoAmount,
|
||||
Revision = blob.Revision,
|
||||
State = p.State
|
||||
State = p.State,
|
||||
Metadata = blob.Metadata?? new JObject(),
|
||||
};
|
||||
model.Destination = blob.Destination;
|
||||
model.PaymentMethod = p.PaymentMethodId;
|
||||
@ -321,27 +323,20 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
ModelState.AddModelError(nameof(request.Destination), destination.error ?? "The destination is invalid for the payment specified");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
if (request.Amount is null && destination.destination.Amount != null)
|
||||
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount, paymentMethodId.CryptoCode, ppBlob.Currency);
|
||||
if (amtError.error is not null)
|
||||
{
|
||||
request.Amount = destination.destination.Amount;
|
||||
}
|
||||
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
if (request.Amount is { } v && (v < ppBlob.MinimumClaim || v == 0.0m))
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Amount), $"Amount too small (should be at least {ppBlob.MinimumClaim})");
|
||||
ModelState.AddModelError(nameof(request.Amount), amtError.error );
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
request.Amount = amtError.amount;
|
||||
var result = await _pullPaymentService.Claim(new ClaimRequest()
|
||||
{
|
||||
Destination = destination.destination,
|
||||
PullPaymentId = pullPaymentId,
|
||||
Value = request.Amount,
|
||||
PaymentMethodId = paymentMethodId,
|
||||
PaymentMethodId = paymentMethodId
|
||||
});
|
||||
|
||||
return HandleClaimResult(result);
|
||||
@ -393,15 +388,13 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
|
||||
if (request.Amount is null && destination.destination.Amount != null)
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, request.Amount);
|
||||
if (amtError.error is not null)
|
||||
{
|
||||
request.Amount = destination.destination.Amount;
|
||||
}
|
||||
else if (request.Amount != null && destination.destination.Amount != null && request.Amount != destination.destination.Amount)
|
||||
{
|
||||
ModelState.AddModelError(nameof(request.Amount), $"Amount is implied in destination ({destination.destination.Amount}) that does not match the payout amount provided {request.Amount})");
|
||||
ModelState.AddModelError(nameof(request.Amount), amtError.error );
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
request.Amount = amtError.amount;
|
||||
if (request.Amount is { } v && (v < ppBlob?.MinimumClaim || v == 0.0m))
|
||||
{
|
||||
var minimumClaim = ppBlob?.MinimumClaim is decimal val ? val : 0.0m;
|
||||
@ -415,7 +408,8 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
PreApprove = request.Approved,
|
||||
Value = request.Amount,
|
||||
PaymentMethodId = paymentMethodId,
|
||||
StoreId = storeId
|
||||
StoreId = storeId,
|
||||
Metadata = request.Metadata
|
||||
});
|
||||
return HandleClaimResult(result);
|
||||
}
|
||||
|
@ -0,0 +1,80 @@
|
||||
#nullable enable
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore.Internal;
|
||||
using NBitcoin;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using BTCPayServer.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace BTCPayServer.Controllers.GreenField;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[EnableCors(CorsPolicies.All)]
|
||||
public class GreenfieldReportsController : Controller
|
||||
{
|
||||
public GreenfieldReportsController(
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
ReportService reportService)
|
||||
{
|
||||
DBContextFactory = dbContextFactory;
|
||||
ReportService = reportService;
|
||||
}
|
||||
public ApplicationDbContextFactory DBContextFactory { get; }
|
||||
public ReportService ReportService { get; }
|
||||
|
||||
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||
[HttpPost("~/api/v1/stores/{storeId}/reports")]
|
||||
[NonAction] // Disabling this endpoint as we still need to figure out the request/response model
|
||||
public async Task<IActionResult> StoreReports(string storeId, [FromBody] StoreReportRequest? vm = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
vm ??= new StoreReportRequest();
|
||||
vm.ViewName ??= "Payments";
|
||||
vm.TimePeriod ??= new TimePeriod();
|
||||
vm.TimePeriod.To ??= DateTime.UtcNow;
|
||||
vm.TimePeriod.From ??= vm.TimePeriod.To.Value.AddMonths(-1);
|
||||
var from = vm.TimePeriod.From.Value;
|
||||
var to = vm.TimePeriod.To.Value;
|
||||
|
||||
if (ReportService.ReportProviders.TryGetValue(vm.ViewName, out var report))
|
||||
{
|
||||
if (!report.IsAvailable())
|
||||
return this.CreateAPIError(503, "view-unavailable", $"This view is unavailable at this moment");
|
||||
|
||||
var ctx = new Services.Reporting.QueryContext(storeId, from, to);
|
||||
await report.Query(ctx, cancellationToken);
|
||||
var result = new StoreReportResponse()
|
||||
{
|
||||
Fields = ctx.ViewDefinition?.Fields ?? new List<StoreReportResponse.Field>(),
|
||||
Charts = ctx.ViewDefinition?.Charts ?? new List<ChartDefinition>(),
|
||||
Data = ctx.Data.Select(d => new JArray(d)).ToList(),
|
||||
From = from,
|
||||
To = to
|
||||
};
|
||||
return Json(result);
|
||||
}
|
||||
else
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.ViewName), "View doesn't exist");
|
||||
return this.CreateValidationError(ModelState);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,8 +20,6 @@ using BTCPayServer.Models.AppViewModels;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.Models.PaymentRequestViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Payments.Lightning;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services;
|
||||
using BTCPayServer.Services.Apps;
|
||||
@ -956,6 +954,16 @@ namespace BTCPayServer.Controllers
|
||||
model.PaymentMethodId = paymentMethodId.ToString();
|
||||
model.PaymentType = paymentMethodId.PaymentType.ToString();
|
||||
model.OrderAmountFiat = OrderAmountFromInvoice(model.CryptoCode, invoice, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
|
||||
if (storeBlob.PlaySoundOnPayment)
|
||||
{
|
||||
model.PaymentSoundUrl = string.IsNullOrEmpty(storeBlob.SoundFileId)
|
||||
? string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
|
||||
: await _fileService.GetFileUrl(Request.GetAbsoluteRootUri(), storeBlob.SoundFileId);
|
||||
model.ErrorSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/error.mp3");
|
||||
model.NfcReadSoundUrl = string.Concat(Request.GetAbsoluteRootUri().ToString(), "checkout-v2/nfcread.mp3");
|
||||
}
|
||||
|
||||
var expiration = TimeSpan.FromSeconds(model.ExpirationSeconds);
|
||||
model.TimeLeft = expiration.PrettyPrint();
|
||||
return model;
|
||||
@ -1280,32 +1288,40 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
try
|
||||
{
|
||||
var result = await CreateInvoiceCore(new BitpayCreateInvoiceRequest
|
||||
var result = await CreateInvoiceCoreRaw(new CreateInvoiceRequest()
|
||||
{
|
||||
Price = model.Amount,
|
||||
Amount = model.Amount,
|
||||
Currency = model.Currency,
|
||||
PosData = model.PosData,
|
||||
OrderId = model.OrderId,
|
||||
NotificationURL = model.NotificationUrl,
|
||||
ItemDesc = model.ItemDesc,
|
||||
FullNotifications = true,
|
||||
BuyerEmail = model.BuyerEmail,
|
||||
SupportedTransactionCurrencies = model.SupportedTransactionCurrencies?.ToDictionary(s => s, s => new InvoiceSupportedTransactionCurrency
|
||||
Metadata = new InvoiceMetadata()
|
||||
{
|
||||
Enabled = true
|
||||
}),
|
||||
DefaultPaymentMethod = model.DefaultPaymentMethod,
|
||||
NotificationEmail = model.NotificationEmail,
|
||||
ExtendedNotifications = model.NotificationEmail != null,
|
||||
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
|
||||
? storeBlob.RequiresRefundEmail
|
||||
: model.RequiresRefundEmail == RequiresRefundEmail.On,
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
|
||||
PosDataLegacy = model.PosData,
|
||||
OrderId = model.OrderId,
|
||||
ItemDesc = model.ItemDesc,
|
||||
BuyerEmail = model.BuyerEmail,
|
||||
}.ToJObject(),
|
||||
Checkout = new ()
|
||||
{
|
||||
RedirectURL = store.StoreWebsite,
|
||||
DefaultPaymentMethod = model.DefaultPaymentMethod,
|
||||
RequiresRefundEmail = model.RequiresRefundEmail == RequiresRefundEmail.InheritFromStore
|
||||
? storeBlob.RequiresRefundEmail
|
||||
: model.RequiresRefundEmail == RequiresRefundEmail.On,
|
||||
PaymentMethods = model.SupportedTransactionCurrencies?.ToArray()
|
||||
},
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(),
|
||||
entityManipulator: (entity) =>
|
||||
{
|
||||
entity.NotificationURLTemplate = model.NotificationUrl;
|
||||
entity.FullNotifications = true;
|
||||
entity.NotificationEmail = model.NotificationEmail;
|
||||
entity.ExtendedNotifications = model.NotificationEmail != null;
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Data.Id} just created!";
|
||||
CreatedInvoiceId = result.Data.Id;
|
||||
TempData[WellKnownTempData.SuccessMessage] = $"Invoice {result.Id} just created!";
|
||||
CreatedInvoiceId = result.Id;
|
||||
|
||||
return RedirectToAction(nameof(Invoice), new { storeId = result.Data.StoreId, invoiceId = result.Data.Id });
|
||||
return RedirectToAction(nameof(Invoice), new { storeId = result.StoreId, invoiceId = result.Id });
|
||||
}
|
||||
catch (BitpayHttpException ex)
|
||||
{
|
||||
|
@ -5,14 +5,13 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Contracts;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Events;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Logging;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Models.PaymentRequestViewModels;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Rating;
|
||||
@ -24,16 +23,13 @@ using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.PaymentRequests;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Validation;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using NBitcoin;
|
||||
using NBitpayClient;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using BitpayCreateInvoiceRequest = BTCPayServer.Models.BitpayCreateInvoiceRequest;
|
||||
using StoreData = BTCPayServer.Data.StoreData;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
@ -60,6 +56,7 @@ namespace BTCPayServer.Controllers
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
private readonly IAuthorizationService _authorizationService;
|
||||
private readonly AppService _appService;
|
||||
private readonly IFileService _fileService;
|
||||
|
||||
public WebhookSender WebhookNotificationManager { get; }
|
||||
|
||||
@ -84,6 +81,7 @@ namespace BTCPayServer.Controllers
|
||||
InvoiceActivator invoiceActivator,
|
||||
LinkGenerator linkGenerator,
|
||||
AppService appService,
|
||||
IFileService fileService,
|
||||
IAuthorizationService authorizationService)
|
||||
{
|
||||
_displayFormatter = displayFormatter;
|
||||
@ -105,101 +103,10 @@ namespace BTCPayServer.Controllers
|
||||
_invoiceActivator = invoiceActivator;
|
||||
_linkGenerator = linkGenerator;
|
||||
_authorizationService = authorizationService;
|
||||
_fileService = fileService;
|
||||
_appService = appService;
|
||||
}
|
||||
|
||||
|
||||
internal async Task<DataWrapper<InvoiceResponse>> CreateInvoiceCore(BitpayCreateInvoiceRequest invoice,
|
||||
StoreData store, string serverUrl, List<string>? additionalTags = null,
|
||||
CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
|
||||
{
|
||||
var entity = await CreateInvoiceCoreRaw(invoice, store, serverUrl, additionalTags, cancellationToken, entityManipulator);
|
||||
var resp = entity.EntityToDTO();
|
||||
return new DataWrapper<InvoiceResponse>(resp) { Facade = "pos/invoice" };
|
||||
}
|
||||
|
||||
internal async Task<InvoiceEntity> CreateInvoiceCoreRaw(BitpayCreateInvoiceRequest invoice, StoreData store, string serverUrl, List<string>? additionalTags = null, CancellationToken cancellationToken = default, Action<InvoiceEntity>? entityManipulator = null)
|
||||
{
|
||||
var storeBlob = store.GetStoreBlob();
|
||||
var entity = _InvoiceRepository.CreateNewInvoice(store.Id);
|
||||
entity.ExpirationTime = invoice.ExpirationTime is DateTimeOffset v ? v : entity.InvoiceTime + storeBlob.InvoiceExpiration;
|
||||
entity.MonitoringExpiration = entity.ExpirationTime + storeBlob.MonitoringExpiration;
|
||||
if (entity.ExpirationTime - TimeSpan.FromSeconds(30.0) < entity.InvoiceTime)
|
||||
{
|
||||
throw new BitpayHttpException(400, "The expirationTime is set too soon");
|
||||
}
|
||||
if (entity.Price < 0.0m)
|
||||
{
|
||||
throw new BitpayHttpException(400, "The price should be 0 or more.");
|
||||
}
|
||||
if (entity.Price > GreenfieldConstants.MaxAmount)
|
||||
{
|
||||
throw new BitpayHttpException(400, $"The price should less than {GreenfieldConstants.MaxAmount}.");
|
||||
}
|
||||
entity.Metadata.OrderId = invoice.OrderId;
|
||||
entity.Metadata.PosDataLegacy = invoice.PosData;
|
||||
entity.ServerUrl = serverUrl;
|
||||
entity.FullNotifications = invoice.FullNotifications || invoice.ExtendedNotifications;
|
||||
entity.ExtendedNotifications = invoice.ExtendedNotifications;
|
||||
entity.NotificationURLTemplate = invoice.NotificationURL;
|
||||
entity.NotificationEmail = invoice.NotificationEmail;
|
||||
if (additionalTags != null)
|
||||
entity.InternalTags.AddRange(additionalTags);
|
||||
FillBuyerInfo(invoice, entity);
|
||||
|
||||
var taxIncluded = invoice.TaxIncluded.HasValue ? invoice.TaxIncluded.Value : 0m;
|
||||
var price = invoice.Price;
|
||||
|
||||
entity.Metadata.ItemCode = invoice.ItemCode;
|
||||
entity.Metadata.ItemDesc = invoice.ItemDesc;
|
||||
entity.Metadata.Physical = invoice.Physical;
|
||||
entity.Metadata.TaxIncluded = invoice.TaxIncluded;
|
||||
entity.Currency = invoice.Currency;
|
||||
if (price is decimal vv)
|
||||
{
|
||||
entity.Price = vv;
|
||||
entity.Type = InvoiceType.Standard;
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.Price = 0m;
|
||||
entity.Type = InvoiceType.TopUp;
|
||||
}
|
||||
|
||||
entity.StoreSupportUrl = storeBlob.StoreSupportUrl;
|
||||
entity.RedirectURLTemplate = invoice.RedirectURL ?? store.StoreWebsite;
|
||||
entity.RedirectAutomatically =
|
||||
invoice.RedirectAutomatically.GetValueOrDefault(storeBlob.RedirectAutomatically);
|
||||
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
|
||||
entity.SpeedPolicy = ParseSpeedPolicy(invoice.TransactionSpeed, store.SpeedPolicy);
|
||||
|
||||
IPaymentFilter? excludeFilter = null;
|
||||
if (invoice.PaymentCurrencies?.Any() is true)
|
||||
{
|
||||
invoice.SupportedTransactionCurrencies ??=
|
||||
new Dictionary<string, InvoiceSupportedTransactionCurrency>();
|
||||
foreach (string paymentCurrency in invoice.PaymentCurrencies)
|
||||
{
|
||||
invoice.SupportedTransactionCurrencies.TryAdd(paymentCurrency,
|
||||
new InvoiceSupportedTransactionCurrency() { Enabled = true });
|
||||
}
|
||||
}
|
||||
if (invoice.SupportedTransactionCurrencies != null && invoice.SupportedTransactionCurrencies.Count != 0)
|
||||
{
|
||||
var supportedTransactionCurrencies = invoice.SupportedTransactionCurrencies
|
||||
.Where(c => c.Value.Enabled)
|
||||
.Select(c => PaymentMethodId.TryParse(c.Key, out var p) ? p : null)
|
||||
.Where(c => c != null)
|
||||
.ToHashSet();
|
||||
excludeFilter = PaymentFilter.Where(p => !supportedTransactionCurrencies.Contains(p));
|
||||
}
|
||||
entity.PaymentTolerance = storeBlob.PaymentTolerance;
|
||||
entity.DefaultPaymentMethod = invoice.DefaultPaymentMethod;
|
||||
entity.RequiresRefundEmail = invoice.RequiresRefundEmail;
|
||||
|
||||
return await CreateInvoiceCoreRaw(entity, store, excludeFilter, null, cancellationToken, entityManipulator);
|
||||
}
|
||||
|
||||
internal async Task<InvoiceEntity> CreatePaymentRequestInvoice(Data.PaymentRequestData prData, decimal? amount, decimal amountDue, StoreData storeData, HttpRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = prData.Id;
|
||||
@ -548,45 +455,5 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private SpeedPolicy ParseSpeedPolicy(string transactionSpeed, SpeedPolicy defaultPolicy)
|
||||
{
|
||||
if (transactionSpeed == null)
|
||||
return defaultPolicy;
|
||||
var mappings = new Dictionary<string, SpeedPolicy>();
|
||||
mappings.Add("low", SpeedPolicy.LowSpeed);
|
||||
mappings.Add("low-medium", SpeedPolicy.LowMediumSpeed);
|
||||
mappings.Add("medium", SpeedPolicy.MediumSpeed);
|
||||
mappings.Add("high", SpeedPolicy.HighSpeed);
|
||||
if (!mappings.TryGetValue(transactionSpeed, out SpeedPolicy policy))
|
||||
policy = defaultPolicy;
|
||||
return policy;
|
||||
}
|
||||
|
||||
private void FillBuyerInfo(BitpayCreateInvoiceRequest req, InvoiceEntity invoiceEntity)
|
||||
{
|
||||
var buyerInformation = invoiceEntity.Metadata;
|
||||
buyerInformation.BuyerAddress1 = req.BuyerAddress1;
|
||||
buyerInformation.BuyerAddress2 = req.BuyerAddress2;
|
||||
buyerInformation.BuyerCity = req.BuyerCity;
|
||||
buyerInformation.BuyerCountry = req.BuyerCountry;
|
||||
buyerInformation.BuyerEmail = req.BuyerEmail;
|
||||
buyerInformation.BuyerName = req.BuyerName;
|
||||
buyerInformation.BuyerPhone = req.BuyerPhone;
|
||||
buyerInformation.BuyerState = req.BuyerState;
|
||||
buyerInformation.BuyerZip = req.BuyerZip;
|
||||
var buyer = req.Buyer;
|
||||
if (buyer == null)
|
||||
return;
|
||||
buyerInformation.BuyerAddress1 ??= buyer.Address1;
|
||||
buyerInformation.BuyerAddress2 ??= buyer.Address2;
|
||||
buyerInformation.BuyerCity ??= buyer.City;
|
||||
buyerInformation.BuyerCountry ??= buyer.country;
|
||||
buyerInformation.BuyerEmail ??= buyer.email;
|
||||
buyerInformation.BuyerName ??= buyer.Name;
|
||||
buyerInformation.BuyerPhone ??= buyer.phone;
|
||||
buyerInformation.BuyerState ??= buyer.State;
|
||||
buyerInformation.BuyerZip ??= buyer.zip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,12 +3,16 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using BTCPayServer.Abstractions.Extensions;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.Greenfield;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Models;
|
||||
using BTCPayServer.Plugins.PayButton.Models;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using NicolasDorier.RateLimits;
|
||||
|
||||
namespace BTCPayServer.Controllers
|
||||
@ -16,14 +20,17 @@ namespace BTCPayServer.Controllers
|
||||
public class UIPublicController : Controller
|
||||
{
|
||||
public UIPublicController(UIInvoiceController invoiceController,
|
||||
StoreRepository storeRepository)
|
||||
StoreRepository storeRepository,
|
||||
LinkGenerator linkGenerator)
|
||||
{
|
||||
_InvoiceController = invoiceController;
|
||||
_StoreRepository = storeRepository;
|
||||
_linkGenerator = linkGenerator;
|
||||
}
|
||||
|
||||
private readonly UIInvoiceController _InvoiceController;
|
||||
private readonly StoreRepository _StoreRepository;
|
||||
private readonly LinkGenerator _linkGenerator;
|
||||
|
||||
[HttpGet]
|
||||
[IgnoreAntiforgeryToken]
|
||||
@ -57,21 +64,31 @@ namespace BTCPayServer.Controllers
|
||||
if (!ModelState.IsValid)
|
||||
return View();
|
||||
|
||||
DataWrapper<InvoiceResponse> invoice = null;
|
||||
InvoiceEntity invoice = null;
|
||||
try
|
||||
{
|
||||
invoice = await _InvoiceController.CreateInvoiceCore(new BitpayCreateInvoiceRequest()
|
||||
invoice = await _InvoiceController.CreateInvoiceCoreRaw(new CreateInvoiceRequest()
|
||||
{
|
||||
Price = model.Price,
|
||||
Amount = model.Price,
|
||||
Currency = model.Currency,
|
||||
ItemDesc = model.CheckoutDesc,
|
||||
OrderId = model.OrderId,
|
||||
NotificationEmail = model.NotifyEmail,
|
||||
NotificationURL = model.ServerIpn,
|
||||
RedirectURL = model.BrowserRedirect,
|
||||
FullNotifications = true,
|
||||
DefaultPaymentMethod = model.DefaultPaymentMethod
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(), cancellationToken: cancellationToken);
|
||||
Metadata = new InvoiceMetadata()
|
||||
{
|
||||
ItemDesc = model.CheckoutDesc,
|
||||
OrderId = model.OrderId
|
||||
}.ToJObject(),
|
||||
Checkout = new ()
|
||||
{
|
||||
RedirectURL = model.BrowserRedirect ?? store?.StoreWebsite,
|
||||
DefaultPaymentMethod = model.DefaultPaymentMethod
|
||||
}
|
||||
}, store, HttpContext.Request.GetAbsoluteRoot(),
|
||||
entityManipulator: (entity) =>
|
||||
{
|
||||
entity.NotificationEmail = model.NotifyEmail;
|
||||
entity.NotificationURLTemplate = model.ServerIpn;
|
||||
entity.FullNotifications = true;
|
||||
},
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
catch (BitpayHttpException e)
|
||||
{
|
||||
@ -84,26 +101,25 @@ namespace BTCPayServer.Controllers
|
||||
return View();
|
||||
}
|
||||
|
||||
var url = GreenfieldInvoiceController.ToModel(invoice, _linkGenerator, HttpContext.Request).CheckoutLink;
|
||||
if (!string.IsNullOrEmpty(model.CheckoutQueryString))
|
||||
{
|
||||
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
|
||||
var uriBuilder = new UriBuilder(url);
|
||||
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
paramValues.Add(additionalParamValues);
|
||||
uriBuilder.Query = paramValues.ToString()!;
|
||||
url = uriBuilder.Uri.AbsoluteUri;
|
||||
}
|
||||
if (model.JsonResponse)
|
||||
{
|
||||
return Json(new
|
||||
{
|
||||
InvoiceId = invoice.Data.Id,
|
||||
InvoiceUrl = invoice.Data.Url
|
||||
InvoiceId = invoice.Id,
|
||||
InvoiceUrl = url
|
||||
});
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(model.CheckoutQueryString))
|
||||
{
|
||||
return Redirect(invoice.Data.Url);
|
||||
}
|
||||
|
||||
var additionalParamValues = HttpUtility.ParseQueryString(model.CheckoutQueryString);
|
||||
var uriBuilder = new UriBuilder(invoice.Data.Url);
|
||||
var paramValues = HttpUtility.ParseQueryString(uriBuilder.Query);
|
||||
paramValues.Add(additionalParamValues);
|
||||
uriBuilder.Query = paramValues.ToString();
|
||||
return Redirect(uriBuilder.Uri.AbsoluteUri);
|
||||
return Redirect(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -199,21 +199,15 @@ namespace BTCPayServer.Controllers
|
||||
ModelState.AddModelError(nameof(vm.Destination), destination.error ?? "Invalid destination with selected payment method");
|
||||
return await ViewPullPayment(pullPaymentId);
|
||||
}
|
||||
|
||||
if (vm.ClaimedAmount == 0)
|
||||
|
||||
var amtError = ClaimRequest.IsPayoutAmountOk(destination.destination, vm.ClaimedAmount == 0? null: vm.ClaimedAmount, paymentMethodId.CryptoCode, ppBlob.Currency);
|
||||
if (amtError.error is not null)
|
||||
{
|
||||
ModelState.AddModelError(nameof(vm.ClaimedAmount), "Amount is required");
|
||||
ModelState.AddModelError(nameof(vm.ClaimedAmount), amtError.error );
|
||||
}
|
||||
else
|
||||
else if (amtError.amount is not null)
|
||||
{
|
||||
var amount = ppBlob.Currency == "SATS" ? new Money(vm.ClaimedAmount, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) : vm.ClaimedAmount;
|
||||
if (destination.destination.Amount != null && amount != destination.destination.Amount)
|
||||
{
|
||||
var implied = _displayFormatter.Currency(destination.destination.Amount.Value, paymentMethodId.CryptoCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var provided = _displayFormatter.Currency(vm.ClaimedAmount, ppBlob.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
ModelState.AddModelError(nameof(vm.ClaimedAmount),
|
||||
$"Amount implied in destination ({implied}) does not match the payout amount provided ({provided}).");
|
||||
}
|
||||
vm.ClaimedAmount = amtError.amount.Value;
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
|
122
BTCPayServer/Controllers/UIReportsController.CheatMode.cs
Normal file
122
BTCPayServer/Controllers/UIReportsController.CheatMode.cs
Normal file
@ -0,0 +1,122 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using Dapper;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.GreenField;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models.StoreReportsViewModels;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Internal;
|
||||
using System.Text.Json.Nodes;
|
||||
using Org.BouncyCastle.Ocsp;
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NBitcoin.DataEncoders;
|
||||
using System.Net;
|
||||
|
||||
namespace BTCPayServer.Controllers;
|
||||
|
||||
public partial class UIReportsController
|
||||
{
|
||||
private IList<IList<object?>> Generate(IList<StoreReportResponse.Field> fields)
|
||||
{
|
||||
var rand = new Random();
|
||||
int rowCount = 1_000;
|
||||
List<object?> row = new List<object?>();
|
||||
List<IList<object?>> result = new List<IList<object?>>();
|
||||
for (int i = 0; i < rowCount; i++)
|
||||
{
|
||||
int fi = 0;
|
||||
foreach (var f in fields)
|
||||
{
|
||||
row.Add(GenerateData(fields, f, fi, row, rand));
|
||||
fi++;
|
||||
}
|
||||
result.Add(row);
|
||||
row = new List<object?>();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private object? GenerateData(IList<StoreReportResponse.Field> fields, StoreReportResponse.Field f, int fi, List<object?> row, Random rand)
|
||||
{
|
||||
byte[] GenerateBytes(int count)
|
||||
{
|
||||
var bytes = new byte[count];
|
||||
rand.NextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
T TakeOne<T>(params T[] v)
|
||||
{
|
||||
return v[rand.NextInt64(0, v.Length)];
|
||||
}
|
||||
decimal GenerateDecimal(decimal from, decimal to, int precision)
|
||||
{
|
||||
decimal range = to - from;
|
||||
decimal randomValue = ((decimal)rand.NextDouble() * range) + from;
|
||||
return decimal.Round(randomValue, precision);
|
||||
}
|
||||
if (f.Type == "invoice_id")
|
||||
return Encoders.Base58.EncodeData(GenerateBytes(20));
|
||||
if (f.Type == "boolean")
|
||||
return GenerateBytes(1)[0] % 2 == 0;
|
||||
if (f.Name == "PaymentType")
|
||||
return TakeOne("On-Chain", "Lightning");
|
||||
if (f.Name == "PaymentId")
|
||||
if (row[fi -1] is "On-Chain")
|
||||
return Encoders.Hex.EncodeData(GenerateBytes(32)) + "-" + rand.NextInt64(0, 4);
|
||||
else
|
||||
return Encoders.Hex.EncodeData(GenerateBytes(32));
|
||||
if (f.Name == "Address")
|
||||
return Encoders.Bech32("bc1").Encode(0, GenerateBytes(20));
|
||||
if (f.Name == "Crypto")
|
||||
return rand.NextSingle() > 0.2 ? "BTC" : TakeOne("LTC", "DOGE", "DASH");
|
||||
if (f.Name == "CryptoAmount")
|
||||
return GenerateDecimal(0.1m, 5m, 8);
|
||||
if (f.Name == "LightningAddress")
|
||||
return TakeOne("satoshi", "satosan", "satoichi") + "@bitcoin.org";
|
||||
if (f.Name == "BalanceChange")
|
||||
return GenerateDecimal(-5.0m, 5.0m, 8);
|
||||
if (f.Type == "datetime")
|
||||
return DateTimeOffset.UtcNow - TimeSpan.FromHours(rand.Next(0, 24 * 30 * 6)) - TimeSpan.FromMinutes(rand.Next(0, 60));
|
||||
if (f.Name == "Product")
|
||||
return TakeOne("green-tea", "black-tea", "oolong-tea", "coca-cola");
|
||||
if (f.Name == "State")
|
||||
return TakeOne("Settled", "Processing");
|
||||
if (f.Name == "AppId")
|
||||
return TakeOne("AppA", "AppB");
|
||||
if (f.Name == "Quantity")
|
||||
return TakeOne(1, 2, 3, 4, 5);
|
||||
if (f.Name == "Currency")
|
||||
return rand.NextSingle() > 0.2 ? "USD" : TakeOne("JPY", "EUR", "CHF");
|
||||
if (f.Name == "CurrencyAmount")
|
||||
return row[fi - 1] switch
|
||||
{
|
||||
"USD" or "EUR" or "CHF" => GenerateDecimal(100.0m, 10_000m, 2),
|
||||
"JPY" => GenerateDecimal(10_000m, 1000_0000, 0),
|
||||
_ => GenerateDecimal(100.0m, 10_000m, 2)
|
||||
};
|
||||
if (f.Type == "tx_id")
|
||||
return Encoders.Hex.EncodeData(GenerateBytes(32));
|
||||
if (f.Name == "Rate")
|
||||
{
|
||||
return row[fi - 1] switch
|
||||
{
|
||||
"USD" or "EUR" or "CHF" => GenerateDecimal(30_000m, 60_000, 2),
|
||||
"JPY" => GenerateDecimal(400_0000m, 1000_0000m, 0),
|
||||
_ => GenerateDecimal(30_000m, 60_000, 2)
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
91
BTCPayServer/Controllers/UIReportsController.cs
Normal file
91
BTCPayServer/Controllers/UIReportsController.cs
Normal file
@ -0,0 +1,91 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using Dapper;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Constants;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers.GreenField;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Filters;
|
||||
using BTCPayServer.Models.StoreReportsViewModels;
|
||||
using BTCPayServer.Models.StoreViewModels;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Internal;
|
||||
using System.Text.Json.Nodes;
|
||||
using Org.BouncyCastle.Ocsp;
|
||||
using System.Threading;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using NBitcoin.DataEncoders;
|
||||
|
||||
|
||||
namespace BTCPayServer.Controllers;
|
||||
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[AutoValidateAntiforgeryToken]
|
||||
public partial class UIReportsController : Controller
|
||||
{
|
||||
public UIReportsController(
|
||||
BTCPayNetworkProvider networkProvider,
|
||||
ApplicationDbContextFactory dbContextFactory,
|
||||
GreenfieldReportsController api,
|
||||
ReportService reportService,
|
||||
BTCPayServerEnvironment env
|
||||
)
|
||||
{
|
||||
Api = api;
|
||||
ReportService = reportService;
|
||||
Env = env;
|
||||
DBContextFactory = dbContextFactory;
|
||||
NetworkProvider = networkProvider;
|
||||
}
|
||||
private BTCPayNetworkProvider NetworkProvider { get; }
|
||||
public GreenfieldReportsController Api { get; }
|
||||
public ReportService ReportService { get; }
|
||||
public BTCPayServerEnvironment Env { get; }
|
||||
public ApplicationDbContextFactory DBContextFactory { get; }
|
||||
|
||||
[HttpPost("stores/{storeId}/reports")]
|
||||
[AcceptMediaTypeConstraint("application/json")]
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public async Task<IActionResult> StoreReportsJson(string storeId, [FromBody] StoreReportRequest? request = null, bool fakeData = false, CancellationToken cancellation = default)
|
||||
{
|
||||
var result = await Api.StoreReports(storeId, request, cancellation);
|
||||
if (fakeData && Env.CheatMode)
|
||||
{
|
||||
var r = (StoreReportResponse)((JsonResult)result!).Value!;
|
||||
r.Data = Generate(r.Fields).Select(r => new JArray(r)).ToList();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
[HttpGet("stores/{storeId}/reports")]
|
||||
[AcceptMediaTypeConstraint("text/html")]
|
||||
[Authorize(Policy = Policies.CanViewStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||
public IActionResult StoreReports(
|
||||
string storeId,
|
||||
string ? viewName = null)
|
||||
{
|
||||
var vm = new StoreReportsViewModel()
|
||||
{
|
||||
InvoiceTemplateUrl = this.Url.Action(nameof(UIInvoiceController.Invoice), "UIInvoice", new { invoiceId = "INVOICE_ID" }),
|
||||
ExplorerTemplateUrls = NetworkProvider.GetAll().ToDictionary(network => network.CryptoCode, network => network.BlockExplorerLink?.Replace("{0}", "TX_ID")),
|
||||
Request = new StoreReportRequest()
|
||||
{
|
||||
ViewName = viewName ?? "Payments"
|
||||
}
|
||||
};
|
||||
vm.AvailableViews = ReportService.ReportProviders
|
||||
.Values
|
||||
.Where(r => r.IsAvailable())
|
||||
.Select(k => k.Name)
|
||||
.OrderBy(k => k).ToList();
|
||||
return View(vm);
|
||||
}
|
||||
}
|
@ -1050,29 +1050,28 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.LogoFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
|
||||
}
|
||||
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.LogoFile.Bufferize();
|
||||
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.LogoFile = formFile;
|
||||
// delete existing image
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(settings.LogoFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(settings.LogoFileId, userId);
|
||||
}
|
||||
|
||||
// add new image
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.LogoFile, userId);
|
||||
|
@ -20,6 +20,7 @@ using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using MarkPayoutRequest = BTCPayServer.HostedServices.MarkPayoutRequest;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PullPaymentData = BTCPayServer.Data.PullPaymentData;
|
||||
@ -529,10 +530,32 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
var ppBlob = item.PullPayment?.GetBlob();
|
||||
var payoutBlob = item.Payout.GetBlob(_jsonSerializerSettings);
|
||||
string payoutSource;
|
||||
if (payoutBlob.Metadata?.TryGetValue("source", StringComparison.InvariantCultureIgnoreCase,
|
||||
out var source) is true)
|
||||
{
|
||||
payoutSource = source.Value<string>();
|
||||
}
|
||||
else
|
||||
{
|
||||
payoutSource = ppBlob?.Name ?? item.PullPayment?.Id;
|
||||
}
|
||||
|
||||
string payoutSourceLink = null;
|
||||
if (payoutBlob.Metadata?.TryGetValue("sourceLink", StringComparison.InvariantCultureIgnoreCase,
|
||||
out var sourceLink) is true)
|
||||
{
|
||||
payoutSourceLink = sourceLink.Value<string>();
|
||||
}
|
||||
else if(item.PullPayment?.Id is not null)
|
||||
{
|
||||
payoutSourceLink = Url.Action("ViewPullPayment", "UIPullPayment", new { pullPaymentId = item.PullPayment?.Id });
|
||||
}
|
||||
var m = new PayoutsModel.PayoutModel
|
||||
{
|
||||
PullPaymentId = item.PullPayment?.Id,
|
||||
PullPaymentName = ppBlob?.Name ?? item.PullPayment?.Id,
|
||||
Source = payoutSource,
|
||||
SourceLink = payoutSourceLink,
|
||||
Date = item.Payout.Date,
|
||||
PayoutId = item.Payout.Id,
|
||||
Amount = _displayFormatter.Currency(payoutBlob.Amount, ppBlob?.Currency ?? PaymentMethodId.Parse(item.Payout.PaymentMethodId).CryptoCode),
|
||||
|
@ -392,6 +392,7 @@ namespace BTCPayServer.Controllers
|
||||
|
||||
vm.UseClassicCheckout = storeBlob.CheckoutType == Client.Models.CheckoutType.V1;
|
||||
vm.CelebratePayment = storeBlob.CelebratePayment;
|
||||
vm.PlaySoundOnPayment = storeBlob.PlaySoundOnPayment;
|
||||
vm.OnChainWithLnInvoiceFallback = storeBlob.OnChainWithLnInvoiceFallback;
|
||||
vm.ShowPayInWalletButton = storeBlob.ShowPayInWalletButton;
|
||||
vm.ShowStoreHeader = storeBlob.ShowStoreHeader;
|
||||
@ -401,6 +402,7 @@ namespace BTCPayServer.Controllers
|
||||
vm.RedirectAutomatically = storeBlob.RedirectAutomatically;
|
||||
vm.CustomCSS = storeBlob.CustomCSS;
|
||||
vm.CustomLogo = storeBlob.CustomLogo;
|
||||
vm.SoundFileId = storeBlob.SoundFileId;
|
||||
vm.HtmlTitle = storeBlob.HtmlTitle;
|
||||
vm.DisplayExpirationTimer = (int)storeBlob.DisplayExpirationTimer.TotalMinutes;
|
||||
vm.ReceiptOptions = CheckoutAppearanceViewModel.ReceiptOptionsViewModel.Create(storeBlob.ReceiptOptions);
|
||||
@ -450,7 +452,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
|
||||
[HttpPost("{storeId}/checkout")]
|
||||
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model)
|
||||
public async Task<IActionResult> CheckoutAppearance(CheckoutAppearanceViewModel model, [FromForm] bool RemoveSoundFile = false)
|
||||
{
|
||||
bool needUpdate = false;
|
||||
var blob = CurrentStore.GetStoreBlob();
|
||||
@ -475,6 +477,57 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var userId = GetUserId();
|
||||
if (userId is null)
|
||||
return NotFound();
|
||||
|
||||
if (model.SoundFile != null)
|
||||
{
|
||||
if (model.SoundFile.Length > 1_000_000)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file should be less than 1MB");
|
||||
}
|
||||
else if (!model.SoundFile.ContentType.StartsWith("audio/", StringComparison.InvariantCulture))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.SoundFile.Bufferize();
|
||||
if (!FileTypeDetector.IsAudio(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), "The uploaded sound file needs to be an audio file");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.SoundFile = formFile;
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.SoundFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.SoundFileId, userId);
|
||||
}
|
||||
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.SoundFile, userId);
|
||||
blob.SoundFileId = storedFile.Id;
|
||||
needUpdate = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.SoundFile), $"Could not save sound: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (RemoveSoundFile && !string.IsNullOrEmpty(blob.SoundFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.SoundFileId, userId);
|
||||
blob.SoundFileId = null;
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
@ -516,6 +569,7 @@ namespace BTCPayServer.Controllers
|
||||
blob.ShowStoreHeader = model.ShowStoreHeader;
|
||||
blob.CheckoutType = model.UseClassicCheckout ? Client.Models.CheckoutType.V1 : Client.Models.CheckoutType.V2;
|
||||
blob.CelebratePayment = model.CelebratePayment;
|
||||
blob.PlaySoundOnPayment = model.PlaySoundOnPayment;
|
||||
blob.OnChainWithLnInvoiceFallback = model.OnChainWithLnInvoiceFallback;
|
||||
blob.LightningAmountInSatoshi = model.LightningAmountInSatoshi;
|
||||
blob.RequiresRefundEmail = model.RequiresRefundEmail;
|
||||
@ -674,28 +728,27 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.LogoFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file should be less than 1MB");
|
||||
}
|
||||
else if (!model.LogoFile.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
var formFile = await model.LogoFile.Bufferize();
|
||||
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded logo file needs to be an image";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), "The uploaded logo file needs to be an image");
|
||||
}
|
||||
else
|
||||
{
|
||||
model.LogoFile = formFile;
|
||||
// delete existing image
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.LogoFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.LogoFileId, userId);
|
||||
}
|
||||
|
||||
// add new image
|
||||
try
|
||||
{
|
||||
@ -704,7 +757,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = $"Could not save logo: {e.Message}";
|
||||
ModelState.AddModelError(nameof(model.LogoFile), $"Could not save logo: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -720,25 +773,24 @@ namespace BTCPayServer.Controllers
|
||||
{
|
||||
if (model.CssFile.Length > 1_000_000)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file should be less than 1MB";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file should be less than 1MB");
|
||||
}
|
||||
else if (!model.CssFile.ContentType.Equals("text/css", StringComparison.InvariantCulture))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
|
||||
}
|
||||
else if (!model.CssFile.FileName.EndsWith(".css", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = "The uploaded file needs to be a CSS file";
|
||||
ModelState.AddModelError(nameof(model.CssFile), "The uploaded file needs to be a CSS file");
|
||||
}
|
||||
else
|
||||
{
|
||||
// delete existing CSS file
|
||||
// delete existing file
|
||||
if (!string.IsNullOrEmpty(blob.CssFileId))
|
||||
{
|
||||
await _fileService.RemoveFile(blob.CssFileId, userId);
|
||||
}
|
||||
|
||||
// add new CSS file
|
||||
// add new file
|
||||
try
|
||||
{
|
||||
var storedFile = await _fileService.AddFile(model.CssFile, userId);
|
||||
@ -746,7 +798,7 @@ namespace BTCPayServer.Controllers
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
TempData[WellKnownTempData.ErrorMessage] = $"Could not save CSS file: {e.Message}";
|
||||
ModelState.AddModelError(nameof(model.CssFile), $"Could not save CSS file: {e.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -6,5 +6,6 @@ namespace BTCPayServer.Data
|
||||
{
|
||||
public string? Id { get; }
|
||||
decimal? Amount { get; }
|
||||
bool IsExplicitAmountMinimum => false;
|
||||
}
|
||||
}
|
||||
|
@ -23,5 +23,6 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
public uint256 PaymentHash { get; }
|
||||
public string Id => PaymentHash.ToString();
|
||||
public decimal? Amount { get; }
|
||||
public bool IsExplicitAmountMinimum => true;
|
||||
}
|
||||
}
|
||||
|
@ -264,7 +264,7 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
PaymentMethodId pmi, CancellationToken cancellationToken)
|
||||
{
|
||||
var boltAmount = bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC);
|
||||
if (boltAmount != payoutBlob.CryptoAmount)
|
||||
if (boltAmount > payoutBlob.CryptoAmount)
|
||||
{
|
||||
|
||||
payoutData.State = PayoutState.Cancelled;
|
||||
@ -295,9 +295,8 @@ namespace BTCPayServer.Data.Payouts.LightningLike
|
||||
var result = await lightningClient.Pay(bolt11PaymentRequest.ToString(),
|
||||
new PayInvoiceParams()
|
||||
{
|
||||
Amount = bolt11PaymentRequest.MinimumAmount == LightMoney.Zero
|
||||
? new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
|
||||
: null
|
||||
// CLN does not support explicit amount param if it is the same as the invoice amount
|
||||
Amount = payoutBlob.CryptoAmount == bolt11PaymentRequest.MinimumAmount.ToDecimal(LightMoneyUnit.BTC)? null: new LightMoney((decimal)payoutBlob.CryptoAmount, LightMoneyUnit.BTC)
|
||||
}, cancellationToken);
|
||||
string message = null;
|
||||
if (result.Result == PayResult.Ok)
|
||||
|
@ -1,5 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.JsonConverters;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data
|
||||
{
|
||||
@ -12,5 +14,10 @@ namespace BTCPayServer.Data
|
||||
public int MinimumConfirmation { get; set; } = 1;
|
||||
public string Destination { get; set; }
|
||||
public int Revision { get; set; }
|
||||
|
||||
[JsonExtensionData]
|
||||
public Dictionary<string, JToken> AdditionalData { get; set; } = new();
|
||||
|
||||
public JObject Metadata { get; set; }
|
||||
}
|
||||
}
|
||||
|
@ -35,7 +35,9 @@ namespace BTCPayServer.Data
|
||||
|
||||
public static PayoutBlob GetBlob(this PayoutData data, BTCPayNetworkJsonSerializerSettings serializers)
|
||||
{
|
||||
return JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
|
||||
var result = JsonConvert.DeserializeObject<PayoutBlob>(Encoding.UTF8.GetString(data.Blob), serializers.GetSerializer(data.GetPaymentMethodId().CryptoCode));
|
||||
result.Metadata ??= new JObject();
|
||||
return result;
|
||||
}
|
||||
public static void SetBlob(this PayoutData data, PayoutBlob blob, BTCPayNetworkJsonSerializerSettings serializers)
|
||||
{
|
||||
|
@ -237,6 +237,12 @@ namespace BTCPayServer.Data
|
||||
[DefaultValue(true)]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
public bool CelebratePayment { get; set; } = true;
|
||||
|
||||
[DefaultValue(true)]
|
||||
[JsonProperty(DefaultValueHandling = DefaultValueHandling.Populate)]
|
||||
public bool PlaySoundOnPayment { get; set; } = false;
|
||||
|
||||
public string SoundFileId { get; set; }
|
||||
|
||||
public IPaymentFilter GetExcludedPaymentMethods()
|
||||
{
|
||||
|
@ -22,6 +22,7 @@ using BTCPayServer.Payments;
|
||||
using BTCPayServer.Payments.Bitcoin;
|
||||
using BTCPayServer.Security;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Reporting;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@ -134,6 +135,14 @@ namespace BTCPayServer
|
||||
}
|
||||
}
|
||||
|
||||
public static IServiceCollection AddReportProvider<T>(this IServiceCollection services)
|
||||
where T : ReportProvider
|
||||
{
|
||||
services.AddSingleton<T>();
|
||||
services.AddSingleton<ReportProvider, T>();
|
||||
return services;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddScheduledTask<T>(this IServiceCollection services, TimeSpan every)
|
||||
where T : class, IPeriodicTask
|
||||
{
|
||||
|
@ -1,4 +1,5 @@
|
||||
#nullable enable
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Data;
|
||||
@ -43,5 +44,13 @@ namespace BTCPayServer
|
||||
.FirstOrDefault(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike && p.PaymentId.CryptoCode == cryptoCode);
|
||||
return paymentMethod;
|
||||
}
|
||||
public static IEnumerable<DerivationSchemeSettings> GetDerivationSchemeSettings(this StoreData store, BTCPayNetworkProvider networkProvider)
|
||||
{
|
||||
var paymentMethod = store
|
||||
.GetSupportedPaymentMethods(networkProvider)
|
||||
.OfType<DerivationSchemeSettings>()
|
||||
.Where(p => p.PaymentId.PaymentType == Payments.PaymentTypes.BTCLike);
|
||||
return paymentMethod;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ namespace BTCPayServer
|
||||
{
|
||||
// Thanks to https://www.garykessler.net/software/FileSigs_20220731.zip
|
||||
|
||||
|
||||
const string pictureSigs =
|
||||
"JPEG2000 image files,00 00 00 0C 6A 50 20 20,JP2,Picture,0,(null)\n" +
|
||||
"Bitmap image,42 4D,BMP|DIB,Picture,0,(null)\n" +
|
||||
@ -19,19 +20,30 @@ namespace BTCPayServer
|
||||
"JPEG-EXIF-SPIFF images,FF D8 FF,JFIF|JPE|JPEG|JPG,Picture,0,FF D9\n" +
|
||||
"SVG images, 3C 73 76 67,SVG,Picture,0,(null)\n" +
|
||||
"Google WebP image file, 52 49 46 46 XX XX XX XX 57 45 42 50,WEBP,Picture,0,(null)\n" +
|
||||
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n";
|
||||
"AVIF image file, XX XX XX XX 66 74 79 70,AVIF,Picture,0,(null)\n" +
|
||||
"MP3 audio file,49 44 33,MP3,Multimedia,0,(null)\n" +
|
||||
"MP3 audio file,FF,MP3,Multimedia,0,(null)\n" +
|
||||
"RIFF Windows Audio,57 41 56 45 66 6D 74 20,WAV,Multimedia,8,(null)\n" +
|
||||
"Free Lossless Audio Codec file,66 4C 61 43 00 00 00 22,FLAC,Multimedia,0,(null)\n" +
|
||||
"MPEG-4 AAC audio,FF F1,AAC,Audio,0,(null)\n" +
|
||||
"Ogg Vorbis Codec compressed file,4F 67 67 53,OGA|OGG|OGV|OGX,Multimedia,0,(null)\n" +
|
||||
"Apple Lossless Audio Codec file,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
|
||||
"WebM/WebA,66 74 79 70 4D 34 41 20,M4A,Multimedia,4,(null)\n" +
|
||||
"WebM/WEBA video file,1A 45 DF A3,WEBM|WEBA,Multimedia,0,(null)\n" +
|
||||
"Resource Interchange File Format,52 49 46 46,AVI|CDA|QCP|RMI|WAV|WEBP,Multimedia,0,(null)\n";
|
||||
|
||||
readonly static (int[] Header, int[]? Trailer, string[] Extensions)[] headerTrailers;
|
||||
readonly static (int[] Header, int[]? Trailer, string Type, string[] Extensions)[] headerTrailers;
|
||||
static FileTypeDetector()
|
||||
{
|
||||
var lines = pictureSigs.Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
headerTrailers = new (int[] Header, int[]? Trailer, string[] Extensions)[lines.Length];
|
||||
headerTrailers = new (int[] Header, int[]? Trailer, string Type, string[] Extensions)[lines.Length];
|
||||
for (int i = 0; i < lines.Length; i++)
|
||||
{
|
||||
var cells = lines[i].Split(',');
|
||||
headerTrailers[i] = (
|
||||
DecodeData(cells[1]),
|
||||
cells[^1] == "(null)" ? null : DecodeData(cells[^1]),
|
||||
cells[3],
|
||||
cells[2].Split('|').Select(p => $".{p}").ToArray()
|
||||
);
|
||||
}
|
||||
@ -51,11 +63,21 @@ namespace BTCPayServer
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
public static bool IsPicture(byte[] bytes, string? filename)
|
||||
{
|
||||
return IsFileType(bytes, filename, new[] { "Picture" });
|
||||
}
|
||||
public static bool IsAudio(byte[] bytes, string? filename)
|
||||
{
|
||||
return IsFileType(bytes, filename, new[] { "Multimedia", "Audio" });
|
||||
}
|
||||
|
||||
static bool IsFileType(byte[] bytes, string? filename, string[] types)
|
||||
{
|
||||
for (int i = 0; i < headerTrailers.Length; i++)
|
||||
{
|
||||
if (!types.Contains(headerTrailers[i].Type))
|
||||
goto next;
|
||||
if (headerTrailers[i].Header is int[] header)
|
||||
{
|
||||
if (header.Length > bytes.Length)
|
||||
@ -80,7 +102,7 @@ namespace BTCPayServer
|
||||
if (filename is not null)
|
||||
{
|
||||
if (!headerTrailers[i].Extensions.Any(ext => filename.Length > ext.Length && filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
goto next;
|
||||
}
|
||||
return true;
|
||||
next:
|
||||
|
@ -597,7 +597,8 @@ namespace BTCPayServer.HostedServices
|
||||
var payoutBlob = new PayoutBlob()
|
||||
{
|
||||
Amount = claimed,
|
||||
Destination = req.ClaimRequest.Destination.ToString()
|
||||
Destination = req.ClaimRequest.Destination.ToString(),
|
||||
Metadata = req.ClaimRequest.Metadata?? new JObject(),
|
||||
};
|
||||
payout.SetBlob(payoutBlob, _jsonSerializerSettings);
|
||||
await ctx.Payouts.AddAsync(payout);
|
||||
@ -833,6 +834,31 @@ namespace BTCPayServer.HostedServices
|
||||
|
||||
public class ClaimRequest
|
||||
{
|
||||
public static (string error, decimal? amount) IsPayoutAmountOk(IClaimDestination destination, decimal? amount, string payoutCurrency = null, string ppCurrency = null)
|
||||
{
|
||||
return amount switch
|
||||
{
|
||||
null when destination.Amount is null && ppCurrency is null => ("Amount is not specified in destination or payout request", null),
|
||||
null when destination.Amount is null => (null, null),
|
||||
null when destination.Amount != null => (null,destination.Amount),
|
||||
not null when destination.Amount is null => (null,amount),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
payoutCurrency == "BTC" && ppCurrency == "SATS" &&
|
||||
new Money(amount.Value, MoneyUnit.Satoshi).ToUnit(MoneyUnit.BTC) < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
destination.IsExplicitAmountMinimum &&
|
||||
!(payoutCurrency == "BTC" && ppCurrency == "SATS") &&
|
||||
amount < destination.Amount =>
|
||||
($"Amount is implied in both destination ({destination.Amount}) and payout request ({amount}), but the payout request amount is less than the destination amount",null),
|
||||
not null when destination.Amount != null && amount != destination.Amount &&
|
||||
!destination.IsExplicitAmountMinimum =>
|
||||
($"Amount is implied in destination ({destination.Amount}) that does not match the payout amount provided {amount})", null),
|
||||
_ => (null, amount)
|
||||
};
|
||||
}
|
||||
|
||||
public static string GetErrorMessage(ClaimResult result)
|
||||
{
|
||||
switch (result)
|
||||
@ -890,6 +916,7 @@ namespace BTCPayServer.HostedServices
|
||||
public IClaimDestination Destination { get; set; }
|
||||
public string StoreId { get; set; }
|
||||
public bool? PreApprove { get; set; }
|
||||
public JObject Metadata { get; set; }
|
||||
}
|
||||
|
||||
public record PayoutEvent(PayoutEvent.PayoutEventType Type,PayoutData Payout)
|
||||
|
@ -65,6 +65,7 @@ using NBXplorer.DerivationStrategy;
|
||||
using Newtonsoft.Json;
|
||||
using NicolasDorier.RateLimits;
|
||||
using Serilog;
|
||||
using BTCPayServer.Services.Reporting;
|
||||
#if ALTCOINS
|
||||
using BTCPayServer.Services.Altcoins.Monero;
|
||||
using BTCPayServer.Services.Altcoins.Zcash;
|
||||
@ -325,6 +326,7 @@ namespace BTCPayServer.Hosting
|
||||
|
||||
services.TryAddSingleton<LightningConfigurationProvider>();
|
||||
services.TryAddSingleton<LanguageService>();
|
||||
services.TryAddSingleton<ReportService>();
|
||||
services.TryAddSingleton<NBXplorerDashboard>();
|
||||
services.AddSingleton<ISyncSummaryProvider, NBXSyncSummaryProvider>();
|
||||
services.TryAddSingleton<StoreRepository>();
|
||||
@ -354,6 +356,10 @@ namespace BTCPayServer.Hosting
|
||||
services.AddSingleton<IHostedService, PeriodicTaskLauncherHostedService>();
|
||||
services.AddScheduledTask<CleanupWebhookDeliveriesTask>(TimeSpan.FromHours(6.0));
|
||||
|
||||
services.AddReportProvider<PaymentsReportProvider>();
|
||||
services.AddReportProvider<OnChainWalletReportProvider>();
|
||||
services.AddReportProvider<ProductsReportProvider>();
|
||||
|
||||
services.AddHttpClient(WebhookSender.OnionNamedClient)
|
||||
.ConfigurePrimaryHttpMessageHandler<Socks5HttpClientHandler>();
|
||||
services.AddHttpClient(WebhookSender.LoopbackNamedClient)
|
||||
|
@ -27,6 +27,9 @@ namespace BTCPayServer.Models.InvoicingModels
|
||||
public string CustomLogoLink { get; set; }
|
||||
public string CssFileId { get; set; }
|
||||
public string LogoFileId { get; set; }
|
||||
public string PaymentSoundUrl { get; set; }
|
||||
public string NfcReadSoundUrl { get; set; }
|
||||
public string ErrorSoundUrl { get; set; }
|
||||
public string BrandColor { get; set; }
|
||||
public string HtmlTitle { get; set; }
|
||||
public string DefaultLang { get; set; }
|
||||
|
@ -0,0 +1,16 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Converters;
|
||||
|
||||
namespace BTCPayServer.Models.StoreReportsViewModels;
|
||||
|
||||
public class StoreReportsViewModel
|
||||
{
|
||||
public string InvoiceTemplateUrl { get; set; }
|
||||
public Dictionary<string,string> ExplorerTemplateUrls { get; set; }
|
||||
public StoreReportRequest Request { get; set; }
|
||||
public List<string> AvailableViews { get; set; }
|
||||
public StoreReportResponse Result { get; set; }
|
||||
}
|
@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
@ -44,6 +45,9 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
[Display(Name = "Celebrate payment with confetti")]
|
||||
public bool CelebratePayment { get; set; }
|
||||
|
||||
[Display(Name = "Enable sounds on checkout page")]
|
||||
public bool PlaySoundOnPayment { get; set; }
|
||||
|
||||
[Display(Name = "Requires a refund email")]
|
||||
public bool RequiresRefundEmail { get; set; }
|
||||
|
||||
@ -61,9 +65,14 @@ namespace BTCPayServer.Models.StoreViewModels
|
||||
|
||||
[Display(Name = "Link to a custom CSS stylesheet")]
|
||||
public string CustomCSS { get; set; }
|
||||
|
||||
[Display(Name = "Link to a custom logo")]
|
||||
public string CustomLogo { get; set; }
|
||||
|
||||
[Display(Name = "Custom sound file for successful payment")]
|
||||
public IFormFile SoundFile { get; set; }
|
||||
public string SoundFileId { get; set; }
|
||||
|
||||
[Display(Name = "Custom HTML title to display on Checkout page")]
|
||||
public string HtmlTitle { get; set; }
|
||||
|
||||
|
@ -26,7 +26,8 @@ namespace BTCPayServer.Models.WalletViewModels
|
||||
public bool Selected { get; set; }
|
||||
public DateTimeOffset Date { get; set; }
|
||||
public string PullPaymentId { get; set; }
|
||||
public string PullPaymentName { get; set; }
|
||||
public string Source { get; set; }
|
||||
public string SourceLink { get; set; }
|
||||
public string Destination { get; set; }
|
||||
public string Amount { get; set; }
|
||||
public string ProofLink { get; set; }
|
||||
|
@ -1,3 +1,4 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@ -8,12 +9,9 @@ using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.HostedServices;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NBitcoin.Protocol;
|
||||
using PayoutData = BTCPayServer.Data.PayoutData;
|
||||
using PayoutProcessorData = BTCPayServer.Data.PayoutProcessorData;
|
||||
|
||||
@ -35,7 +33,7 @@ public class AutomatedPayoutConstants
|
||||
}
|
||||
}
|
||||
}
|
||||
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob
|
||||
public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T : AutomatedPayoutBlob, new()
|
||||
{
|
||||
protected readonly StoreRepository _storeRepository;
|
||||
protected readonly PayoutProcessorData PayoutProcessorSettings;
|
||||
@ -73,7 +71,7 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
|
||||
public override Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_subscription.Dispose();
|
||||
_subscription?.Dispose();
|
||||
return base.StopAsync(cancellationToken);
|
||||
}
|
||||
|
||||
@ -93,9 +91,8 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
|
||||
private async Task Act()
|
||||
{
|
||||
_timerCTs = null;
|
||||
var store = await _storeRepository.FindStore(PayoutProcessorSettings.StoreId);
|
||||
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider)?.FirstOrDefault(
|
||||
var paymentMethod = store?.GetEnabledPaymentMethods(_btcPayNetworkProvider).FirstOrDefault(
|
||||
method =>
|
||||
method.PaymentId == PaymentMethodId);
|
||||
|
||||
@ -131,21 +128,18 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MinIntervalMinutes);
|
||||
if (blob.Interval > TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes))
|
||||
blob.Interval = TimeSpan.FromMinutes(AutomatedPayoutConstants.MaxIntervalMinutes);
|
||||
|
||||
_timerCTs??= new CancellationTokenSource();
|
||||
try
|
||||
{
|
||||
var cts= CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, _timerCTs.Token);
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken, _timerCTs.Token);
|
||||
await Task.Delay(blob.Interval, cts.Token);
|
||||
cts.Dispose();
|
||||
}
|
||||
catch (TaskCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private CancellationTokenSource _timerCTs;
|
||||
private IEventAggregatorSubscription _subscription;
|
||||
private CancellationTokenSource _timerCTs = new CancellationTokenSource();
|
||||
private IEventAggregatorSubscription? _subscription;
|
||||
|
||||
private readonly object _intervalLock = new object();
|
||||
|
||||
@ -153,14 +147,13 @@ public abstract class BaseAutomatedPayoutProcessor<T> : BaseAsyncService where T
|
||||
{
|
||||
lock (_intervalLock)
|
||||
{
|
||||
_timerCTs ??= new CancellationTokenSource();
|
||||
_timerCTs?.Cancel();
|
||||
_timerCTs.Cancel();
|
||||
_timerCTs = new CancellationTokenSource();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static T GetBlob(PayoutProcessorData payoutProcesserSettings)
|
||||
{
|
||||
return payoutProcesserSettings.HasTypedBlob<T>().GetBlob();
|
||||
return payoutProcesserSettings.HasTypedBlob<T>().GetBlob() ?? new T();
|
||||
}
|
||||
}
|
||||
|
@ -93,7 +93,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
|
||||
.ToList();
|
||||
if (groups.Count == 0)
|
||||
return;
|
||||
groups.Insert(0, new KeyValuePair<string, string>("All items", "*"));
|
||||
groups.Insert(0, new KeyValuePair<string, string>("All", "*"));
|
||||
AllCategories = new SelectList(groups, "Value", "Key", "*");
|
||||
}
|
||||
|
||||
|
@ -721,8 +721,11 @@ namespace BTCPayServer.Services.Invoices
|
||||
query = query.Take(queryObject.Take.Value);
|
||||
return query;
|
||||
}
|
||||
|
||||
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
|
||||
public Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject)
|
||||
{
|
||||
return GetInvoices(queryObject, default);
|
||||
}
|
||||
public async Task<InvoiceEntity[]> GetInvoices(InvoiceQuery queryObject, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var context = _applicationDbContextFactory.CreateContext();
|
||||
var query = GetInvoiceQuery(context, queryObject);
|
||||
@ -733,7 +736,7 @@ namespace BTCPayServer.Services.Invoices
|
||||
query = query.Include(o => o.Events);
|
||||
if (queryObject.IncludeRefunds)
|
||||
query = query.Include(o => o.Refunds).ThenInclude(refundData => refundData.PullPaymentData);
|
||||
var data = await query.ToArrayAsync().ConfigureAwait(false);
|
||||
var data = await query.ToArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
return data.Select(ToEntity).ToArray();
|
||||
}
|
||||
|
||||
|
20
BTCPayServer/Services/ReportService.cs
Normal file
20
BTCPayServer/Services/ReportService.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Services.Reporting;
|
||||
|
||||
namespace BTCPayServer.Services
|
||||
{
|
||||
public class ReportService
|
||||
{
|
||||
public ReportService(IEnumerable<ReportProvider> reportProviders)
|
||||
{
|
||||
foreach (var r in reportProviders)
|
||||
{
|
||||
ReportProviders.Add(r.Name, r);
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, ReportProvider> ReportProviders { get; } = new Dictionary<string, ReportProvider>();
|
||||
}
|
||||
}
|
122
BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs
Normal file
122
BTCPayServer/Services/Reporting/OnChainWalletReportProvider.cs
Normal file
@ -0,0 +1,122 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Services.Stores;
|
||||
using BTCPayServer.Services.Wallets;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NBitcoin;
|
||||
using NBXplorer.DerivationStrategy;
|
||||
using static Microsoft.EntityFrameworkCore.DbLoggerCategory.Database;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting;
|
||||
|
||||
public class OnChainWalletReportProvider : ReportProvider
|
||||
{
|
||||
public OnChainWalletReportProvider(
|
||||
NBXplorerConnectionFactory NbxplorerConnectionFactory,
|
||||
StoreRepository storeRepository,
|
||||
BTCPayNetworkProvider networkProvider,
|
||||
WalletRepository walletRepository)
|
||||
{
|
||||
this.NbxplorerConnectionFactory = NbxplorerConnectionFactory;
|
||||
StoreRepository = storeRepository;
|
||||
NetworkProvider = networkProvider;
|
||||
WalletRepository = walletRepository;
|
||||
}
|
||||
public NBXplorerConnectionFactory NbxplorerConnectionFactory { get; }
|
||||
public StoreRepository StoreRepository { get; }
|
||||
public BTCPayNetworkProvider NetworkProvider { get; }
|
||||
public WalletRepository WalletRepository { get; }
|
||||
public override string Name => "On-Chain Wallets";
|
||||
ViewDefinition CreateViewDefinition()
|
||||
{
|
||||
return
|
||||
new()
|
||||
{
|
||||
Fields =
|
||||
{
|
||||
new ("Date", "datetime"),
|
||||
new ("Crypto", "string"),
|
||||
// For proper rendering of explorer links, Crypto should always be before tx_id
|
||||
new ("TransactionId", "tx_id"),
|
||||
new ("InvoiceId", "invoice_id"),
|
||||
new ("Confirmed", "boolean"),
|
||||
new ("BalanceChange", "decimal")
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Name = "Group by Crypto",
|
||||
Totals = { "Crypto" },
|
||||
Groups = { "Crypto", "Confirmed" },
|
||||
Aggregates = { "BalanceChange" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public override bool IsAvailable()
|
||||
{
|
||||
return this.NbxplorerConnectionFactory.Available;
|
||||
}
|
||||
|
||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||
{
|
||||
queryContext.ViewDefinition = CreateViewDefinition();
|
||||
await using var conn = await NbxplorerConnectionFactory.OpenConnection();
|
||||
var store = await StoreRepository.FindStore(queryContext.StoreId);
|
||||
if (store is null)
|
||||
return;
|
||||
var interval = DateTimeOffset.UtcNow - queryContext.From;
|
||||
foreach (var settings in store.GetDerivationSchemeSettings(NetworkProvider))
|
||||
{
|
||||
var walletId = new WalletId(store.Id, settings.Network.CryptoCode);
|
||||
var command = new CommandDefinition(
|
||||
commandText:
|
||||
"SELECT r.tx_id, r.seen_at, t.blk_id, t.blk_height, r.balance_change " +
|
||||
"FROM get_wallets_recent(@wallet_id, @code, @interval, NULL, NULL) r " +
|
||||
"JOIN txs t USING (code, tx_id) " +
|
||||
"ORDER BY r.seen_at",
|
||||
parameters: new
|
||||
{
|
||||
wallet_id = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(settings.Network.CryptoCode, settings.AccountDerivation.ToString()),
|
||||
code = settings.Network.CryptoCode,
|
||||
interval = interval
|
||||
},
|
||||
cancellationToken: cancellation);
|
||||
|
||||
var rows = await conn.QueryAsync(command);
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var date = (DateTimeOffset)r.seen_at;
|
||||
if (date > queryContext.To)
|
||||
continue;
|
||||
var values = queryContext.AddData();
|
||||
values.Add((DateTimeOffset)date);
|
||||
values.Add(settings.Network.CryptoCode);
|
||||
values.Add((string)r.tx_id);
|
||||
values.Add(null);
|
||||
values.Add((long?)r.blk_height is not null);
|
||||
values.Add(Money.Satoshis((long)r.balance_change).ToDecimal(MoneyUnit.BTC));
|
||||
}
|
||||
var objects = await WalletRepository.GetWalletObjects(new GetWalletObjectsQuery()
|
||||
{
|
||||
Ids = queryContext.Data.Select(d => (string)d[2]).ToArray(),
|
||||
WalletId = walletId,
|
||||
Type = "tx"
|
||||
});
|
||||
foreach (var row in queryContext.Data)
|
||||
{
|
||||
if (!objects.TryGetValue(new WalletObjectId(walletId, "tx", (string)row[2]), out var txObject))
|
||||
continue;
|
||||
var invoiceId = txObject.GetLinks().Where(t => t.type == "invoice").Select(t => t.id).FirstOrDefault();
|
||||
row[3] = invoiceId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
187
BTCPayServer/Services/Reporting/PaymentsReportProvider.cs
Normal file
187
BTCPayServer/Services/Reporting/PaymentsReportProvider.cs
Normal file
@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data.Common;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Lightning.LND;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Internal;
|
||||
using NBitcoin;
|
||||
using NBitpayClient;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using static BTCPayServer.HostedServices.PullPaymentHostedService.PayoutApproval;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting;
|
||||
|
||||
public class PaymentsReportProvider : ReportProvider
|
||||
{
|
||||
|
||||
public PaymentsReportProvider(ApplicationDbContextFactory dbContextFactory, CurrencyNameTable currencyNameTable)
|
||||
{
|
||||
DbContextFactory = dbContextFactory;
|
||||
CurrencyNameTable = currencyNameTable;
|
||||
}
|
||||
public override string Name => "Payments";
|
||||
public ApplicationDbContextFactory DbContextFactory { get; }
|
||||
public CurrencyNameTable CurrencyNameTable { get; }
|
||||
|
||||
ViewDefinition CreateViewDefinition()
|
||||
{
|
||||
return
|
||||
new()
|
||||
{
|
||||
Fields =
|
||||
{
|
||||
new ("Date", "datetime"),
|
||||
new ("InvoiceId", "invoice_id"),
|
||||
new ("OrderId", "string"),
|
||||
new ("PaymentType", "string"),
|
||||
new ("PaymentId", "string"),
|
||||
new ("Confirmed", "boolean"),
|
||||
new ("Address", "string"),
|
||||
new ("Crypto", "string"),
|
||||
new ("CryptoAmount", "decimal"),
|
||||
new ("NetworkFee", "decimal"),
|
||||
new ("LightningAddress", "string"),
|
||||
new ("Currency", "string"),
|
||||
new ("CurrencyAmount", "decimal"),
|
||||
new ("Rate", "decimal")
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Name = "Aggregated crypto amount",
|
||||
Groups = { "Crypto", "PaymentType" },
|
||||
Totals = { "Crypto" },
|
||||
HasGrandTotal = false,
|
||||
Aggregates = { "CryptoAmount" }
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Aggregated currency amount",
|
||||
Groups = { "Currency" },
|
||||
Totals = { "Currency" },
|
||||
HasGrandTotal = false,
|
||||
Aggregates = { "CurrencyAmount" }
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Group by Lightning Address (Currency amount)",
|
||||
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
|
||||
Groups = { "LightningAddress", "Currency" },
|
||||
Aggregates = { "CurrencyAmount" },
|
||||
HasGrandTotal = true
|
||||
},
|
||||
new ()
|
||||
{
|
||||
Name = "Group by Lightning Address (Crypto amount)",
|
||||
Filters = { "typeof this.LightningAddress === 'string' && this.Crypto == \"BTC\"" },
|
||||
Groups = { "LightningAddress" },
|
||||
Aggregates = { "CryptoAmount" },
|
||||
HasGrandTotal = true
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||
{
|
||||
queryContext.ViewDefinition = CreateViewDefinition();
|
||||
await using var ctx = DbContextFactory.CreateContext();
|
||||
var conn = ctx.Database.GetDbConnection();
|
||||
string[] fields = new[]
|
||||
{
|
||||
$"i.\"Created\" created",
|
||||
"i.\"Id\" invoice_id",
|
||||
"i.\"OrderId\" order_id",
|
||||
"p.\"Id\" payment_id",
|
||||
"p.\"Type\" payment_type",
|
||||
"i.\"Blob2\" invoice_blob",
|
||||
"p.\"Blob2\" payment_blob",
|
||||
};
|
||||
string select = "SELECT " + String.Join(", ", fields) + " ";
|
||||
string body =
|
||||
"FROM \"Payments\" p " +
|
||||
"JOIN \"Invoices\" i ON i.\"Id\" = p.\"InvoiceDataId\" " +
|
||||
$"WHERE p.\"Accounted\" IS TRUE AND i.\"Created\" >= @from AND i.\"Created\" < @to AND i.\"StoreDataId\"=@storeId " +
|
||||
"ORDER BY i.\"Created\"";
|
||||
var command = new CommandDefinition(
|
||||
commandText: select + body,
|
||||
parameters: new
|
||||
{
|
||||
storeId = queryContext.StoreId,
|
||||
from = queryContext.From,
|
||||
to = queryContext.To
|
||||
},
|
||||
cancellationToken: cancellation);
|
||||
var rows = await conn.QueryAsync(command);
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var values = queryContext.CreateData();
|
||||
values.Add((DateTime)r.created);
|
||||
values.Add((string)r.invoice_id);
|
||||
values.Add((string)r.order_id);
|
||||
if (PaymentMethodId.TryParse((string)r.payment_type, out var paymentType))
|
||||
{
|
||||
if (paymentType.PaymentType == PaymentTypes.LightningLike || paymentType.PaymentType == PaymentTypes.LNURLPay)
|
||||
values.Add("Lightning");
|
||||
else if (paymentType.PaymentType == PaymentTypes.BTCLike)
|
||||
values.Add("On-Chain");
|
||||
else
|
||||
values.Add(paymentType.ToStringNormalized());
|
||||
}
|
||||
else
|
||||
continue;
|
||||
values.Add((string)r.payment_id);
|
||||
var invoiceBlob = JObject.Parse((string)r.invoice_blob);
|
||||
var paymentBlob = JObject.Parse((string)r.payment_blob);
|
||||
|
||||
|
||||
var data = JObject.Parse(paymentBlob.SelectToken("$.cryptoPaymentData")?.Value<string>()!);
|
||||
var conf = data.SelectToken("$.confirmationCount")?.Value<int>();
|
||||
values.Add(conf is int o ? o > 0 :
|
||||
paymentType.PaymentType != PaymentTypes.BTCLike ? true : null);
|
||||
values.Add(data.SelectToken("$.address")?.Value<string>());
|
||||
values.Add(paymentType.CryptoCode);
|
||||
|
||||
decimal cryptoAmount;
|
||||
if (data.SelectToken("$.amount")?.Value<long>() is long v)
|
||||
{
|
||||
cryptoAmount = LightMoney.MilliSatoshis(v).ToDecimal(LightMoneyUnit.BTC);
|
||||
}
|
||||
else if (data.SelectToken("$.value")?.Value<long>() is long amount)
|
||||
{
|
||||
cryptoAmount = Money.Satoshis(amount).ToDecimal(MoneyUnit.BTC);
|
||||
}
|
||||
else
|
||||
{
|
||||
continue;
|
||||
}
|
||||
values.Add(cryptoAmount);
|
||||
values.Add(paymentBlob.SelectToken("$.networkFee", false)?.Value<decimal>());
|
||||
values.Add(invoiceBlob.SelectToken("$.cryptoData.BTC_LNURLPAY.paymentMethod.ConsumedLightningAddress", false)?.Value<string>());
|
||||
var currency = invoiceBlob.SelectToken("$.currency")?.Value<string>();
|
||||
values.Add(currency);
|
||||
|
||||
values.Add(null); // Currency amount
|
||||
var rate = invoiceBlob.SelectToken($"$.cryptoData.{paymentType}.rate")?.Value<decimal>();
|
||||
values.Add(rate);
|
||||
if (rate is not null)
|
||||
{
|
||||
values[^2] = (rate.Value * cryptoAmount).RoundToSignificant(CurrencyNameTable.GetCurrencyData(currency ?? "USD", true).Divisibility);
|
||||
}
|
||||
|
||||
queryContext.Data.Add(values);
|
||||
}
|
||||
}
|
||||
}
|
131
BTCPayServer/Services/Reporting/ProductsReportProvider.cs
Normal file
131
BTCPayServer/Services/Reporting/ProductsReportProvider.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Rating;
|
||||
using BTCPayServer.Services.Apps;
|
||||
using BTCPayServer.Services.Invoices;
|
||||
using BTCPayServer.Services.Rates;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Internal;
|
||||
using Newtonsoft.Json;
|
||||
using Org.BouncyCastle.Crypto.Signers;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting;
|
||||
|
||||
public class ProductsReportProvider : ReportProvider
|
||||
{
|
||||
public ProductsReportProvider(InvoiceRepository invoiceRepository, CurrencyNameTable currencyNameTable, AppService apps)
|
||||
{
|
||||
InvoiceRepository = invoiceRepository;
|
||||
CurrencyNameTable = currencyNameTable;
|
||||
Apps = apps;
|
||||
}
|
||||
|
||||
public InvoiceRepository InvoiceRepository { get; }
|
||||
public CurrencyNameTable CurrencyNameTable { get; }
|
||||
public AppService Apps { get; }
|
||||
|
||||
public override string Name => "Products sold";
|
||||
|
||||
public override async Task Query(QueryContext queryContext, CancellationToken cancellation)
|
||||
{
|
||||
var appsById = (await Apps.GetApps(queryContext.StoreId)).ToDictionary(o => o.Id);
|
||||
var tagAllinvoicesApps = appsById.Values.Where(a => a.TagAllInvoices).ToList();
|
||||
queryContext.ViewDefinition = CreateDefinition();
|
||||
foreach (var i in (await InvoiceRepository.GetInvoices(new InvoiceQuery()
|
||||
{
|
||||
IncludeArchived = true,
|
||||
IncludeAddresses = false,
|
||||
IncludeEvents = false,
|
||||
IncludeRefunds = false,
|
||||
StartDate = queryContext.From,
|
||||
EndDate = queryContext.To,
|
||||
StoreId = new[] { queryContext.StoreId }
|
||||
}, cancellation)).OrderBy(c => c.InvoiceTime))
|
||||
{
|
||||
var values = queryContext.CreateData();
|
||||
values.Add(i.InvoiceTime);
|
||||
values.Add(i.Id);
|
||||
var status = i.Status.ToModernStatus();
|
||||
if (status == Client.Models.InvoiceStatus.Expired && i.ExceptionStatus == Client.Models.InvoiceExceptionStatus.None)
|
||||
continue;
|
||||
values.Add(status.ToString());
|
||||
|
||||
// There are two ways an invoice belong to a particular app.
|
||||
// 1. The invoice is internally tagged with the app id
|
||||
// 2. The app is a tag all invoices app
|
||||
// In both cases, we want to include the invoice in the report
|
||||
var appIds = tagAllinvoicesApps.Select(a => a.Id);
|
||||
var taggedAppId = AppService.GetAppInternalTags(i)?.FirstOrDefault();
|
||||
if (taggedAppId is string)
|
||||
appIds = appIds.Concat(new[] { taggedAppId }).Distinct().ToArray();
|
||||
|
||||
foreach (var appId in appIds)
|
||||
{
|
||||
values = values.ToList();
|
||||
values.Add(appId);
|
||||
if (i.Metadata?.ItemCode is string code)
|
||||
{
|
||||
values.Add(code);
|
||||
values.Add(1);
|
||||
values.Add(i.Currency);
|
||||
values.Add(i.Price);
|
||||
queryContext.Data.Add(values);
|
||||
}
|
||||
else
|
||||
{
|
||||
var posData = i.Metadata.PosData?.ToObject<PosAppData>();
|
||||
if (posData.Cart is { } cart)
|
||||
{
|
||||
foreach (var item in cart)
|
||||
{
|
||||
var copy = values.ToList();
|
||||
copy.Add(item.Id);
|
||||
copy.Add(item.Count);
|
||||
copy.Add(i.Currency);
|
||||
copy.Add(item.Price * item.Count);
|
||||
queryContext.Data.Add(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Round the currency amount
|
||||
foreach (var r in queryContext.Data)
|
||||
{
|
||||
r[^1] = ((decimal)r[^1]).RoundToSignificant(CurrencyNameTable.GetCurrencyData((string)r[^2] ?? "USD", true).Divisibility);
|
||||
}
|
||||
}
|
||||
|
||||
private ViewDefinition CreateDefinition()
|
||||
{
|
||||
return new ViewDefinition()
|
||||
{
|
||||
Fields =
|
||||
{
|
||||
new ("Date", "datetime"),
|
||||
new ("InvoiceId", "invoice_id"),
|
||||
new ("State", "string"),
|
||||
new ("AppId", "string"),
|
||||
new ("Product", "string"),
|
||||
new ("Quantity", "decimal"),
|
||||
new ("Currency", "string"),
|
||||
new ("CurrencyAmount", "decimal")
|
||||
},
|
||||
Charts =
|
||||
{
|
||||
new ()
|
||||
{
|
||||
Name = "Summary by products",
|
||||
Groups = { "AppId", "Currency", "State", "Product" },
|
||||
Aggregates = { "Quantity", "CurrencyAmount" },
|
||||
Totals = { "State" }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
34
BTCPayServer/Services/Reporting/QueryContext.cs
Normal file
34
BTCPayServer/Services/Reporting/QueryContext.cs
Normal file
@ -0,0 +1,34 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting
|
||||
{
|
||||
public record QueryContext
|
||||
{
|
||||
public QueryContext(string storeId, DateTimeOffset from, DateTimeOffset to)
|
||||
{
|
||||
StoreId = storeId;
|
||||
From = from;
|
||||
To = to;
|
||||
}
|
||||
public string StoreId { get; }
|
||||
public DateTimeOffset From { get; }
|
||||
public DateTimeOffset To { get; }
|
||||
public ViewDefinition? ViewDefinition { get; set; }
|
||||
|
||||
public IList<object> AddData()
|
||||
{
|
||||
var l = CreateData();
|
||||
Data.Add(l);
|
||||
return l;
|
||||
}
|
||||
|
||||
public IList<object> CreateData()
|
||||
{
|
||||
return new List<object>(ViewDefinition.Fields.Count);
|
||||
}
|
||||
|
||||
public IList<IList<object>> Data { get; set; } = new List<IList<object>>();
|
||||
}
|
||||
}
|
19
BTCPayServer/Services/Reporting/ReportProvider.cs
Normal file
19
BTCPayServer/Services/Reporting/ReportProvider.cs
Normal file
@ -0,0 +1,19 @@
|
||||
#nullable enable
|
||||
using System.Threading.Tasks;
|
||||
using System.Threading;
|
||||
using BTCPayServer.Data;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting
|
||||
{
|
||||
public abstract class ReportProvider
|
||||
{
|
||||
public virtual bool IsAvailable()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
public abstract string Name { get; }
|
||||
public abstract Task Query(QueryContext queryContext, CancellationToken cancellation);
|
||||
}
|
||||
}
|
16
BTCPayServer/Services/Reporting/ViewDefinition.cs
Normal file
16
BTCPayServer/Services/Reporting/ViewDefinition.cs
Normal file
@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
using BTCPayServer.Client.Models;
|
||||
|
||||
namespace BTCPayServer.Services.Reporting
|
||||
{
|
||||
public class ViewDefinition
|
||||
{
|
||||
public IList<StoreReportResponse.Field> Fields
|
||||
{
|
||||
get;
|
||||
set;
|
||||
} = new List<StoreReportResponse.Field>();
|
||||
|
||||
public List<ChartDefinition> Charts { get; set; } = new List<ChartDefinition>();
|
||||
}
|
||||
}
|
30
BTCPayServer/TagHelpers/CheatModeTagHelper.cs
Normal file
30
BTCPayServer/TagHelpers/CheatModeTagHelper.cs
Normal file
@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Razor.TagHelpers;
|
||||
using System.Xml.Linq;
|
||||
using BTCPayServer.Configuration;
|
||||
|
||||
namespace BTCPayServer.TagHelpers;
|
||||
|
||||
|
||||
[HtmlTargetElement(Attributes = "[cheat-mode]")]
|
||||
public class CheatModeTagHelper
|
||||
{
|
||||
public CheatModeTagHelper(BTCPayServerOptions env)
|
||||
{
|
||||
Env = env;
|
||||
}
|
||||
|
||||
public BTCPayServerOptions Env { get; }
|
||||
public bool CheatMode { get; set; }
|
||||
public void Process(TagHelperContext context, TagHelperOutput output)
|
||||
{
|
||||
if (Env.CheatMode != CheatMode)
|
||||
{
|
||||
output.SuppressOutput();
|
||||
}
|
||||
}
|
||||
}
|
@ -53,7 +53,7 @@
|
||||
<canvas id="fireworks" class="d-none"></canvas>
|
||||
}
|
||||
|
||||
<div class="public-page-wrap flex-column container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
|
||||
<div class="public-page-wrap container" id="app" @(Model.SimpleDisplay ? "" : "v-cloak")>
|
||||
@if (!string.IsNullOrEmpty(Model.MainImageUrl))
|
||||
{
|
||||
<img v-if="srvModel.mainImageUrl" :src="srvModel.mainImageUrl" :alt="srvModel.title" id="crowdfund-main-image" asp-append-version="true"/>
|
||||
|
@ -159,7 +159,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
|
||||
await ndef.scan({ signal: this.readerAbortController.signal })
|
||||
|
||||
ndef.onreadingerror = this.reportNfcError
|
||||
ndef.onreadingerror = () => this.reportNfcError('Could not read NFC tag')
|
||||
|
||||
ndef.onreading = async ({ message }) => {
|
||||
const record = message.records[0]
|
||||
@ -180,7 +180,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
await this.sendData(data)
|
||||
break;
|
||||
case 'nfc:error':
|
||||
this.reportNfcError()
|
||||
this.reportNfcError('Could not read NFC tag')
|
||||
break;
|
||||
}
|
||||
});
|
||||
@ -189,7 +189,7 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
// we came here, so the user must have allowed NFC access
|
||||
this.permissionGranted = true;
|
||||
} catch (error) {
|
||||
this.errorMessage = `NFC scan failed: ${error}`;
|
||||
this.reportNfcError(`NFC scan failed: ${error}`);
|
||||
}
|
||||
},
|
||||
async sendData (lnurl) {
|
||||
@ -197,6 +197,8 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
this.successMessage = null;
|
||||
this.errorMessage = null;
|
||||
|
||||
if (this.isV2) this.$root.playSound('nfcRead');
|
||||
|
||||
// Post LNURL-Withdraw data to server
|
||||
const body = JSON.stringify({ lnurl, invoiceId: this.model.invoiceId, amount: this.amount })
|
||||
const opts = { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }
|
||||
@ -208,15 +210,16 @@ Vue.component("lnurl-withdraw-checkout", {
|
||||
if (response.ok) {
|
||||
this.successMessage = result;
|
||||
} else {
|
||||
this.errorMessage = result;
|
||||
this.reportNfcError(error);
|
||||
}
|
||||
} catch (error) {
|
||||
this.errorMessage = error;
|
||||
this.reportNfcError(error);
|
||||
}
|
||||
this.submitting = false;
|
||||
},
|
||||
reportNfcError() {
|
||||
this.errorMessage = 'Could not read NFC tag';
|
||||
reportNfcError(message) {
|
||||
this.errorMessage = message;
|
||||
if (this.isV2) this.$root.playSound('error');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -1,349 +1,244 @@
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@using Newtonsoft.Json.Linq;
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@{
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
var customTipPercentages = Model.CustomTipPercentages;
|
||||
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
|
||||
Csp.UnsafeEval();
|
||||
}
|
||||
@section PageHeadContent {
|
||||
<link rel="stylesheet" href="~/cart/css/style.css" asp-append-version="true">
|
||||
<style>
|
||||
.js-cart-item-minus .fa,
|
||||
.js-cart-item-plus .fa {
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.card:not(.d-none:only-of-type) {
|
||||
max-width: 320px;
|
||||
margin: auto !important;
|
||||
}
|
||||
</style>
|
||||
<link href="~/pos/cart.css" asp-append-version="true" rel="stylesheet" />
|
||||
}
|
||||
@section PageFootContent {
|
||||
<script>var srvModel = @Safe.Json(Model);</script>
|
||||
<script src="~/cart/js/cart.js" asp-append-version="true"></script>
|
||||
<script src="~/cart/js/cart.jquery.js" asp-append-version="true"></script>
|
||||
<script id="template-cart-item" type="text/template">
|
||||
<tr data-id="{id}">
|
||||
<td class="align-middle pe-0" width="1%">{image}</td>
|
||||
<td class="align-middle pe-0 ps-2"><b>{title}</b></td>
|
||||
<td class="align-middle px-0">
|
||||
<a class="js-cart-item-remove btn btn-link" href="#"><i class="fa fa-trash text-muted"></i></a>
|
||||
</td>
|
||||
<td class="align-middle px-0">
|
||||
<div class="input-group align-items-center">
|
||||
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
|
||||
<input class="js-cart-item-count form-control form-control-sm pull-left hide-number-spin text-end" type="number" step="1" name="count" placeholder="Qty" max="{inventory}" value="{count}" data-prev="{count}">
|
||||
<a class="input-group-text js-cart-item-plus btn btn-link px-2" href="#">
|
||||
<i class="fa fa-plus-circle fa-fw text-success"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle text-end">{price}</td>
|
||||
</tr>
|
||||
</script>
|
||||
<script id="template-cart-item-image" type="text/template">
|
||||
<img class="cart-item-image" src="{image}" alt="">
|
||||
</script>
|
||||
<script id="template-cart-custom-amount" type="text/template">
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
|
||||
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Pay what you want" id="CartCustomAmount">
|
||||
<div class="input-group-text">
|
||||
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</script>
|
||||
<script id="template-cart-extra" type="text/template">
|
||||
@if (Model.ShowCustomAmount)
|
||||
{
|
||||
<tr>
|
||||
<th colspan="5" class="border-0 pb-0">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-shopping-cart fa-fw"></i></span>
|
||||
<input class="js-cart-custom-amount form-control" type="number" min="0" step="@Model.Step" name="amount" value="{customAmount}" placeholder="Pay what you want" id="CartCustomAmount">
|
||||
<a class="js-cart-custom-amount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
}
|
||||
@if (Model.ShowDiscount)
|
||||
{
|
||||
<tr>
|
||||
<th colspan="5" class="border-top-0">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="fa fa-percent fa-fw"></i></span>
|
||||
<input class="js-cart-discount form-control" type="number" min="0" step="@Model.Step" value="{discount}" name="discount" placeholder="Discount in %" id="CartDiscount">
|
||||
<a class="js-cart-discount-remove btn btn-danger" href="#"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
}
|
||||
</script>
|
||||
<script id="template-cart-tip" type="text/template">
|
||||
@if (Model.EnableTips)
|
||||
{
|
||||
<tr>
|
||||
<th colspan="5" class="border-top-0 pt-4 h5">@Model.CustomTipText</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th colspan="5" class="border-0">
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text"><i class="fa fa-money fa-fw"></i></span>
|
||||
<input
|
||||
class="js-cart-tip form-control form-control-lg"
|
||||
type="number"
|
||||
min="0"
|
||||
step="@Model.Step"
|
||||
value="{tip}"
|
||||
name="tip"
|
||||
placeholder="Tip in @(Model.CurrencyInfo.CurrencySymbol != null ? Model.CurrencyInfo.CurrencySymbol : Model.CurrencyCode)"
|
||||
/>
|
||||
<a class="js-cart-tip-remove btn btn-lg btn-danger" href="#"><i class="fa fa-times"></i></a>
|
||||
</div>
|
||||
<div class="row mb-1">
|
||||
@if (customTipPercentages != null && customTipPercentages.Length > 0)
|
||||
{
|
||||
@for (int i = 0; i < customTipPercentages.Length; i++)
|
||||
{
|
||||
var percentage = customTipPercentages[i];
|
||||
<div class="col">
|
||||
<a class="js-cart-tip-btn btn btn-lg btn-light w-100 border mb-2" href="#" data-tip="@percentage">@percentage%</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
}
|
||||
</script>
|
||||
<script id="template-cart-total" type="text/template">
|
||||
<tr>
|
||||
<th colspan="1" class="pb-4 h4">Total</th>
|
||||
<th colspan="4" class="pb-4 h4 text-end">
|
||||
<span class="js-cart-total" id="CartTotal">{total}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</script>
|
||||
<script>const srvModel = @Safe.Json(Model);</script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/common.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/cart.js" asp-append-version="true"></script>
|
||||
}
|
||||
<div id="cartModal" class="modal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white border-0">
|
||||
<h5 class="modal-title">Confirmation</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
|
||||
<vc:icon symbol="close" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body p-0">
|
||||
<table id="js-cart-summary" class="table m-0">
|
||||
<tbody class="my-3">
|
||||
<tr>
|
||||
<td colspan="2" class="border-top-0 h5">Summary</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="border-0 pb-0 h6">Total products</td>
|
||||
<td class="text-end border-0 pb-0 h6">
|
||||
<span class="js-cart-summary-products text-nowrap"></span>
|
||||
</td>
|
||||
</tr>
|
||||
@if (Model.ShowDiscount)
|
||||
{
|
||||
<tr>
|
||||
<td class="border-0 pb-y h6">Discount</td>
|
||||
<td class="text-end border-0 pb-y h6">
|
||||
<span class="js-cart-summary-discount text-nowrap" id="CartSummaryDiscount"></span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (Model.EnableTips)
|
||||
{
|
||||
<tr>
|
||||
<td class="border-top-0 pt-0 h6">Tip</td>
|
||||
<td class="text-end border-top-0 pt-0 h6">
|
||||
<span class="js-cart-summary-tip text-nowrap" id="CartSummaryTip"></span>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="h3 table-light">Total</td>
|
||||
<td class="h3 table-light text-end">
|
||||
<span class="js-cart-summary-total text-nowrap" id="CartSummaryTotal"></span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer bg-light">
|
||||
<form
|
||||
id="js-cart-pay-form"
|
||||
method="post"
|
||||
asp-action="ViewPointOfSale"
|
||||
asp-route-appId="@Model.AppId"
|
||||
asp-antiforgery="false"
|
||||
data-buy
|
||||
>
|
||||
<input id="js-cart-amount" type="hidden" name="amount">
|
||||
<input id="js-cart-custom-amount" type="hidden" name="customAmount">
|
||||
<input id="js-cart-tip" type="hidden" name="tip">
|
||||
<input id="js-cart-discount" type="hidden" name="discount">
|
||||
<input id="js-cart-posdata" type="hidden" name="posdata">
|
||||
<button id="js-cart-pay" class="btn btn-primary btn-lg" type="submit">
|
||||
<b>@Model.CustomButtonText</b>
|
||||
@functions {
|
||||
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
|
||||
{
|
||||
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
|
||||
if (item.Price == 0) return "free";
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
|
||||
}
|
||||
}
|
||||
|
||||
<div id="PosCart">
|
||||
<div id="content" class="public-page-wrap">
|
||||
<div class="container-xl">
|
||||
<header class="sticky-top bg-body d-flex flex-column py-3 py-lg-4 gap-3">
|
||||
<div class="d-flex align-items-center justify-content-center gap-3 pe-5 position-relative">
|
||||
<h1 class="mb-0">@(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title)</h1>
|
||||
<button id="CartToggle" class="cart-toggle-btn" type="button" v-on:click="toggleCart" aria-controls="cart" :disabled="cartCount === 0">
|
||||
<vc:icon symbol="pos-cart" />
|
||||
<span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrapper">
|
||||
<!-- Page Content -->
|
||||
<div id="content">
|
||||
<div class="p-2 p-sm-4">
|
||||
<partial name="_StatusMessage" />
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<div class="flex-fill position-relative">
|
||||
<input type="text" class="js-search form-control form-control-lg" placeholder="Find product">
|
||||
<a class="js-search-reset btn btn-lg btn-link text-black" href="#">
|
||||
<i class="fa fa-times-circle fa-lg"></i>
|
||||
</a>
|
||||
</div>
|
||||
<a class="js-cart btn btn-lg btn-outline-primary text-nowrap" href="#">
|
||||
<i class="fa fa-shopping-basket"></i>
|
||||
<span class="badge bg-light rounded-pill">
|
||||
<span id="js-cart-items">0</span>
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="lead text-center mt-3">@Safe.Raw(Model.Description)</div>
|
||||
}
|
||||
@if (Model.AllCategories != null)
|
||||
{
|
||||
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3 mt-3" data-toggle="buttons" v-pre>
|
||||
@foreach (var g in Model.AllCategories)
|
||||
{
|
||||
<input id="Category-@g.Value" type="radio" name="category" class="js-categories" value="@g.Value" @(g.Selected ? "checked" : "") autocomplete="off">
|
||||
<label class="btcpay-pill" for="Category-@g.Value">@g.Text</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div id="js-pos-list" class="text-center mx-auto px-2 px-sm-4 py-4 py-sm-2">
|
||||
<div class="card-deck">
|
||||
@for (var index = 0; index < Model.Items.Length; index++)
|
||||
<input id="SearchTerm" class="form-control rounded-pill" placeholder="Search…" v-model="searchTerm">
|
||||
<div v-if="allCategories" class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3">
|
||||
<template v-for="cat in allCategories">
|
||||
<input :id="`Category-${cat.value}`" type="radio" name="category" autocomplete="off" v-model="displayCategory" :value="cat.value">
|
||||
<label :for="`Category-${cat.value}`" class="btcpay-pill">{{ cat.text }}</label>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<partial name="_StatusMessage" />
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
var item = Model.Items[index];
|
||||
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var image = item.Image;
|
||||
var description = item.Description;
|
||||
|
||||
<div class="js-add-cart card px-0 card-wrapper" data-index="@index" data-categories="@(new JArray(item.Categories).ToString())">
|
||||
@if (!string.IsNullOrWhiteSpace(image))
|
||||
{
|
||||
<img class="card-img-top" src="@image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
|
||||
}
|
||||
<div class="card-body p-3">
|
||||
<h6 class="card-title mb-0">@Safe.Raw(item.Title)</h6>
|
||||
@if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
<p class="card-text">@Safe.Raw(description)</p>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
|
||||
<span class="text-muted small">
|
||||
@{
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
buttonText = buttonText.Replace("{0}",formatted)
|
||||
?.Replace("{Price}",formatted);
|
||||
}
|
||||
}
|
||||
@Safe.Raw(buttonText)
|
||||
</span>
|
||||
@if (item.Inventory.HasValue)
|
||||
{
|
||||
|
||||
<div class="w-100 pt-2 text-center text-muted">
|
||||
@if (item.Inventory > 0)
|
||||
{
|
||||
<span>@item.Inventory left</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Sold out</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (anyInventoryItems)
|
||||
{
|
||||
<div class="w-100 pt-2"> </div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="lead">@Safe.Raw(Model.Description)</div>
|
||||
}
|
||||
</div>
|
||||
<div ref="posItems" class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-2 row-cols-xl-3 row-cols-xxl-4 g-4" id="PosItems">
|
||||
@for (var index = 0; index < Model.Items.Length; index++)
|
||||
{
|
||||
var item = Model.Items[index];
|
||||
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var formatted = GetItemPriceFormatted(item);
|
||||
var inStock = item.Inventory is null or > 0;
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
|
||||
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
|
||||
: item.BuyButtonText;
|
||||
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
|
||||
|
||||
<div class="col posItem" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.Raw(item.Title) @Safe.Raw(item.Description)" data-categories="@(new JArray(item.Categories).ToString())">
|
||||
<div class="card h-100 px-0" v-on:click="addToCart(@index)">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Image))
|
||||
{
|
||||
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
|
||||
}
|
||||
<div class="card-body p-3 d-flex flex-column gap-2">
|
||||
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
|
||||
{
|
||||
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fw-semibold">@Safe.Raw(formatted)</span>
|
||||
}
|
||||
@if (item.Inventory.HasValue)
|
||||
{
|
||||
<span class="badge text-bg-warning" v-text="inventoryText(@index)">
|
||||
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
<p class="card-text">@Safe.Raw(item.Description)</p>
|
||||
}
|
||||
</div>
|
||||
@if (inStock)
|
||||
{
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
|
||||
<button type="button" class="btn btn-primary w-100" :disabled="!inStock(@index)">
|
||||
@Safe.Raw(buttonText)
|
||||
</button>
|
||||
</div>
|
||||
<div class="posItem-added"><vc:icon symbol="checkmark" /></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="store-footer">
|
||||
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||
Powered by <partial name="_StoreFooterLogo" />
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<nav id="sidebar">
|
||||
<div class="d-flex align-items-center pt-4 p-2">
|
||||
<h3 class="text-white m-0 me-auto">Cart</h3>
|
||||
<a class="js-cart btn btn-sm bg-white text-black pull-right ms-5" href="#">
|
||||
<i class="fa fa-times fa-lg"></i>
|
||||
</a>
|
||||
<a class="js-cart-destroy btn btn-danger pull-right" href="#" style="display: none;" id="CartClear">Empty cart <i class="fa fa-trash fa-fw fa-lg"></i></a>
|
||||
<aside id="cart" ref="cart" tabindex="-1" aria-labelledby="cartLabel">
|
||||
<div class="public-page-wrap" v-cloak>
|
||||
<div class="container-xl">
|
||||
<header class="sticky-top bg-tile offcanvas-header py-3 py-lg-4 d-flex align-items-baseline justify-content-center gap-3 px-5 pe-lg-0">
|
||||
<h1 class="mb-0" id="cartLabel">Cart</h1>
|
||||
<button id="CartClear" type="reset" v-on:click="clearCart" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
|
||||
Empty
|
||||
</button>
|
||||
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
|
||||
<vc:icon symbol="close" />
|
||||
</button>
|
||||
</header>
|
||||
<div class="offcanvas-body py-0">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" v-if="cartCount !== 0">
|
||||
<input type="hidden" name="amount" :value="totalNumeric">
|
||||
<input type="hidden" name="tip" :value="tipNumeric">
|
||||
<input type="hidden" name="discount" :value="discountPercentNumeric">
|
||||
<input type="hidden" name="posdata" :value="posdata">
|
||||
<table class="table table-borderless mt-0 mb-4">
|
||||
<tbody id="CartItems">
|
||||
<tr v-for="item in cart" :key="item.id">
|
||||
<td class="align-middle">
|
||||
<h6 class="fw-semibold mb-1">{{ item.title }}</h6>
|
||||
<button type="button" v-on:click="removeFromCart(item.id)" class="btn btn-sm btn-link p-0 text-danger fw-semibold">Remove</button>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex align-items-center gap-2 justify-content-end quantity">
|
||||
<span class="badge text-bg-warning" v-if="item.inventory">
|
||||
{{ item.inventory > 0 ? `${item.inventory} left` : "Sold out" }}
|
||||
</span>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" v-on:click="updateQuantity(item.id, -1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
|
||||
<vc:icon symbol="minus" />
|
||||
</button>
|
||||
<input class="form-control hide-number-spin w-50px" type="number" placeholder="Qty" min="1" step="1" :max="item.inventory" v-model.number="item.count">
|
||||
<button type="button" v-on:click="updateQuantity(item.id, +1)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center">
|
||||
<vc:icon symbol="plus" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle text-end">
|
||||
{{ formatCurrency(item.price, true) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="table table-borderless my-4" v-if="showDiscount || enableTips">
|
||||
<tr v-if="showDiscount">
|
||||
<th class="align-middle">Discount</th>
|
||||
<th class="align-middle" colspan="3">
|
||||
<div class="input-group input-group-sm w-100px pull-right">
|
||||
<input class="form-control hide-number-spin" type="number" min="0" step="1" max="100" id="Discount" v-model.number="discountPercent">
|
||||
<span class="input-group-text">%</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr v-if="enableTips">
|
||||
<th class="align-middle">Tip</th>
|
||||
<th class="align-middle" colspan="3">
|
||||
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-end gap-2" v-if="customTipPercentages">
|
||||
<div class="btcpay-pill d-flex align-items-center px-3" id="Tip-Custom" :class="{ active: !tipPercent && tip }" v-on:click.prevent="tipPercent = null">
|
||||
<input
|
||||
v-model.number="tip"
|
||||
class="form-control hide-number-spin shadow-none text-reset d-block bg-transparent border-0 p-0 me-1 fw-semibold"
|
||||
style="height:1.5em;min-height:auto;width:4ch"
|
||||
type="number"
|
||||
min="0"
|
||||
step="@Model.Step" />
|
||||
<span>@(Model.CurrencyInfo.CurrencySymbol ?? Model.CurrencyCode)</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="percentage in customTipPercentages"
|
||||
type="button"
|
||||
class="btcpay-pill px-3"
|
||||
:class="{ active: tipPercent == percentage }"
|
||||
:id="`Tip-${percentage}`"
|
||||
v-on:click.prevent="tipPercentage(percentage)">
|
||||
{{ percentage }}%
|
||||
</button>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="table table-borderless mt-4 mb-0">
|
||||
<tr>
|
||||
<td class="align-middle">Subtotal</td>
|
||||
<td class="align-middle text-end" id="CartAmount">{{ formatCurrency(amountNumeric, true) }}</td>
|
||||
</tr>
|
||||
<tr v-if="discountNumeric">
|
||||
<td class="align-middle">Discount</td>
|
||||
<td class="align-middle text-end" id="CartDiscount">
|
||||
<span v-if="discountPercent">{{discountPercent}}% =</span>
|
||||
{{ formatCurrency(discountNumeric, true) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tipNumeric">
|
||||
<td class="align-middle">Tip</td>
|
||||
<td class="align-middle text-end" id="CartTip">
|
||||
<span v-if="tipPercent">{{tipPercent}}% =</span>
|
||||
{{ formatCurrency(tipNumeric, true) }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="align-middle h5 border-0">Total</td>
|
||||
<td class="align-middle h5 border-0 text-end" id="CartTotal">{{ formatCurrency(totalNumeric, true) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" class="pt-4">
|
||||
<button id="CartSubmit" class="btn btn-primary btn-lg w-100" :disabled="payButtonLoading" type="submit">
|
||||
<div v-if="payButtonLoading" class="spinner-border spinner-border-sm" id="pay-button-spinner" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<template v-else>Pay</template>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</form>
|
||||
<p id="CartItems" v-else class="text-muted text-center my-0">There are no items in your cart yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table id="js-cart-list" class="table table-responsive table-light mt-0 mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colspan="3" width="55%">Product</th>
|
||||
<th class="text-center" width="20%">
|
||||
<div style="width: 84px">Quantity</div>
|
||||
</th>
|
||||
<th class="text-end" width="25%">
|
||||
<div style="min-width: 50px">Price</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<table id="js-cart-extra" class="table table-light mt-0 mb-0">
|
||||
<thead></thead>
|
||||
</table>
|
||||
|
||||
<button id="js-cart-confirm" data-bs-toggle="modal" data-bs-target="#cartModal" class="btn btn-primary btn-lg mx-2 mb-3 p-3" disabled="disabled" type="submit">
|
||||
Confirm
|
||||
</button>
|
||||
|
||||
<footer class="store-footer">
|
||||
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||
Powered by <partial name="_StoreFooterLogo" />
|
||||
</a>
|
||||
</footer>
|
||||
</nav>
|
||||
</aside>
|
||||
</div>
|
||||
|
@ -5,112 +5,15 @@
|
||||
Csp.UnsafeEval();
|
||||
}
|
||||
@section PageHeadContent {
|
||||
<style>
|
||||
.public-page-wrap {
|
||||
max-width: 560px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* modes */
|
||||
#ModeTabs {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* keypad */
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.keypad .btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
font-weight: var(--btcpay-font-weight-semibold);
|
||||
font-size: 24px;
|
||||
min-height: 3.5rem;
|
||||
height: 8vh;
|
||||
max-height: 6rem;
|
||||
color: var(--btcpay-body-text);
|
||||
}
|
||||
.keypad .btn[data-key="del"] svg {
|
||||
--btn-icon-size: 2.25rem;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.btcpay-pills label,
|
||||
.btn-secondary.rounded-pill {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
/* make borders collapse by shifting rows and columns by 1px */
|
||||
/* second column */
|
||||
.keypad .btn:nth-child(3n-1) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
/* third column */
|
||||
.keypad .btn:nth-child(3n) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
/* from second row downwards */
|
||||
.keypad .btn:nth-child(n+4) {
|
||||
margin-top: -1px;
|
||||
}
|
||||
/* ensure highlighted button is topmost */
|
||||
.keypad .btn:hover,
|
||||
.keypad .btn:focus,
|
||||
.keypad .btn:active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.actions .btn {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
#Calculation {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
@@media (max-height: 700px) {
|
||||
.store-header {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@media (max-width: 575px) {
|
||||
.public-page-wrap {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.keypad {
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
}
|
||||
.store-footer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* fix sticky hover effect on mobile browsers */
|
||||
@@media (hover: none) {
|
||||
.keypad .btn-secondary:hover,
|
||||
.actions .btn-secondary:hover {
|
||||
border-color: var(--btcpay-secondary-border-active) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="~/pos/keypad.css" asp-append-version="true" rel="stylesheet" />
|
||||
}
|
||||
@section PageFootContent {
|
||||
<script>var srvModel = @Safe.Json(Model);</script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/light-pos/app.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/common.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/keypad.js" asp-append-version="true"></script>
|
||||
}
|
||||
<div class="public-page-wrap flex-column">
|
||||
<div class="public-page-wrap">
|
||||
<partial name="_StatusMessage" />
|
||||
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
|
||||
@if (Context.Request.Query.ContainsKey("simple"))
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="container p-0 l-pos-wrapper my-0 mx-auto">
|
||||
<div class="py-5 px-3">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">@Model.CurrencySymbol</span>
|
||||
<input class="form-control" type="number" step="@Model.Step" name="amount" placeholder="Amount">
|
||||
|
@ -19,15 +19,7 @@
|
||||
}
|
||||
}
|
||||
@section PageHeadContent {
|
||||
<style>
|
||||
/* This hides unwanted metadata such as url, date, etc from appearing on a printed page. */
|
||||
@@media print {
|
||||
@@page {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<link href="~/pos/print.css" asp-append-version="true" rel="stylesheet" />
|
||||
}
|
||||
|
||||
@if (supported is null)
|
||||
@ -50,14 +42,14 @@ else
|
||||
<a asp-route-viewType="static" class="alert-link">Regular version</a>
|
||||
</div>
|
||||
}
|
||||
<div class="container public-page-wrap flex-column">
|
||||
<div id="PosPrint" class="public-page-wrap container-xl">
|
||||
<partial name="_StatusMessage" />
|
||||
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
|
||||
}
|
||||
<main class="flex-grow-1 justify-content-center align-self-center mx-auto py-3">
|
||||
<main>
|
||||
@if (supported is not null)
|
||||
{
|
||||
if (Model.ShowCustomAmount)
|
||||
@ -75,37 +67,45 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
<div class="card-deck mx-auto">
|
||||
<div class="posItems">
|
||||
@for (var x = 0; x < Model.Items.Length; x++)
|
||||
{
|
||||
var item = Model.Items[x];
|
||||
<div class="card" data-id="@x">
|
||||
<div class="card-body my-auto">
|
||||
<h4 class="card-title text-center">@Safe.Raw(item.Title)</h4>
|
||||
@if (!string.IsNullOrEmpty(item.Description))
|
||||
{
|
||||
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
|
||||
}
|
||||
<div class="w-100 mb-3 fs-5 text-center">
|
||||
@{
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
}
|
||||
@switch (item.PriceType)
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
<div class="d-flex flex-wrap">
|
||||
<div class="card px-0" data-id="@x">
|
||||
<div class="card-body p-3 d-flex flex-column gap-2">
|
||||
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="fw-semibold">
|
||||
@switch (item.PriceType)
|
||||
{
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
|
||||
<span>Any amount</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
|
||||
<span>@formatted minimum</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
|
||||
@formatted
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
}
|
||||
</span>
|
||||
@if (item.Inventory.HasValue)
|
||||
{
|
||||
<span class="badge text-bg-light">
|
||||
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
|
||||
<span>Any amount</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
|
||||
<span>@formatted minimum</span>
|
||||
break;
|
||||
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
|
||||
@formatted
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentOutOfRangeException();
|
||||
<p class="card-text">@Safe.Raw(item.Description)</p>
|
||||
}
|
||||
</div>
|
||||
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
|
||||
@if (item.Inventory is null or > 0)
|
||||
{
|
||||
if (supported != null)
|
||||
{
|
||||
@ -116,7 +116,7 @@ else
|
||||
ItemCode = item.Id
|
||||
}, Context.Request.Scheme, Context.Request.Host.ToString()));
|
||||
var lnUrl = LNURL.EncodeUri(lnurlEndpoint, "payRequest", supported.UseBech32Scheme);
|
||||
<a href="@lnUrl" rel="noreferrer noopener" class="d-block mx-auto text-center">
|
||||
<a href="@lnUrl" rel="noreferrer noopener" class="card-img-bottom">
|
||||
<vc:qr-code data="@lnUrl.ToString().ToUpperInvariant()" />
|
||||
</a>
|
||||
}
|
||||
@ -126,7 +126,7 @@ else
|
||||
}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="store-footer">
|
||||
<footer class="store-footer d-print-none">
|
||||
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||
Powered by <partial name="_StoreFooterLogo" />
|
||||
</a>
|
||||
|
@ -1,93 +1,107 @@
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@{
|
||||
Layout = "PointOfSale/Public/_Layout";
|
||||
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
|
||||
}
|
||||
@functions {
|
||||
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item)
|
||||
{
|
||||
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount";
|
||||
if (item.Price == 0) return "free";
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
|
||||
}
|
||||
}
|
||||
|
||||
<style>
|
||||
.card:only-of-type {
|
||||
max-width: 320px;
|
||||
margin: auto !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="container public-page-wrap flex-column">
|
||||
<partial name="_StatusMessage" />
|
||||
<div id="PosStatic" class="public-page-wrap container-xl">
|
||||
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="lead text-center">@Safe.Raw(Model.Description)</div>
|
||||
}
|
||||
<main class="flex-grow-1 justify-content-center align-self-center text-center mx-auto py-3">
|
||||
<div class="card-deck mx-auto">
|
||||
<main>
|
||||
<partial name="_StatusMessage" />
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
{
|
||||
<div class="lead">@Safe.Raw(Model.Description)</div>
|
||||
}
|
||||
<div class="row row-cols row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 g-4" id="PosItems">
|
||||
@for (var x = 0; x < Model.Items.Length; x++)
|
||||
{
|
||||
var item = Model.Items[x];
|
||||
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
|
||||
var formatted = GetItemPriceFormatted(item);
|
||||
var inStock = item.Inventory is null or > 0;
|
||||
var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
|
||||
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
|
||||
: item.BuyButtonText;
|
||||
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
|
||||
|
||||
<div class="card px-0" data-id="@x">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Image))
|
||||
{
|
||||
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
|
||||
}
|
||||
@{CardBody(item.Title, item.Description);}
|
||||
<div class="card-footer bg-transparent border-0 pb-3">
|
||||
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
|
||||
<div class="col">
|
||||
<div class="card h-100 px-0" data-id="@x">
|
||||
@if (!string.IsNullOrWhiteSpace(item.Image))
|
||||
{
|
||||
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
|
||||
{
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy autocomplete="off">
|
||||
<input type="hidden" name="choiceKey" value="@item.Id" />
|
||||
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.PriceType, item.Price.Value, item.Price.Value);}
|
||||
</form>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
|
||||
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
|
||||
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
|
||||
@Safe.Raw(buttonText)
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
<img class="card-img-top" src="@item.Image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
|
||||
}
|
||||
@if (item.Inventory.HasValue)
|
||||
{
|
||||
<div class="w-100 pt-2 text-center text-muted">
|
||||
@if (item.Inventory > 0)
|
||||
<div class="card-body p-3 d-flex flex-column gap-2">
|
||||
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0)
|
||||
{
|
||||
<span>@item.Inventory left</span>
|
||||
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Sold out</span>
|
||||
<span class="fw-semibold">@Safe.Raw(formatted)</span>
|
||||
}
|
||||
@if (item.Inventory.HasValue)
|
||||
{
|
||||
<span class="badge text-bg-warning">
|
||||
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (anyInventoryItems)
|
||||
{
|
||||
<div class="w-100 pt-2"> </div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(item.Description))
|
||||
{
|
||||
<p class="card-text">@Safe.Raw(item.Description)</p>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
|
||||
@if (inStock)
|
||||
{
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
|
||||
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
|
||||
<input type="hidden" name="choiceKey" value="@item.Id" />
|
||||
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
|
||||
{
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text">@Model.CurrencySymbol</span>
|
||||
<input class="form-control" type="number" min="@(item.Price ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@item.Price" required>
|
||||
</div>
|
||||
}
|
||||
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(buttonText)</button>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.ShowCustomAmount)
|
||||
{
|
||||
<div class="card px-0">
|
||||
@{CardBody("Custom Amount", "Create invoice to pay custom amount");}
|
||||
<div class="card-footer bg-transparent border-0 pb-3">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
|
||||
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.ItemPriceType.Minimum);}
|
||||
</form>
|
||||
@if (anyInventoryItems)
|
||||
{
|
||||
<div class="w-100 pt-2"> </div>
|
||||
}
|
||||
<div class="col">
|
||||
<div class="card h-100 px-0">
|
||||
<div class="card-body p-3 d-flex flex-column gap-2">
|
||||
<h5 class="card-title">Custom Amount</h5>
|
||||
<p class="card-text">Create invoice to pay custom amount</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pb-3">
|
||||
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
|
||||
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
|
||||
<div class="input-group mb-2">
|
||||
<span class="input-group-text">@Model.CurrencySymbol</span>
|
||||
<input class="form-control" type="number" min="0" step="@Model.Step" name="amount" placeholder="Amount" required>
|
||||
</div>
|
||||
<button class="btn btn-primary w-100" type="submit">@Safe.Raw(Model.CustomButtonText ?? Model.ButtonText)</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@ -99,36 +113,3 @@
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@functions {
|
||||
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
|
||||
{
|
||||
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
|
||||
{
|
||||
<div class="input-group">
|
||||
<input class="form-control" type="text" readonly value="Free"/>
|
||||
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">@Model.CurrencySymbol</span>
|
||||
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
|
||||
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)">
|
||||
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
private void CardBody(string title, string description)
|
||||
{
|
||||
<div class="card-body my-auto pb-0">
|
||||
<h5 class="card-title">@Safe.Raw(title)</h5>
|
||||
@if (!string.IsNullOrWhiteSpace(description))
|
||||
{
|
||||
<p class="card-text">@Safe.Raw(description)</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
@ -1,22 +1,22 @@
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
|
||||
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
|
||||
<form id="PosKeypad" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
|
||||
<input id="posdata" type="hidden" name="posdata" v-model="posdata">
|
||||
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
|
||||
<div class="fw-semibold text-muted" id="Currency">{{srvModel.currencyCode}}</div>
|
||||
<div class="fw-semibold text-muted" id="Currency">{{currencyCode}}</div>
|
||||
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
|
||||
<div class="text-muted text-center mt-2" id="Calculation" v-if="srvModel.showDiscount || srvModel.enableTips">{{ calculation }}</div>
|
||||
<div class="text-muted text-center mt-2" id="Calculation" v-if="showDiscount || enableTips">{{ calculation }}</div>
|
||||
</div>
|
||||
<div id="ModeTabs" class="tab-content mb-n2" v-if="srvModel.showDiscount || srvModel.enableTips">
|
||||
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="srvModel.showDiscount">
|
||||
<div id="ModeTabs" class="tab-content mb-n2" v-if="showDiscount || enableTips">
|
||||
<div id="Mode-Discount" class="tab-pane fade px-2" :class="{ show: mode === 'discount', active: mode === 'discount' }" role="tabpanel" aria-labelledby="ModeTablist-Discount" v-if="showDiscount">
|
||||
<div class="h4 fw-semibold text-muted text-center" id="Discount">
|
||||
<span class="h3 text-body me-1">{{discountPercent || 0}}%</span> discount
|
||||
</div>
|
||||
</div>
|
||||
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="srvModel.enableTips">
|
||||
<div id="Mode-Tip" class="tab-pane fade px-2" :class="{ show: mode === 'tip', active: mode === 'tip' }" role="tabpanel" aria-labelledby="ModeTablist-Tip" v-if="enableTips">
|
||||
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2">
|
||||
<template v-if="srvModel.customTipPercentages">
|
||||
<template v-if="customTipPercentages">
|
||||
<button
|
||||
id="Tip-Custom"
|
||||
type="button"
|
||||
@ -27,7 +27,7 @@
|
||||
<template v-else>Custom</template>
|
||||
</button>
|
||||
<button
|
||||
v-for="percentage in srvModel.customTipPercentages"
|
||||
v-for="percentage in customTipPercentages"
|
||||
type="button"
|
||||
class="btcpay-pill"
|
||||
:class="{ active: tipPercent == percentage }"
|
||||
|
@ -3,8 +3,9 @@
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Newtonsoft.Json.Linq
|
||||
@using System.IO
|
||||
@using BTCPayServer.Services
|
||||
@inject IWebHostEnvironment WebHostEnvironment
|
||||
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@{
|
||||
ViewData["Title"] = string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title;
|
||||
@ -38,19 +39,7 @@
|
||||
<link rel="apple-touch-icon" href="~/img/icons/icon-512x512.png">
|
||||
<link rel="apple-touch-startup-image" href="~/img/splash.png">
|
||||
<link rel="manifest" href="@(await GetDynamicManifest(ViewData["Title"]!.ToString()))">
|
||||
<style>
|
||||
.lead :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.card-deck {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-gap: 1.5rem;
|
||||
}
|
||||
.card {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
</style>
|
||||
<link href="~/pos/common.css" asp-append-version="true" rel="stylesheet" />
|
||||
@await RenderSectionAsync("PageHeadContent", false)
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
|
@ -365,7 +365,7 @@
|
||||
show(discounts);
|
||||
show(description);
|
||||
show(buttonPriceText);
|
||||
show(customPayments);
|
||||
hide(customPayments);
|
||||
break;
|
||||
case 'Light':
|
||||
show(tips);
|
||||
|
@ -23,8 +23,7 @@
|
||||
{
|
||||
<th class="w-150px">@key</th>
|
||||
}
|
||||
<td style="white-space:pre-wrap">
|
||||
@if (IsValidURL(str))
|
||||
<td style="white-space:pre-wrap">@* Explicitely remove whitespace at front here *@@if (IsValidURL(str))
|
||||
{
|
||||
<a href="@str" target="_blank" rel="noreferrer noopener">@str</a>
|
||||
}
|
||||
|
@ -100,7 +100,7 @@
|
||||
<span class="text-danger" v-pre>@error.ErrorMessage</span>
|
||||
}
|
||||
}
|
||||
<div class="bg-light card">
|
||||
<div class="bg-tile card">
|
||||
<div class="card-body " v-bind:class="{ 'card-deck': config.length > 0}">
|
||||
<div v-if="!config || config.length === 0" class="col-12 text-center">
|
||||
No items.<br />
|
||||
@ -108,7 +108,7 @@
|
||||
Add your first item
|
||||
</button>
|
||||
</div>
|
||||
<div v-else v-for="(item, index) of config" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
|
||||
<div v-else v-for="(item, index) of config" class="card my-2 template-item me-0 ms-0" v-bind:key="item.id">
|
||||
<div class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" v-html="item.title"></h6>
|
||||
|
@ -13,7 +13,7 @@
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="public-page-wrap flex-column">
|
||||
<div class="public-page-wrap">
|
||||
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })" />
|
||||
@if (!string.IsNullOrEmpty(Model.StoreName) || !string.IsNullOrEmpty(Model.LogoFileId))
|
||||
{
|
||||
|
@ -1,6 +1,7 @@
|
||||
@using BTCPayServer.Services
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Microsoft.EntityFrameworkCore.Diagnostics
|
||||
@inject LanguageService LangService
|
||||
@inject BTCPayServerEnvironment Env
|
||||
@inject IEnumerable<IUIExtension> UiExtensions
|
||||
@ -27,6 +28,18 @@
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<link href="~/checkout-v2/checkout.css" asp-append-version="true" rel="stylesheet" />
|
||||
<partial name="LayoutHeadStoreBranding" model="@(Model.BrandColor, Model.CssFileId, "", "")" />
|
||||
@if (!string.IsNullOrEmpty(Model.PaymentSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.PaymentSoundUrl" as="audio" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.NfcReadSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.NfcReadSoundUrl" as="audio" />
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ErrorSoundUrl))
|
||||
{
|
||||
<link rel="preload" href="@Model.ErrorSoundUrl" as="audio" />
|
||||
}
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div id="Checkout-v2" class="public-page-wrap" v-cloak>
|
||||
|
@ -42,7 +42,7 @@
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-vh-100">
|
||||
<div class="public-page-wrap flex-column">
|
||||
<div class="public-page-wrap">
|
||||
<main class="flex-grow-1">
|
||||
<div class="container" style="max-width:720px;">
|
||||
<partial name="_StatusMessage" model="@(new ViewDataDictionary(ViewData) { { "Margin", "mb-4" } })"/>
|
||||
|
@ -26,7 +26,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
|
||||
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
|
||||
<span class="input-group-text">minutes</span>
|
||||
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
|
||||
</div>
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="CancelPayoutAfterFailures" class="form-label">Max Payout Failure Attempts</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:10ch;">
|
||||
<input asp-for="CancelPayoutAfterFailures" min="1" class="form-control" inputmode="numeric" style="max-width:12ch;">
|
||||
<span class="input-group-text">attempts</span>
|
||||
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
|
||||
</div>
|
||||
|
@ -26,7 +26,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="IntervalMinutes" class="form-label" data-required>Interval</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:10ch;">
|
||||
<input asp-for="IntervalMinutes" class="form-control" inputmode="numeric" style="max-width:12ch;">
|
||||
<span class="input-group-text">minutes</span>
|
||||
<span asp-validation-for="IntervalMinutes" class="text-danger"></span>
|
||||
</div>
|
||||
@ -34,7 +34,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="FeeTargetBlock" class="form-label" data-required>Fee block target</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:10ch;">
|
||||
<input asp-for="FeeTargetBlock" class="form-control" min="1" inputmode="numeric" style="max-width:12ch;">
|
||||
<span class="input-group-text">blocks</span>
|
||||
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
|
||||
</div>
|
||||
@ -42,7 +42,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="Threshold" class="form-label" data-required>Threshold</label>
|
||||
<div class="input-group">
|
||||
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:10ch;">
|
||||
<input asp-for="Threshold" class="form-control" min="0" inputmode="numeric" style="max-width:12ch;">
|
||||
<span class="input-group-text">@cryptoCode</span>
|
||||
<span asp-validation-for="FeeTargetBlock" class="text-danger"></span>
|
||||
</div>
|
||||
|
128
BTCPayServer/Views/UIReports/StoreReports.cshtml
Normal file
128
BTCPayServer/Views/UIReports/StoreReports.cshtml
Normal file
@ -0,0 +1,128 @@
|
||||
@using BTCPayServer.Abstractions.Extensions;
|
||||
@using BTCPayServer.Client.Models;
|
||||
@using BTCPayServer.Models.StoreReportsViewModels;
|
||||
@using BTCPayServer.Views.Invoice;
|
||||
@using BTCPayServer.Views.Stores;
|
||||
@using BTCPayServer.Abstractions.Services;
|
||||
@using Microsoft.AspNetCore.Routing;
|
||||
@inject Safe Safe
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model StoreReportsViewModel
|
||||
@{
|
||||
ViewData.SetActivePage(StoreNavPages.Reporting, "Reporting");
|
||||
Csp.UnsafeEval();
|
||||
}
|
||||
@section PageHeadContent
|
||||
{
|
||||
@* Set a height for the responsive table container to make it work with the fixed table headers.
|
||||
Details described here: thttps://uxdesign.cc/position-stuck-96c9f55d9526 *@
|
||||
<style>#app .table-responsive { max-height: 80vh; }</style>
|
||||
}
|
||||
|
||||
<div class="sticky-header">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<a cheat-mode="true" class="btn btn-outline-info text-nowrap" asp-action="StoreReports" asp-route-fakeData="true" asp-route-viewName="@Model.Request?.ViewName">Create fake date</a>
|
||||
<button id="exportCSV" class="btn btn-primary text-nowrap" type="button">
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-sm-row align-items-sm-0center gap-3">
|
||||
<div class="dropdown" v-pre>
|
||||
<button id="ViewNameToggle" class="btn btn-secondary dropdown-toggle dropdown-toggle-custom-caret" type="button" data-bs-toggle="dropdown" aria-expanded="false">@Model.Request.ViewName</button>
|
||||
<div class="dropdown-menu" aria-labelledby="ViewNameToggle">
|
||||
@foreach (var v in Model.AvailableViews)
|
||||
{
|
||||
<a href="#" data-view="@v" class="available-view dropdown-item @(Model.Request.ViewName == v ? "custom-active" : "")">@v</a>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input id="fromDate" class="form-control flatdtpicker" type="datetime-local"
|
||||
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
|
||||
placeholder="Start Date" />
|
||||
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
|
||||
<span class="fa fa-times"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<input id="toDate" class="form-control flatdtpicker" type="datetime-local"
|
||||
data-fdtp='{ "enableTime": true, "enableSeconds": true, "dateFormat": "Y-m-d H:i:S", "time_24hr": true, "defaultHour": 0 }'
|
||||
placeholder="End Date" />
|
||||
<button type="button" class="btn btn-primary input-group-clear" title="Clear">
|
||||
<span class="fa fa-times"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" v-cloak class="w-100-fixed">
|
||||
<article v-for="chart in srv.charts" class="mb-5">
|
||||
<h3>{{ chart.name }}</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover w-auto">
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
<th v-for="group in chart.groups">{{ group }}</th>
|
||||
<th v-for="agg in chart.aggregates">{{ agg }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in chart.rows">
|
||||
<td v-for="group in row.groups" :rowspan="group.rowCount">{{ group.name }}</td>
|
||||
<td v-if="row.isTotal" :colspan="row.rLevel">Total</td>
|
||||
<td v-for="value in row.values">{{ value }}</td>
|
||||
</tr>
|
||||
<tr v-if="chart.hasGrandTotal"><td :colspan="chart.groups.length">Grand total</td><td v-for="value in chart.grandTotalValues">{{ value }}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
<article>
|
||||
<h3 id="raw-data">Raw data</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover w-auto">
|
||||
<thead class="sticky-top bg-body">
|
||||
<tr>
|
||||
<th v-for="field in srv.result.fields">
|
||||
<a class="text-nowrap sort-column"
|
||||
href="#"
|
||||
:data-field="field.name"
|
||||
@@click.prevent="srv.sortBy(field.name)"
|
||||
:title="srv.fieldViews[field.name].sortByTitle">
|
||||
{{ field.name }}
|
||||
<span :class="srv.fieldViews[field.name].sortIconClass" />
|
||||
</a>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in srv.result.data" :key="index">
|
||||
<td class="text-nowrap" v-for="(value, columnIndex) in row" :key="columnIndex">
|
||||
<a :href="getInvoiceUrl(value)"
|
||||
target="_blank"
|
||||
v-if="srv.result.fields[columnIndex].type === 'invoice_id'">{{ value }}</a>
|
||||
|
||||
<a :href="getExplorerUrl(value, row[columnIndex-1])"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
v-else-if="srv.result.fields[columnIndex].type === 'tx_id'">{{ value }}</a>
|
||||
<span v-else>{{ value }}</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@section PageFootContent {
|
||||
<script src="~/vendor/FileSaver/FileSaver.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/papaparse/papaparse.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script>const srv = @Safe.Json(Model);</script>
|
||||
<script src="~/js/datatable.js" asp-append-version="true"></script>
|
||||
<script src="~/js/store-reports.js" asp-append-version="true"></script>
|
||||
}
|
@ -111,7 +111,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="BOLT11Expiration" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
|
||||
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
|
||||
|
@ -196,7 +196,14 @@
|
||||
<span>@pp.Date.ToBrowserDate()</span>
|
||||
</td>
|
||||
<td class="mw-100">
|
||||
<span>@pp.PullPaymentName</span>
|
||||
@if (pp.SourceLink is not null && pp.Source is not null)
|
||||
{
|
||||
<a href="@pp.SourceLink" rel="noreferrer noopener">@pp.Source</a>
|
||||
}
|
||||
else if (pp.Source is not null)
|
||||
{
|
||||
<span>@pp.Source</span>
|
||||
}
|
||||
</td>
|
||||
<td title="@pp.Destination">
|
||||
<span class="text-break">@pp.Destination</span>
|
||||
|
@ -1,12 +1,14 @@
|
||||
@using BTCPayServer.Payments
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using BTCPayServer.Services.Stores
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@using BTCPayServer.Abstractions.Contracts
|
||||
@inject IFileService FileService
|
||||
@model CheckoutAppearanceViewModel
|
||||
@{
|
||||
Layout = "../Shared/_NavLayout.cshtml";
|
||||
ViewData.SetActivePage(StoreNavPages.CheckoutAppearance, "Checkout experience", Context.GetStoreData().Id);
|
||||
|
||||
var store = ViewContext.HttpContext.GetStoreData();
|
||||
var canUpload = await FileService.IsAvailable();
|
||||
}
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
@ -22,6 +24,7 @@
|
||||
$("#UseClassicCheckout").prop('checked', false);
|
||||
$("#CheckoutV2Settings").addClass('show');
|
||||
$("#ClassicCheckoutSettings").removeClass('show');
|
||||
$("#PlaySoundOnPayment").prop('checked', true);
|
||||
$("#ShowPayInWalletButton").prop('checked', false);
|
||||
$("#ShowStoreHeader").prop('checked', false);
|
||||
});
|
||||
@ -29,6 +32,7 @@
|
||||
$("#UseClassicCheckout").prop('checked', false);
|
||||
$("#CheckoutV2Settings").addClass('show');
|
||||
$("#ClassicCheckoutSettings").removeClass('show');
|
||||
$("#PlaySoundOnPayment").prop('checked', false);
|
||||
$("#ShowPayInWalletButton").prop('checked', true);
|
||||
$("#ShowStoreHeader").prop('checked', true);
|
||||
});
|
||||
@ -37,7 +41,7 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xxl-constrain col-xl-8">
|
||||
<form method="post">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
@if (!ViewContext.ModelState.IsValid)
|
||||
{
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
@ -105,7 +109,7 @@
|
||||
<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;" />
|
||||
<input inputmode="numeric" asp-for="DisplayExpirationTimer" class="form-control" style="max-width:12ch;" />
|
||||
<span class="input-group-text">minutes</span>
|
||||
</div>
|
||||
<span asp-validation-for="DisplayExpirationTimer" class="text-danger"></span>
|
||||
@ -113,6 +117,41 @@
|
||||
<div class="form-check">
|
||||
<input asp-for="CelebratePayment" type="checkbox" class="form-check-input" />
|
||||
<label asp-for="CelebratePayment" class="form-check-label"></label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="PlaySoundOnPayment" type="checkbox" class="form-check-input" data-bs-toggle="collapse" data-bs-target="#PlaySoundOnPaymentOptions" aria-expanded="@Model.PlaySoundOnPayment" aria-controls="PlaySoundOnPaymentOptions" />
|
||||
<label asp-for="PlaySoundOnPayment" class="form-check-label"></label>
|
||||
<div class="collapse @(Model.PlaySoundOnPayment ? "show" : "")" id="PlaySoundOnPaymentOptions">
|
||||
<div class="form-group mb-0 py-3">
|
||||
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||
<label asp-for="SoundFile" class="form-label"></label>
|
||||
@if (!string.IsNullOrEmpty(Model.SoundFileId))
|
||||
{
|
||||
<button type="submit" class="btn btn-link p-0 text-danger" name="RemoveSoundFile" value="true">
|
||||
<span class="fa fa-times"></span> Remove
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (canUpload)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<input asp-for="SoundFile" type="file" class="form-control flex-grow">
|
||||
@{
|
||||
var soundUrl = string.IsNullOrEmpty(Model.SoundFileId)
|
||||
? string.Concat(Context.Request.GetAbsoluteRootUri().ToString(), "checkout-v2/payment.mp3")
|
||||
: await FileService.GetFileUrl(Context.Request.GetAbsoluteRootUri(), Model.SoundFileId);
|
||||
}
|
||||
<audio controls src="@soundUrl" style="height:2.1rem;max-width:10.5rem;"></audio>
|
||||
</div>
|
||||
<span asp-validation-for="SoundFile" class="text-danger"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<input asp-for="SoundFile" type="file" class="form-control" disabled>
|
||||
<div class="form-text">In order to upload a custom sound, a <a asp-controller="UIServer" asp-action="Files">file storage</a> must be configured.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input asp-for="ShowStoreHeader" type="checkbox" class="form-check-input" />
|
||||
|
@ -145,7 +145,7 @@
|
||||
<vc:icon symbol="info"/>
|
||||
</a>
|
||||
<div class="input-group">
|
||||
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:10ch;"/>
|
||||
<input inputmode="numeric" asp-for="InvoiceExpiration" class="form-control" style="max-width:12ch;"/>
|
||||
<span class="input-group-text">minutes</span>
|
||||
</div>
|
||||
<span asp-validation-for="InvoiceExpiration" class="text-danger"></span>
|
||||
@ -156,7 +156,7 @@
|
||||
<vc:icon symbol="info"/>
|
||||
</a>
|
||||
<div class="input-group">
|
||||
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:10ch;"/>
|
||||
<input inputmode="decimal" asp-for="PaymentTolerance" class="form-control" style="max-width:12ch;"/>
|
||||
<span class="input-group-text">percent</span>
|
||||
</div>
|
||||
<span asp-validation-for="PaymentTolerance" class="text-danger"></span>
|
||||
@ -164,7 +164,7 @@
|
||||
<div class="form-group">
|
||||
<label asp-for="BOLT11Expiration" class="form-label"></label>
|
||||
<div class="input-group">
|
||||
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:10ch;"/>
|
||||
<input inputmode="numeric" asp-for="BOLT11Expiration" class="form-control" style="max-width:12ch;"/>
|
||||
<span class="input-group-text">days</span>
|
||||
</div>
|
||||
<span asp-validation-for="BOLT11Expiration" class="text-danger"></span>
|
||||
|
@ -38,7 +38,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="accountNumber" class="form-label">Account</label>
|
||||
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:10ch;" />
|
||||
<input id="accountNumber" class="form-control" name="accountNumber" type="number" value="0" min="0" step="1" style="max-width:12ch;" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
@ -19,6 +19,7 @@ namespace BTCPayServer.Views.Stores
|
||||
Plugins,
|
||||
Webhooks,
|
||||
PullPayments,
|
||||
Reporting,
|
||||
Payouts,
|
||||
PayoutProcessors,
|
||||
[Obsolete("Use StoreNavPages.Plugins instead")]
|
||||
|
@ -1,160 +0,0 @@
|
||||
.logo {
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.logo-brand-text {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.modal-header {
|
||||
border-top-left-radius: 0.4rem;
|
||||
border-top-right-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
width: 100%;
|
||||
max-height: 210px;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.cart-item-image {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.js-cart-added {
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
border-radius: 0.25rem;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: none;
|
||||
}
|
||||
.js-cart-added .fa {
|
||||
height: 50px;
|
||||
position: relative;
|
||||
top: 50%;
|
||||
margin-top: -25px;
|
||||
}
|
||||
|
||||
.js-add-cart:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.js-cart-tip-btn:focus {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
|
||||
.js-search-reset {
|
||||
position: absolute;
|
||||
right: 0px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1049;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------
|
||||
SIDEBAR STYLE
|
||||
----------------------------------------------------- */
|
||||
|
||||
.wrapper {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
width: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
top: 0;
|
||||
right: 0;
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
z-index: 999;
|
||||
color: var(--btcpay-white);
|
||||
background: var(--btcpay-bg-dark);
|
||||
transition: all 0.3s;
|
||||
padding-bottom: var(--btcpay-space-l);
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#sidebar .js-cart {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#sidebar #js-cart-list,
|
||||
#sidebar #js-cart-extra {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#sidebar.active {
|
||||
margin-right: -400px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------
|
||||
CONTENT STYLE
|
||||
----------------------------------------------------- */
|
||||
|
||||
#content {
|
||||
width: calc(100% - 400px);
|
||||
min-height: 100vh;
|
||||
transition: all 0.3s;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#content.active {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bg-gray {
|
||||
background-color: #aaa;
|
||||
}
|
||||
.text-black {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------
|
||||
MEDIAQUERIES
|
||||
----------------------------------------------------- */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#sidebar {
|
||||
margin-right: -400px;
|
||||
}
|
||||
#sidebar .js-cart {
|
||||
display: inline;
|
||||
}
|
||||
#sidebar.active {
|
||||
margin-right: 0;
|
||||
}
|
||||
#content {
|
||||
width: 100%;
|
||||
}
|
||||
#content.active {
|
||||
width: calc(100% - 400px);
|
||||
}
|
||||
#sidebarCollapse span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 575px) {
|
||||
#sidebar {
|
||||
width: 100%;
|
||||
margin-right: -575px;
|
||||
}
|
||||
#content.active {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
@ -1,167 +0,0 @@
|
||||
$.fn.addAnimate = function(completeCallback) {
|
||||
if ($(this).find('.js-cart-added').length === 0) {
|
||||
$(this).append('<div class="js-cart-added"><i class="fa fa-check fa-3x text-white align-middle"></i></div>');
|
||||
|
||||
// Animate the element
|
||||
$(this).find('.js-cart-added').fadeIn(200, function(){
|
||||
var self = this;
|
||||
// Show it for 200ms
|
||||
setTimeout(function(){
|
||||
// Hide and remove
|
||||
$(self).fadeOut(100, function(){
|
||||
$(this).remove();
|
||||
|
||||
completeCallback && completeCallback();
|
||||
})
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function removeAccents(input){
|
||||
var accents = 'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇČçčÐĎďÌÍÎÏìíîïĽľÙÚÛÜùúûüÑŇñňŠšŤťŸÿýŽž ́',
|
||||
accentsOut = 'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCCccDDdIIIIiiiiLlUUUUuuuuNNnnSsTtYyyZz ',
|
||||
output = '',
|
||||
index = -1;
|
||||
|
||||
for( var i = 0; i < input.length; i++ ) {
|
||||
index = accents.indexOf(input[i]);
|
||||
|
||||
if( index != -1 ) {
|
||||
output += typeof accentsOut[index] != 'undefined' ? accentsOut[index] : '';
|
||||
}
|
||||
else {
|
||||
output += typeof input[i] != 'undefined' ? input[i] : '';
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
jQuery.expr[':'].icontains = function (a, i, m) {
|
||||
var string = removeAccents(jQuery(a).text().toLowerCase());
|
||||
|
||||
return string.indexOf(removeAccents(m[3].toLowerCase())) >= 0;
|
||||
};
|
||||
|
||||
$(document).ready(function(){
|
||||
var cart = new Cart();
|
||||
|
||||
$('.js-add-cart').click(function(event){
|
||||
event.preventDefault();
|
||||
|
||||
var $btn = $(event.target),
|
||||
self = this;
|
||||
index = $btn.closest('.card').data('index'),
|
||||
item = srvModel.items[index],
|
||||
items = cart.items;
|
||||
|
||||
// Is event catching disabled?
|
||||
if (!$(this).hasClass('disabled')) {
|
||||
// Disable catching events for this element
|
||||
$(this).addClass('disabled');
|
||||
|
||||
// Add-to-cart animation only once
|
||||
$(this).addAnimate(function(){
|
||||
// Enable the event
|
||||
$(self).removeClass('disabled');
|
||||
});
|
||||
|
||||
cart.addItem({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
image: typeof item.image != 'undefined' ? item.image : null,
|
||||
inventory: item.inventory
|
||||
});
|
||||
cart.listItems();
|
||||
}
|
||||
});
|
||||
|
||||
// Destroy the cart when the "pay button is clicked"
|
||||
$('#js-cart-pay').click(function(){
|
||||
cart.destroy(true);
|
||||
});
|
||||
|
||||
// Disable pay button and add loading animation when pay form is submitted
|
||||
$('#js-cart-pay-form').on('submit', function() {
|
||||
var button = $('#js-cart-pay');
|
||||
if (button) {
|
||||
// Disable the pay button
|
||||
button.attr('disabled', true);
|
||||
|
||||
// Add loading animation to the pay button
|
||||
button.prepend([
|
||||
'<div class="spinner-grow spinner-grow-sm align-baseline" role="status">',
|
||||
' <span class="visually-hidden">Loading...</span>',
|
||||
'</div>'
|
||||
].join(''));
|
||||
}
|
||||
});
|
||||
|
||||
$('.js-cart').on('click', function () {
|
||||
$('#sidebar, #content').toggleClass('active');
|
||||
$('.collapse.in').toggleClass('in');
|
||||
$('a[aria-expanded=true]').attr('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
$('.js-search').keyup(function(event){
|
||||
var str = $(this).val();
|
||||
|
||||
$('#js-pos-list').find(".card-wrapper").show();
|
||||
|
||||
if (str.length > 1) {
|
||||
var $list = $('#js-pos-list').find(".card-title:not(:icontains('" + $.escapeSelector(str) + "'))");
|
||||
$list.parents('.card-wrapper').hide();
|
||||
$('.js-search-reset').show();
|
||||
} else if (str.length === 0) {
|
||||
$('.js-search-reset').hide();
|
||||
}
|
||||
});
|
||||
|
||||
$('.js-search-reset').click(function(event){
|
||||
event.preventDefault();
|
||||
|
||||
$('.js-search').val('');
|
||||
$('.js-search').trigger('keyup');
|
||||
$(this).hide();
|
||||
});
|
||||
|
||||
$('#js-cart-summary').find('tbody').prepend(cart.template($('#template-cart-tip'), {
|
||||
'tip': cart.fromCents(cart.getTip()) || ''
|
||||
}));
|
||||
|
||||
$('#cartModal').one('show.bs.modal', function () {
|
||||
cart.updateDiscount();
|
||||
cart.updateTip();
|
||||
cart.updateSummaryProducts();
|
||||
cart.updateSummaryTotal();
|
||||
|
||||
var $tipInput = $('.js-cart-tip');
|
||||
$tipInput[0].addEventListener('input', function(e) {
|
||||
var value = parseFloat(e.target.value)
|
||||
if (Number.isNaN(value) || value < 0) {
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Change total when tip is changed
|
||||
$tipInput.inputAmount(cart, 'tip');
|
||||
// Remove tip
|
||||
$('.js-cart-tip-remove').removeAmount(cart, 'tip');
|
||||
|
||||
$('.js-cart-tip-btn').click(function(event){
|
||||
event.preventDefault();
|
||||
|
||||
var $tip = $('.js-cart-tip'),
|
||||
discount = cart.percentage(cart.getTotalProducts(), cart.getDiscount());
|
||||
|
||||
var purchaseAmount = cart.getTotalProducts() - discount;
|
||||
var tipPercentage = parseInt($(this).data('tip'));
|
||||
var tipValue = cart.percentage(purchaseAmount, tipPercentage).toFixed(srvModel.currencyInfo.divisibility);
|
||||
$tip.val(tipValue);
|
||||
$tip.trigger('input');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,765 +0,0 @@
|
||||
function Cart() {
|
||||
this.items = 0;
|
||||
this.totalAmount = 0;
|
||||
this.content = [];
|
||||
|
||||
this.loadLocalStorage();
|
||||
this.buildUI();
|
||||
|
||||
this.$list = $('#js-cart-list');
|
||||
this.$items = $('#js-cart-items');
|
||||
this.$total = $('.js-cart-total');
|
||||
this.$summaryProducts = $('.js-cart-summary-products');
|
||||
this.$summaryDiscount = $('.js-cart-summary-discount');
|
||||
this.$summaryTotal = $('.js-cart-summary-total');
|
||||
this.$summaryTip = $('.js-cart-summary-tip');
|
||||
this.$destroy = $('.js-cart-destroy');
|
||||
this.$confirm = $('#js-cart-confirm');
|
||||
this.$categories = $('.js-categories');
|
||||
this.listItems();
|
||||
this.bindEmptyCart();
|
||||
|
||||
this.updateItemsCount();
|
||||
this.updateAmount();
|
||||
this.updatePosData();
|
||||
}
|
||||
|
||||
Cart.prototype.setCustomAmount = function(amount) {
|
||||
if (!srvModel.showCustomAmount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.customAmount = this.toNumber(amount);
|
||||
|
||||
if (this.customAmount > 0) {
|
||||
localStorage.setItem(this.getStorageKey('cartCustomAmount'), this.customAmount);
|
||||
} else {
|
||||
localStorage.removeItem(this.getStorageKey('cartCustomAmount'));
|
||||
}
|
||||
return this.customAmount;
|
||||
}
|
||||
|
||||
Cart.prototype.getCustomAmount = function() {
|
||||
if (!srvModel.showCustomAmount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.toCents(this.customAmount);
|
||||
}
|
||||
|
||||
Cart.prototype.setTip = function(amount) {
|
||||
if (!srvModel.enableTips) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.tip = this.toNumber(amount);
|
||||
|
||||
if (this.tip > 0) {
|
||||
localStorage.setItem(this.getStorageKey('cartTip'), this.tip);
|
||||
} else {
|
||||
localStorage.removeItem(this.getStorageKey('cartTip'));
|
||||
}
|
||||
return this.tip;
|
||||
}
|
||||
|
||||
Cart.prototype.getTip = function() {
|
||||
if (!srvModel.enableTips) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.toCents(this.tip);
|
||||
}
|
||||
|
||||
Cart.prototype.setDiscount = function(amount) {
|
||||
if (!srvModel.showDiscount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
this.discount = this.toNumber(amount);
|
||||
|
||||
if (this.discount > 0) {
|
||||
localStorage.setItem(this.getStorageKey('cartDiscount'), this.discount);
|
||||
} else {
|
||||
localStorage.removeItem(this.getStorageKey('cartDiscount'));
|
||||
}
|
||||
return this.discount;
|
||||
}
|
||||
|
||||
Cart.prototype.getDiscount = function() {
|
||||
if (!srvModel.showDiscount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.toCents(this.discount);
|
||||
}
|
||||
|
||||
Cart.prototype.getDiscountAmount = function(amount) {
|
||||
if (!srvModel.showDiscount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return this.percentage(amount, this.getDiscount());
|
||||
}
|
||||
|
||||
// Get total amount of products
|
||||
Cart.prototype.getTotalProducts = function() {
|
||||
var amount = 0 ;
|
||||
|
||||
// Always calculate the total amount based on the cart content
|
||||
for (var key in this.content) {
|
||||
if (
|
||||
this.content.hasOwnProperty(key) &&
|
||||
typeof this.content[key] != 'undefined' &&
|
||||
!this.content[key].disabled
|
||||
) {
|
||||
const price = this.toCents(this.content[key].price ||0);
|
||||
amount += (this.content[key].count * price);
|
||||
}
|
||||
}
|
||||
|
||||
// Add custom amount
|
||||
amount += this.getCustomAmount();
|
||||
|
||||
return amount;
|
||||
}
|
||||
|
||||
// Get absolute total amount
|
||||
Cart.prototype.getTotal = function(includeTip) {
|
||||
this.totalAmount = this.getTotalProducts();
|
||||
|
||||
if (this.getDiscount() > 0) {
|
||||
this.totalAmount -= this.getDiscountAmount(this.totalAmount);
|
||||
}
|
||||
|
||||
if (includeTip) {
|
||||
this.totalAmount += this.getTip();
|
||||
}
|
||||
|
||||
return this.fromCents(this.totalAmount);
|
||||
}
|
||||
|
||||
/*
|
||||
* Data manipulation
|
||||
*/
|
||||
// Add item to the cart or update its count
|
||||
Cart.prototype.addItem = function(item) {
|
||||
var id = item.id,
|
||||
result = this.content.filter(function(obj){
|
||||
return obj.id === id;
|
||||
});
|
||||
|
||||
// Add new item because it doesn't exist yet
|
||||
if (!result.length) {
|
||||
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image, inventory: item.inventory});
|
||||
this.emptyCartToggle();
|
||||
}
|
||||
|
||||
// Increment item count
|
||||
this.incrementItem(id);
|
||||
}
|
||||
|
||||
Cart.prototype.incrementItem = function(id) {
|
||||
var oldItemsCount = this.items;
|
||||
this.items = 0; // Calculate total # of items from scratch just to make sure
|
||||
var result = true;
|
||||
for (var i = 0; i < this.content.length; i++) {
|
||||
var obj = this.content[i];
|
||||
if (obj.id === id){
|
||||
if(obj.inventory != null && obj.inventory <= obj.count){
|
||||
result = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
obj.count++;
|
||||
delete(obj.disabled);
|
||||
}
|
||||
|
||||
// Increment the total # of items
|
||||
this.items += obj.count;
|
||||
}
|
||||
if(!result){
|
||||
this.items = oldItemsCount;
|
||||
}
|
||||
|
||||
this.updateAll();
|
||||
return result;
|
||||
}
|
||||
|
||||
// Disable cart item so it doesn't count towards total amount
|
||||
Cart.prototype.disableItem = function(id) {
|
||||
var self = this;
|
||||
|
||||
this.content.filter(function(obj){
|
||||
if (obj.id === id){
|
||||
obj.disabled = true;
|
||||
self.items -= obj.count;
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAll();
|
||||
}
|
||||
|
||||
// Enable cart item so it counts towards total amount
|
||||
Cart.prototype.enableItem = function(id) {
|
||||
var self = this;
|
||||
|
||||
this.content.filter(function(obj){
|
||||
if (obj.id === id){
|
||||
delete(obj.disabled);
|
||||
self.items += obj.count;
|
||||
}
|
||||
});
|
||||
|
||||
this.updateAll();
|
||||
}
|
||||
|
||||
Cart.prototype.decrementItem = function(id) {
|
||||
var self = this;
|
||||
this.items = 0; // Calculate total # of items from scratch just to make sure
|
||||
|
||||
this.content.filter(function(obj, index, arr){
|
||||
// Decrement the item count
|
||||
if (obj.id === id)
|
||||
{
|
||||
obj.count--;
|
||||
delete(obj.disabled);
|
||||
|
||||
// It's the last item with the same ID, remove it
|
||||
if (obj.count <= 0) {
|
||||
self.removeItem(id, index, arr);
|
||||
}
|
||||
}
|
||||
|
||||
self.items += obj.count;
|
||||
});
|
||||
|
||||
this.updateAll();
|
||||
}
|
||||
|
||||
Cart.prototype.removeItemAll = function(id) {
|
||||
var self = this;
|
||||
this.items = 0;
|
||||
|
||||
// Remove by item
|
||||
if (typeof id != 'undefined') {
|
||||
this.content.filter(function(obj, index, arr){
|
||||
if (obj.id === id) {
|
||||
self.removeItem(id, index, arr);
|
||||
|
||||
for (var i = 0; i < obj.count; i++) {
|
||||
self.items--;
|
||||
}
|
||||
}
|
||||
|
||||
self.items += obj.count;
|
||||
});
|
||||
} else { // Remove all
|
||||
this.$list.find('tbody').empty();
|
||||
this.content = [];
|
||||
}
|
||||
|
||||
this.emptyCartToggle();
|
||||
this.updateAll();
|
||||
}
|
||||
|
||||
Cart.prototype.removeItem = function(id, index, arr) {
|
||||
// Remove from the array
|
||||
arr.splice(index, 1);
|
||||
// Remove from the DOM
|
||||
this.$list.find('tr').eq(index+1).remove();
|
||||
}
|
||||
|
||||
/*
|
||||
* Update DOM
|
||||
*/
|
||||
// Update all data elements
|
||||
Cart.prototype.updateAll = function() {
|
||||
this.saveLocalStorage();
|
||||
this.updateItemsCount();
|
||||
this.updateDiscount();
|
||||
this.updateSummaryProducts();
|
||||
this.updateSummaryTotal();
|
||||
this.updateTotal();
|
||||
this.updateAmount();
|
||||
this.updatePosData();
|
||||
}
|
||||
|
||||
// Update number of cart items
|
||||
Cart.prototype.updateItemsCount = function() {
|
||||
this.$items.text(this.items);
|
||||
}
|
||||
|
||||
// Update total products (including the custom amount and discount) in the cart
|
||||
Cart.prototype.updateTotal = function() {
|
||||
this.$total.text(this.formatCurrency(this.getTotal()));
|
||||
}
|
||||
|
||||
// Update total amount in the summary
|
||||
Cart.prototype.updateSummaryTotal = function() {
|
||||
this.$summaryTotal.text(this.formatCurrency(this.getTotal(true)));
|
||||
}
|
||||
|
||||
// Update total products amount in the summary
|
||||
Cart.prototype.updateSummaryProducts = function() {
|
||||
this.$summaryProducts.text(this.formatCurrency(this.fromCents(this.getTotalProducts())));
|
||||
}
|
||||
|
||||
// Update discount amount in the summary
|
||||
Cart.prototype.updateDiscount = function(amount) {
|
||||
var discount = 0;
|
||||
|
||||
if (typeof amount != 'undefined') {
|
||||
discount = amount;
|
||||
} else {
|
||||
discount = this.percentage(this.getTotalProducts(), this.getDiscount());
|
||||
discount = this.fromCents(discount);
|
||||
}
|
||||
|
||||
this.$summaryDiscount.text((discount > 0 ? '-' : '') + this.formatCurrency(discount));
|
||||
}
|
||||
|
||||
// Update tip amount in the summary
|
||||
Cart.prototype.updateTip = function(amount) {
|
||||
var tip = typeof amount != 'undefined' ? amount : this.fromCents(this.getTip());
|
||||
|
||||
this.$summaryTip.text(this.formatCurrency(tip));
|
||||
}
|
||||
|
||||
// Update hidden total amount value to be sent to the checkout page
|
||||
Cart.prototype.updateAmount = function() {
|
||||
$('#js-cart-amount').val(this.getTotal(true));
|
||||
$('#js-cart-tip').val(this.tip);
|
||||
$('#js-cart-discount').val(this.discount);
|
||||
$('#js-cart-custom-amount').val(this.customAmount);
|
||||
}
|
||||
Cart.prototype.updatePosData = function() {
|
||||
var result = {
|
||||
cart: this.content,
|
||||
customAmount: this.fromCents(this.getCustomAmount()),
|
||||
discountPercentage: this.discount? parseFloat(this.discount): 0,
|
||||
subTotal: this.fromCents(this.getTotalProducts()),
|
||||
discountAmount: this.fromCents(this.getDiscountAmount(this.totalAmount)),
|
||||
tip: this.tip? this.tip: 0,
|
||||
total: this.getTotal(true)
|
||||
};
|
||||
$('#js-cart-posdata').val(JSON.stringify(result));
|
||||
}
|
||||
|
||||
Cart.prototype.resetDiscount = function() {
|
||||
this.setDiscount(0);
|
||||
this.updateDiscount(0);
|
||||
$('.js-cart-discount').val('');
|
||||
}
|
||||
|
||||
Cart.prototype.resetTip = function() {
|
||||
this.setTip(0);
|
||||
this.updateTip(0);
|
||||
$('.js-cart-tip').val('');
|
||||
}
|
||||
|
||||
Cart.prototype.resetCustomAmount = function() {
|
||||
this.setCustomAmount(0);
|
||||
$('.js-cart-custom-amount').val('');
|
||||
}
|
||||
|
||||
// Escape html characters
|
||||
Cart.prototype.escape = function(input) {
|
||||
return ('' + input) /* Forces the conversion to string. */
|
||||
.replace(/&/g, '&') /* This MUST be the 1st replacement. */
|
||||
.replace(/'/g, ''') /* The 4 other predefined entities, required. */
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
;
|
||||
}
|
||||
|
||||
// Load the template
|
||||
Cart.prototype.template = function($template, obj) {
|
||||
var template = $template.text();
|
||||
|
||||
for (var key in obj) {
|
||||
var re = new RegExp('{' + key + '}', 'mg');
|
||||
template = template.replace(re, obj[key]);
|
||||
}
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
// Build the cart skeleton
|
||||
Cart.prototype.buildUI = function() {
|
||||
var $table = $('#js-cart-extra').find('thead'),
|
||||
list = [];
|
||||
|
||||
tableTemplate = this.template($('#template-cart-extra'), {
|
||||
'discount': this.escape(this.fromCents(this.getDiscount()) || ''),
|
||||
'customAmount': this.escape(this.fromCents(this.getCustomAmount()) || '')
|
||||
});
|
||||
list.push($(tableTemplate));
|
||||
|
||||
tableTemplate = this.template($('#template-cart-total'), {
|
||||
'total': this.escape(this.formatCurrency(this.getTotal()))
|
||||
});
|
||||
list.push($(tableTemplate));
|
||||
|
||||
// Add the list to DOM
|
||||
$table.append(list);
|
||||
|
||||
// Change total when discount is changed
|
||||
$('.js-cart-discount').inputAmount(this, 'discount');
|
||||
// Remove discount
|
||||
$('.js-cart-discount-remove').removeAmount(this, 'discount');
|
||||
|
||||
// Change total when discount is changed
|
||||
$('.js-cart-custom-amount').inputAmount(this, 'customAmount');
|
||||
// Remove discount
|
||||
$('.js-cart-custom-amount-remove').removeAmount(this, 'customAmount');
|
||||
}
|
||||
|
||||
// List cart items and bind their events
|
||||
Cart.prototype.listItems = function() {
|
||||
var $table = this.$list.find('tbody'),
|
||||
self = this,
|
||||
list = [],
|
||||
tableTemplate = '';
|
||||
this.$categories.on('change', function (event) {
|
||||
if ($(this).is(':checked')) {
|
||||
var selectedCategory = $(this).val();
|
||||
$(".js-add-cart").each(function () {
|
||||
var categories = JSON.parse(this.getAttribute("data-categories"));
|
||||
if (selectedCategory === "*" || categories.includes(selectedCategory))
|
||||
this.classList.remove("d-none");
|
||||
else
|
||||
this.classList.add("d-none");
|
||||
});
|
||||
}
|
||||
});
|
||||
if (this.content.length > 0) {
|
||||
// Prepare the list of items in the cart
|
||||
for (var key in this.content) {
|
||||
var item = this.content[key],
|
||||
image = item.image && this.escape(item.image);
|
||||
|
||||
if (image && image.startsWith("~")) {
|
||||
image = image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps')));
|
||||
}
|
||||
|
||||
tableTemplate = this.template($('#template-cart-item'), {
|
||||
'id': this.escape(item.id),
|
||||
'image': image ? this.template($('#template-cart-item-image'), {
|
||||
'image' : image
|
||||
}) : '',
|
||||
'title': this.escape(item.title),
|
||||
'count': this.escape(item.count),
|
||||
'inventory': this.escape(item.inventory < 0? 99999: item.inventory),
|
||||
'price': this.escape(item.price || 0)
|
||||
});
|
||||
list.push($(tableTemplate));
|
||||
}
|
||||
|
||||
// Add the list to DOM
|
||||
$table.html(list);
|
||||
list = [];
|
||||
|
||||
// Update the cart when number of items is changed
|
||||
$('.js-cart-item-count').off().on('input', function(event){
|
||||
var _this = this,
|
||||
id = $(this).closest('tr').data('id'),
|
||||
qty = parseInt($(this).val()),
|
||||
isQty = !isNaN(qty),
|
||||
prevQty = parseInt($(this).data('prev')),
|
||||
qtyDiff = Math.abs(qty - prevQty),
|
||||
qtyIncreased = qty > prevQty;
|
||||
|
||||
if (isQty) {
|
||||
$(this).data('prev', qty);
|
||||
} else {
|
||||
// User hasn't inputed any quantity
|
||||
qty = null;
|
||||
}
|
||||
|
||||
self.resetTip();
|
||||
|
||||
// Quantity was increased
|
||||
if (qtyIncreased) {
|
||||
var item = self.content.filter(function(obj){
|
||||
return obj.id === id;
|
||||
});
|
||||
|
||||
// Quantity may have been increased by more than one
|
||||
for (var i = 0; i < qtyDiff; i++) {
|
||||
self.addItem({
|
||||
id: id,
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
image: item.image
|
||||
});
|
||||
}
|
||||
} else if (!qtyIncreased) { // Quantity decreased
|
||||
// No quantity set (e.g. empty string)
|
||||
if (!isQty) {
|
||||
// Disable the item so it doesn't count towards total amount
|
||||
self.disableItem(id);
|
||||
} else {
|
||||
// Quantity vas decreased
|
||||
if (qtyDiff > 0) {
|
||||
// Quantity may have been decreased by more than one
|
||||
for (var i = 0; i < qtyDiff; i++) {
|
||||
self.decrementItem(id);
|
||||
}
|
||||
} else {
|
||||
// Quantity hasn't changed, enable the item so it counts towards the total amount
|
||||
self.enableItem(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove item from the cart
|
||||
$('.js-cart-item-remove').off().on('click', function(event){
|
||||
event.preventDefault();
|
||||
|
||||
self.resetTip();
|
||||
self.removeItemAll($(this).closest('tr').data('id'));
|
||||
});
|
||||
|
||||
// Increment item
|
||||
$('.js-cart-item-plus').off().on('click', function(event){
|
||||
event.preventDefault();
|
||||
if(self.incrementItem($(this).closest('tr').data('id'))){
|
||||
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
|
||||
val = parseInt($val.val() || $val.data('prev')) + 1;
|
||||
|
||||
$val.val(val);
|
||||
$val.data('prev', val);
|
||||
self.resetTip();
|
||||
}
|
||||
});
|
||||
|
||||
// Decrement item
|
||||
$('.js-cart-item-minus').off().on('click', function(event){
|
||||
event.preventDefault();
|
||||
|
||||
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
|
||||
id = $(this).closest('tr').data('id'),
|
||||
val = parseInt($val.val() || $val.data('prev')) - 1;
|
||||
|
||||
self.resetTip();
|
||||
|
||||
if (val === 0) {
|
||||
self.removeItemAll(id);
|
||||
} else {
|
||||
$val.val(val);
|
||||
$val.data('prev', val);
|
||||
self.decrementItem(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Cart.prototype.bindEmptyCart = function() {
|
||||
var self = this;
|
||||
|
||||
this.emptyCartToggle();
|
||||
|
||||
this.$destroy.click(function(event){
|
||||
event.preventDefault();
|
||||
|
||||
self.destroy();
|
||||
self.emptyCartToggle();
|
||||
});
|
||||
}
|
||||
|
||||
Cart.prototype.emptyCartToggle = function() {
|
||||
if (this.content.length > 0 || this.getCustomAmount()) {
|
||||
this.$destroy.show();
|
||||
this.$confirm.removeAttr('disabled');
|
||||
} else {
|
||||
this.$destroy.hide();
|
||||
this.$confirm.attr('disabled', 'disabled');
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Currencies and numbers
|
||||
*/
|
||||
Cart.prototype.formatCurrency = function(amount) {
|
||||
var amt = '',
|
||||
thousandsSep = '',
|
||||
decimalSep = ''
|
||||
prefix = '',
|
||||
postfix = '';
|
||||
|
||||
if (srvModel.currencyInfo.prefixed) {
|
||||
prefix = srvModel.currencyInfo.currencySymbol;
|
||||
if (srvModel.currencyInfo.symbolSpace) {
|
||||
prefix = prefix + ' ';
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
postfix = srvModel.currencyInfo.currencySymbol;
|
||||
if (srvModel.currencyInfo.symbolSpace) {
|
||||
postfix = ' ' + postfix;
|
||||
}
|
||||
|
||||
}
|
||||
thousandsSep = srvModel.currencyInfo.thousandSeparator;
|
||||
decimalSep = srvModel.currencyInfo.decimalSeparator;
|
||||
amt = amount.toFixed(srvModel.currencyInfo.divisibility);
|
||||
|
||||
// Add currency sign and thousands separator
|
||||
var splittedAmount = amt.split('.');
|
||||
amt = (splittedAmount[0] + '.').replace(/(\d)(?=(\d{3})+\.)/g, '$1' + thousandsSep);
|
||||
amt = amt.substr(0, amt.length - 1);
|
||||
if(splittedAmount.length == 2) {
|
||||
amt = amt + decimalSep + splittedAmount[1];
|
||||
}
|
||||
if (srvModel.currencyInfo.divisibility !== 0) {
|
||||
amt[amt.length - srvModel.currencyInfo.divisibility - 1] = decimalSep;
|
||||
}
|
||||
amt = prefix + amt + postfix;
|
||||
|
||||
return amt;
|
||||
}
|
||||
|
||||
Cart.prototype.toNumber = function(num) {
|
||||
return (num * 1) || 0;
|
||||
}
|
||||
|
||||
Cart.prototype.toCents = function(num) {
|
||||
return num * Math.pow(10, srvModel.currencyInfo.divisibility);
|
||||
}
|
||||
|
||||
Cart.prototype.fromCents = function(num) {
|
||||
return num / Math.pow(10, srvModel.currencyInfo.divisibility);
|
||||
}
|
||||
|
||||
Cart.prototype.percentage = function(amount, percentage) {
|
||||
return this.fromCents((amount / 100) * percentage);
|
||||
}
|
||||
|
||||
/*
|
||||
* Storage
|
||||
*/
|
||||
Cart.prototype.getStorageKey = function (name) {
|
||||
return (name + srvModel.appId + srvModel.currencyCode);
|
||||
}
|
||||
|
||||
Cart.prototype.saveLocalStorage = function() {
|
||||
localStorage.setItem(this.getStorageKey('cart'), JSON.stringify(this.content));
|
||||
}
|
||||
|
||||
Cart.prototype.loadLocalStorage = function() {
|
||||
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
|
||||
var self = this;
|
||||
|
||||
// Get number of cart items
|
||||
for (var i = this.content.length-1; i >= 0; i--) {
|
||||
if (!this.content[i]) {
|
||||
this.content.splice(i,1);
|
||||
continue;
|
||||
}
|
||||
|
||||
//check if the pos items still has the cached cart items
|
||||
var matchedItem = srvModel.items.find(function(item){
|
||||
return item.id === self.content[i].id;
|
||||
});
|
||||
if(!matchedItem){
|
||||
//remove if no longer available
|
||||
this.content.splice(i,1);
|
||||
continue;
|
||||
}else{
|
||||
|
||||
if(matchedItem.inventory != null && matchedItem.inventory <= 0){
|
||||
//item is out of stock
|
||||
this.content.splice(i,1);
|
||||
}else if(matchedItem.inventory != null && matchedItem.inventory < this.content[i].count){
|
||||
//not enough stock for original cart amount, reduce to available stock
|
||||
this.content[i].count = matchedItem.inventory;
|
||||
}
|
||||
//update its stock
|
||||
this.content[i].inventory = matchedItem.inventory;
|
||||
|
||||
}
|
||||
this.items += this.content[i].count;
|
||||
// Delete the disabled flag if any
|
||||
delete(this.content[i].disabled);
|
||||
}
|
||||
|
||||
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));
|
||||
this.customAmount = localStorage.getItem(this.getStorageKey('cartCustomAmount'));
|
||||
this.tip = localStorage.getItem(this.getStorageKey('cartTip'));
|
||||
}
|
||||
|
||||
Cart.prototype.destroy = function(keepAmount) {
|
||||
this.resetDiscount();
|
||||
this.resetTip();
|
||||
this.resetCustomAmount();
|
||||
|
||||
// When form is sent
|
||||
if (keepAmount) {
|
||||
this.content = [];
|
||||
this.items = 0;
|
||||
} else {
|
||||
this.removeItemAll();
|
||||
}
|
||||
localStorage.removeItem(this.getStorageKey('cart'));
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* jQuery helpers
|
||||
*/
|
||||
$.fn.inputAmount = function(obj, type) {
|
||||
$(this).off().on('input', function(event){
|
||||
var val = obj.toNumber($(this).val());
|
||||
|
||||
switch (type) {
|
||||
case 'customAmount':
|
||||
obj.setCustomAmount(val);
|
||||
obj.updateDiscount();
|
||||
obj.updateSummaryProducts();
|
||||
obj.updateTotal();
|
||||
obj.resetTip();
|
||||
break;
|
||||
case 'discount':
|
||||
obj.setDiscount(val);
|
||||
obj.updateDiscount();
|
||||
obj.updateSummaryProducts();
|
||||
obj.updateTotal();
|
||||
obj.resetTip();
|
||||
break;
|
||||
case 'tip':
|
||||
obj.setTip(val);
|
||||
obj.updateTip();
|
||||
break;
|
||||
}
|
||||
|
||||
obj.updateSummaryTotal();
|
||||
obj.updateAmount();
|
||||
obj.updatePosData();
|
||||
obj.emptyCartToggle();
|
||||
});
|
||||
}
|
||||
|
||||
$.fn.removeAmount = function(obj, type) {
|
||||
$(this).off().on('click', function(event){
|
||||
event.preventDefault();
|
||||
|
||||
switch (type) {
|
||||
case 'customAmount':
|
||||
obj.resetCustomAmount();
|
||||
obj.updateSummaryProducts();
|
||||
break;
|
||||
case 'discount':
|
||||
obj.resetDiscount();
|
||||
obj.updateSummaryProducts();
|
||||
break;
|
||||
}
|
||||
|
||||
obj.resetTip();
|
||||
obj.updateTotal();
|
||||
obj.updateSummaryTotal();
|
||||
obj.emptyCartToggle();
|
||||
});
|
||||
}
|
@ -8,7 +8,6 @@ body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
.public-page-wrap {
|
||||
flex-direction: column;
|
||||
max-width: var(--wrap-max-width);
|
||||
}
|
||||
main {
|
||||
@ -141,6 +140,9 @@ section dl > div dd {
|
||||
height: 3rem;
|
||||
margin: .5rem auto 1.5rem;
|
||||
}
|
||||
#result #sound {
|
||||
display: none;
|
||||
}
|
||||
#result #confetti {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
|
@ -116,7 +116,10 @@ function initApp() {
|
||||
paymentMethodId: null,
|
||||
endData: null,
|
||||
isModal: srvModel.isModal,
|
||||
pollTimeoutID: null
|
||||
pollTimeoutID: null,
|
||||
paymentSound: null,
|
||||
nfcReadSound: null,
|
||||
errorSound: null,
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@ -236,6 +239,11 @@ function initApp() {
|
||||
if (this.isProcessing) {
|
||||
this.listenForConfirmations();
|
||||
}
|
||||
if (this.srvModel.paymentSoundUrl) {
|
||||
this.prepareSound(this.srvModel.paymentSoundUrl).then(sound => this.paymentSound = sound);
|
||||
this.prepareSound(this.srvModel.nfcReadSoundUrl).then(sound => this.nfcReadSound = sound);
|
||||
this.prepareSound(this.srvModel.errorSoundUrl).then(sound => this.errorSound = sound);
|
||||
}
|
||||
updateLanguageSelect();
|
||||
window.parent.postMessage('loaded', '*');
|
||||
},
|
||||
@ -338,7 +346,23 @@ function initApp() {
|
||||
replaceNewlines (value) {
|
||||
return value ? value.replace(/\n/ig, '<br>') : '';
|
||||
},
|
||||
playSound (soundName) {
|
||||
// sound
|
||||
const sound = this[soundName + 'Sound'];
|
||||
if (sound && !sound.playing) {
|
||||
const { audioContext, audioBuffer } = sound;
|
||||
const source = audioContext.createBufferSource();
|
||||
source.onended = () => { sound.playing = false; };
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
source.start();
|
||||
sound.playing = true;
|
||||
}
|
||||
},
|
||||
async celebratePayment (duration) {
|
||||
// sound
|
||||
this.playSound('payment')
|
||||
// confetti
|
||||
const $confettiEl = document.getElementById('confetti')
|
||||
if (window.confetti && $confettiEl && !$confettiEl.dataset.running) {
|
||||
$confettiEl.dataset.running = true;
|
||||
@ -351,6 +375,14 @@ function initApp() {
|
||||
});
|
||||
delete $confettiEl.dataset.running;
|
||||
}
|
||||
},
|
||||
async prepareSound (url) {
|
||||
const audioContext = new AudioContext();
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) return console.error(`Could not load payment sound, HTTP error ${response.status}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
return { audioContext, audioBuffer, playing: false };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
BIN
BTCPayServer/wwwroot/checkout-v2/error.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/error.mp3
Normal file
Binary file not shown.
BIN
BTCPayServer/wwwroot/checkout-v2/nfcread.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/nfcread.mp3
Normal file
Binary file not shown.
BIN
BTCPayServer/wwwroot/checkout-v2/payment.mp3
Normal file
BIN
BTCPayServer/wwwroot/checkout-v2/payment.mp3
Normal file
Binary file not shown.
@ -29,6 +29,7 @@
|
||||
<symbol id="lightningterminal" viewBox="0 0 28 55" fill="none"><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="manage-plugins" viewBox="0 0 24 24" fill="none"><path d="M6 7H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 12H18" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><path d="M6 17H14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
|
||||
<symbol id="mattermost" viewBox="0 0 206 206" fill="none"><path fill="currentColor" d="m163.012 19.596 1.082 21.794c17.667 19.519 24.641 47.161 15.846 73.14-13.129 38.782-56.419 59.169-96.693 45.535-40.272-13.633-62.278-56.124-49.15-94.905 8.825-26.066 31.275-43.822 57.276-48.524L105.422.038C61.592-1.15 20.242 26.056 5.448 69.76c-18.178 53.697 10.616 111.963 64.314 130.142 53.698 18.178 111.964-10.617 130.143-64.315 14.77-43.633-1.474-90.283-36.893-115.99"/><path fill="currentColor" d="m137.097 53.436-.596-17.531-.404-15.189s.084-7.322-.17-9.043a2.776 2.776 0 0 0-.305-.914l-.05-.109-.06-.094a2.378 2.378 0 0 0-1.293-1.07 2.382 2.382 0 0 0-1.714.078l-.033.014-.18.092a2.821 2.821 0 0 0-.75.518c-1.25 1.212-5.63 7.08-5.63 7.08l-9.547 11.82-11.123 13.563-19.098 23.75s-8.763 10.938-6.827 24.4c1.937 13.464 11.946 20.022 19.71 22.65 7.765 2.63 19.7 3.5 29.417-6.019 9.716-9.518 9.397-23.53 9.397-23.53l-.744-30.466z"/></symbol>
|
||||
<symbol id="minus" viewBox="0 0 16 16" fill="none"><path d="M14 7H2V9H14V7Z" fill="currentColor"/></symbol>
|
||||
<symbol id="new-store" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
|
||||
<symbol id="new-wallet" viewBox="0 0 32 32" fill="none"><path d="M16 10V22" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M22 16H10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><circle fill="none" cx="16" cy="16" r="15" stroke="currentColor" stroke-width="2"/></symbol>
|
||||
<symbol id="new" viewBox="0 0 24 24" fill="none"><path d="M17 11H13V7C13 6.45 12.55 6 12 6C11.45 6 11 6.45 11 7V11H7C6.45 11 6 11.45 6 12C6 12.55 6.45 13 7 13H11V17C11 17.55 11.45 18 12 18C12.55 18 13 17.55 13 17V13H17C17.55 13 18 12.55 18 12C18 11.45 17.55 11 17 11Z" fill="currentColor"/></symbol>
|
||||
@ -46,6 +47,7 @@
|
||||
<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="payouts" viewBox="0 0 24 24" fill="none"><path d="M8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8.30774 8.92383H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 12H15.6924" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.30774 15.0156H12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="plugin" viewBox="0 0 24 24" fill="none"><path d="M12.0002 10.2354L4.73633 7.38747M12.0002 10.2354L19.2642 7.38747M12.0002 10.2354V19.5M5.21166 7.01614L11.2783 4.6375C11.7412 4.45417 12.2566 4.45417 12.7196 4.6375L18.7862 7.01614C19.0023 7.1083 19.1858 7.26312 19.3131 7.46062C19.4404 7.65812 19.5055 7.88923 19.5002 8.12413V15.876C19.5058 16.1106 19.441 16.3415 19.3142 16.539C19.1874 16.7365 19.0045 16.8915 18.7888 16.984L12.7222 19.3633C12.259 19.5453 11.7441 19.5453 11.2809 19.3633L5.21433 16.984C4.9982 16.8919 4.81466 16.737 4.68739 16.5395C4.56012 16.342 4.49496 16.1109 4.50033 15.876V8.12413C4.49475 7.88953 4.55951 7.65864 4.68628 7.46117C4.81305 7.26371 4.99603 7.10871 5.21166 7.01614Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="plus" viewBox="0 0 16 16" fill="none"><path d="M14 7.18182H8.81818V2H7.18182V7.18182H2V8.81818H7.18182V14H8.81818V8.81818H14V7.18182Z" fill="currentColor"/></symbol>
|
||||
<symbol id="pointofsale" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.4303H7.12002C6.79748 19.4322 6.47817 19.3659 6.18309 19.2356C5.88802 19.1054 5.62385 18.9141 5.40795 18.6745C5.19206 18.4349 5.02933 18.1522 4.93046 17.8452C4.83159 17.5382 4.79882 17.2137 4.83431 16.8931L5.60002 10.1617C5.6311 9.88087 5.76509 9.62152 5.97615 9.43368C6.1872 9.24584 6.46035 9.14284 6.74288 9.14455H17.2572C17.5397 9.14284 17.8129 9.24584 18.0239 9.43368C18.235 9.62152 18.369 9.88087 18.4 10.1617L19.1429 16.8931C19.1782 17.2118 19.146 17.5343 19.0485 17.8398C18.951 18.1452 18.7903 18.4267 18.5769 18.666C18.3634 18.9053 18.1021 19.097 17.8097 19.2286C17.5174 19.3603 17.2006 19.429 16.88 19.4303Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.42859 9.14369C7.42859 7.93128 7.91022 6.76852 8.76753 5.91121C9.62484 5.0539 10.7876 4.57227 12 4.57227C13.2124 4.57227 14.3752 5.0539 15.2325 5.91121C16.0898 6.76852 16.5714 7.93128 16.5714 9.14369" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9.14282 12.5723H14.8571" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="pos-cart" viewBox="0 0 24 24" fill="none"><path d="M16.88 19.426H7.12a2.286 2.286 0 0 1-2.286-2.537l.766-6.731A1.143 1.143 0 0 1 6.743 9.14h10.514a1.143 1.143 0 0 1 1.143 1.017l.743 6.731a2.286 2.286 0 0 1-2.263 2.537Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M7.43 9.142a4.571 4.571 0 1 1 9.143 0M9.14 12.57h5.715" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
||||
<symbol id="pos-light" viewBox="0 0 24 24" fill="none"><path d="M8 4h8c2.2 0 4 1.8 4 4v8c0 2.2-1.8 4-4 4H8c-2.2 0-4-1.8-4-4V8c0-2.2 1.8-4 4-4Z" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M8 13h8M8 16.25h8" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/><rect x="7" y="7" width="10" height="3.5" rx="1" fill="currentColor"/></symbol>
|
||||
@ -70,4 +72,4 @@
|
||||
<symbol id="warning" viewBox="0 0 24 24" fill="none"><path d="M12.337 3.101a.383.383 0 00-.674 0l-9.32 17.434a.383.383 0 00.338.564h18.638a.384.384 0 00.337-.564L12.337 3.101zM9.636 2.018c1.01-1.89 3.719-1.89 4.728 0l9.32 17.434a2.681 2.681 0 01-2.365 3.945H2.681a2.68 2.68 0 01-2.364-3.945L9.636 2.018zm3.896 15.25a1.532 1.532 0 11-3.064 0 1.532 1.532 0 013.064 0zm-.383-8.044a1.15 1.15 0 00-2.298 0v3.83a1.15 1.15 0 002.298 0v-3.83z" fill="currentColor"/></symbol>
|
||||
<symbol id="watchonly-wallet" viewBox="0 0 32 32" fill="none"><path d="M26.5362 7.08746H25.9614V3.25512C25.9614 2.10542 25.3865 1.14734 24.6201 0.572488C23.8536 -0.00236247 22.7039 -0.193979 21.7458 -0.00236246L4.11707 5.36291C2.00929 5.93776 0.667969 7.85392 0.667969 9.96171V12.0695V12.836V27.3988C0.667969 30.0815 2.77575 32.1893 5.45839 32.1893H26.5362C29.2189 32.1893 31.3267 30.0815 31.3267 27.3988V12.0695C31.3267 9.38686 29.2189 7.08746 26.5362 7.08746ZM4.69192 7.08746L22.129 1.91381C22.5123 1.72219 23.0871 1.91381 23.4704 2.10542C23.8536 2.29704 24.0452 2.87189 24.0452 3.25512V7.08746H5.45839C4.88354 7.08746 4.5003 7.27908 3.92545 7.47069C4.11707 7.27908 4.5003 7.08746 4.69192 7.08746ZM29.4105 27.2072C29.4105 28.7402 28.0692 30.0815 26.5362 30.0815H5.45839C3.92545 30.0815 2.58414 28.7402 2.58414 27.2072V12.836V11.8779C2.58414 10.3449 3.92545 9.00362 5.45839 9.00362H26.5362C28.0692 9.00362 29.4105 10.3449 29.4105 11.8779V27.2072Z" fill="currentColor"/><path d="M25.9591 21.6487C27.0174 21.6487 27.8753 20.7908 27.8753 19.7326C27.8753 18.6743 27.0174 17.8164 25.9591 17.8164C24.9009 17.8164 24.043 18.6743 24.043 19.7326C24.043 20.7908 24.9009 21.6487 25.9591 21.6487Z" fill="currentColor"/></symbol>
|
||||
<symbol id="xpub" viewBox="0 0 32 32" fill="none"><path d="M21.3911 14.0298C20.4238 14.0396 19.4831 13.713 18.73 13.1059C17.9769 12.4988 17.4581 11.649 17.2622 10.7017C17.0664 9.75436 17.2057 8.76844 17.6564 7.91249C18.1071 7.05655 18.8412 6.38377 19.733 6.00919C20.6249 5.6346 21.6192 5.58148 22.5459 5.85891C23.4726 6.13634 24.2742 6.72709 24.8134 7.53015C25.3528 8.33319 25.5964 9.29866 25.5026 10.2614C25.4088 11.2242 24.9834 12.1246 24.2992 12.8084C23.5288 13.5829 22.4836 14.022 21.3911 14.0298ZM21.3911 7.5228C20.9277 7.52249 20.4746 7.65927 20.0888 7.91592C19.703 8.17258 19.4017 8.53764 19.223 8.96514C19.0442 9.39264 18.9959 9.86347 19.0842 10.3184C19.1724 10.7733 19.3933 11.1919 19.7189 11.5215C20.1653 11.9482 20.759 12.1863 21.3765 12.1863C21.9941 12.1863 22.5878 11.9482 23.0342 11.5215C23.359 11.1928 23.5796 10.7755 23.6683 10.3219C23.7571 9.86838 23.71 9.39874 23.5329 8.97182C23.356 8.54491 23.057 8.1797 22.6734 7.92194C22.2898 7.66419 21.8387 7.52534 21.3765 7.5228H21.3911Z" fill="currentColor"/><path d="M11.3293 29.9927C10.6744 29.9903 10.0472 29.7289 9.58436 29.2657L7.81038 27.4844L7.71586 27.608C7.18174 28.1431 6.45693 28.444 5.70089 28.4448C4.94485 28.4454 4.2195 28.1458 3.68441 27.6117C3.14933 27.0776 2.84834 26.3527 2.84766 25.5967C2.84698 24.8406 3.14666 24.1153 3.68078 23.5802L14.172 13.0672C13.4303 11.3826 13.301 9.49181 13.8065 7.722C14.312 5.9522 15.4204 4.41487 16.9399 3.37617C18.4594 2.33747 20.2942 1.8628 22.1268 2.03435C23.9594 2.20589 25.6743 3.01285 26.9746 4.31551C28.2749 5.61816 29.0787 7.3345 29.2469 9.16737C29.4152 11.0002 28.9372 12.8343 27.8957 14.3519C26.8543 15.8695 25.315 16.9751 23.5443 17.4774C21.7736 17.9797 19.883 17.847 18.1998 17.1023L15.0954 20.2067L16.3241 21.4354C16.5544 21.6639 16.7373 21.9357 16.8621 22.2352C16.9868 22.5346 17.0511 22.8559 17.0511 23.1803C17.0511 23.5048 16.9868 23.826 16.8621 24.1255C16.7373 24.425 16.5544 24.6968 16.3241 24.9252C15.8548 25.3728 15.2312 25.6225 14.5828 25.6225C13.9343 25.6225 13.3107 25.3728 12.8415 24.9252L11.6128 23.6893L11.2929 24.0092L13.0742 25.7904C13.4162 26.1364 13.6484 26.5757 13.742 27.0532C13.8354 27.5307 13.7859 28.0252 13.5996 28.4746C13.4132 28.9241 13.0984 29.3086 12.6946 29.5799C12.2908 29.8512 11.8158 29.9974 11.3293 30V29.9927ZM7.81038 25.296C7.92899 25.2954 8.04656 25.3182 8.15636 25.3631C8.26615 25.408 8.36599 25.4742 8.45017 25.5578L10.8712 27.9861C10.9961 28.1011 11.1596 28.1649 11.3293 28.1649C11.4989 28.1649 11.6624 28.1011 11.7873 27.9861C11.8474 27.9259 11.8949 27.8545 11.9274 27.7759C11.9598 27.6973 11.9764 27.613 11.9763 27.5281C11.9769 27.443 11.9604 27.3587 11.928 27.28C11.8955 27.2013 11.8477 27.1299 11.7873 27.07L9.36624 24.649C9.27688 24.5611 9.2068 24.4557 9.16049 24.3393C9.11417 24.2228 9.09263 24.098 9.09724 23.9728C9.09677 23.8536 9.12035 23.7354 9.16656 23.6255C9.21278 23.5156 9.2807 23.4161 9.36624 23.333L10.9948 21.7917C11.0792 21.707 11.1795 21.6399 11.2899 21.594C11.4003 21.5482 11.5187 21.5247 11.6383 21.5247C11.7578 21.5247 11.8762 21.5482 11.9865 21.594C12.0969 21.6399 12.1973 21.707 12.2817 21.7917L14.1575 23.6675C14.2802 23.7835 14.4428 23.8481 14.6119 23.8481C14.7808 23.8481 14.9434 23.7835 15.0663 23.6675C15.1276 23.6078 15.1766 23.5367 15.2102 23.4581C15.2439 23.3795 15.2618 23.2949 15.2626 23.2094C15.2605 23.0381 15.1929 22.8742 15.0735 22.7514L13.176 20.8465C13.0041 20.675 12.9073 20.4423 12.907 20.1995C12.9065 20.0802 12.93 19.9621 12.9763 19.8521C13.0225 19.7423 13.0904 19.6427 13.176 19.5597L17.3855 15.3501C17.5244 15.2094 17.7056 15.1183 17.9014 15.0906C18.0971 15.063 18.2965 15.1006 18.4688 15.1974C19.7515 15.9077 21.2475 16.131 22.6816 15.8261C24.1158 15.5214 25.3917 14.7091 26.2747 13.5387C27.1577 12.3681 27.5884 10.9182 27.4877 9.45553C27.3869 7.99281 26.7614 6.61566 25.7262 5.57732C24.691 4.539 23.3158 3.90933 21.8534 3.8041C20.391 3.69889 18.9398 4.1252 17.7666 5.00464C16.5935 5.88408 15.7773 7.15751 15.4681 8.59074C15.1591 10.024 15.3777 11.5206 16.0841 12.8055C16.1792 12.977 16.2157 13.1749 16.1881 13.369C16.1606 13.5632 16.0705 13.7432 15.9314 13.8815L4.96764 24.8307C4.8026 25.0286 4.71754 25.281 4.72917 25.5385C4.74081 25.796 4.84829 26.0397 5.0305 26.2219C5.21272 26.4041 5.4565 26.5117 5.71392 26.5233C5.97135 26.5349 6.22383 26.4499 6.42173 26.2848L7.14877 25.5578C7.32593 25.3863 7.56388 25.2922 7.81038 25.296Z" fill="currentColor"/></symbol>
|
||||
</svg>
|
||||
</svg>
|
Before (image error) Size: 51 KiB After (image error) Size: 52 KiB |
269
BTCPayServer/wwwroot/js/datatable.js
Normal file
269
BTCPayServer/wwwroot/js/datatable.js
Normal file
@ -0,0 +1,269 @@
|
||||
(function () {
|
||||
// Given sorted data, build a tabular data of given groups and aggregates.
|
||||
function groupBy(groupIndices, aggregatesIndices, data) {
|
||||
var summaryRows = [];
|
||||
var summaryRow = null;
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
if (summaryRow) {
|
||||
for (var gi = 0; gi < groupIndices.length; gi++) {
|
||||
if (summaryRow[gi] !== data[i][groupIndices[gi]]) {
|
||||
summaryRows.push(summaryRow);
|
||||
summaryRow = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!summaryRow) {
|
||||
summaryRow = new Array(groupIndices.length + aggregatesIndices.length);
|
||||
for (var gi = 0; gi < groupIndices.length; gi++) {
|
||||
summaryRow[gi] = data[i][groupIndices[gi]];
|
||||
}
|
||||
summaryRow.fill(0, groupIndices.length);
|
||||
}
|
||||
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
|
||||
var v = data[i][aggregatesIndices[ai]];
|
||||
// TODO: support other aggregate functions
|
||||
if (v)
|
||||
summaryRow[groupIndices.length + ai] += v;
|
||||
}
|
||||
}
|
||||
if (summaryRow) {
|
||||
summaryRows.push(summaryRow);
|
||||
}
|
||||
return summaryRows;
|
||||
}
|
||||
|
||||
// Sort tabular data by the column indices
|
||||
function byColumns(columnIndices) {
|
||||
return (a, b) => {
|
||||
for (var i = 0; i < columnIndices.length; i++) {
|
||||
var fieldIndex = columnIndices[i];
|
||||
if (!a[fieldIndex]) return 1;
|
||||
if (!b[fieldIndex]) return -1;
|
||||
|
||||
if (a[fieldIndex] < b[fieldIndex])
|
||||
return -1;
|
||||
if (a[fieldIndex] > b[fieldIndex])
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a representation of the HTML table's data 'rows' from the tree of nodes.
|
||||
function buildRows(node, rows) {
|
||||
if (node.children.length === 0 && node.level !== 0) {
|
||||
var row =
|
||||
{
|
||||
values: node.values,
|
||||
groups: [],
|
||||
isTotal: node.isTotal,
|
||||
rLevel: node.rLevel
|
||||
};
|
||||
// Round the nuber to 8 decimal to avoid weird decimal outputs
|
||||
for (var i = 0; i < row.values.length; i++) {
|
||||
if (typeof row.values[i] === 'number')
|
||||
row.values[i] = new Number(row.values[i].toFixed(8));
|
||||
}
|
||||
if (!node.isTotal)
|
||||
row.groups.push({ name: node.groups[node.groups.length - 1], rowCount: node.leafCount })
|
||||
var parent = node.parent;
|
||||
var n = node;
|
||||
while (parent && parent.level != 0 && parent.children[0] === n) {
|
||||
row.groups.push({ name: parent.groups[parent.groups.length - 1], rowCount: parent.leafCount })
|
||||
n = parent;
|
||||
parent = parent.parent;
|
||||
}
|
||||
row.groups.reverse();
|
||||
rows.push(row);
|
||||
}
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
buildRows(node.children[i], rows);
|
||||
}
|
||||
}
|
||||
|
||||
// Add a leafCount property, the number of leaf below each nodes
|
||||
// Remove total if there is only one child outside of the total
|
||||
function visitTree(node) {
|
||||
node.leafCount = 0;
|
||||
if (node.children.length === 0) {
|
||||
node.leafCount++;
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < node.children.length; i++) {
|
||||
visitTree(node.children[i]);
|
||||
node.leafCount += node.children[i].leafCount;
|
||||
}
|
||||
// Remove total if there is only one child outside of the total
|
||||
if (node.children.length == 2 && node.children[0].isTotal) {
|
||||
node.children.shift();
|
||||
node.leafCount--;
|
||||
}
|
||||
}
|
||||
|
||||
// Build a tree of nodes from all the group levels.
|
||||
function makeTree(totalLevels, parent, groupLevels, level) {
|
||||
if (totalLevels.indexOf(level - 1) !== -1) {
|
||||
parent.children.push({
|
||||
parent: parent,
|
||||
groups: parent.groups,
|
||||
values: parent.values,
|
||||
children: [],
|
||||
level: level,
|
||||
rLevel: groupLevels.length - level,
|
||||
isTotal: true
|
||||
});
|
||||
}
|
||||
for (var i = 0; i < groupLevels[level].length; i++) {
|
||||
var foundFirst = false;
|
||||
var groupData = groupLevels[level][i];
|
||||
var gotoNextRow = false;
|
||||
var stop = false;
|
||||
for (var gi = 0; gi < parent.groups.length; gi++) {
|
||||
if (parent.groups[gi] !== groupData[gi]) {
|
||||
if (foundFirst) {
|
||||
stop = true;
|
||||
}
|
||||
else {
|
||||
gotoNextRow = true;
|
||||
foundFirst = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (stop)
|
||||
break;
|
||||
if (gotoNextRow)
|
||||
continue;
|
||||
var node =
|
||||
{
|
||||
parent: parent,
|
||||
groups: groupData.slice(0, level),
|
||||
values: groupData.slice(level),
|
||||
children: [],
|
||||
level: level,
|
||||
rLevel: groupLevels.length - level
|
||||
};
|
||||
parent.children.push(node);
|
||||
if (groupLevels.length > level + 1)
|
||||
makeTree(totalLevels, node, groupLevels, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function applyFilters(rows, fields, filterStrings) {
|
||||
if (!filterStrings || filterStrings.length === 0)
|
||||
return rows;
|
||||
// filterStrings are aggregated into one filter function:
|
||||
// filter(){ return filter1 && filter2 && filter3; }
|
||||
var newData = [];
|
||||
var o = {};
|
||||
eval('function filter() {return ' + filterStrings.join(' && ') + ';}');
|
||||
// For each row, build a JSON objects representing it, and evaluate it on the fitler
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
for (var fi = 0; fi < fields.length; fi++) {
|
||||
o[fields[fi]] = rows[i][fi];
|
||||
}
|
||||
if (!filter.bind(o)())
|
||||
continue;
|
||||
newData.push(rows[i]);
|
||||
}
|
||||
return newData;
|
||||
}
|
||||
|
||||
|
||||
function clone(a) {
|
||||
return Array.from(a, subArray => [...subArray]);
|
||||
}
|
||||
|
||||
function createTable(summaryDefinition, fields, rows) {
|
||||
var groupIndices = summaryDefinition.groups.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
|
||||
var aggregatesIndices = summaryDefinition.aggregates.map(g => fields.findIndex((a) => a === g)).filter(g => g !== -1);
|
||||
aggregatesIndices = aggregatesIndices.filter(g => g !== -1);
|
||||
|
||||
// Filter rows
|
||||
rows = applyFilters(rows, fields, summaryDefinition.filters);
|
||||
|
||||
// Sort by group columns
|
||||
rows.sort(byColumns(groupIndices));
|
||||
|
||||
// Group data represent tabular data of all the groups and aggregates given the data.
|
||||
// [Region, Crypto, PaymentType]
|
||||
var groupRows = groupBy(groupIndices, aggregatesIndices, rows);
|
||||
|
||||
|
||||
// There will be several level of aggregation
|
||||
// For example, if you have 3 groups: [Region, Crypto, PaymentType] then you have 4 group data.
|
||||
// [Region, Crypto, PaymentType]
|
||||
// [Region, Crypto]
|
||||
// [Region]
|
||||
// []
|
||||
var groupLevels = [];
|
||||
groupLevels.push(groupRows);
|
||||
|
||||
// We build the group rows with less columns
|
||||
// Those builds the level:
|
||||
// [Region, Crypto], [Region] and []
|
||||
for (var i = 1; i < groupIndices.length + 1; i++) {
|
||||
|
||||
// We are grouping the group data.
|
||||
// For our example of 3 groups and 2 aggregate2, then:
|
||||
// First iteration: newGroupIndices = [0, 1], newAggregatesIndices = [3, 4]
|
||||
// Second iteration: newGroupIndices = [0], newAggregatesIndices = [2, 3]
|
||||
// Last iteration: newGroupIndices = [], newAggregatesIndices = [1, 2]
|
||||
var newGroupIndices = [];
|
||||
for (var gi = 0; gi < groupIndices.length - i; gi++) {
|
||||
newGroupIndices.push(gi);
|
||||
}
|
||||
var newAggregatesIndices = [];
|
||||
for (var ai = 0; ai < aggregatesIndices.length; ai++) {
|
||||
newAggregatesIndices.push(newGroupIndices.length + 1 + ai);
|
||||
}
|
||||
// Group the group rows
|
||||
groupRows = groupBy(newGroupIndices, newAggregatesIndices, groupRows);
|
||||
groupLevels.push(groupRows);
|
||||
}
|
||||
|
||||
// Put the highest level ([]) on top
|
||||
groupLevels.reverse();
|
||||
|
||||
var root =
|
||||
{
|
||||
parent: null,
|
||||
groups: [],
|
||||
// Note that the top group data always have one row aggregating all
|
||||
values: groupLevels[0][0],
|
||||
children: [],
|
||||
// level=0 means the root, it increments 1 each level
|
||||
level: 0,
|
||||
// rlevel is the reverse. It starts from the highest level and goes down to 0
|
||||
rLevel: groupLevels.length
|
||||
};
|
||||
|
||||
|
||||
// Which levels will have a total row
|
||||
var totalLevels = [];
|
||||
if (summaryDefinition.totals) {
|
||||
totalLevels = summaryDefinition.totals.map(g => summaryDefinition.groups.findIndex((a) => a === g) + 1).filter(a => a !== 0);
|
||||
}
|
||||
// Build the tree of nodes
|
||||
makeTree(totalLevels, root, groupLevels, 1);
|
||||
|
||||
// Add a leafCount property to each node, it is the number of leaf below each nodes.
|
||||
visitTree(root);
|
||||
|
||||
// Create a representation that can easily be binded to VueJS
|
||||
var rows = [];
|
||||
buildRows(root, rows);
|
||||
|
||||
return {
|
||||
groups: summaryDefinition.groups,
|
||||
aggregates: summaryDefinition.aggregates,
|
||||
hasGrandTotal: root.values && summaryDefinition.hasGrandTotal,
|
||||
grandTotalValues: root.values,
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
window.clone = clone;
|
||||
window.createTable = createTable;
|
||||
})();
|
212
BTCPayServer/wwwroot/js/store-reports.js
Normal file
212
BTCPayServer/wwwroot/js/store-reports.js
Normal file
@ -0,0 +1,212 @@
|
||||
let app, origData;
|
||||
srv.sortBy = function (field) {
|
||||
for (let key in this.fieldViews) {
|
||||
if (this.fieldViews.hasOwnProperty(key)) {
|
||||
const sortedField = field === key;
|
||||
const fieldView = this.fieldViews[key];
|
||||
|
||||
if (sortedField && (fieldView.sortBy === "" || fieldView.sortBy === "desc")) {
|
||||
fieldView.sortByTitle = "asc";
|
||||
fieldView.sortBy = "asc";
|
||||
fieldView.sortIconClass = "fa fa-sort-alpha-asc";
|
||||
}
|
||||
else if (sortedField && (fieldView.sortByTitle === "asc")) {
|
||||
fieldView.sortByTitle = "desc";
|
||||
fieldView.sortBy = "desc";
|
||||
fieldView.sortIconClass = "fa fa-sort-alpha-desc";
|
||||
}
|
||||
else {
|
||||
fieldView.sortByTitle = "";
|
||||
fieldView.sortBy = "";
|
||||
fieldView.sortIconClass = "fa fa-sort";
|
||||
}
|
||||
}
|
||||
}
|
||||
this.applySort();
|
||||
}
|
||||
|
||||
srv.applySort = function () {
|
||||
let fieldIndex, fieldView;
|
||||
for (let key in this.fieldViews) {
|
||||
if (this.fieldViews.hasOwnProperty(key)) {
|
||||
fieldView = this.fieldViews[key];
|
||||
if (fieldView.sortBy !== "") {
|
||||
fieldIndex = this.result.fields.findIndex((a) => a.name === key);
|
||||
break;
|
||||
}
|
||||
fieldView = null;
|
||||
}
|
||||
}
|
||||
if (!fieldView)
|
||||
return;
|
||||
const sortType = fieldView.sortBy === "desc" ? 1 : -1;
|
||||
srv.result.data.sort(function (a, b) {
|
||||
const aVal = a[fieldIndex];
|
||||
const bVal = b[fieldIndex];
|
||||
if (aVal === bVal) return 0;
|
||||
if (aVal === null) return 1 * sortType;
|
||||
if (bVal === null) return -1 * sortType;
|
||||
if (aVal > bVal) return 1 * sortType;
|
||||
return -1 * sortType;
|
||||
});
|
||||
};
|
||||
srv.dataUpdated = function () {
|
||||
this.updateFieldViews();
|
||||
origData = clone(this.result.data);
|
||||
this.applySort();
|
||||
};
|
||||
srv.updateFieldViews = function () {
|
||||
this.fieldViews = this.fieldViews || {};
|
||||
|
||||
// First we remove the fieldViews that doesn't apply anymore
|
||||
for (let key in this.fieldViews) {
|
||||
if (this.fieldViews.hasOwnProperty(key)) {
|
||||
if (!this.result.fields.find(i => i.name === key))
|
||||
delete this.fieldViews[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Then we add those that are missing
|
||||
for (let i = 0; i < this.result.fields.length; i++) {
|
||||
const field = this.result.fields[i];
|
||||
if (!this.fieldViews.hasOwnProperty(field.name)) {
|
||||
this.fieldViews[field.name] =
|
||||
{
|
||||
sortBy: "",
|
||||
sortByTitle: "",
|
||||
sortIconClass: "fa fa-sort"
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
delegate("input", ".flatdtpicker", function () {
|
||||
// We don't use vue to bind dates, because VueJS break the flatpickr as soon as binding occurs.
|
||||
let to = document.getElementById("toDate").value
|
||||
let from = document.getElementById("fromDate").value
|
||||
|
||||
if (!to || !from)
|
||||
return;
|
||||
|
||||
from = moment(from).unix();
|
||||
to = moment(to).endOf('day').unix();
|
||||
|
||||
srv.request.timePeriod.from = from;
|
||||
srv.request.timePeriod.to = to;
|
||||
fetchStoreReports();
|
||||
});
|
||||
|
||||
delegate("click", "#exportCSV", downloadCSV);
|
||||
|
||||
const $viewNameToggle = document.getElementById("ViewNameToggle")
|
||||
delegate("click", ".available-view", function (e) {
|
||||
e.preventDefault();
|
||||
const { view } = e.target.dataset;
|
||||
$viewNameToggle.innerText = view;
|
||||
document.querySelectorAll(".available-view").forEach($el => $el.classList.remove("custom-active"));
|
||||
e.target.classList.add("custom-active");
|
||||
srv.request.viewName = view;
|
||||
fetchStoreReports();
|
||||
});
|
||||
|
||||
let to = new Date();
|
||||
let from = new Date(to.getTime() - 1000 * 60 * 60 * 24 * 30);
|
||||
var urlParams = new URLSearchParams(new URL(window.location).search);
|
||||
if (urlParams.has("from")) {
|
||||
from = new Date(parseInt(urlParams.get("from")) * 1000);
|
||||
}
|
||||
if (urlParams.has("to")) {
|
||||
to = new Date(parseInt(urlParams.get("to")) * 1000);
|
||||
}
|
||||
srv.request = srv.request || {};
|
||||
srv.request.timePeriod = srv.request.timePeriod || {};
|
||||
srv.request.timePeriod.to = moment(to).unix();
|
||||
srv.request.viewName = srv.request.viewName || "Payments";
|
||||
srv.request.timePeriod.from = moment(from).unix();
|
||||
srv.request.timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
srv.result = { fields: [], values: [] };
|
||||
updateUIDateRange();
|
||||
app = new Vue({
|
||||
el: '#app',
|
||||
data() { return { srv } }
|
||||
});
|
||||
fetchStoreReports();
|
||||
});
|
||||
|
||||
function updateUIDateRange() {
|
||||
document.getElementById("toDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.to).toDate());
|
||||
document.getElementById("fromDate")._flatpickr.setDate(moment.unix(srv.request.timePeriod.from).toDate());
|
||||
}
|
||||
|
||||
// This function modify all the fields of a given type
|
||||
function modifyFields(fields, data, type, action) {
|
||||
var fieldIndices = fields.map((f, i) => ({ i: i, type: f.type })).filter(f => f.type == type).map(f => f.i);
|
||||
if (fieldIndices.length === 0)
|
||||
return;
|
||||
for (var i = 0; i < data.length; i++) {
|
||||
for (var f = 0; f < fieldIndices.length; f++) {
|
||||
data[i][fieldIndices[f]] = action(data[i][fieldIndices[f]]);
|
||||
}
|
||||
}
|
||||
}
|
||||
function downloadCSV() {
|
||||
if (!origData) return;
|
||||
const data = clone(origData);
|
||||
|
||||
// Convert ISO8601 dates to YYYY-MM-DD HH:mm:ss so the CSV easily integrate with Excel
|
||||
modifyFields(srv.result.fields, data, 'datetime', v => moment(v).format('YYYY-MM-DD hh:mm:ss'));
|
||||
const csv = Papa.unparse({ fields: srv.result.fields.map(f => f.name), data });
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
saveAs(blob, "export.csv");
|
||||
}
|
||||
|
||||
async function fetchStoreReports() {
|
||||
const result = await fetch(window.location, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(srv.request)
|
||||
});
|
||||
|
||||
srv.result = await result.json();
|
||||
srv.dataUpdated();
|
||||
|
||||
// Dates from API are UTC, convert them to local time
|
||||
modifyFields(srv.result.fields, srv.result.data, 'datetime', a => moment(a).format());
|
||||
var urlParams = new URLSearchParams(new URL(window.location).search);
|
||||
urlParams.set("viewName", srv.request.viewName);
|
||||
urlParams.set("from", srv.request.timePeriod.from);
|
||||
urlParams.set("to", srv.request.timePeriod.to);
|
||||
history.replaceState(null, null, "?" + urlParams.toString());
|
||||
updateUIDateRange();
|
||||
|
||||
srv.charts = [];
|
||||
for (let i = 0; i < srv.result.charts.length; i++) {
|
||||
const chart = srv.result.charts[i];
|
||||
const table = createTable(chart, srv.result.fields.map(f => f.name), srv.result.data);
|
||||
table.name = chart.name;
|
||||
srv.charts.push(table);
|
||||
}
|
||||
|
||||
app.srv = srv;
|
||||
}
|
||||
|
||||
function getInvoiceUrl(value) {
|
||||
if (!value)
|
||||
return;
|
||||
return srv.invoiceTemplateUrl.replace("INVOICE_ID", value);
|
||||
}
|
||||
window.getInvoiceUrl = getInvoiceUrl;
|
||||
|
||||
function getExplorerUrl(tx_id, cryptoCode) {
|
||||
if (!tx_id || !cryptoCode)
|
||||
return null;
|
||||
var explorer = srv.explorerTemplateUrls[cryptoCode];
|
||||
if (!explorer)
|
||||
return null;
|
||||
return explorer.replace("TX_ID", tx_id);
|
||||
}
|
||||
window.getExplorerUrl = getExplorerUrl;
|
@ -1,201 +0,0 @@
|
||||
document.addEventListener("DOMContentLoaded",function () {
|
||||
const displayFontSize = 64;
|
||||
new Vue({
|
||||
el: '#app',
|
||||
data () {
|
||||
return {
|
||||
srvModel: window.srvModel,
|
||||
mode: 'amount',
|
||||
amount: null,
|
||||
tip: null,
|
||||
tipPercent: null,
|
||||
discount: null,
|
||||
discountPercent: null,
|
||||
fontSize: displayFontSize,
|
||||
defaultFontSize: displayFontSize,
|
||||
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', 'del'],
|
||||
payButtonLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
modes () {
|
||||
const modes = [{ title: 'Amount', type: 'amount' }]
|
||||
if (this.srvModel.showDiscount) modes.push({ title: 'Discount', type: 'discount' })
|
||||
if (this.srvModel.enableTips) modes.push({ title: 'Tip', type: 'tip'})
|
||||
return modes
|
||||
},
|
||||
keypadTarget () {
|
||||
switch (this.mode) {
|
||||
case 'amount':
|
||||
return 'amount';
|
||||
case 'discount':
|
||||
return 'discountPercent';
|
||||
case 'tip':
|
||||
return 'tip';
|
||||
}
|
||||
},
|
||||
calculation () {
|
||||
if (!this.tipNumeric && !this.discountNumeric) return null
|
||||
let calc = this.formatCurrency(this.amountNumeric, true)
|
||||
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
|
||||
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
|
||||
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
|
||||
return calc
|
||||
},
|
||||
amountNumeric () {
|
||||
const value = parseFloat(this.amount)
|
||||
return isNaN(value) ? 0.0 : value
|
||||
},
|
||||
discountPercentNumeric () {
|
||||
const value = parseFloat(this.discountPercent)
|
||||
return isNaN(value) ? 0.0 : value;
|
||||
},
|
||||
discountNumeric () {
|
||||
return this.amountNumeric && this.discountPercentNumeric
|
||||
? this.amountNumeric * (this.discountPercentNumeric / 100)
|
||||
: 0.0;
|
||||
},
|
||||
amountMinusDiscountNumeric () {
|
||||
return this.amountNumeric - this.discountNumeric;
|
||||
},
|
||||
tipNumeric () {
|
||||
if (this.tipPercent) {
|
||||
return this.amountMinusDiscountNumeric * (this.tipPercent / 100);
|
||||
} else {
|
||||
const value = parseFloat(this.tip)
|
||||
return isNaN(value) ? 0.0 : value;
|
||||
}
|
||||
},
|
||||
total () {
|
||||
return (this.amountNumeric - this.discountNumeric + this.tipNumeric);
|
||||
},
|
||||
totalNumeric () {
|
||||
return parseFloat(this.total);
|
||||
},
|
||||
posdata () {
|
||||
const data = {
|
||||
subTotal: this.amountNumeric,
|
||||
total: this.totalNumeric
|
||||
}
|
||||
if (this.tipNumeric > 0) data.tip = this.tipNumeric
|
||||
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
|
||||
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
discountPercent (val) {
|
||||
const value = parseFloat(val)
|
||||
if (isNaN(value)) this.discountPercent = null
|
||||
else if (value > 100) this.discountPercent = '100'
|
||||
else this.discountPercent = value.toString();
|
||||
},
|
||||
tip (val) {
|
||||
this.tipPercent = null;
|
||||
},
|
||||
total () {
|
||||
// This must be timed out because the updated width is not available yet
|
||||
this.$nextTick(function () {
|
||||
const displayWidth = this.getWidth(this.$refs.display),
|
||||
amountWidth = this.getWidth(this.$refs.amount),
|
||||
gamma = displayWidth / amountWidth || 0,
|
||||
isAmountWider = displayWidth < amountWidth;
|
||||
|
||||
if (isAmountWider) {
|
||||
// Font size will get smaller
|
||||
this.fontSize = Math.floor(this.fontSize * gamma);
|
||||
} else if (!isAmountWider && this.fontSize < this.defaultFontSize) {
|
||||
// Font size will get larger up to the max size
|
||||
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getWidth (el) {
|
||||
const styles = window.getComputedStyle(el),
|
||||
width = parseFloat(el.clientWidth),
|
||||
padL = parseFloat(styles.paddingLeft),
|
||||
padR = parseFloat(styles.paddingRight);
|
||||
return width - padL - padR;
|
||||
},
|
||||
clear () {
|
||||
this.amount = this.tip = this.discount = this.tipPercent = this.discountPercent = null;
|
||||
this.mode = 'amount';
|
||||
},
|
||||
handleFormSubmit () {
|
||||
this.payButtonLoading = true;
|
||||
},
|
||||
unsetPayButtonLoading () {
|
||||
this.payButtonLoading = false;
|
||||
},
|
||||
formatCrypto (value, withSymbol) {
|
||||
const symbol = withSymbol ? ` ${this.srvModel.currencySymbol || this.srvModel.currencyCode}` : '';
|
||||
const divisibility = this.srvModel.currencyInfo.divisibility;
|
||||
return parseFloat(value).toFixed(divisibility) + symbol;
|
||||
},
|
||||
formatCurrency (value, withSymbol) {
|
||||
const currency = this.srvModel.currencyCode;
|
||||
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol);
|
||||
const divisibility = this.srvModel.currencyInfo.divisibility;
|
||||
const locale = this.getLocale(currency);
|
||||
const style = withSymbol ? 'currency' : 'decimal';
|
||||
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility };
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, opts).format(value);
|
||||
} catch (err) {
|
||||
return this.formatCrypto(value, withSymbol);
|
||||
}
|
||||
},
|
||||
applyKeyToValue (key, value) {
|
||||
if (!value) value = '';
|
||||
if (key === 'del') {
|
||||
value = value.substring(0, value.length - 1);
|
||||
value = value === '' ? '0' : value;
|
||||
} else if (key === '.') {
|
||||
// Only add decimal point if it doesn't exist yet
|
||||
if (value.indexOf('.') === -1) {
|
||||
value += key;
|
||||
}
|
||||
} else { // Is a digit
|
||||
if (!value || value === '0') {
|
||||
value = '';
|
||||
}
|
||||
value += key;
|
||||
const { divisibility } = this.srvModel.currencyInfo;
|
||||
const decimalIndex = value.indexOf('.')
|
||||
if (decimalIndex !== -1 && (value.length - decimalIndex - 1 > divisibility)) {
|
||||
value = value.replace('.', '');
|
||||
value = value.substr(0, value.length - divisibility) + '.' +
|
||||
value.substr(value.length - divisibility);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
},
|
||||
keyPressed (key) {
|
||||
this[this.keypadTarget] = this.applyKeyToValue(key, this[this.keypadTarget]);
|
||||
},
|
||||
tipPercentage (percentage) {
|
||||
this.tipPercent = this.tipPercent !== percentage
|
||||
? percentage
|
||||
: null;
|
||||
},
|
||||
getLocale(currency) {
|
||||
switch (currency) {
|
||||
case 'USD': return 'en-US';
|
||||
case 'EUR': return 'de-DE';
|
||||
case 'JPY': return 'ja-JP';
|
||||
default: return navigator.language;
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
/** We need to unset state in case user clicks the browser back button */
|
||||
window.addEventListener('pagehide', this.unsetPayButtonLoading);
|
||||
},
|
||||
destroyed () {
|
||||
window.removeEventListener('pagehide', this.unsetPayButtonLoading);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
115
BTCPayServer/wwwroot/main/bootstrap/bootstrap.css
vendored
115
BTCPayServer/wwwroot/main/bootstrap/bootstrap.css
vendored
@ -2028,7 +2028,7 @@ progress {
|
||||
.form-control {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--btcpay-font-weight-normal);
|
||||
line-height: 1.6;
|
||||
@ -2087,10 +2087,10 @@ progress {
|
||||
}
|
||||
|
||||
.form-control::-webkit-file-upload-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: -0.5rem -0.75rem;
|
||||
-webkit-margin-end: 0.75rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: -0.5rem -1rem;
|
||||
-webkit-margin-end: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
color: var(--btcpay-form-text);
|
||||
background-color: var(--btcpay-tertiary-bg);
|
||||
pointer-events: none;
|
||||
@ -2104,10 +2104,10 @@ progress {
|
||||
}
|
||||
|
||||
.form-control::file-selector-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: -0.5rem -0.75rem;
|
||||
-webkit-margin-end: 0.75rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: -0.5rem -1rem;
|
||||
-webkit-margin-end: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
color: var(--btcpay-form-text);
|
||||
background-color: var(--btcpay-tertiary-bg);
|
||||
pointer-events: none;
|
||||
@ -2150,23 +2150,23 @@ progress {
|
||||
|
||||
.form-control-sm {
|
||||
min-height: calc(1.6em + 1rem + calc(var(--btcpay-border-width) * 2));
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--btcpay-border-radius-sm);
|
||||
}
|
||||
|
||||
.form-control-sm::-webkit-file-upload-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: -0.5rem -0.75rem;
|
||||
-webkit-margin-end: 0.75rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: -0.5rem -1rem;
|
||||
-webkit-margin-end: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
}
|
||||
|
||||
.form-control-sm::file-selector-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin: -0.5rem -0.75rem;
|
||||
-webkit-margin-end: 0.75rem;
|
||||
margin-inline-end: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
margin: -0.5rem -1rem;
|
||||
-webkit-margin-end: 1rem;
|
||||
margin-inline-end: 1rem;
|
||||
}
|
||||
|
||||
.form-control-lg {
|
||||
@ -2234,7 +2234,7 @@ textarea.form-control-lg {
|
||||
--btcpay-form-select-bg-img: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.5rem 2.25rem 0.5rem 0.75rem;
|
||||
padding: 0.5rem 3rem 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--btcpay-font-weight-normal);
|
||||
line-height: 1.6;
|
||||
@ -2242,7 +2242,7 @@ textarea.form-control-lg {
|
||||
background-color: var(--btcpay-form-bg);
|
||||
background-image: var(--btcpay-form-select-bg-img), var(--btcpay-form-select-bg-icon, none);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-position: right 1rem center;
|
||||
background-size: 16px 12px;
|
||||
border: var(--btcpay-border-width) solid var(--btcpay-form-border);
|
||||
border-radius: var(--btcpay-border-radius);
|
||||
@ -2259,7 +2259,7 @@ textarea.form-control-lg {
|
||||
}
|
||||
|
||||
.form-select[multiple], .form-select[size]:not([size="1"]) {
|
||||
padding-right: 0.75rem;
|
||||
padding-right: 1rem;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
@ -2276,7 +2276,7 @@ textarea.form-control-lg {
|
||||
.form-select-sm {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
padding-left: 0.75rem;
|
||||
padding-left: 1rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--btcpay-border-radius-sm);
|
||||
}
|
||||
@ -2540,7 +2540,7 @@ textarea.form-control-lg {
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
padding: 1rem 0.75rem;
|
||||
padding: 1rem 1rem;
|
||||
overflow: hidden;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
@ -2553,7 +2553,7 @@ textarea.form-control-lg {
|
||||
|
||||
.form-floating > .form-control,
|
||||
.form-floating > .form-control-plaintext {
|
||||
padding: 1rem 0.75rem;
|
||||
padding: 1rem 1rem;
|
||||
}
|
||||
|
||||
.form-floating > .form-control::-moz-placeholder, .form-floating > .form-control-plaintext::-moz-placeholder {
|
||||
@ -2603,7 +2603,7 @@ textarea.form-control-lg {
|
||||
|
||||
.form-floating > .form-control:not(:-moz-placeholder-shown) ~ label::after {
|
||||
position: absolute;
|
||||
inset: 1rem 0.375rem;
|
||||
inset: 1rem 0.5rem;
|
||||
z-index: -1;
|
||||
height: 1.5em;
|
||||
content: "";
|
||||
@ -2616,7 +2616,7 @@ textarea.form-control-lg {
|
||||
.form-floating > .form-control-plaintext ~ label::after,
|
||||
.form-floating > .form-select ~ label::after {
|
||||
position: absolute;
|
||||
inset: 1rem 0.375rem;
|
||||
inset: 1rem 0.5rem;
|
||||
z-index: -1;
|
||||
height: 1.5em;
|
||||
content: "";
|
||||
@ -2676,7 +2676,7 @@ textarea.form-control-lg {
|
||||
.input-group-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(--btcpay-font-weight-normal);
|
||||
line-height: 1.6;
|
||||
@ -2701,14 +2701,14 @@ textarea.form-control-lg {
|
||||
.input-group-sm > .form-select,
|
||||
.input-group-sm > .input-group-text,
|
||||
.input-group-sm > .btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: var(--btcpay-border-radius-sm);
|
||||
}
|
||||
|
||||
.input-group-lg > .form-select,
|
||||
.input-group-sm > .form-select {
|
||||
padding-right: 3rem;
|
||||
padding-right: 4rem;
|
||||
}
|
||||
|
||||
.input-group:not(.has-validation) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating),
|
||||
@ -2793,8 +2793,8 @@ textarea.form-control-lg {
|
||||
|
||||
.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-valid:not([multiple])[size="1"] {
|
||||
--btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");
|
||||
padding-right: 4.125rem;
|
||||
background-position: right 0.75rem center, center right 2.25rem;
|
||||
padding-right: 5.5rem;
|
||||
background-position: right 1rem center, center right 3rem;
|
||||
background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem);
|
||||
}
|
||||
|
||||
@ -2889,8 +2889,8 @@ textarea.form-control-lg {
|
||||
|
||||
.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-invalid:not([multiple])[size="1"] {
|
||||
--btcpay-form-select-bg-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");
|
||||
padding-right: 4.125rem;
|
||||
background-position: right 0.75rem center, center right 2.25rem;
|
||||
padding-right: 5.5rem;
|
||||
background-position: right 1rem center, center right 3rem;
|
||||
background-size: 16px 12px, calc(0.8em + 0.5rem) calc(0.8em + 0.5rem);
|
||||
}
|
||||
|
||||
@ -4861,7 +4861,7 @@ fieldset:disabled .btn {
|
||||
.badge {
|
||||
--btcpay-badge-padding-x: 0.75em;
|
||||
--btcpay-badge-padding-y: 0.5em;
|
||||
--btcpay-badge-font-size: 0.75em;
|
||||
--btcpay-badge-font-size: var(--btcpay-font-size-s);
|
||||
--btcpay-badge-font-weight: var(--btcpay-font-weight-semibold);
|
||||
--btcpay-badge-color: var(--btcpay-white);
|
||||
--btcpay-badge-border-radius: var(--btcpay-border-radius);
|
||||
@ -5522,7 +5522,7 @@ fieldset:disabled .btn {
|
||||
--btcpay-modal-margin: 0.5rem;
|
||||
--btcpay-modal-color: var(--btcpay-body-text);
|
||||
--btcpay-modal-bg: var(--btcpay-bg-tile);
|
||||
--btcpay-modal-border-color: var(--btcpay-border-color-translucent);
|
||||
--btcpay-modal-border-color: var(--btcpay-border-color);
|
||||
--btcpay-modal-border-width: var(--btcpay-border-width);
|
||||
--btcpay-modal-border-radius: var(--btcpay-border-radius-lg);
|
||||
--btcpay-modal-box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
@ -5557,7 +5557,7 @@ fieldset:disabled .btn {
|
||||
}
|
||||
|
||||
.modal.fade .modal-dialog {
|
||||
transition: transform 0.3s cubic-bezier(0.175, 0.855, 0.32, 1.275);
|
||||
transition: transform var(--btcpay-transition-duration-default) cubic-bezier(0.175, 0.855, 0.32, 1.275);
|
||||
transform: translate(0, -0.5rem);
|
||||
}
|
||||
|
||||
@ -5604,7 +5604,7 @@ fieldset:disabled .btn {
|
||||
|
||||
.modal-backdrop {
|
||||
--btcpay-backdrop-zindex: 1050;
|
||||
--btcpay-backdrop-bg: var(--btcpay-black);
|
||||
--btcpay-backdrop-bg: var(--btcpay-bg-tile);
|
||||
--btcpay-backdrop-opacity: 0.85;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
@ -5829,10 +5829,10 @@ fieldset:disabled .btn {
|
||||
--btcpay-tooltip-padding-y: 0.25rem;
|
||||
--btcpay-tooltip-margin: ;
|
||||
--btcpay-tooltip-font-size: 0.75rem;
|
||||
--btcpay-tooltip-color: var(--btcpay-body-bg);
|
||||
--btcpay-tooltip-bg: var(--btcpay-primary);
|
||||
--btcpay-tooltip-color: var(--btcpay-body-text);
|
||||
--btcpay-tooltip-bg: var(--btcpay-bg-tile);
|
||||
--btcpay-tooltip-border-radius: var(--btcpay-border-radius);
|
||||
--btcpay-tooltip-opacity: 0.9;
|
||||
--btcpay-tooltip-opacity: 0.95;
|
||||
--btcpay-tooltip-arrow-width: 0.8rem;
|
||||
--btcpay-tooltip-arrow-height: 0.4rem;
|
||||
z-index: var(--btcpay-tooltip-zindex);
|
||||
@ -6353,16 +6353,16 @@ fieldset:disabled .btn {
|
||||
|
||||
.offcanvas-sm, .offcanvas-md, .offcanvas-lg, .offcanvas-xl, .offcanvas-xxl, .offcanvas {
|
||||
--btcpay-offcanvas-zindex: 1045;
|
||||
--btcpay-offcanvas-width: 100vw;
|
||||
--btcpay-offcanvas-width: 400px;
|
||||
--btcpay-offcanvas-height: auto;
|
||||
--btcpay-offcanvas-padding-x: 0;
|
||||
--btcpay-offcanvas-padding-y: 0.5rem;
|
||||
--btcpay-offcanvas-color: var(--btcpay-body-text);
|
||||
--btcpay-offcanvas-bg: var(--btcpay-black);
|
||||
--btcpay-offcanvas-border-width: 0;
|
||||
--btcpay-offcanvas-border-color: var(--btcpay-border-color-translucent);
|
||||
--btcpay-offcanvas-color: var(--btcpay-body-color);
|
||||
--btcpay-offcanvas-bg: var(--btcpay-bg-tile);
|
||||
--btcpay-offcanvas-border-width: var(--btcpay-border-width);
|
||||
--btcpay-offcanvas-border-color: var(--btcpay-border-color);
|
||||
--btcpay-offcanvas-box-shadow: none;
|
||||
--btcpay-offcanvas-transition: transform 0.3s ease-in-out;
|
||||
--btcpay-offcanvas-transition: transform var(--btcpay-transition-duration-default) ease-in-out;
|
||||
--btcpay-offcanvas-title-line-height: 1.6;
|
||||
}
|
||||
|
||||
@ -6791,7 +6791,7 @@ fieldset:disabled .btn {
|
||||
z-index: 1040;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: var(--btcpay-black);
|
||||
background-color: var(--btcpay-bg-tile);
|
||||
}
|
||||
|
||||
.offcanvas-backdrop.fade {
|
||||
@ -6799,7 +6799,7 @@ fieldset:disabled .btn {
|
||||
}
|
||||
|
||||
.offcanvas-backdrop.show {
|
||||
opacity: 1;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.offcanvas-header {
|
||||
@ -12129,8 +12129,9 @@ fieldset:disabled .btn {
|
||||
[list] {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-position: right 1rem center;
|
||||
background-size: 16px 12px;
|
||||
padding: 0.5rem 3rem 0.5rem 1rem;
|
||||
}
|
||||
|
||||
[list]::-webkit-calendar-picker-indicator {
|
||||
@ -12144,7 +12145,7 @@ fieldset:disabled .btn {
|
||||
.dropdown-toggle.dropdown-toggle-custom-caret {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%236E7681' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
background-position: right 1rem center;
|
||||
background-size: 16px 12px;
|
||||
padding-right: 2.25rem;
|
||||
}
|
||||
@ -12189,7 +12190,7 @@ html {
|
||||
|
||||
a,
|
||||
.btn-link {
|
||||
transition-duration: 0.2s;
|
||||
transition-duration: var(--btcpay-transition-duration-fast);
|
||||
transition-property: background, color;
|
||||
}
|
||||
|
||||
@ -12395,6 +12396,15 @@ textarea.w-auto {
|
||||
flex-grow: 0 !important;
|
||||
}
|
||||
|
||||
.form-floating > .form-control:focus ~ label::after,
|
||||
.form-floating > .form-control:not(:placeholder-shown) ~ label::after,
|
||||
.form-floating > .form-control-plaintext ~ label::after,
|
||||
.form-floating > .form-select ~ label::after,
|
||||
.form-floating > :disabled ~ label::after
|
||||
{
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Prevent form zoom on mobile Safari */
|
||||
@supports (-webkit-overflow-scrolling: touch) {
|
||||
input.form-control,
|
||||
@ -12447,6 +12457,9 @@ main [class*='table-responsive'] {
|
||||
}
|
||||
|
||||
@media screen {
|
||||
.w-50px {
|
||||
width: 50px;
|
||||
}
|
||||
.w-75px {
|
||||
width: 75px;
|
||||
}
|
||||
|
@ -396,20 +396,13 @@
|
||||
background: var(--btcpay-nav-bg-active);
|
||||
}
|
||||
|
||||
/* Sticky Header: The <div class="sticky-header-setup"></div> needs to be included once
|
||||
before the first sticky-header on the page. The sticky-header has a padding-top so
|
||||
that it does not scroll underneath the fixed header on mobile. The sticky-header-setup
|
||||
negates that padding with a negative margin, so that everything fits in the end. */
|
||||
.sticky-header-setup {
|
||||
margin-top: calc(var(--content-padding-top) * -1);
|
||||
}
|
||||
|
||||
.sticky-header {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1020;
|
||||
z-index: 1021;
|
||||
background: var(--btcpay-body-bg);
|
||||
margin-top: calc(var(--content-padding-top) * -1);
|
||||
/* pull it out of the content padding and adjust its inner padding to make up for that space */
|
||||
margin-left: calc(var(--content-padding-horizontal) * -1);
|
||||
margin-right: calc(var(--content-padding-horizontal) * -1);
|
||||
@ -502,15 +495,11 @@
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.offcanvas-backdrop {
|
||||
#mainMenu .offcanvas-backdrop {
|
||||
top: var(--mobile-header-height);
|
||||
transition-duration: var(--btcpay-transition-duration-fast);
|
||||
}
|
||||
|
||||
.offcanvas-backdrop.show {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
#StoreSelector {
|
||||
margin: 0 auto;
|
||||
max-width: 60vw;
|
||||
@ -655,7 +644,7 @@
|
||||
}
|
||||
|
||||
#mainMenuToggle,
|
||||
.offcanvas-backdrop {
|
||||
#mainMenu .offcanvas-backdrop {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@ -677,6 +666,11 @@
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
#mainContent > section .w-100-fixed {
|
||||
/* constrains the content to respect the maximum width and enable responsive tables */
|
||||
width: calc(100vw - var(--sidebar-width) - var(--content-padding-horizontal) * 2);
|
||||
}
|
||||
|
||||
#SectionNav .nav {
|
||||
margin-top: calc(var(--btcpay-space-m) * -1);
|
||||
}
|
||||
|
@ -255,6 +255,14 @@ h2 svg.icon.icon-info {
|
||||
.toasted-container {
|
||||
display: none !important;
|
||||
}
|
||||
.truncate-center a,
|
||||
.truncate-center button,
|
||||
.truncate-center-truncated {
|
||||
display: none;
|
||||
}
|
||||
.card {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
#markStatusDropdownMenuButton {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@ -650,6 +658,7 @@ 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);
|
||||
@ -1011,14 +1020,6 @@ input.ts-wrapper.form-control:not(.ts-hidden-accessible,.ts-inline) {
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
.truncate-center a,
|
||||
.truncate-center button,
|
||||
.truncate-center-truncated {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Copy */
|
||||
[data-clipboard],
|
||||
[data-clipboard] input[readonly] {
|
||||
|
@ -153,7 +153,7 @@ const initLabelManagers = () => {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
// sticky header
|
||||
const stickyHeader = document.querySelector('.sticky-header-setup + .sticky-header');
|
||||
const stickyHeader = document.querySelector('#mainContent > section > .sticky-header');
|
||||
if (stickyHeader) {
|
||||
document.documentElement.style.scrollPaddingTop = `calc(${stickyHeader.offsetHeight}px + var(--btcpay-space-m))`;
|
||||
}
|
||||
|
@ -13,7 +13,7 @@
|
||||
--btcpay-bg-tile: var(--btcpay-bg-dark);
|
||||
--btcpay-body-bg-light: var(--btcpay-neutral-50);
|
||||
--btcpay-body-bg-hover: var(--btcpay-neutral-50);
|
||||
--btcpay-body-bg-rgb: 41, 41, 41;
|
||||
--btcpay-body-bg-rgb: 22, 27, 34;
|
||||
--btcpay-body-text: var(--btcpay-white);
|
||||
--btcpay-body-text-muted: var(--btcpay-neutral-600);
|
||||
--btcpay-body-text-rgb: 255, 255, 255;
|
||||
@ -37,11 +37,10 @@
|
||||
--btcpay-warning-text-hover: var(--btcpay-neutral-100);
|
||||
--btcpay-warning-text-active: var(--btcpay-neutral-100);
|
||||
--btcpay-warning-dim-text: var(--btcpay-neutral-200);
|
||||
--btcpay-light-accent: var(--btcpay-black);
|
||||
--btcpay-light-dim-bg: var(--btcpay-neutral-50);
|
||||
--btcpay-light-shadow: rgba(66, 70, 73, 0.33);
|
||||
--btcpay-light-rgb: 33, 38, 45;
|
||||
--btcpay-dark-accent: var(--btcpay-neutral-400);
|
||||
--btcpay-dark-accent: var(--btcpay-neutral-600);
|
||||
--btcpay-dark-dim-bg: var(--btcpay-white);
|
||||
--btcpay-dark-shadow: rgba(211, 212, 213, 0.33);
|
||||
--btcpay-dark-rgb: 201, 209, 217;
|
||||
|
@ -414,7 +414,7 @@
|
||||
--btcpay-light-rgb: 233, 236, 239;
|
||||
|
||||
--btcpay-dark: var(--btcpay-neutral-800);
|
||||
--btcpay-dark-accent: var(--btcpay-black);
|
||||
--btcpay-dark-accent: var(--btcpay-neutral-900);
|
||||
--btcpay-dark-text: var(--btcpay-neutral-200);
|
||||
--btcpay-dark-text-hover: var(--btcpay-neutral-200);
|
||||
--btcpay-dark-text-active: var(--btcpay-neutral-200);
|
||||
|
145
BTCPayServer/wwwroot/pos/cart.css
Normal file
145
BTCPayServer/wwwroot/pos/cart.css
Normal file
@ -0,0 +1,145 @@
|
||||
#PosCart {
|
||||
--sidebar-width: 480px;
|
||||
}
|
||||
|
||||
#PosCart .public-page-wrap {
|
||||
padding: 0 0 var(--btcpay-space-l);
|
||||
}
|
||||
|
||||
#PosCart .offcanvas-backdrop {
|
||||
top: var(--mobile-header-height);
|
||||
transition-duration: var(--btcpay-transition-duration-fast);
|
||||
}
|
||||
|
||||
.cart-toggle-btn {
|
||||
--button-width: 40px;
|
||||
--button-height: 40px;
|
||||
--button-padding: 7px;
|
||||
--icon-size: 1rem;
|
||||
position: absolute;
|
||||
top: calc(50% - var(--button-height) / 2);
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--button-width);
|
||||
height: var(--button-height);
|
||||
padding: var(--button-padding);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
header .cart-toggle-btn {
|
||||
--icon-size: 32px;
|
||||
}
|
||||
|
||||
.cart-toggle-btn .icon-pos-cart {
|
||||
width: var(--icon-size);
|
||||
height: var(--icon-size);
|
||||
color: var(--btcpay-header-link);
|
||||
}
|
||||
|
||||
.cart-toggle-btn:disabled svg {
|
||||
color: var(--btcpay-body-text-muted);
|
||||
}
|
||||
|
||||
.cart-toggle-btn:not(:disabled):hover svg {
|
||||
color: var(--btcpay-header-link-accent);
|
||||
}
|
||||
|
||||
#SearchTerm {
|
||||
max-width: 47em;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
#cart {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 1045;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
color: var(--btcpay-body-text);
|
||||
background-color: var(--btcpay-bg-tile);
|
||||
}
|
||||
|
||||
#cart .quantity .btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
#cart .quantity .btn .icon{
|
||||
--btn-icon-size: .75rem;
|
||||
}
|
||||
|
||||
#CartBadge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
min-width: 1.75em;
|
||||
}
|
||||
|
||||
.card-img-top {
|
||||
max-height: 210px;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
.posItem {
|
||||
position: relative;
|
||||
}
|
||||
.posItem.posItem--inStock {
|
||||
cursor: pointer;
|
||||
}
|
||||
.posItem-added {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--btcpay-success);
|
||||
color: var(--btcpay-success-text);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity var(--btcpay-transition-duration-default) ease-in-out;
|
||||
}
|
||||
.posItem-added .icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
.posItem--added {
|
||||
pointer-events: none;
|
||||
}
|
||||
.posItem--added .posItem-added {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
#cart {
|
||||
left: 0;
|
||||
transform: translateX(100%);
|
||||
transition: transform var(--btcpay-transition-duration-fast) ease-in-out;
|
||||
}
|
||||
#cart.show {
|
||||
transform: none;
|
||||
}
|
||||
#CartClose {
|
||||
color: var(--btcpay-body-text);
|
||||
}
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
#content {
|
||||
margin-right: var(--sidebar-width);
|
||||
}
|
||||
#cart {
|
||||
width: var(--sidebar-width);
|
||||
border-left: 1px solid var(--btcpay-body-border-light);
|
||||
}
|
||||
.cart-toggle-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
179
BTCPayServer/wwwroot/pos/cart.js
Normal file
179
BTCPayServer/wwwroot/pos/cart.js
Normal file
@ -0,0 +1,179 @@
|
||||
document.addEventListener("DOMContentLoaded",function () {
|
||||
function storageKey(name) {
|
||||
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
|
||||
}
|
||||
function saveState(name, data) {
|
||||
localStorage.setItem(storageKey(name), JSON.stringify(data));
|
||||
}
|
||||
function loadState(name) {
|
||||
const data = localStorage.getItem(storageKey(name))
|
||||
if (!data) return []
|
||||
const cart = JSON.parse(data);
|
||||
|
||||
for (let i = cart.length-1; i >= 0; i--) {
|
||||
if (!cart[i]) {
|
||||
cart.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
//check if the pos items still has the cached cart items
|
||||
const matchedItem = srvModel.items.find(item => item.id === cart[i].id);
|
||||
if (!matchedItem){
|
||||
cart.splice(i, 1);
|
||||
} else {
|
||||
if (matchedItem.inventory != null && matchedItem.inventory <= 0){
|
||||
//item is out of stock
|
||||
cart.splice(i, 1);
|
||||
} else if (matchedItem.inventory != null && matchedItem.inventory < cart[i].count){
|
||||
//not enough stock for original cart amount, reduce to available stock
|
||||
cart[i].count = matchedItem.inventory;
|
||||
//update its stock
|
||||
cart[i].inventory = matchedItem.inventory;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
const POS_ITEM_ADDED_CLASS = 'posItem--added';
|
||||
|
||||
new Vue({
|
||||
el: '#PosCart',
|
||||
mixins: [posCommon],
|
||||
data () {
|
||||
return {
|
||||
displayCategory: '*',
|
||||
searchTerm: null,
|
||||
cart: loadState('cart'),
|
||||
$cart: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cartCount() {
|
||||
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
|
||||
},
|
||||
amountNumeric () {
|
||||
return parseFloat(this.cart.reduce((res, item) => res + item.price * item.count, 0).toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
posdata () {
|
||||
const data = {
|
||||
cart: this.cart,
|
||||
subTotal: this.amountNumeric,
|
||||
total: this.totalNumeric
|
||||
}
|
||||
if (this.tipNumeric > 0) data.tip = this.tipNumeric
|
||||
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
|
||||
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
const t = term.toLowerCase();
|
||||
this.forEachItem(item => {
|
||||
const terms = item.dataset.search.toLowerCase()
|
||||
const included = terms.indexOf(t) !== -1
|
||||
item.classList[included ? 'remove' : 'add']("d-none")
|
||||
})
|
||||
},
|
||||
displayCategory(category) {
|
||||
this.forEachItem(item => {
|
||||
const categories = JSON.parse(item.dataset.categories)
|
||||
const included = category === "*" || categories.includes(category)
|
||||
item.classList[included ? 'remove' : 'add']("d-none")
|
||||
})
|
||||
},
|
||||
cart: {
|
||||
handler(newCart) {
|
||||
newCart.forEach(item => {
|
||||
if (!item.count) item.count = 1
|
||||
if (item.inventory && item.inventory < item.count) item.count = item.inventory
|
||||
})
|
||||
saveState('cart', newCart)
|
||||
if (!newCart || newCart.length === 0) {
|
||||
this.$cart.hide()
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleCart() {
|
||||
this.$cart.toggle()
|
||||
},
|
||||
forEachItem(callback) {
|
||||
this.$refs.posItems.querySelectorAll('.posItem').forEach(callback)
|
||||
},
|
||||
inStock(index) {
|
||||
const item = this.items[index]
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
|
||||
|
||||
return item.inventory == null || item.inventory > (itemInCart ? itemInCart.count : 0)
|
||||
},
|
||||
inventoryText(index) {
|
||||
const item = this.items[index]
|
||||
if (item.inventory == null) return null
|
||||
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
|
||||
const left = item.inventory - (itemInCart ? itemInCart.count : 0)
|
||||
return left > 0 ? `${item.inventory} left` : 'Sold out'
|
||||
},
|
||||
addToCart(index) {
|
||||
if (!this.inStock(index)) return false;
|
||||
|
||||
const item = this.items[index];
|
||||
let itemInCart = this.cart.find(lineItem => lineItem.id === item.id);
|
||||
|
||||
// Add new item because it doesn't exist yet
|
||||
if (!itemInCart) {
|
||||
itemInCart = {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
inventory: item.inventory,
|
||||
count: 0
|
||||
}
|
||||
this.cart.push(itemInCart);
|
||||
}
|
||||
|
||||
itemInCart.count += 1;
|
||||
|
||||
// Animate
|
||||
const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index];
|
||||
if(!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
|
||||
|
||||
return true;
|
||||
},
|
||||
removeFromCart(id) {
|
||||
const index = this.cart.findIndex(lineItem => lineItem.id === id);
|
||||
this.cart.splice(index, 1);
|
||||
},
|
||||
updateQuantity(id, count) {
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
|
||||
const applyable = (count < 0 && itemInCart.count + count > 0) ||
|
||||
(count > 0 && (itemInCart.inventory == null || itemInCart.count + count <= itemInCart.inventory));
|
||||
if (applyable) {
|
||||
itemInCart.count += count;
|
||||
}
|
||||
},
|
||||
clearCart() {
|
||||
this.cart = [];
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$cart = new bootstrap.Offcanvas(this.$refs.cart, {backdrop: false})
|
||||
window.addEventListener('pagehide', () => {
|
||||
if (this.payButtonLoading) {
|
||||
this.unsetPayButtonLoading();
|
||||
localStorage.removeItem(storageKey('cart'));
|
||||
}
|
||||
})
|
||||
this.forEachItem(item => {
|
||||
item.addEventListener('transitionend', () => {
|
||||
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
|
||||
item.classList.remove(POS_ITEM_ADDED_CLASS);
|
||||
}
|
||||
});
|
||||
})
|
||||
},
|
||||
});
|
||||
});
|
8
BTCPayServer/wwwroot/pos/common.css
Normal file
8
BTCPayServer/wwwroot/pos/common.css
Normal file
@ -0,0 +1,8 @@
|
||||
.lead {
|
||||
max-width: 36em;
|
||||
text-align: center;
|
||||
margin: 0 auto 2.5rem;
|
||||
}
|
||||
.lead :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
113
BTCPayServer/wwwroot/pos/common.js
Normal file
113
BTCPayServer/wwwroot/pos/common.js
Normal file
@ -0,0 +1,113 @@
|
||||
const posCommon = {
|
||||
data () {
|
||||
return {
|
||||
...srvModel,
|
||||
amount: null,
|
||||
tip: null,
|
||||
tipPercent: null,
|
||||
discount: null,
|
||||
discountPercent: null,
|
||||
payButtonLoading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
amountNumeric () {
|
||||
const value = parseFloat(this.amount)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
discountPercentNumeric () {
|
||||
const value = parseFloat(this.discountPercent)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
discountNumeric () {
|
||||
return this.amountNumeric && this.discountPercentNumeric
|
||||
? parseFloat((this.amountNumeric * (this.discountPercentNumeric / 100)).toFixed(this.currencyInfo.divisibility))
|
||||
: 0.0;
|
||||
},
|
||||
amountMinusDiscountNumeric () {
|
||||
return parseFloat((this.amountNumeric - this.discountNumeric).toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
tipNumeric () {
|
||||
if (this.tipPercent) {
|
||||
return parseFloat((this.amountMinusDiscountNumeric * (this.tipPercent / 100)).toFixed(this.currencyInfo.divisibility))
|
||||
} else {
|
||||
const value = parseFloat(this.tip)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
}
|
||||
},
|
||||
total () {
|
||||
return this.amountNumeric - this.discountNumeric + this.tipNumeric
|
||||
},
|
||||
totalNumeric () {
|
||||
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
calculation () {
|
||||
if (!this.tipNumeric && !this.discountNumeric) return null
|
||||
let calc = this.formatCurrency(this.amountNumeric, true)
|
||||
if (this.discountNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
|
||||
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
|
||||
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
|
||||
return calc
|
||||
},
|
||||
posdata () {
|
||||
const data = {
|
||||
subTotal: this.amountNumeric,
|
||||
total: this.totalNumeric
|
||||
}
|
||||
if (this.tipNumeric > 0) data.tip = this.tipNumeric
|
||||
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
|
||||
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
|
||||
return JSON.stringify(data)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
discountPercent (val) {
|
||||
const value = parseFloat(val)
|
||||
if (isNaN(value)) this.discountPercent = null
|
||||
else if (value > 100) this.discountPercent = '100'
|
||||
else this.discountPercent = value.toString()
|
||||
},
|
||||
tip (val) {
|
||||
this.tipPercent = null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleFormSubmit() {
|
||||
this.payButtonLoading = true;
|
||||
},
|
||||
getLocale(currency) {
|
||||
switch (currency) {
|
||||
case 'USD': return 'en-US'
|
||||
case 'EUR': return 'de-DE'
|
||||
case 'JPY': return 'ja-JP'
|
||||
default: return navigator.language
|
||||
}
|
||||
},
|
||||
tipPercentage (percentage) {
|
||||
this.tipPercent = this.tipPercent !== percentage
|
||||
? percentage
|
||||
: null;
|
||||
},
|
||||
unsetPayButtonLoading () {
|
||||
this.payButtonLoading = false
|
||||
},
|
||||
formatCrypto (value, withSymbol) {
|
||||
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
|
||||
const { divisibility } = this.currencyInfo
|
||||
return parseFloat(value).toFixed(divisibility) + symbol
|
||||
},
|
||||
formatCurrency (value, withSymbol) {
|
||||
const currency = this.currencyCode
|
||||
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol)
|
||||
const { divisibility } = this.currencyInfo
|
||||
const locale = this.getLocale(currency);
|
||||
const style = withSymbol ? 'currency' : 'decimal'
|
||||
const opts = { currency, style, maximumFractionDigits: divisibility, minimumFractionDigits: divisibility }
|
||||
try {
|
||||
return new Intl.NumberFormat(locale, opts).format(value)
|
||||
} catch (err) {
|
||||
return this.formatCrypto(value, withSymbol)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
97
BTCPayServer/wwwroot/pos/keypad.css
Normal file
97
BTCPayServer/wwwroot/pos/keypad.css
Normal file
@ -0,0 +1,97 @@
|
||||
.public-page-wrap {
|
||||
max-width: 560px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* modes */
|
||||
#ModeTabs {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* keypad */
|
||||
.keypad {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
.keypad .btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
font-weight: var(--btcpay-font-weight-semibold);
|
||||
font-size: 24px;
|
||||
min-height: 3.5rem;
|
||||
height: 8vh;
|
||||
max-height: 6rem;
|
||||
color: var(--btcpay-body-text);
|
||||
}
|
||||
.keypad .btn[data-key="del"] svg {
|
||||
--btn-icon-size: 2.25rem;
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.btcpay-pills label,
|
||||
.btn-secondary.rounded-pill {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
/* make borders collapse by shifting rows and columns by 1px */
|
||||
/* second column */
|
||||
.keypad .btn:nth-child(3n-1) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
/* third column */
|
||||
.keypad .btn:nth-child(3n) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
/* from second row downwards */
|
||||
.keypad .btn:nth-child(n+4) {
|
||||
margin-top: -1px;
|
||||
}
|
||||
/* ensure highlighted button is topmost */
|
||||
.keypad .btn:hover,
|
||||
.keypad .btn:focus,
|
||||
.keypad .btn:active {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.actions .btn {
|
||||
flex: 1 1 50%;
|
||||
}
|
||||
|
||||
#Calculation {
|
||||
min-height: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-height: 700px) {
|
||||
.store-header {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 575px) {
|
||||
.public-page-wrap {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
.keypad {
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
}
|
||||
.store-footer {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
/* fix sticky hover effect on mobile browsers */
|
||||
@media (hover: none) {
|
||||
.keypad .btn-secondary:hover,
|
||||
.actions .btn-secondary:hover {
|
||||
border-color: var(--btcpay-secondary-border-active) !important;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user