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;
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public PosViewType DefaultView { get; set; }
|
||||
public bool ShowItems { get; set; } = false;
|
||||
public bool ShowCustomAmount { get; set; } = false;
|
||||
public bool ShowDiscount { get; set; } = false;
|
||||
public bool ShowSearch { get; set; } = true;
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace BTCPayServer.Client.Models
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public string DefaultView { get; set; }
|
||||
public bool ShowItems { get; set; }
|
||||
public bool ShowCustomAmount { get; set; }
|
||||
public bool ShowDiscount { get; set; }
|
||||
public bool ShowSearch { get; set; }
|
||||
|
||||
@@ -90,7 +90,6 @@ namespace BTCPayServer.Tests
|
||||
|
||||
public void PayInvoice(bool mine = false, decimal? amount = null)
|
||||
{
|
||||
|
||||
if (amount is not null)
|
||||
{
|
||||
Driver.FindElement(By.Id("test-payment-amount")).Clear();
|
||||
@@ -98,12 +97,12 @@ namespace BTCPayServer.Tests
|
||||
}
|
||||
Driver.WaitUntilAvailable(By.Id("FakePayment"));
|
||||
Driver.FindElement(By.Id("FakePayment")).Click();
|
||||
if (mine)
|
||||
{
|
||||
TestUtils.Eventually(() =>
|
||||
{
|
||||
Driver.WaitForElement(By.Id("CheatSuccessMessage"));
|
||||
});
|
||||
if (mine)
|
||||
{
|
||||
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)
|
||||
{
|
||||
GoToUrl(url);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
@@ -14,10 +13,8 @@ using System.Threading.Tasks;
|
||||
using BTCPayServer.Abstractions.Models;
|
||||
using BTCPayServer.Client;
|
||||
using BTCPayServer.Client.Models;
|
||||
using BTCPayServer.Controllers;
|
||||
using BTCPayServer.Data;
|
||||
using BTCPayServer.Lightning;
|
||||
using BTCPayServer.Models.InvoicingModels;
|
||||
using BTCPayServer.NTag424;
|
||||
using BTCPayServer.Payments;
|
||||
using BTCPayServer.Services;
|
||||
@@ -27,7 +24,6 @@ using BTCPayServer.Views.Manage;
|
||||
using BTCPayServer.Views.Server;
|
||||
using BTCPayServer.Views.Stores;
|
||||
using BTCPayServer.Views.Wallets;
|
||||
using Dapper;
|
||||
using ExchangeSharp;
|
||||
using LNURL;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
@@ -42,7 +38,6 @@ using OpenQA.Selenium.Support.Extensions;
|
||||
using OpenQA.Selenium.Support.UI;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
using Xunit.Sdk;
|
||||
|
||||
namespace BTCPayServer.Tests
|
||||
{
|
||||
@@ -2504,7 +2499,6 @@ namespace BTCPayServer.Tests
|
||||
using var s = CreateSeleniumTester();
|
||||
s.Server.ActivateLightning();
|
||||
await s.StartAsync();
|
||||
await s.Server.EnsureChannelsSetup();
|
||||
|
||||
s.RegisterNewUser(true);
|
||||
s.CreateNewStore();
|
||||
@@ -2512,12 +2506,7 @@ namespace BTCPayServer.Tests
|
||||
s.AddLightningNode(LightningConnectionType.CLightning, false);
|
||||
s.GoToLightningSettings();
|
||||
s.Driver.SetCheckbox(By.Id("LNURLEnabled"), true);
|
||||
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
|
||||
|
||||
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.CreateApp("PointOfSale");
|
||||
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Print']")).Click();
|
||||
s.Driver.FindElement(By.Id("SaveSettings")).Click();
|
||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||
@@ -2536,13 +2525,10 @@ namespace BTCPayServer.Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
[Trait("Lightning", "Lightning")]
|
||||
public async Task CanUsePOSKeypad()
|
||||
{
|
||||
using var s = CreateSeleniumTester();
|
||||
s.Server.ActivateLightning();
|
||||
await s.StartAsync();
|
||||
await s.Server.EnsureChannelsSetup();
|
||||
|
||||
// Create users
|
||||
var user = s.RegisterNewUser();
|
||||
@@ -2553,25 +2539,16 @@ namespace BTCPayServer.Tests
|
||||
s.RegisterNewUser(true);
|
||||
|
||||
// Setup store and associate user
|
||||
s.CreateNewStore();
|
||||
(_, string storeId) = s.CreateNewStore();
|
||||
s.GoToStore();
|
||||
s.AddLightningNode(LightningConnectionType.CLightning, false);
|
||||
s.GoToStore(StoreNavPages.Users);
|
||||
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);
|
||||
s.AddDerivationScheme();
|
||||
s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
|
||||
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
|
||||
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.CreateApp("PointOfSale");
|
||||
var editUrl = s.Driver.Url;
|
||||
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Light']")).Click();
|
||||
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
|
||||
|
||||
Assert.False(s.Driver.FindElement(By.Id("EnableTips")).Selected);
|
||||
s.Driver.FindElement(By.Id("EnableTips")).Click();
|
||||
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")).SendKeys("10,21");
|
||||
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("SaveSettings")).Click();
|
||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||
|
||||
// View
|
||||
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
||||
var windows = s.Driver.WindowHandles;
|
||||
Assert.Equal(2, windows.Count);
|
||||
@@ -2591,6 +2571,7 @@ namespace BTCPayServer.Tests
|
||||
// basic checks
|
||||
var keypadUrl = s.Driver.Url;
|
||||
s.Driver.FindElement(By.Id("RecentTransactionsToggle"));
|
||||
s.Driver.ElementDoesNotExist(By.Id("ItemsListToggle"));
|
||||
Assert.Contains("EUR", s.Driver.FindElement(By.Id("Currency")).Text);
|
||||
Assert.Contains("0,00", s.Driver.FindElement(By.Id("Amount")).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.WaitForElement(By.Id("PaymentDetails-TotalFiat"));
|
||||
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
|
||||
s.GoToHome();
|
||||
@@ -2653,24 +2714,27 @@ namespace BTCPayServer.Tests
|
||||
|
||||
[Fact]
|
||||
[Trait("Selenium", "Selenium")]
|
||||
[Trait("Lightning", "Lightning")]
|
||||
public async Task CanUsePOSCart()
|
||||
{
|
||||
using var s = CreateSeleniumTester();
|
||||
s.Server.ActivateLightning();
|
||||
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.CreateNewStore();
|
||||
s.GoToStore();
|
||||
s.AddLightningNode(LightningConnectionType.CLightning, false);
|
||||
|
||||
var appName = $"PoS-{Guid.NewGuid().ToString()[..21]}";
|
||||
s.Driver.FindElement(By.Id("StoreNav-CreatePointOfSale")).Click();
|
||||
s.Driver.FindElement(By.Id("AppName")).SendKeys(appName);
|
||||
s.Driver.FindElement(By.Id("Create")).Click();
|
||||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
||||
// Setup store and associate user
|
||||
(_, string storeId) = s.CreateNewStore();
|
||||
s.GoToStore();
|
||||
s.AddDerivationScheme();
|
||||
s.AddUserToStore(storeId, user, "Guest");
|
||||
|
||||
// Setup POS
|
||||
s.CreateApp("PointOfSale");
|
||||
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
|
||||
s.Driver.FindElement(By.Id("Currency")).SendKeys("EUR");
|
||||
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("SaveSettings")).Click();
|
||||
Assert.Contains("App updated", s.FindAlertMessage().Text);
|
||||
|
||||
// View
|
||||
s.Driver.FindElement(By.Id("ViewApp")).Click();
|
||||
var windows = s.Driver.WindowHandles;
|
||||
Assert.Equal(2, windows.Count);
|
||||
@@ -2768,11 +2834,59 @@ namespace BTCPayServer.Tests
|
||||
Assert.Contains("9,90 €", s.Driver.FindElement(By.Id("PaymentDetails-TotalFiat")).Text);
|
||||
|
||||
// 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
|
||||
s.Driver.Navigate().GoToUrl(posUrl);
|
||||
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]
|
||||
|
||||
@@ -2274,18 +2274,18 @@ namespace BTCPayServer.Tests
|
||||
});
|
||||
|
||||
// Test on the webhooks
|
||||
user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
|
||||
await user.AssertHasWebhookEvent<WebhookInvoiceSettledEvent>(WebhookEventType.InvoiceSettled,
|
||||
c =>
|
||||
{
|
||||
Assert.False(c.ManuallyMarked);
|
||||
Assert.True(c.OverPaid);
|
||||
});
|
||||
user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing,
|
||||
await user.AssertHasWebhookEvent<WebhookInvoiceProcessingEvent>(WebhookEventType.InvoiceProcessing,
|
||||
c =>
|
||||
{
|
||||
Assert.True(c.OverPaid);
|
||||
});
|
||||
user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
|
||||
await user.AssertHasWebhookEvent<WebhookInvoiceReceivedPaymentEvent>(WebhookEventType.InvoiceReceivedPayment,
|
||||
c =>
|
||||
{
|
||||
Assert.False(c.AfterExpiration);
|
||||
@@ -2295,7 +2295,7 @@ namespace BTCPayServer.Tests
|
||||
Assert.StartsWith(txId.ToString(), c.Payment.Id);
|
||||
|
||||
});
|
||||
user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled,
|
||||
await user.AssertHasWebhookEvent<WebhookInvoicePaymentSettledEvent>(WebhookEventType.InvoicePaymentSettled,
|
||||
c =>
|
||||
{
|
||||
Assert.False(c.AfterExpiration);
|
||||
|
||||
@@ -274,6 +274,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
{
|
||||
Title = request.Title ?? request.AppName,
|
||||
DefaultView = (PosViewType)request.DefaultView,
|
||||
ShowItems = request.ShowItems,
|
||||
ShowCustomAmount = request.ShowCustomAmount,
|
||||
ShowDiscount = request.ShowDiscount,
|
||||
ShowSearch = request.ShowSearch,
|
||||
@@ -335,6 +336,7 @@ namespace BTCPayServer.Controllers.Greenfield
|
||||
Created = appData.Created,
|
||||
Title = settings.Title,
|
||||
DefaultView = settings.DefaultView.ToString(),
|
||||
ShowItems = settings.ShowItems,
|
||||
ShowCustomAmount = settings.ShowCustomAmount,
|
||||
ShowDiscount = settings.ShowDiscount,
|
||||
ShowSearch = settings.ShowSearch,
|
||||
|
||||
@@ -101,6 +101,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
StoreBranding = storeBranding,
|
||||
Step = step.ToString(CultureInfo.InvariantCulture),
|
||||
ViewType = (PosViewType)viewType,
|
||||
ShowItems = settings.ShowItems,
|
||||
ShowCustomAmount = settings.ShowCustomAmount,
|
||||
ShowDiscount = settings.ShowDiscount,
|
||||
ShowSearch = settings.ShowSearch,
|
||||
@@ -216,9 +217,11 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
title = settings.Title;
|
||||
// if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items
|
||||
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);
|
||||
foreach (var cartItem in cartItems)
|
||||
{
|
||||
@@ -379,6 +382,14 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
var key = selectedChoice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
|
||||
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("Subtotal", _displayFormatter.Currency(appPosData.Subtotal, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol));
|
||||
@@ -580,6 +591,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
AppName = app.Name,
|
||||
Title = settings.Title,
|
||||
DefaultView = settings.DefaultView,
|
||||
ShowItems = settings.ShowItems,
|
||||
ShowCustomAmount = settings.ShowCustomAmount,
|
||||
ShowDiscount = settings.ShowDiscount,
|
||||
ShowSearch = settings.ShowSearch,
|
||||
@@ -670,6 +682,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
|
||||
{
|
||||
Title = vm.Title,
|
||||
DefaultView = vm.DefaultView,
|
||||
ShowItems = vm.ShowItems,
|
||||
ShowCustomAmount = vm.ShowCustomAmount,
|
||||
ShowDiscount = vm.ShowDiscount,
|
||||
ShowSearch = vm.ShowSearch,
|
||||
|
||||
@@ -27,6 +27,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
|
||||
|
||||
[Display(Name = "Point of Sale Style")]
|
||||
public PosViewType DefaultView { get; set; }
|
||||
[Display(Name = "Display item selection for keypad")]
|
||||
public bool ShowItems { get; set; }
|
||||
[Display(Name = "User can input custom amount")]
|
||||
public bool ShowCustomAmount { get; set; }
|
||||
[Display(Name = "User can input discount in %")]
|
||||
|
||||
@@ -62,6 +62,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
|
||||
public string StoreName { get; set; }
|
||||
public CurrencyInfoData CurrencyInfo { get; set; }
|
||||
public PosViewType ViewType { get; set; }
|
||||
public bool ShowItems { get; set; }
|
||||
public bool ShowCustomAmount { get; set; }
|
||||
public bool ShowDiscount { get; set; }
|
||||
public bool ShowSearch { get; set; } = true;
|
||||
|
||||
@@ -87,6 +87,7 @@ namespace BTCPayServer.Services.Apps
|
||||
public string Template { get; set; }
|
||||
public bool EnableShoppingCart { get; set; }
|
||||
public PosViewType DefaultView { get; set; }
|
||||
public bool ShowItems { get; set; }
|
||||
public bool ShowCustomAmount { get; set; }
|
||||
public bool ShowDiscount { get; set; }
|
||||
public bool ShowSearch { get; set; } = true;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
@using BTCPayServer.Plugins.PointOfSale.Models
|
||||
@using BTCPayServer.Services
|
||||
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@using Newtonsoft.Json.Linq
|
||||
@using BTCPayServer.Client
|
||||
@using BTCPayServer.Abstractions.TagHelpers
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
|
||||
@@ -37,6 +40,9 @@
|
||||
<vc:icon symbol="pos-cart" />
|
||||
<span id="CartBadge" class="badge rounded-pill bg-danger p-1 ms-1" v-text="cartCount" v-if="cartCount !== 0"></span>
|
||||
</button>
|
||||
<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>
|
||||
@if (Model.ShowSearch)
|
||||
{
|
||||
@@ -64,7 +70,6 @@
|
||||
@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 buttonText = string.IsNullOrEmpty(item.BuyButtonText)
|
||||
@@ -73,7 +78,7 @@
|
||||
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
|
||||
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="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))
|
||||
{
|
||||
<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>
|
||||
<header class="sticky-top bg-tile offcanvas-header py-3 py-lg-4 d-flex align-items-baseline justify-content-center gap-3 px-5 pe-lg-0">
|
||||
<h1 class="mb-0" id="cartLabel">Cart</h1>
|
||||
<button id="CartClear" type="reset" v-on:click="clearCart" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
|
||||
<button id="CartClear" type="reset" v-on:click="clear" class="btn btn-text text-primary p-1" v-if="cartCount > 0">
|
||||
Empty
|
||||
</button>
|
||||
<button id="CartClose" type="button" class="cart-toggle-btn" v-on:click="toggleCart" aria-controls="cart" aria-label="Close">
|
||||
@@ -251,4 +256,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
|
||||
</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
|
||||
@inject DisplayFormatter DisplayFormatter
|
||||
@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>
|
||||
<input type="hidden" name="posdata" v-model="posdata" id="posdata">
|
||||
<input type="hidden" name="amount" v-model="totalNumeric">
|
||||
<input type="hidden" name="amount" :value="totalNumeric">
|
||||
<input type="hidden" name="tip" :value="tipNumeric">
|
||||
<input type="hidden" name="discount" :value="discountPercentNumeric">
|
||||
<input type="hidden" name="posdata" :value="posdata">
|
||||
<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-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }" id="Amount">{{ formatCurrency(total, false) }}</div>
|
||||
@@ -58,39 +73,84 @@
|
||||
</div>
|
||||
<template v-else>Charge</template>
|
||||
</button>
|
||||
<div class="modal" tabindex="-1" id="RecentTransactions" ref="RecentTransactions" data-bs-backdrop="static" data-url="@Url.Action("RecentTransactions", "UIPointOfSale", new { appId = Model.AppId })">
|
||||
<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>
|
||||
<partial name="PointOfSale/Public/RecentTransactions" model="Model"/>
|
||||
<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 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>
|
||||
|
||||
@@ -128,6 +128,14 @@
|
||||
<span asp-validation-for="FormId" class="text-danger"></span>
|
||||
</div>
|
||||
</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">
|
||||
<legend class="h5 mb-3 fw-semibold">Cart</legend>
|
||||
<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 tips = document.getElementById('tips');
|
||||
const cart = document.getElementById('cart-display');
|
||||
const keypad = document.getElementById('keypad-display');
|
||||
const discounts = document.getElementById('discounts');
|
||||
const buttonPriceText = document.getElementById('button-price-text');
|
||||
const customPayments = document.getElementById('custom-payments');
|
||||
@@ -18,6 +19,7 @@ function updateFormForDefaultView(type) {
|
||||
case 'Print':
|
||||
hide(tips);
|
||||
hide(cart);
|
||||
hide(keypad);
|
||||
hide(discounts);
|
||||
hide(buttonPriceText);
|
||||
show(description);
|
||||
@@ -32,15 +34,17 @@ function updateFormForDefaultView(type) {
|
||||
show(description);
|
||||
show(buttonPriceText);
|
||||
hide(customPayments);
|
||||
hide(keypad);
|
||||
break;
|
||||
case 'Light':
|
||||
show(tips);
|
||||
show(discounts);
|
||||
show(keypad);
|
||||
hide(cart);
|
||||
hide(products);
|
||||
hide(description);
|
||||
hide(buttonPriceText);
|
||||
hide(customPayments);
|
||||
document.getElementById('ShowItems').checked ? show(products) : hide(products);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -55,3 +59,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
delegate('change', 'input[name="DefaultView"]', e => {
|
||||
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);
|
||||
}
|
||||
|
||||
#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 */
|
||||
#Categories.scrollable:before,
|
||||
#Categories.scrollable:after {
|
||||
@@ -60,41 +38,48 @@
|
||||
right: calc(var(--scroll-indicator-spacing) * -1);
|
||||
}
|
||||
|
||||
#RecentTransactionsToggle,
|
||||
.cart-toggle-btn {
|
||||
--button-width: 40px;
|
||||
--button-height: 40px;
|
||||
--button-padding: 7px;
|
||||
--icon-size: 1rem;
|
||||
position: absolute;
|
||||
top: calc(50% - var(--button-height) / 2);
|
||||
right: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--button-width);
|
||||
height: var(--button-height);
|
||||
padding: var(--button-padding);
|
||||
color: var(--btcpay-header-link);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
header .cart-toggle-btn {
|
||||
--icon-size: 32px;
|
||||
#RecentTransactionsToggle {
|
||||
--icon-size: 1.5rem;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
header .cart-toggle-btn {
|
||||
--icon-size: 2rem;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
#RecentTransactionsToggle .icon,
|
||||
.cart-toggle-btn .icon-pos-cart {
|
||||
width: 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);
|
||||
}
|
||||
|
||||
.cart-toggle-btn:not(:disabled):hover svg {
|
||||
#RecentTransactionsToggle:hover,
|
||||
.cart-toggle-btn:not(:disabled):hover {
|
||||
color: var(--btcpay-header-link-accent);
|
||||
}
|
||||
|
||||
@@ -139,9 +124,6 @@ header .cart-toggle-btn {
|
||||
#cart.show {
|
||||
transform: none;
|
||||
}
|
||||
#CartClose {
|
||||
color: var(--btcpay-body-text);
|
||||
}
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
#content {
|
||||
|
||||
@@ -1,222 +1,30 @@
|
||||
document.addEventListener("DOMContentLoaded",function () {
|
||||
function storageKey(name) {
|
||||
return `${srvModel.appId}-${srvModel.currencyCode}-${name}`;
|
||||
}
|
||||
function saveState(name, data) {
|
||||
localStorage.setItem(storageKey(name), JSON.stringify(data));
|
||||
}
|
||||
function loadState(name) {
|
||||
const data = localStorage.getItem(storageKey(name))
|
||||
if (!data) return []
|
||||
const cart = JSON.parse(data);
|
||||
|
||||
for (let i = cart.length-1; i >= 0; i--) {
|
||||
if (!cart[i]) {
|
||||
cart.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
//check if the pos items still has the cached cart items
|
||||
const matchedItem = srvModel.items.find(item => item.id === cart[i].id);
|
||||
if (!matchedItem){
|
||||
cart.splice(i, 1);
|
||||
} else {
|
||||
if (matchedItem.inventory != null && matchedItem.inventory <= 0){
|
||||
//item is out of stock
|
||||
cart.splice(i, 1);
|
||||
} else if (matchedItem.inventory != null && matchedItem.inventory < cart[i].count){
|
||||
//not enough stock for original cart amount, reduce to available stock
|
||||
cart[i].count = matchedItem.inventory;
|
||||
//update its stock
|
||||
cart[i].inventory = matchedItem.inventory;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cart;
|
||||
}
|
||||
|
||||
const POS_ITEM_ADDED_CLASS = 'posItem--added';
|
||||
|
||||
new Vue({
|
||||
el: '#PosCart',
|
||||
mixins: [posCommon],
|
||||
data () {
|
||||
return {
|
||||
displayCategory: '*',
|
||||
searchTerm: null,
|
||||
cart: loadState('cart'),
|
||||
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)
|
||||
$cart: null,
|
||||
amount: 0,
|
||||
persistState: true
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
this.updateDisplay()
|
||||
},
|
||||
displayCategory(category) {
|
||||
this.updateDisplay()
|
||||
},
|
||||
cart: {
|
||||
handler(newCart) {
|
||||
newCart.forEach(item => {
|
||||
if (!item.count) item.count = 1
|
||||
if (item.inventory && item.inventory < item.count) item.count = item.inventory
|
||||
})
|
||||
saveState('cart', newCart)
|
||||
if (!newCart || newCart.length === 0) {
|
||||
this.$cart.hide()
|
||||
}
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggleCart() {
|
||||
this.$cart.toggle()
|
||||
},
|
||||
forEachItem(callback) {
|
||||
this.$refs.posItems.querySelectorAll('.posItem').forEach(callback)
|
||||
},
|
||||
inStock(index) {
|
||||
const item = this.items[index]
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
|
||||
|
||||
return item.inventory == null || item.inventory > (itemInCart ? itemInCart.count : 0)
|
||||
},
|
||||
inventoryText(index) {
|
||||
const item = this.items[index]
|
||||
if (item.inventory == null) return null
|
||||
|
||||
const itemInCart = this.cart.find(lineItem => lineItem.id === item.id)
|
||||
const left = item.inventory - (itemInCart ? itemInCart.count : 0)
|
||||
return left > 0 ? `${item.inventory} left` : 'Sold out'
|
||||
},
|
||||
addToCart(index) {
|
||||
if (!this.inStock(index)) return false;
|
||||
|
||||
const item = this.items[index];
|
||||
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() {
|
||||
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;
|
||||
}
|
||||
|
||||
#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 {
|
||||
max-width: 36em;
|
||||
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 = {
|
||||
data () {
|
||||
return {
|
||||
@@ -7,13 +44,34 @@ const posCommon = {
|
||||
tipPercent: null,
|
||||
discount: 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: {
|
||||
amountNumeric () {
|
||||
const value = parseFloat(this.amount)
|
||||
return isNaN(value) ? 0.0 : parseFloat(value.toFixed(this.currencyInfo.divisibility))
|
||||
const { divisibility } = this.currencyInfo
|
||||
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 () {
|
||||
const value = parseFloat(this.discountPercent)
|
||||
@@ -44,19 +102,17 @@ const posCommon = {
|
||||
totalNumeric () {
|
||||
return parseFloat(parseFloat(this.total).toFixed(this.currencyInfo.divisibility))
|
||||
},
|
||||
posdata () {
|
||||
const data = {
|
||||
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)
|
||||
cartCount() {
|
||||
return this.cart.reduce((res, item) => res + (parseInt(item.count) || 0), 0)
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm(term) {
|
||||
this.updateDisplay()
|
||||
},
|
||||
displayCategory(category) {
|
||||
this.updateDisplay()
|
||||
},
|
||||
discountPercent(val) {
|
||||
const value = parseFloat(val)
|
||||
if (isNaN(value)) this.discountPercent = null
|
||||
@@ -66,6 +122,21 @@ const posCommon = {
|
||||
},
|
||||
tip(val) {
|
||||
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: {
|
||||
@@ -103,5 +174,178 @@ const posCommon = {
|
||||
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;
|
||||
}
|
||||
|
||||
#RecentTransactionsToggle {
|
||||
button[data-bs-toggle] {
|
||||
position: absolute;
|
||||
top: var(--btcpay-space-l);
|
||||
left: var(--btcpay-space-m);
|
||||
top: 1.75rem;
|
||||
z-index: 1;
|
||||
color: var(--btcpay-header-link);
|
||||
}
|
||||
|
||||
#RecentTransactionsToggle .icon {
|
||||
--btn-icon-size: 2.25em;
|
||||
button[data-bs-toggle]:hover {
|
||||
color: var(--btcpay-header-link-accent);
|
||||
}
|
||||
#RecentTransactionsRefresh[disabled] .icon {
|
||||
animation: 1.25s linear infinite spinner-border;
|
||||
button[data-bs-toggle] .icon {
|
||||
--btn-icon-size: 1.75em;
|
||||
}
|
||||
#RecentTransactions .list-group {
|
||||
margin: calc(var(--btcpay-modal-padding) * -1);
|
||||
width: calc(100% + var(--btcpay-modal-padding) * 2);
|
||||
#ItemsListToggle {
|
||||
right: var(--btcpay-space-m);
|
||||
}
|
||||
|
||||
#RecentTransactions .list-group-item {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#RecentTransactions .list-group .badge-container {
|
||||
flex: 0 0 5.125rem;
|
||||
text-align: right;
|
||||
#RecentTransactionsToggle {
|
||||
left: var(--btcpay-space-m);
|
||||
}
|
||||
|
||||
@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 */
|
||||
#ModeTabs {
|
||||
min-height: 2.75rem;
|
||||
|
||||
@@ -9,10 +9,7 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
fontSize: displayFontSize,
|
||||
defaultFontSize: displayFontSize,
|
||||
keys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'C', '0', '+'],
|
||||
amounts: [null],
|
||||
recentTransactions: [],
|
||||
recentTransactionsLoading: false,
|
||||
dateFormatter: new Intl.DateTimeFormat('default', { dateStyle: 'short', timeStyle: 'short' })
|
||||
persistState: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -33,8 +30,11 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
}
|
||||
},
|
||||
calculation () {
|
||||
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2) return null
|
||||
let calc = this.amounts.map(amt => this.formatCurrency(amt, true)).join(' + ')
|
||||
if (!this.tipNumeric && !(this.discountNumeric > 0 || this.discountPercentNumeric > 0) && this.amounts.length < 2 && this.cart.length === 0) return null
|
||||
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.tipNumeric > 0) calc += ` + ${this.formatCurrency(this.tipNumeric, true)}`
|
||||
if (this.tipPercent) calc += ` (${this.tipPercent}%)`
|
||||
@@ -58,9 +58,6 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
this.fontSize = Math.min(this.fontSize * gamma, this.defaultFontSize);
|
||||
}
|
||||
});
|
||||
},
|
||||
amounts (values) {
|
||||
this.amount = values.reduce((total, current) => total + parseFloat(current || '0'), 0);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -71,9 +68,8 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
padR = parseFloat(styles.paddingRight);
|
||||
return width - padL - padR;
|
||||
},
|
||||
clear() {
|
||||
this.amounts = [null];
|
||||
this.tip = this.discount = this.tipPercent = this.discountPercent = null;
|
||||
clearKeypad() {
|
||||
this.clear();
|
||||
this.mode = 'amounts';
|
||||
},
|
||||
applyKeyToValue(key, value, divisibility) {
|
||||
@@ -91,7 +87,7 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
if (key === 'C') {
|
||||
if (!lastAmount && lastIndex === 0) {
|
||||
// clear completely
|
||||
this.clear();
|
||||
this.clearKeypad();
|
||||
} else if (!lastAmount) {
|
||||
// remove latest value
|
||||
this.amounts.pop();
|
||||
@@ -118,37 +114,13 @@ document.addEventListener("DOMContentLoaded",function () {
|
||||
doubleClick (key) {
|
||||
if (key === 'C') {
|
||||
// clear completely
|
||||
this.clear();
|
||||
}
|
||||
},
|
||||
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;
|
||||
this.clearKeypad();
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
// We need to unset state in case user clicks the browser back button
|
||||
window.addEventListener('pagehide', () => { this.payButtonLoading = false })
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.RecentTransactions.addEventListener('show.bs.modal', this.loadRecentTransactions);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -460,6 +460,12 @@
|
||||
"Print"
|
||||
]
|
||||
},
|
||||
"showItems": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Display item selection for keypad",
|
||||
"example": true
|
||||
},
|
||||
"showCustomAmount": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the option to enter a custom amount is shown",
|
||||
|
||||
Reference in New Issue
Block a user