POS: Add item list to keypad (#5814)

* Add admin option to show item list for keypad view

* Refactor common POS Vue mixin

* Add item list to POS keypad

* Add recent transactions to cart

* Keypad: Pass tip and discount as cart does

* Keypad and cart tests

* Improve offcanvas button

---------

Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
This commit is contained in:
d11n
2024-03-14 11:11:54 +01:00
committed by GitHub
parent e5adc630af
commit 9b5c8a8254
22 changed files with 712 additions and 393 deletions

View File

@@ -26,6 +26,7 @@ namespace BTCPayServer.Client.Models
public string Template { get; set; } = null; public string Template { get; set; } = null;
[JsonConverter(typeof(StringEnumConverter))] [JsonConverter(typeof(StringEnumConverter))]
public PosViewType DefaultView { get; set; } public PosViewType DefaultView { get; set; }
public bool ShowItems { get; set; } = false;
public bool ShowCustomAmount { get; set; } = false; public bool ShowCustomAmount { get; set; } = false;
public bool ShowDiscount { get; set; } = false; public bool ShowDiscount { get; set; } = false;
public bool ShowSearch { get; set; } = true; public bool ShowSearch { get; set; } = true;

View File

@@ -19,6 +19,7 @@ namespace BTCPayServer.Client.Models
{ {
public string Title { get; set; } public string Title { get; set; }
public string DefaultView { get; set; } public string DefaultView { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; } public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; } public bool ShowDiscount { get; set; }
public bool ShowSearch { get; set; } public bool ShowSearch { get; set; }

View File

@@ -90,7 +90,6 @@ namespace BTCPayServer.Tests
public void PayInvoice(bool mine = false, decimal? amount = null) public void PayInvoice(bool mine = false, decimal? amount = null)
{ {
if (amount is not null) if (amount is not null)
{ {
Driver.FindElement(By.Id("test-payment-amount")).Clear(); Driver.FindElement(By.Id("test-payment-amount")).Clear();
@@ -98,12 +97,12 @@ namespace BTCPayServer.Tests
} }
Driver.WaitUntilAvailable(By.Id("FakePayment")); Driver.WaitUntilAvailable(By.Id("FakePayment"));
Driver.FindElement(By.Id("FakePayment")).Click(); Driver.FindElement(By.Id("FakePayment")).Click();
if (mine)
{
TestUtils.Eventually(() => TestUtils.Eventually(() =>
{ {
Driver.WaitForElement(By.Id("CheatSuccessMessage")); Driver.WaitForElement(By.Id("CheatSuccessMessage"));
}); });
if (mine)
{
MineBlockOnInvoiceCheckout(); MineBlockOnInvoiceCheckout();
} }
} }
@@ -646,6 +645,18 @@ retry:
} }
} }
public void AddUserToStore(string storeId, string email, string role)
{
if (Driver.FindElements(By.Id("AddUser")).Count == 0)
{
GoToStore(storeId, StoreNavPages.Users);
}
Driver.FindElement(By.Id("Email")).SendKeys(email);
new SelectElement(Driver.FindElement(By.Id("Role"))).SelectByValue(role);
Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("User added successfully", FindAlertMessage().Text);
}
public void AssertPageAccess(bool shouldHaveAccess, string url) public void AssertPageAccess(bool shouldHaveAccess, string url)
{ {
GoToUrl(url); GoToUrl(url);

View File

@@ -1,5 +1,4 @@
using System; using System;
using System.Buffers;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Globalization; using System.Globalization;
@@ -14,10 +13,8 @@ using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models; using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models; using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.InvoicingModels;
using BTCPayServer.NTag424; using BTCPayServer.NTag424;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Services; using BTCPayServer.Services;
@@ -27,7 +24,6 @@ using BTCPayServer.Views.Manage;
using BTCPayServer.Views.Server; using BTCPayServer.Views.Server;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using BTCPayServer.Views.Wallets; using BTCPayServer.Views.Wallets;
using Dapper;
using ExchangeSharp; using ExchangeSharp;
using LNURL; using LNURL;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
@@ -42,7 +38,6 @@ using OpenQA.Selenium.Support.Extensions;
using OpenQA.Selenium.Support.UI; using OpenQA.Selenium.Support.UI;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using Xunit.Sdk;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@@ -2504,7 +2499,6 @@ namespace BTCPayServer.Tests
using var s = CreateSeleniumTester(); using var s = CreateSeleniumTester();
s.Server.ActivateLightning(); s.Server.ActivateLightning();
await s.StartAsync(); await s.StartAsync();
await s.Server.EnsureChannelsSetup();
s.RegisterNewUser(true); s.RegisterNewUser(true);
s.CreateNewStore(); s.CreateNewStore();
@@ -2512,12 +2506,7 @@ namespace BTCPayServer.Tests
s.AddLightningNode(LightningConnectionType.CLightning, false); s.AddLightningNode(LightningConnectionType.CLightning, false);
s.GoToLightningSettings(); s.GoToLightningSettings();
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), true); s.Driver.SetCheckbox(By.Id("LNURLEnabled"), true);
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click(); s.CreateApp("PointOfSale");
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
s.Driver.FindElement(By.Id("AppName")).SendKeys(appName);
s.Driver.FindElement(By.Id("Create")).Click();
TestUtils.Eventually(() => Assert.Contains("App successfully created", s.FindAlertMessage().Text));
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Print']")).Click(); s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Print']")).Click();
s.Driver.FindElement(By.Id("SaveSettings")).Click(); s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text); Assert.Contains("App updated", s.FindAlertMessage().Text);
@@ -2536,13 +2525,10 @@ namespace BTCPayServer.Tests
[Fact] [Fact]
[Trait("Selenium", "Selenium")] [Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSKeypad() public async Task CanUsePOSKeypad()
{ {
using var s = CreateSeleniumTester(); using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync(); await s.StartAsync();
await s.Server.EnsureChannelsSetup();
// Create users // Create users
var user = s.RegisterNewUser(); var user = s.RegisterNewUser();
@@ -2553,25 +2539,16 @@ namespace BTCPayServer.Tests
s.RegisterNewUser(true); s.RegisterNewUser(true);
// Setup store and associate user // Setup store and associate user
s.CreateNewStore(); (_, string storeId) = s.CreateNewStore();
s.GoToStore(); s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false); s.AddDerivationScheme();
s.GoToStore(StoreNavPages.Users); s.AddUserToStore(storeId, user, "Guest");
s.Driver.FindElement(By.Id("Email")).Clear();
s.Driver.FindElement(By.Id("Email")).SendKeys(user);
new SelectElement(s.Driver.FindElement(By.Id("Role"))).SelectByValue("Guest");
s.Driver.FindElement(By.Id("AddUser")).Click();
Assert.Contains("User added successfully", s.FindAlertMessage().Text);
// Setup POS // Setup POS
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}"; s.CreateApp("PointOfSale");
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click(); var editUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("AppName")).SendKeys(appName);
s.Driver.FindElement(By.Id("Create")).Click();
TestUtils.Eventually(() => Assert.Contains("App successfully created", s.FindAlertMessage().Text));
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click(); s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR"); s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected); Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected);
s.Driver.FindElement(By.Id("EnableTips")).Click(); s.Driver.FindElement(By.Id("EnableTips")).Click();
Assert.True(s.Driver.FindElement(By.Id("EnableTips")).Selected); Assert.True(s.Driver.FindElement(By.Id("EnableTips")).Selected);
@@ -2579,9 +2556,12 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear(); s.Driver.FindElement(By.Id("CustomTipPercentages")).Clear();
s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21"); s.Driver.FindElement(By.Id("CustomTipPercentages")).SendKeys("10,21");
Assert.False(s.Driver.FindElement(By.Id("ShowDiscount")).Selected); Assert.False(s.Driver.FindElement(By.Id("ShowDiscount")).Selected);
Assert.False(s.Driver.FindElement(By.Id("ShowItems")).Selected);
s.Driver.FindElement(By.Id("ShowDiscount")).Click(); s.Driver.FindElement(By.Id("ShowDiscount")).Click();
s.Driver.FindElement(By.Id("SaveSettings")).Click(); s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text); Assert.Contains("App updated", s.FindAlertMessage().Text);
// View
s.Driver.FindElement(By.Id("ViewApp")).Click(); s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles; var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count); Assert.Equal(2, windows.Count);
@@ -2591,6 +2571,7 @@ namespace BTCPayServer.Tests
// basic checks // basic checks
var keypadUrl = s.Driver.Url; var keypadUrl = s.Driver.Url;
s.Driver.FindElement(By.Id("RecentTransactionsToggle")); s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.Driver.ElementDoesNotExist(By.Id("ItemsListToggle"));
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text); Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text); Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text); Assert.Equal("", s.Driver.FindElement(By.Id("Calculation")).Text);
@@ -2636,6 +2617,86 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("DetailsToggle")).Click(); s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat")); s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("1 222,21 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text); Assert.Contains("1 222,21 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(2, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Manual entry 1", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 2", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("0,56 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 234,56 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 123,46 €", sums[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 111,11 €", sums[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 222,21 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Once more with items
s.GoToUrl(editUrl);
s.Driver.FindElement(By.Id("ShowItems")).Click();
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
s.GoToUrl(keypadUrl);
s.Driver.WaitForElement(By.ClassName("keypad"));
s.Driver.FindElement(By.Id("ItemsListToggle")).Click();
Thread.Sleep(250);
Assert.True(s.Driver.WaitForElement(By.Id("PosItems")).Displayed);
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(1) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(1) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#PosItems .posItem--displayed:nth-child(2) .btn-plus")).Click();
s.Driver.FindElement(By.CssSelector("#ItemsListOffcanvas button[data-bs-dismiss=\"offcanvas\"]")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='1']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='2']")).Click();
s.Driver.FindElement(By.CssSelector(".keypad [data-key='3']")).Click();
Assert.Contains("4,23", s.Driver.FindElement(By.Id("Amount")).Text);
Assert.Contains("2 x Green Tea (1,00 €) = 2,00 € + 1 x Black Tea (1,00 €) = 1,00 € + 1,23 €", s.Driver.FindElement(By.Id("Calculation")).Text);
// Pay
s.Driver.FindElement(By.Id("pay-button")).Click();
s.Driver.WaitUntilAvailable(By.Id("Checkout-v2"));
s.Driver.FindElement(By.Id("DetailsToggle")).Click();
s.Driver.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
Assert.Contains("4,23 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
items = additionalData.FindElements(By.CssSelector("tbody tr"));
sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(3, items.Count);
Assert.Equal(2, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Manual entry 1", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1,23 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("4,23 €", sums[1].FindElement(By.CssSelector("td")).Text);
// Guest user can access recent transactions // Guest user can access recent transactions
s.GoToHome(); s.GoToHome();
@@ -2653,24 +2714,27 @@ namespace BTCPayServer.Tests
[Fact] [Fact]
[Trait("Selenium", "Selenium")] [Trait("Selenium", "Selenium")]
[Trait("Lightning", "Lightning")]
public async Task CanUsePOSCart() public async Task CanUsePOSCart()
{ {
using var s = CreateSeleniumTester(); using var s = CreateSeleniumTester();
s.Server.ActivateLightning();
await s.StartAsync(); await s.StartAsync();
await s.Server.EnsureChannelsSetup();
// Create users
var user = s.RegisterNewUser();
var userAccount = s.AsTestAccount();
s.GoToHome();
s.Logout();
s.GoToRegister();
s.RegisterNewUser(true); s.RegisterNewUser(true);
s.CreateNewStore();
s.GoToStore();
s.AddLightningNode(LightningConnectionType.CLightning, false);
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}"; // Setup store and associate user
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click(); (_, string storeId) = s.CreateNewStore();
s.Driver.FindElement(By.Id("AppName")).SendKeys(appName); s.GoToStore();
s.Driver.FindElement(By.Id("Create")).Click(); s.AddDerivationScheme();
Assert.Contains("App successfully created", s.FindAlertMessage().Text); s.AddUserToStore(storeId, user, "Guest");
// Setup POS
s.CreateApp("PointOfSale");
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click(); s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR"); s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected); Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected);
@@ -2683,6 +2747,8 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("ShowDiscount")).Click(); s.Driver.FindElement(By.Id("ShowDiscount")).Click();
s.Driver.FindElement(By.Id("SaveSettings")).Click(); s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text); Assert.Contains("App updated", s.FindAlertMessage().Text);
// View
s.Driver.FindElement(By.Id("ViewApp")).Click(); s.Driver.FindElement(By.Id("ViewApp")).Click();
var windows = s.Driver.WindowHandles; var windows = s.Driver.WindowHandles;
Assert.Equal(2, windows.Count); Assert.Equal(2, windows.Count);
@@ -2768,11 +2834,59 @@ namespace BTCPayServer.Tests
Assert.Contains("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text); Assert.Contains("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
// Pay // Pay
s.PayInvoice(); s.PayInvoice(true);
TestUtils.Eventually(() =>
{
s.MineBlockOnInvoiceCheckout();
Assert.True(s.Driver.WaitForElement(By.Id("settled")).Displayed);
});
// Receipt
s.Driver.WaitForElement(By.Id("ReceiptLink")).Click();
var additionalData = s.Driver.FindElement(By.CssSelector("#AdditionalData table"));
var items = additionalData.FindElements(By.CssSelector("tbody tr"));
var sums = additionalData.FindElements(By.CssSelector("tfoot tr"));
Assert.Equal(7, items.Count);
Assert.Equal(4, sums.Count);
Assert.Contains("Black Tea", items[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,00 € = 2,00 €", items[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Green Tea", items[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,00 € = 1,00 €", items[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Rooibos (limited)", items[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("2 x 1,20 € = 2,40 €", items[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Herbal Tea (minimum) (1,80 €)", items[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 1,80 € = 1,80 €", items[3].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Herbal Tea (minimum) (2,30 €)", items[4].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 2,30 € = 2,30 €", items[4].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Fruit Tea (any amount) (0,20 €)", items[5].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 0,20 € = 0,20 €", items[5].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Fruit Tea (any amount) (0,30 €)", items[6].FindElement(By.CssSelector("th")).Text);
Assert.Contains("1 x 0,30 € = 0,30 €", items[6].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Subtotal", sums[0].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10,00 €", sums[0].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Discount", sums[1].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 1,00 €", sums[1].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Tip", sums[2].FindElement(By.CssSelector("th")).Text);
Assert.Contains("10% = 0,90 €", sums[2].FindElement(By.CssSelector("td")).Text);
Assert.Contains("Total", sums[3].FindElement(By.CssSelector("th")).Text);
Assert.Contains("9,90 €", sums[3].FindElement(By.CssSelector("td")).Text);
// Check inventory got updated and is now 3 instead of 5 // Check inventory got updated and is now 3 instead of 5
s.Driver.Navigate().GoToUrl(posUrl); s.Driver.Navigate().GoToUrl(posUrl);
Assert.Equal("3 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text); Assert.Equal("3 left", s.Driver.FindElement(By.CssSelector(".posItem:nth-child(3) .badge.inventory")).Text);
// Guest user can access recent transactions
s.GoToHome();
s.Logout();
s.LogIn(user, userAccount.RegisterDetails.Password);
s.GoToUrl(posUrl);
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
s.GoToHome();
s.Logout();
// Unauthenticated user can't access recent transactions
s.GoToUrl(posUrl);
s.Driver.ElementDoesNotExist(By.Id("RecentTransactionsToggle"));
} }
[Fact] [Fact]

View File

@@ -2274,18 +2274,18 @@ namespace BTCPayServer.Tests
}); });
// Test on the webhooks // Test on the webhooks
user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled, await user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
c => c =>
{ {
Assert.False(c.ManuallyMarked); Assert.False(c.ManuallyMarked);
Assert.True(c.OverPaid); Assert.True(c.OverPaid);
}); });
user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing, await user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing,
c => c =>
{ {
Assert.True(c.OverPaid); Assert.True(c.OverPaid);
}); });
user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment, await user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
c => c =>
{ {
Assert.False(c.AfterExpiration); Assert.False(c.AfterExpiration);
@@ -2295,7 +2295,7 @@ namespace BTCPayServer.Tests
Assert.StartsWith(txId.ToString(), c.Payment.Id); Assert.StartsWith(txId.ToString(), c.Payment.Id);
}); });
user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled, await user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled,
c => c =>
{ {
Assert.False(c.AfterExpiration); Assert.False(c.AfterExpiration);

View File

@@ -274,6 +274,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
Title = request.Title ?? request.AppName, Title = request.Title ?? request.AppName,
DefaultView = (PosViewType)request.DefaultView, DefaultView = (PosViewType)request.DefaultView,
ShowItems = request.ShowItems,
ShowCustomAmount = request.ShowCustomAmount, ShowCustomAmount = request.ShowCustomAmount,
ShowDiscount = request.ShowDiscount, ShowDiscount = request.ShowDiscount,
ShowSearch = request.ShowSearch, ShowSearch = request.ShowSearch,
@@ -335,6 +336,7 @@ namespace BTCPayServer.Controllers.Greenfield
Created = appData.Created, Created = appData.Created,
Title = settings.Title, Title = settings.Title,
DefaultView = settings.DefaultView.ToString(), DefaultView = settings.DefaultView.ToString(),
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount, ShowCustomAmount = settings.ShowCustomAmount,
ShowDiscount = settings.ShowDiscount, ShowDiscount = settings.ShowDiscount,
ShowSearch = settings.ShowSearch, ShowSearch = settings.ShowSearch,

View File

@@ -101,6 +101,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
StoreBranding = storeBranding, StoreBranding = storeBranding,
Step = step.ToString(CultureInfo.InvariantCulture), Step = step.ToString(CultureInfo.InvariantCulture),
ViewType = (PosViewType)viewType, ViewType = (PosViewType)viewType,
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount, ShowCustomAmount = settings.ShowCustomAmount,
ShowDiscount = settings.ShowDiscount, ShowDiscount = settings.ShowDiscount,
ShowSearch = settings.ShowSearch, ShowSearch = settings.ShowSearch,
@@ -216,9 +217,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
title = settings.Title; title = settings.Title;
// if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items // if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
price = amount; price = amount;
if (currentView == PosViewType.Cart && AppService.TryParsePosCartItems(jposData, out cartItems)) if (AppService.TryParsePosCartItems(jposData, out cartItems))
{ {
price = 0.0m; price = jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray
? amountsArray.Values<decimal>().Sum()
: 0.0m;
choices = AppService.Parse(settings.Template, false); choices = AppService.Parse(settings.Template, false);
foreach (var cartItem in cartItems) foreach (var cartItem in cartItems)
{ {
@@ -379,6 +382,14 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var key = selectedChoice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})"; var key = selectedChoice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}"); cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
} }
if (jposData.TryGetValue("amounts", out var amounts) && amounts is JArray { Count: > 0 } amountsArray)
{
for (var i = 0; i < amountsArray.Count; i++)
{
cartData.Add($"Manual entry {i+1}", _displayFormatter.Currency(amountsArray[i].ToObject<decimal>(), settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
}
}
receiptData.Add("Cart", cartData); receiptData.Add("Cart", cartData);
} }
receiptData.Add("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)); receiptData.Add("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
@@ -580,6 +591,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
AppName = app.Name, AppName = app.Name,
Title = settings.Title, Title = settings.Title,
DefaultView = settings.DefaultView, DefaultView = settings.DefaultView,
ShowItems = settings.ShowItems,
ShowCustomAmount = settings.ShowCustomAmount, ShowCustomAmount = settings.ShowCustomAmount,
ShowDiscount = settings.ShowDiscount, ShowDiscount = settings.ShowDiscount,
ShowSearch = settings.ShowSearch, ShowSearch = settings.ShowSearch,
@@ -670,6 +682,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{ {
Title = vm.Title, Title = vm.Title,
DefaultView = vm.DefaultView, DefaultView = vm.DefaultView,
ShowItems = vm.ShowItems,
ShowCustomAmount = vm.ShowCustomAmount, ShowCustomAmount = vm.ShowCustomAmount,
ShowDiscount = vm.ShowDiscount, ShowDiscount = vm.ShowDiscount,
ShowSearch = vm.ShowSearch, ShowSearch = vm.ShowSearch,

View File

@@ -27,6 +27,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
[Display(Name = "Point of Sale Style")] [Display(Name = "Point of Sale Style")]
public PosViewType DefaultView { get; set; } public PosViewType DefaultView { get; set; }
[Display(Name = "Display item selection for keypad")]
public bool ShowItems { get; set; }
[Display(Name = "User can input custom amount")] [Display(Name = "User can input custom amount")]
public bool ShowCustomAmount { get; set; } public bool ShowCustomAmount { get; set; }
[Display(Name = "User can input discount in %")] [Display(Name = "User can input discount in %")]

View File

@@ -62,6 +62,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string StoreName { get; set; } public string StoreName { get; set; }
public CurrencyInfoData CurrencyInfo { get; set; } public CurrencyInfoData CurrencyInfo { get; set; }
public PosViewType ViewType { get; set; } public PosViewType ViewType { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; } public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; } public bool ShowDiscount { get; set; }
public bool ShowSearch { get; set; } = true; public bool ShowSearch { get; set; } = true;

View File

@@ -87,6 +87,7 @@ namespace BTCPayServer.Services.Apps
public string Template { get; set; } public string Template { get; set; }
public bool EnableShoppingCart { get; set; } public bool EnableShoppingCart { get; set; }
public PosViewType DefaultView { get; set; } public PosViewType DefaultView { get; set; }
public bool ShowItems { get; set; }
public bool ShowCustomAmount { get; set; } public bool ShowCustomAmount { get; set; }
public bool ShowDiscount { get; set; } public bool ShowDiscount { get; set; }
public bool ShowSearch { get; set; } = true; public bool ShowSearch { get; set; } = true;

View File

@@ -1,6 +1,9 @@
@using BTCPayServer.Plugins.PointOfSale.Models @using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services @using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq @using Newtonsoft.Json.Linq
@using BTCPayServer.Client
@using BTCPayServer.Abstractions.TagHelpers
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp @inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@@ -37,6 +40,9 @@
<vc:icon symbol="pos-cart" /> <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> <span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
</button> </button>
<button type="button" class="btn btn-link p-1" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
<vc:icon symbol="invoice-2 "/>
</button>
</div> </div>
@if (Model.ShowSearch) @if (Model.ShowSearch)
{ {
@@ -64,7 +70,6 @@
@for (var index = 0; index < Model.Items.Length; index++) @for (var index = 0; index < Model.Items.Length; index++)
{ {
var item = Model.Items[index]; var item = Model.Items[index];
var formatted = GetItemPriceFormatted(item); var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0; var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
@@ -73,7 +78,7 @@
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted); buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
var categories = new JArray(item.Categories ?? new object[] { }); var categories = new JArray(item.Categories ?? new object[] { });
<div class="col posItem posItem--displayed" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)'> <div class="col posItem posItem--displayed" :class="{ 'posItem--inStock': inStock(@index) }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)'>
<div class="card h-100 px-0" v-on:click="addToCart(@index)"> <div class="card h-100 px-0" v-on:click="addToCart(@index, 1)">
@if (!string.IsNullOrWhiteSpace(item.Image)) @if (!string.IsNullOrWhiteSpace(item.Image))
{ {
<img class="card-img-top" src="@item.Image" alt="@item.Title" asp-append-version="true"> <img class="card-img-top" src="@item.Image" alt="@item.Title" asp-append-version="true">
@@ -133,7 +138,7 @@
<div class="public-page-wrap" v-cloak> <div class="public-page-wrap" v-cloak>
<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"> <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> <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"> <button id="CartClear" type="reset" v-on:click="clear" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
Empty Empty
</button> </button>
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close"> <button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
@@ -251,4 +256,5 @@
</div> </div>
</div> </div>
</aside> </aside>
<partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
</div> </div>

View File

@@ -0,0 +1,36 @@
@using BTCPayServer.Client
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
<div class="modal" tabindex="-1" id="RecentTransactions" ref="RecentTransactions" data-bs-backdrop="static" data-url="@Url.Action("RecentTransactions", "UIPointOfSale", new { appId = Model.AppId })" permission="@Policies.CanViewInvoices">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Recent Transactions</h5>
<button type="button" class="btn btn-link px-3 py-0" aria-label="Refresh" v-on:click="loadRecentTransactions" :disabled="recentTransactionsLoading" id="RecentTransactionsRefresh">
<vc:icon symbol="refresh"/>
<span v-if="recentTransactionsLoading" class="visually-hidden">Loading...</span>
</button>
<button type="button" class="btn-close py-3" aria-label="Close" v-on:click="hideRecentTransactions">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<div v-if="recentTransactions.length" class="list-group list-group-flush">
<a v-for="t in recentTransactions" :key="t.id" :href="t.url" class="list-group-item list-group-item-action d-flex align-items-center gap-3 pe-1 py-3">
<div class="d-flex align-items-baseline justify-content-between flex-wrap flex-grow-1 gap-2">
<span class="flex-grow-1">{{displayDate(t.date)}}</span>
<span class="flex-grow-1 text-end">{{t.price}}</span>
<div class="badge-container">
<span class="badge" :class="`badge-${t.status.toLowerCase()}`">{{t.status}}</span>
</div>
</div>
<vc:icon symbol="caret-right" />
</a>
</div>
<p v-else-if="recentTransactionsLoading" class="text-muted my-0">Loading...</p>
<p v-else class="text-muted my-0">No transactions, yet.</p>
</div>
</div>
</div>
</div>

View File

@@ -1,9 +1,24 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq
@using BTCPayServer.Client @using BTCPayServer.Client
@inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@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;
}
}
<form id="app" 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> <form id="app" 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 type="hidden" name="posdata" v-model="posdata" id="posdata"> <input type="hidden" name="amount" :value="totalNumeric">
<input type="hidden" name="amount" v-model="totalNumeric"> <input type="hidden" name="tip" :value="tipNumeric">
<input type="hidden" name="discount" :value="discountPercentNumeric">
<input type="hidden" name="posdata" :value="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto"> <div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted" id="Currency">{{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="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
@@ -58,39 +73,84 @@
</div> </div>
<template v-else>Charge</template> <template v-else>Charge</template>
</button> </button>
<div class="modal" tabindex="-1" id="RecentTransactions" ref="RecentTransactions" data-bs-backdrop="static" data-url="@Url.Action("RecentTransactions", "UIPointOfSale", new { appId = Model.AppId })"> <partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Recent Transactions</h5>
<button type="button" class="btn btn-link px-3 py-0" aria-label="Refresh" v-on:click="loadRecentTransactions" :disabled="recentTransactionsLoading" id="RecentTransactionsRefresh">
<vc:icon symbol="refresh"/>
<span v-if="recentTransactionsLoading" class="visually-hidden">Loading...</span>
</button>
<button type="button" class="btn-close py-3" aria-label="Close" v-on:click="closeModal">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body">
<div v-if="recentTransactions.length" class="list-group list-group-flush">
<a v-for="t in recentTransactions" :key="t.id" :href="t.url" class="list-group-item list-group-item-action d-flex align-items-center gap-3 pe-1 py-3">
<div class="d-flex align-items-baseline justify-content-between flex-wrap flex-grow-1 gap-2">
<span class="flex-grow-1">{{displayDate(t.date)}}</span>
<span class="flex-grow-1 text-end">{{t.price}}</span>
<div class="badge-container">
<span class="badge" :class="`badge-${t.status.toLowerCase()}`">{{t.status}}</span>
</div>
</div>
<vc:icon symbol="caret-right" />
</a>
</div>
<p v-else-if="recentTransactionsLoading" class="text-muted my-0">Loading...</p>
<p v-else class="text-muted my-0">No transactions, yet.</p>
</div>
</div>
</div>
</div>
<button type="button" class="btn btn-link p-1" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices"> <button type="button" class="btn btn-link p-1" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
<vc:icon symbol="manage-plugins"/> <vc:icon symbol="invoice-2 "/>
</button> </button>
<button type="button" class="btn btn-link p-1" data-bs-toggle="offcanvas" data-bs-target="#ItemsListOffcanvas" id="ItemsListToggle" aria-controls="ItemsList" v-if="showItems">
<vc:icon symbol="menu"/>
</button>
<div class="offcanvas offcanvas-end" data-bs-backdrop="static" tabindex="-1" id="ItemsListOffcanvas" aria-labelledby="ItemsListToggle" v-if="showItems">
<div class="offcanvas-header flex-wrap p-3">
<h5 class="offcanvas-title" id="offcanvasExampleLabel">Products</h5>
<button type="button" class="btn btn-sm rounded-pill" :class="{ 'btn-primary': cart.length > 0, 'btn-outline-secondary': cart.length === 0}" data-bs-dismiss="offcanvas" v-text="cart.length > 0 ? 'Apply' : 'Close'"></button>
@if (Model.ShowSearch)
{
<div class="w-100 mt-3">
<input id="SearchTerm" class="form-control rounded-pill" placeholder="Search…" v-model="searchTerm" v-if="showSearch">
</div>
}
@if (Model.ShowCategories)
{
<div id="Categories" ref="categories" v-if="showCategories && allCategories" class="w-100 mt-3 btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-2" :class="{ 'scrollable': categoriesScrollable }">
<nav class="btcpay-pills d-flex align-items-center gap-3" ref="categoriesNav">
<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 text-nowrap">{{ cat.text }}</label>
</template>
</nav>
</div>
}
</div>
<div class="offcanvas-body">
<div ref="posItems" id="PosItems">
@for (var index = 0; index < Model.Items.Length; index++)
{
var item = Model.Items[index];
var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0;
var displayed = item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && inStock ? "true" : "false";
var categories = new JArray(item.Categories ?? new object[] { });
<div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed">
<div class="d-flex align-items-start w-100 gap-3">
@if (!string.IsNullOrWhiteSpace(item.Image))
{
<div class="img">
<img src="@item.Image" alt="@item.Title" />
</div>
}
<div class="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 inventory" v-text="inventoryText(@index)">
@(item.Inventory > 0 ? $"{item.Inventory} left" : "Sold out")
</span>
}
</div>
</div>
<div class="d-flex align-items-center gap-2 ms-auto" v-if="inStock(@index)">
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, -1, true)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center btn-minus" :disabled="getQuantity(`@Safe.Raw(item.Id)`) <= 0">
<vc:icon symbol="minus" />
</button>
<div class="quantity text-center fs-6" style="width:2rem">{{ getQuantity(`@Safe.Raw(item.Id)`) }}</div>
<button type="button" v-on:click="updateQuantity(`@Safe.Raw(item.Id)`, +1, true)" class="btn btn-light p-1 rounded-pill d-flex align-items-center justify-content-center btn-plus">
<vc:icon symbol="plus" />
</button>
</div>
</div>
</div>
}
</div>
</div>
</div>
</form> </form>

View File

@@ -128,6 +128,14 @@
<span asp-validation-for="FormId" class="text-danger"></span> <span asp-validation-for="FormId" class="text-danger"></span>
</div> </div>
</fieldset> </fieldset>
<fieldset id="keypad-display" class="mt-2">
<legend class="h5 mb-3 fw-semibold">Keypad</legend>
<div class="form-group d-flex align-items-center pt-2">
<input asp-for="ShowItems" type="checkbox" class="btcpay-toggle me-3" />
<label asp-for="ShowItems" class="form-label mb-0"></label>
<span asp-validation-for="ShowItems" class="text-danger"></span>
</div>
</fieldset>
<fieldset id="cart-display" class="mt-2"> <fieldset id="cart-display" class="mt-2">
<legend class="h5 mb-3 fw-semibold">Cart</legend> <legend class="h5 mb-3 fw-semibold">Cart</legend>
<div class="form-group d-flex align-items-center pt-2"> <div class="form-group d-flex align-items-center pt-2">

View File

@@ -2,6 +2,7 @@ const description = document.getElementById('description');
const products = document.getElementById('products'); const products = document.getElementById('products');
const tips = document.getElementById('tips'); const tips = document.getElementById('tips');
const cart = document.getElementById('cart-display'); const cart = document.getElementById('cart-display');
const keypad = document.getElementById('keypad-display');
const discounts = document.getElementById('discounts'); const discounts = document.getElementById('discounts');
const buttonPriceText = document.getElementById('button-price-text'); const buttonPriceText = document.getElementById('button-price-text');
const customPayments = document.getElementById('custom-payments'); const customPayments = document.getElementById('custom-payments');
@@ -18,6 +19,7 @@ function updateFormForDefaultView(type) {
case 'Print': case 'Print':
hide(tips); hide(tips);
hide(cart); hide(cart);
hide(keypad);
hide(discounts); hide(discounts);
hide(buttonPriceText); hide(buttonPriceText);
show(description); show(description);
@@ -32,15 +34,17 @@ function updateFormForDefaultView(type) {
show(description); show(description);
show(buttonPriceText); show(buttonPriceText);
hide(customPayments); hide(customPayments);
hide(keypad);
break; break;
case 'Light': case 'Light':
show(tips); show(tips);
show(discounts); show(discounts);
show(keypad);
hide(cart); hide(cart);
hide(products);
hide(description); hide(description);
hide(buttonPriceText); hide(buttonPriceText);
hide(customPayments); hide(customPayments);
document.getElementById('ShowItems').checked ? show(products) : hide(products);
break; break;
} }
} }
@@ -55,3 +59,7 @@ document.addEventListener('DOMContentLoaded', () => {
delegate('change', 'input[name="DefaultView"]', e => { delegate('change', 'input[name="DefaultView"]', e => {
updateFormForDefaultView(e.target.value); updateFormForDefaultView(e.target.value);
}); });
delegate('change', 'input[name="ShowItems"]', e => {
e.target.checked ? show(products) : hide(products);
});

View File

@@ -18,28 +18,6 @@
transition-duration: var(--btcpay-transition-duration-fast); transition-duration: var(--btcpay-transition-duration-fast);
} }
#Categories nav {
justify-content: center;
}
#Categories.scrollable {
--scroll-bar-spacing: var(--btcpay-space-m);
--scroll-indicator-spacing: var(--btcpay-space-m);
position: relative;
margin-bottom: calc(var(--scroll-bar-spacing) * -1);
}
#Categories.scrollable nav {
justify-content: start;
overflow: auto visible;
-webkit-overflow-scrolling: touch;
margin-left: calc(var(--scroll-indicator-spacing) * -1);
margin-right: calc(var(--scroll-indicator-spacing) * -1);
padding: 0 var(--scroll-indicator-spacing) var(--scroll-bar-spacing);
}
#Categories.scrollable nav::-webkit-scrollbar {
display: none;
}
/* Horizontal scroll indicators */ /* Horizontal scroll indicators */
#Categories.scrollable:before, #Categories.scrollable:before,
#Categories.scrollable:after { #Categories.scrollable:after {
@@ -60,41 +38,48 @@
right: calc(var(--scroll-indicator-spacing) * -1); right: calc(var(--scroll-indicator-spacing) * -1);
} }
#RecentTransactionsToggle,
.cart-toggle-btn { .cart-toggle-btn {
--button-width: 40px; --button-width: 40px;
--button-height: 40px; --button-height: 40px;
--button-padding: 7px; --button-padding: 7px;
--icon-size: 1rem;
position: absolute; position: absolute;
top: calc(50% - var(--button-height) / 2); top: calc(50% - var(--button-height) / 2);
right: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--button-width); width: var(--button-width);
height: var(--button-height); height: var(--button-height);
padding: var(--button-padding); padding: var(--button-padding);
color: var(--btcpay-header-link);
background: transparent; background: transparent;
border: none; border: none;
cursor: pointer; cursor: pointer;
outline: none; outline: none;
} }
header .cart-toggle-btn { #RecentTransactionsToggle {
--icon-size: 32px; --icon-size: 1.5rem;
left: 0;
} }
header .cart-toggle-btn {
--icon-size: 2rem;
right: 0;
}
#RecentTransactionsToggle .icon,
.cart-toggle-btn .icon-pos-cart { .cart-toggle-btn .icon-pos-cart {
width: var(--icon-size); width: var(--icon-size);
height: var(--icon-size); height: var(--icon-size);
color: var(--btcpay-header-link);
} }
.cart-toggle-btn:disabled svg { .cart-toggle-btn:disabled {
color: var(--btcpay-body-text-muted); color: var(--btcpay-body-text-muted);
} }
.cart-toggle-btn:not(:disabled):hover svg { #RecentTransactionsToggle:hover,
.cart-toggle-btn:not(:disabled):hover {
color: var(--btcpay-header-link-accent); color: var(--btcpay-header-link-accent);
} }
@@ -139,9 +124,6 @@ header .cart-toggle-btn {
#cart.show { #cart.show {
transform: none; transform: none;
} }
#CartClose {
color: var(--btcpay-body-text);
}
} }
@media (min-width: 992px) { @media (min-width: 992px) {
#content { #content {

View File

@@ -1,222 +1,30 @@
document.addEventListener("DOMContentLoaded",function () { 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({ new Vue({
el: '#PosCart', el: '#PosCart',
mixins: [posCommon], mixins: [posCommon],
data () { data () {
return { return {
displayCategory: '*', $cart: null,
searchTerm: null, amount: 0,
cart: loadState('cart'), persistState: true
categoriesScrollable: false,
$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||0) * item.count, 0).toFixed(this.currencyInfo.divisibility))
},
posdata () {
const data = { cart: this.cart, subTotal: this.amountNumeric }
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
if (this.tipNumeric > 0) data.tip = this.tipNumeric
data.total = this.totalNumeric
return JSON.stringify(data)
} }
}, },
watch: { watch: {
searchTerm(term) {
this.updateDisplay()
},
displayCategory(category) {
this.updateDisplay()
},
cart: { cart: {
handler(newCart) { 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) { if (!newCart || newCart.length === 0) {
this.$cart.hide() this.$cart.hide()
} }
}, }
deep: true
} }
}, },
methods: { methods: {
toggleCart() { toggleCart() {
this.$cart.toggle() 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];
const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index];
// Check if price is needed
const isFixedPrice = item.priceType.toLowerCase() === 'fixed';
if (!isFixedPrice) {
const $amount = $posItem.querySelector('input[name="amount"]');
if (!$amount.reportValidity()) return false;
item.price = parseFloat($amount.value);
}
let itemInCart = this.cart.find(lineItem => lineItem.id === item.id && lineItem.price === item.price);
// 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
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 = [];
},
displayItem(item) {
const inSearch = !this.searchTerm ||
decodeURIComponent(item.dataset.search ? item.dataset.search.toLowerCase() : '')
.indexOf(this.searchTerm.toLowerCase()) !== -1
const inCategories = this.displayCategory === "*" ||
(item.dataset.categories ? JSON.parse(item.dataset.categories) : [])
.includes(this.displayCategory)
return inSearch && inCategories
},
updateDisplay() {
this.forEachItem(item => {
item.classList[this.displayItem(item) ? 'add' : 'remove']('posItem--displayed')
item.classList.remove('posItem--first')
item.classList.remove('posItem--last')
})
const $displayed = this.$refs.posItems.querySelectorAll('.posItem.posItem--displayed')
if ($displayed.length > 0) {
$displayed[0].classList.add('posItem--first')
$displayed[$displayed.length - 1].classList.add('posItem--last')
}
} }
}, },
mounted() { mounted() {
this.$cart = new bootstrap.Offcanvas(this.$refs.cart, { backdrop: false }) this.$cart = new bootstrap.Offcanvas(this.$refs.cart, { backdrop: false })
if (this.$refs.categories) {
const getInnerNavWidth = () => {
// set to inline display, get width to get the real inner width, then set back to flex
this.$refs.categoriesNav.classList.remove('d-flex');
this.$refs.categoriesNav.classList.add('d-inline-flex');
const navWidth = this.$refs.categoriesNav.clientWidth - 32; // 32 is the margin
this.$refs.categoriesNav.classList.remove('d-inline-flex');
this.$refs.categoriesNav.classList.add('d-flex');
return navWidth;
} }
const adjustCategories = () => {
const navWidth = getInnerNavWidth();
Vue.set(this, 'categoriesScrollable', this.$refs.categories.clientWidth < navWidth);
const activeEl = document.querySelector('#Categories .btcpay-pills input:checked + label')
if (activeEl) activeEl.scrollIntoView({ block: 'end', inline: 'center' })
}
window.addEventListener('resize', e => {
debounce('resize', adjustCategories, 50)
});
adjustCategories();
}
window.addEventListener('pagehide', () => {
if (this.payButtonLoading) {
this.payButtonLoading = false;
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);
}
});
})
this.updateDisplay()
},
}); });
}); });

