mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 06:24:24 +01:00
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:
@@ -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;
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
TestUtils.Eventually(() =>
|
||||||
|
{
|
||||||
|
Driver.WaitForElement(By.Id("CheatSuccessMessage"));
|
||||||
|
});
|
||||||
if (mine)
|
if (mine)
|
||||||
{
|
{
|
||||||
TestUtils.Eventually(() =>
|
|
||||||
{
|
|
||||||
Driver.WaitForElement(By.Id("CheatSuccessMessage"));
|
|
||||||
});
|
|
||||||
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);
|
||||||
|
|||||||
@@ -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();
|
|
||||||
|
// Setup store and associate user
|
||||||
|
(_, string storeId) = s.CreateNewStore();
|
||||||
s.GoToStore();
|
s.GoToStore();
|
||||||
s.AddLightningNode(LightningConnectionType.CLightning, false);
|
s.AddDerivationScheme();
|
||||||
|
s.AddUserToStore(storeId, user, "Guest");
|
||||||
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
|
|
||||||
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
|
// Setup POS
|
||||||
s.Driver.FindElement(By.Id("AppName")).SendKeys(appName);
|
s.CreateApp("PointOfSale");
|
||||||
s.Driver.FindElement(By.Id("Create")).Click();
|
|
||||||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
|
||||||
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]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 %")]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
@@ -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">
|
<button type="button" class="btn btn-link p-1" data-bs-toggle="modal" data-bs-target="#RecentTransactions" id="RecentTransactionsToggle" permission="@Policies.CanViewInvoices">
|
||||||
<div class="modal-content">
|
<vc:icon symbol="invoice-2 "/>
|
||||||
<div class="modal-header">
|
</button>
|
||||||
<h5 class="modal-title">Recent Transactions</h5>
|
<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">
|
||||||
<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="menu"/>
|
||||||
<vc:icon symbol="refresh"/>
|
</button>
|
||||||
<span v-if="recentTransactionsLoading" class="visually-hidden">Loading...</span>
|
<div class="offcanvas offcanvas-end" data-bs-backdrop="static" tabindex="-1" id="ItemsListOffcanvas" aria-labelledby="ItemsListToggle" v-if="showItems">
|
||||||
</button>
|
<div class="offcanvas-header flex-wrap p-3">
|
||||||
<button type="button" class="btn-close py-3" aria-label="Close" v-on:click="closeModal">
|
<h5 class="offcanvas-title" id="offcanvasExampleLabel">Products</h5>
|
||||||
<vc:icon symbol="close"/>
|
<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>
|
||||||
</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>
|
</div>
|
||||||
<div class="modal-body">
|
}
|
||||||
<div v-if="recentTransactions.length" class="list-group list-group-flush">
|
@if (Model.ShowCategories)
|
||||||
<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">
|
<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 }">
|
||||||
<span class="flex-grow-1">{{displayDate(t.date)}}</span>
|
<nav class="btcpay-pills d-flex align-items-center gap-3" ref="categoriesNav">
|
||||||
<span class="flex-grow-1 text-end">{{t.price}}</span>
|
<template v-for="cat in allCategories">
|
||||||
<div class="badge-container">
|
<input :id="`Category-${cat.value}`" type="radio" name="category" autocomplete="off" v-model="displayCategory" :value="cat.value">
|
||||||
<span class="badge" :class="`badge-${t.status.toLowerCase()}`">{{t.status}}</span>
|
<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>
|
</div>
|
||||||
<vc:icon symbol="caret-right" />
|
<div class="d-flex align-items-center gap-2 ms-auto" v-if="inStock(@index)">
|
||||||
</a>
|
<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>
|
||||||
<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>
|
</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">
|
|
||||||
<vc:icon symbol="manage-plugins"/>
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user