View File

@@ -3,6 +3,41 @@
--wrap-max-width: 1320px; --wrap-max-width: 1320px;
} }
#Categories nav {
justify-content: center;
}
#Categories.scrollable {
--scroll-bar-spacing: var(--btcpay-space-m);
--scroll-indicator-spacing: var(--btcpay-space-m);
position: relative;
margin-bottom: calc(var(--scroll-bar-spacing) * -1);
}
#Categories.scrollable nav {
justify-content: start;
overflow: auto visible;
-webkit-overflow-scrolling: touch;
margin-left: calc(var(--scroll-indicator-spacing) * -1);
margin-right: calc(var(--scroll-indicator-spacing) * -1);
padding: 0 var(--scroll-indicator-spacing) var(--scroll-bar-spacing);
}
#Categories.scrollable nav::-webkit-scrollbar {
display: none;
}
#RecentTransactionsRefresh[disabled] .icon {
animation: 1.25s linear infinite spinner-border;
}
#RecentTransactions .list-group {
margin: calc(var(--btcpay-modal-padding) * -1);
width: calc(100% + var(--btcpay-modal-padding) * 2);
}
#RecentTransactions .list-group-item {
background-color: transparent;
}
#RecentTransactions .list-group .badge-container {
flex: 0 0 5.125rem;
text-align: right;
}
.lead { .lead {
max-width: 36em; max-width: 36em;
margin: 0 auto 2.5rem; margin: 0 auto 2.5rem;

View File

@@ -1,3 +1,40 @@
const POS_ITEM_ADDED_CLASS = 'posItem--added';
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 posCommon = { const posCommon = {
data () { data () {
return { return {
@@ -7,13 +44,34 @@ const posCommon = {
tipPercent: null, tipPercent: null,
discount: null, discount: null,
discountPercent: null, discountPercent: null,
payButtonLoading: false payButtonLoading: false,
categoriesScrollable: false,
displayCategory: '*',
searchTerm: null,
cart: [],
amounts: [null],
recentTransactions: [],
recentTransactionsLoading: false,
dateFormatter: new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' }),
} }
}, },
computed: { computed: {
amountNumeric () { amountNumeric () {
const value = parseFloat(this.amount) const { divisibility } = this.currencyInfo
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility)) const cart = this.cart.reduce((res, item) => res + (item.price || 0) * item.count, 0).toFixed(divisibility)
const value = parseFloat(this.amount || 0) + parseFloat(cart)
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(divisibility))
},
posdata () {
const data = { subTotal: this.amountNumeric, total: this.totalNumeric }
const amounts = this.amounts.filter(e => e) // clear empty or zero values
if (amounts) data.amounts = amounts.map(parseFloat)
if (this.cart) data.cart = this.cart
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.tipPercent > 0) data.tipPercentage = this.tipPercent
return JSON.stringify(data)
}, },
discountPercentNumeric () { discountPercentNumeric () {
const value = parseFloat(this.discountPercent) const value = parseFloat(this.discountPercent)
@@ -44,28 +102,41 @@ const posCommon = {
totalNumeric () { totalNumeric () {
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility)) return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
}, },
posdata () { cartCount() {
const data = { return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
subTotal: this.amountNumeric,
total: this.totalNumeric
}
if (this.tipNumeric > 0) data.tip = this.tipNumeric
if (this.tipPercent > 0) data.tipPercentage = this.tipPercent
if (this.discountNumeric > 0) data.discountAmount = this.discountNumeric
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
} }
}, },
watch: { watch: {
discountPercent (val) { searchTerm(term) {
this.updateDisplay()
},
displayCategory(category) {
this.updateDisplay()
},
discountPercent(val) {
const value = parseFloat(val) const value = parseFloat(val)
if (isNaN(value)) this.discountPercent = null if (isNaN(value)) this.discountPercent = null
else if (value < 0) this.discountPercent = '0' else if (value < 0) this.discountPercent = '0'
else if (value > 100) this.discountPercent = '100' else if (value > 100) this.discountPercent = '100'
else this.discountPercent = value.toString() else this.discountPercent = value.toString()
}, },
tip (val) { tip(val) {
this.tipPercent = null this.tipPercent = null
},
cart: {
handler(newCart) {
newCart.forEach(item => {
if (!item.count) item.count = 1
if (item.inventory && item.inventory < item.count) item.count = item.inventory
})
if (this.persistState) {
saveState('cart', newCart)
}
},
deep: true
},
amounts (values) {
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
} }
}, },
methods: { methods: {
@@ -80,17 +151,17 @@ const posCommon = {
default: return navigator.language default: return navigator.language
} }
}, },
tipPercentage (percentage) { tipPercentage(percentage) {
this.tipPercent = this.tipPercent !== percentage this.tipPercent = this.tipPercent !== percentage
? percentage ? percentage
: null; : null;
}, },
formatCrypto (value, withSymbol) { formatCrypto(value, withSymbol) {
const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : '' const symbol = withSymbol ? ` ${this.currencySymbol || this.currencyCode}` : ''
const { divisibility } = this.currencyInfo const { divisibility } = this.currencyInfo
return parseFloat(value).toFixed(divisibility) + symbol return parseFloat(value).toFixed(divisibility) + symbol
}, },
formatCurrency (value, withSymbol) { formatCurrency(value, withSymbol) {
const currency = this.currencyCode const currency = this.currencyCode
if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol) if (currency === 'BTC' || currency === 'SATS') return this.formatCrypto(value, withSymbol)
const { divisibility } = this.currencyInfo; const { divisibility } = this.currencyInfo;
@@ -103,5 +174,178 @@ const posCommon = {
return this.formatCrypto(value, withSymbol) return this.formatCrypto(value, withSymbol)
} }
}, },
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, count) {
if (!this.inStock(index)) return null;
const item = this.items[index];
const $posItem = this.$refs.posItems.querySelectorAll('.posItem')[index];
// Check if price is needed
const isFixedPrice = item.priceType.toLowerCase() === 'fixed';
if (!isFixedPrice) {
const $amount = $posItem.querySelector('input[name="amount"]');
if (!$amount.reportValidity()) return false;
item.price = parseFloat($amount.value);
}
let itemInCart = this.cart.find(lineItem => lineItem.id === item.id && lineItem.price === item.price);
// 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
}
this.cart.push(itemInCart);
} else {
itemInCart.count += count;
}
// Animate
if (!$posItem.classList.contains(POS_ITEM_ADDED_CLASS)) $posItem.classList.add(POS_ITEM_ADDED_CLASS);
return itemInCart;
},
removeFromCart(id) {
const index = this.cart.findIndex(lineItem => lineItem.id === id);
this.cart.splice(index, 1);
},
getQuantity(id) {
const itemInCart = this.cart.find(lineItem => lineItem.id === id);
return itemInCart ? itemInCart.count : 0;
},
updateQuantity(id, count, addOrRemove) {
let itemInCart = this.cart.find(lineItem => lineItem.id === id);
if (!itemInCart && addOrRemove && count > 0) {
const index = this.items.findIndex(lineItem => lineItem.id === id);
itemInCart = this.addToCart(index, 0);
}
const applyable = addOrRemove || (count < 0 && itemInCart.count + count > 0) ||
(count > 0 && (itemInCart.inventory == null || itemInCart.count + count <= itemInCart.inventory));
if (applyable) {
itemInCart.count += count;
}
if (itemInCart && itemInCart.count <= 0 && addOrRemove) {
this.removeFromCart(itemInCart.id);
}
},
clear() {
this.cart = [];
this.amounts = [null];
this.tip = this.discount = this.tipPercent = this.discountPercent = null;
},
forEachItem(callback) {
if (this.$refs.posItems) {
this.$refs.posItems.querySelectorAll('.posItem').forEach(callback)
}
},
displayItem(item) {
const inSearch = !this.searchTerm ||
decodeURIComponent(item.dataset.search ? item.dataset.search.toLowerCase() : '')
.indexOf(this.searchTerm.toLowerCase()) !== -1
const inCategories = this.displayCategory === "*" ||
(item.dataset.categories ? JSON.parse(item.dataset.categories) : [])
.includes(this.displayCategory)
return inSearch && inCategories
},
updateDisplay() {
this.forEachItem(item => {
item.classList[this.displayItem(item) ? 'add' : 'remove']('posItem--displayed')
item.classList.remove('posItem--first')
item.classList.remove('posItem--last')
})
const $displayed = this.$refs.posItems.querySelectorAll('.posItem.posItem--displayed')
if ($displayed.length > 0) {
$displayed[0].classList.add('posItem--first')
$displayed[$displayed.length - 1].classList.add('posItem--last')
}
},
hideRecentTransactions() {
bootstrap.Modal.getInstance(this.$refs.RecentTransactions).hide();
},
displayDate(val) {
const date = new Date(val);
return this.dateFormatter.format(date);
},
async loadRecentTransactions() {
this.recentTransactionsLoading = true;
const { url } = this.$refs.RecentTransactions.dataset;
try {
const response = await fetch(url);
if (response.ok) {
this.recentTransactions = await response.json();
}
} catch (error) {
console.error(error);
} finally {
this.recentTransactionsLoading = false;
}
}
},
beforeMount() {
if (this.persistState) {
this.cart = loadState('cart');
}
},
mounted () {
if (this.$refs.categories) {
const getInnerNavWidth = () => {
// set to inline display, get width to get the real inner width, then set back to flex
this.$refs.categoriesNav.classList.remove('d-flex');
this.$refs.categoriesNav.classList.add('d-inline-flex');
const navWidth = this.$refs.categoriesNav.clientWidth - 32; // 32 is the margin
this.$refs.categoriesNav.classList.remove('d-inline-flex');
this.$refs.categoriesNav.classList.add('d-flex');
return navWidth;
}
const adjustCategories = () => {
const navWidth = getInnerNavWidth();
Vue.set(this, 'categoriesScrollable', this.$refs.categories.clientWidth <= navWidth);
const activeEl = document.querySelector('#Categories .btcpay-pills input:checked + label')
if (activeEl) activeEl.scrollIntoView({ block: 'end', inline: 'center' })
}
window.addEventListener('resize', e => {
debounce('resize', adjustCategories, 50)
});
adjustCategories();
}
this.forEachItem(item => {
item.addEventListener('transitionend', () => {
if (item.classList.contains(POS_ITEM_ADDED_CLASS)) {
item.classList.remove(POS_ITEM_ADDED_CLASS);
}
});
})
if (this.$refs.RecentTransactions) {
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
}
window.addEventListener('pagehide', () => {
if (this.payButtonLoading) {
this.payButtonLoading = false;
localStorage.removeItem(storageKey('cart'));
}
})
this.updateDisplay()
} }
} }

View File

@@ -4,31 +4,23 @@
position: relative; position: relative;
} }
#RecentTransactionsToggle { button[data-bs-toggle] {
position: absolute; position: absolute;
top: var(--btcpay-space-l); top: 1.75rem;
left: var(--btcpay-space-m);
z-index: 1; z-index: 1;
color: var(--btcpay-header-link);
} }
button[data-bs-toggle]:hover {
#RecentTransactionsToggle .icon { color: var(--btcpay-header-link-accent);
--btn-icon-size: 2.25em;
} }
#RecentTransactionsRefresh[disabled] .icon { button[data-bs-toggle] .icon {
animation: 1.25s linear infinite spinner-border; --btn-icon-size: 1.75em;
} }
#RecentTransactions .list-group { #ItemsListToggle {
margin: calc(var(--btcpay-modal-padding) * -1); right: var(--btcpay-space-m);
width: calc(100% + var(--btcpay-modal-padding) * 2);
} }
#RecentTransactionsToggle {
#RecentTransactions .list-group-item { left: var(--btcpay-space-m);
background-color: transparent;
}
#RecentTransactions .list-group .badge-container {
flex: 0 0 5.125rem;
text-align: right;
} }
@media (max-width: 359px) { @media (max-width: 359px) {
@@ -37,6 +29,22 @@
} }
} }
#PosItems {
--image-size: 3rem;
}
#PosItems .img img {
width: var(--image-size);
height: var(--image-size);
}
#PosItems .img img {
max-width: var(--image-size);
max-height: var(--image-size);
object-fit: scale-down;
border-radius: var(--btcpay-border-radius);
}
/* modes */ /* modes */
#ModeTabs { #ModeTabs {
min-height: 2.75rem; min-height: 2.75rem;

View File

@@ -9,10 +9,7 @@ document.addEventListener("DOMContentLoaded",function () {
fontSize: displayFontSize, fontSize: displayFontSize,
defaultFontSize: displayFontSize, defaultFontSize: displayFontSize,
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '+'], keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '+'],
amounts: [null], persistState: false
recentTransactions: [],
recentTransactionsLoading: false,
dateFormatter: new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' })
} }
}, },
computed: { computed: {
@@ -33,8 +30,11 @@ document.addEventListener("DOMContentLoaded",function () {
} }
}, },
calculation () { calculation () {
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2) return null if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2 && this.cart.length === 0) return null
let calc = this.amounts.map(amt => this.formatCurrency(amt, true)).join(' + ') let calc = ''
if (this.cart.length) calc += this.cart.map(item => `${item.count} x ${item.title} (${this.formatCurrency(item.price, true)}) = ${this.formatCurrency((item.price||0) * item.count, true)}`).join(' + ')
if (this.cart.length && this.amounts.length) calc += ' + '
if (this.amounts.length) calc += this.amounts.map(amt => this.formatCurrency(amt, true)).join(' + ')
if (this.discountNumeric > 0 || this.discountPercentNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)` if (this.discountNumeric > 0 || this.discountPercentNumeric > 0) calc += ` - ${this.formatCurrency(this.discountNumeric, true)} (${this.discountPercent}%)`
if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}` if (this.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
if (this.tipPercent) calc += ` (${this.tipPercent}%)` if (this.tipPercent) calc += ` (${this.tipPercent}%)`
@@ -58,9 +58,6 @@ document.addEventListener("DOMContentLoaded",function () {
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize); this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
} }
}); });
},
amounts (values) {
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
} }
}, },
methods: { methods: {
@@ -71,9 +68,8 @@ document.addEventListener("DOMContentLoaded",function () {
padR = parseFloat(styles.paddingRight); padR = parseFloat(styles.paddingRight);
return width - padL - padR; return width - padL - padR;
}, },
clear() { clearKeypad() {
this.amounts = [null]; this.clear();
this.tip = this.discount = this.tipPercent = this.discountPercent = null;
this.mode = 'amounts'; this.mode = 'amounts';
}, },
applyKeyToValue(key, value, divisibility) { applyKeyToValue(key, value, divisibility) {
@@ -91,7 +87,7 @@ document.addEventListener("DOMContentLoaded",function () {
if (key === 'C') { if (key === 'C') {
if (!lastAmount && lastIndex === 0) { if (!lastAmount && lastIndex === 0) {
// clear completely // clear completely
this.clear(); this.clearKeypad();
} else if (!lastAmount) { } else if (!lastAmount) {
// remove latest value // remove latest value
this.amounts.pop(); this.amounts.pop();
@@ -118,37 +114,13 @@ document.addEventListener("DOMContentLoaded",function () {
doubleClick (key) { doubleClick (key) {
if (key === 'C') { if (key === 'C') {
// clear completely // clear completely
this.clear(); this.clearKeypad();
}
},
closeModal() {
bootstrap.Modal.getInstance(this.$refs.RecentTransactions).hide();
},
displayDate(val) {
const date = new Date(val);
return this.dateFormatter.format(date);
},
async loadRecentTransactions() {
this.recentTransactionsLoading = true;
const { url } = this.$refs.RecentTransactions.dataset;
try {
const response = await fetch(url);
if (response.ok) {
this.recentTransactions = await response.json();
}
} catch (error) {
console.error(error);
} finally {
this.recentTransactionsLoading = false;
} }
} }
}, },
created() { created() {
// We need to unset state in case user clicks the browser back button // We need to unset state in case user clicks the browser back button
window.addEventListener('pagehide', () => { this.payButtonLoading = false }) window.addEventListener('pagehide', () => { this.payButtonLoading = false })
},
mounted() {
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
} }
}); });
}); });

View File

@@ -460,6 +460,12 @@
"Print" "Print"
] ]
}, },
"showItems": {
"type": "boolean",
"default": false,
"description": "Display item selection for keypad",
"example": true
},
"showCustomAmount": { "showCustomAmount": {
"type": "boolean", "type": "boolean",
"description": "Whether the option to enter a custom amount is shown", "description": "Whether the option to enter a custom amount is shown",