diff --git a/BTCPayServer.Tests/TestUtils.cs b/BTCPayServer.Tests/TestUtils.cs index 800865f83..d966f99cf 100644 --- a/BTCPayServer.Tests/TestUtils.cs +++ b/BTCPayServer.Tests/TestUtils.cs @@ -44,9 +44,9 @@ namespace BTCPayServer.Tests formFile.ContentDisposition = $"form-data; name=\"file\"; filename=\"{fileInfo.Name}\""; return formFile; } - public static void Eventually(Action act) + public static void Eventually(Action act, int ms = 200000) { - CancellationTokenSource cts = new CancellationTokenSource(20000); + CancellationTokenSource cts = new CancellationTokenSource(ms); while (true) { try diff --git a/BTCPayServer.Tests/UnitTest1.cs b/BTCPayServer.Tests/UnitTest1.cs index 302be468c..91104618d 100644 --- a/BTCPayServer.Tests/UnitTest1.cs +++ b/BTCPayServer.Tests/UnitTest1.cs @@ -1861,7 +1861,7 @@ namespace BTCPayServer.Tests [Fact] [Trait("Integration", "Integration")] - public void CanUsePoSApp() + public async Task CanUsePoSApp() { using (var tester = ServerTester.Create()) { @@ -1972,6 +1972,49 @@ donation: Assert.Equal(test.ExpectedDivisibility, vmview.CurrencyInfo.Divisibility); Assert.Equal(test.ExpectedSymbolSpace, vmview.CurrencyInfo.SymbolSpace); } + + + //test inventory related features + vmpos = Assert.IsType(Assert.IsType(apps.UpdatePointOfSale(appId).Result).Model); + vmpos.Title = "hello"; + vmpos.Currency = "BTC"; + vmpos.Template = @" +inventoryitem: + price: 1.0 + title: good apple + inventory: 1 +noninventoryitem: + price: 10.0"; + Assert.IsType(apps.UpdatePointOfSale(appId, vmpos).Result); + + //inventoryitem has 1 item available + Assert.IsType(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "inventoryitem").Result); + //we already bought all available stock so this should fail + await Task.Delay(100); + Assert.IsType(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "inventoryitem").Result); + + //inventoryitem has unlimited items available + Assert.IsType(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "noninventoryitem").Result); + Assert.IsType(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "noninventoryitem").Result); + + //verify invoices where created + invoices = user.BitPay.GetInvoices(); + Assert.Equal(2, invoices.Count(invoice => invoice.ItemCode.Equals("noninventoryitem"))); + var inventoryItemInvoice = invoices.SingleOrDefault(invoice => invoice.ItemCode.Equals("inventoryitem")); + Assert.NotNull(inventoryItemInvoice); + + //let's mark the inventoryitem invoice as invalid, thsi should return the item to back in stock + var controller = tester.PayTester.GetController(user.UserId, user.StoreId); + var appService = tester.PayTester.GetService(); + var eventAggregator = tester.PayTester.GetService(); + Assert.IsType( await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid")); + //check that item is back in stock + TestUtils.Eventually(() => + { + vmpos = Assert.IsType(Assert.IsType(apps.UpdatePointOfSale(appId).Result).Model); + Assert.Equal(1, appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory); + }, 10000); + } } diff --git a/BTCPayServer/Controllers/AppsController.Crowdfund.cs b/BTCPayServer/Controllers/AppsController.Crowdfund.cs index 15e78530f..5e7a50303 100644 --- a/BTCPayServer/Controllers/AppsController.Crowdfund.cs +++ b/BTCPayServer/Controllers/AppsController.Crowdfund.cs @@ -156,7 +156,7 @@ namespace BTCPayServer.Controllers app.TagAllInvoices = vm.UseAllStoreInvoices; app.SetSettings(newSettings); - await UpdateAppSettings(app); + await _AppService.UpdateOrCreateApp(app); _EventAggregator.Publish(new AppUpdated() { diff --git a/BTCPayServer/Controllers/AppsController.PointOfSale.cs b/BTCPayServer/Controllers/AppsController.PointOfSale.cs index 8505b4423..720d3e377 100644 --- a/BTCPayServer/Controllers/AppsController.PointOfSale.cs +++ b/BTCPayServer/Controllers/AppsController.PointOfSale.cs @@ -53,6 +53,7 @@ namespace BTCPayServer.Controllers " title: Fruit Tea\n" + " description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" + " image: https://cdn.pixabay.com/photo/2016/09/16/11/24/darts-1673812__480.jpg\n" + + " inventory: 5\n" + " custom: true"; EnableShoppingCart = false; ShowCustomAmount = true; @@ -198,22 +199,11 @@ namespace BTCPayServer.Controllers RedirectAutomatically = string.IsNullOrEmpty(vm.RedirectAutomatically)? (bool?) null: bool.Parse(vm.RedirectAutomatically) }); - await UpdateAppSettings(app); + await _AppService.UpdateOrCreateApp(app); StatusMessage = "App updated"; return RedirectToAction(nameof(UpdatePointOfSale), new { appId }); } - private async Task UpdateAppSettings(AppData app) - { - using (var ctx = _ContextFactory.CreateContext()) - { - ctx.Apps.Add(app); - ctx.Entry(app).State = EntityState.Modified; - ctx.Entry(app).Property(a => a.Settings).IsModified = true; - ctx.Entry(app).Property(a => a.TagAllInvoices).IsModified = true; - await ctx.SaveChangesAsync(); - } - } private int[] ListSplit(string list, string separator = ",") { diff --git a/BTCPayServer/Controllers/AppsController.cs b/BTCPayServer/Controllers/AppsController.cs index a72117a75..2f6d56cb2 100644 --- a/BTCPayServer/Controllers/AppsController.cs +++ b/BTCPayServer/Controllers/AppsController.cs @@ -127,29 +127,25 @@ namespace BTCPayServer.Controllers StatusMessage = "Error: You are not owner of this store"; return RedirectToAction(nameof(ListApps)); } - var id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); - using (var ctx = _ContextFactory.CreateContext()) + var appData = new AppData { - var appData = new AppData() { Id = id }; - appData.StoreDataId = selectedStore; - appData.Name = vm.Name; - appData.AppType = appType.ToString(); - ctx.Apps.Add(appData); - await ctx.SaveChangesAsync(); - } + StoreDataId = selectedStore, + Name = vm.Name, + AppType = appType.ToString() + }; + await _AppService.UpdateOrCreateApp(appData); StatusMessage = "App successfully created"; - CreatedAppId = id; + CreatedAppId = appData.Id; switch (appType) { case AppType.PointOfSale: - return RedirectToAction(nameof(UpdatePointOfSale), new { appId = id }); + return RedirectToAction(nameof(UpdatePointOfSale), new { appId = appData.Id }); case AppType.Crowdfund: - return RedirectToAction(nameof(UpdateCrowdfund), new { appId = id }); + return RedirectToAction(nameof(UpdateCrowdfund), new { appId = appData.Id }); default: return RedirectToAction(nameof(ListApps)); } - } [HttpGet] diff --git a/BTCPayServer/Controllers/AppsPublicController.cs b/BTCPayServer/Controllers/AppsPublicController.cs index 35044773e..45284a942 100644 --- a/BTCPayServer/Controllers/AppsPublicController.cs +++ b/BTCPayServer/Controllers/AppsPublicController.cs @@ -1,52 +1,41 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; -using System.IO; using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using BTCPayServer.Configuration; -using BTCPayServer.Data; using BTCPayServer.Filters; using BTCPayServer.ModelBinders; using BTCPayServer.Models; using BTCPayServer.Models.AppViewModels; -using BTCPayServer.Payments; -using BTCPayServer.Rating; using BTCPayServer.Security; using BTCPayServer.Services.Apps; -using BTCPayServer.Services.Invoices; -using BTCPayServer.Services.Rates; -using Ganss.XSS; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using Microsoft.EntityFrameworkCore; -using NBitpayClient; -using YamlDotNet.RepresentationModel; using static BTCPayServer.Controllers.AppsController; namespace BTCPayServer.Controllers { public class AppsPublicController : Controller { - public AppsPublicController(AppService AppService, + public AppsPublicController(AppService appService, BTCPayServerOptions btcPayServerOptions, InvoiceController invoiceController, UserManager userManager) { - _AppService = AppService; + _AppService = appService; _BtcPayServerOptions = btcPayServerOptions; _InvoiceController = invoiceController; _UserManager = userManager; } - private AppService _AppService; + private readonly AppService _AppService; private readonly BTCPayServerOptions _BtcPayServerOptions; - private InvoiceController _InvoiceController; + private readonly InvoiceController _InvoiceController; private readonly UserManager _UserManager; [HttpGet] @@ -132,6 +121,14 @@ namespace BTCPayServer.Controllers price = choice.Price.Value; if (amount > price) price = amount; + + if (choice.Inventory.HasValue) + { + if (choice.Inventory <= 0) + { + return RedirectToAction(nameof(ViewPointOfSale), new { appId = appId }); + } + } } else { @@ -139,6 +136,33 @@ namespace BTCPayServer.Controllers return NotFound(); price = amount; title = settings.Title; + + //if cart IS enabled and we detect posdata that matches the cart system's, check inventory for the items + if (!string.IsNullOrEmpty(posData) && + settings.EnableShoppingCart && + AppService.TryParsePosCartItems(posData, out var cartItems)) + { + + var choices = _AppService.Parse(settings.Template, settings.Currency); + var updateNeeded = false; + foreach (var cartItem in cartItems) + { + var itemChoice = choices.FirstOrDefault(c => c.Id == cartItem.Key); + if (itemChoice == null) + return NotFound(); + + if (itemChoice.Inventory.HasValue) + { + switch (itemChoice.Inventory) + { + case int i when i <= 0: + return RedirectToAction(nameof(ViewPointOfSale), new {appId}); + case int inventory when inventory < cartItem.Value: + return RedirectToAction(nameof(ViewPointOfSale), new {appId}); + } + } + } + } } var store = await _AppService.GetStore(app); store.AdditionalClaims.Add(new Claim(Policies.CanCreateInvoice.Key, store.Id)); @@ -164,7 +188,6 @@ namespace BTCPayServer.Controllers return RedirectToAction(nameof(InvoiceController.Checkout), "Invoice", new { invoiceId = invoice.Data.Id }); } - [HttpGet] [Route("/apps/{appId}/crowdfund")] [XFrameOptionsAttribute(XFrameOptionsAttribute.XFrameOptions.AllowAll)] @@ -243,6 +266,15 @@ namespace BTCPayServer.Controllers price = choice.Price.Value; if (request.Amount > price) price = request.Amount; + + + if (choice.Inventory.HasValue) + { + if (choice.Inventory <= 0) + { + return NotFound("Option was out of stock"); + } + } } if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price > diff --git a/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs new file mode 100644 index 000000000..6ba2c98e9 --- /dev/null +++ b/BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using BTCPayServer.Controllers; +using BTCPayServer.Events; +using BTCPayServer.Services.Apps; + +namespace BTCPayServer.HostedServices +{ + public class AppInventoryUpdaterHostedService : EventHostedServiceBase + { + private readonly AppService _AppService; + + protected override void SubscibeToEvents() + { + Subscribe(); + } + + public AppInventoryUpdaterHostedService(EventAggregator eventAggregator, AppService appService) : base( + eventAggregator) + { + _AppService = appService; + } + + protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken) + { + if (evt is InvoiceEvent invoiceEvent) + { + Dictionary cartItems = null; + bool deduct; + switch (invoiceEvent.Name) + { + case InvoiceEvent.Expired: + + case InvoiceEvent.MarkedInvalid: + deduct = false; + break; + case InvoiceEvent.Created: + deduct = true; + break; + default: + return; + } + + + if ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) || + AppService.TryParsePosCartItems(invoiceEvent.Invoice.PosData, out cartItems))) + { + var appIds = AppService.GetAppInternalTags(invoiceEvent.Invoice); + + if (!appIds.Any()) + { + return; + } + + //get all apps that were tagged that have manageable inventory that has an item that matches the item code in the invoice + var apps = (await _AppService.GetApps(appIds)).Select(data => + { + switch (Enum.Parse(data.AppType)) + { + case AppType.PointOfSale: + var possettings = data.GetSettings(); + return (Data: data, Settings: (object)possettings, + Items: _AppService.Parse(possettings.Template, possettings.Currency)); + case AppType.Crowdfund: + var cfsettings = data.GetSettings(); + return (Data: data, Settings: (object)cfsettings, + Items: _AppService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency)); + default: + return (null, null, null); + } + }).Where(tuple => tuple.Data != null && tuple.Items.Any(item => + item.Inventory.HasValue && + ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) && + item.Id == invoiceEvent.Invoice.ProductInformation.ItemCode) || + (cartItems != null && cartItems.ContainsKey(item.Id))))); + foreach (var valueTuple in apps) + { + foreach (var item1 in valueTuple.Items.Where(item => + ((!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) && + item.Id == invoiceEvent.Invoice.ProductInformation.ItemCode) || + (cartItems != null && cartItems.ContainsKey(item.Id))))) + { + if (cartItems != null && cartItems.ContainsKey(item1.Id)) + { + if (deduct) + { + item1.Inventory -= cartItems[item1.Id]; + } + else + { + item1.Inventory += cartItems[item1.Id]; + } + + } + else if (!string.IsNullOrEmpty(invoiceEvent.Invoice.ProductInformation.ItemCode) && + item1.Id == invoiceEvent.Invoice.ProductInformation.ItemCode) + { + if (deduct) + { + item1.Inventory--; + } + else + { + item1.Inventory++; + } + } + } + + switch (Enum.Parse(valueTuple.Data.AppType)) + { + case AppType.PointOfSale: + + ((AppsController.PointOfSaleSettings)valueTuple.Settings).Template = + _AppService.SerializeTemplate(valueTuple.Items); + break; + case AppType.Crowdfund: + ((CrowdfundSettings)valueTuple.Settings).PerksTemplate = + _AppService.SerializeTemplate(valueTuple.Items); + break; + default: + throw new InvalidOperationException(); + } + + valueTuple.Data.SetSettings(valueTuple.Settings); + await _AppService.UpdateOrCreateApp(valueTuple.Data); + } + } + } + } + } +} diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs index 57f5c0d90..471ee67a8 100644 --- a/BTCPayServer/Hosting/BTCPayServerServices.cs +++ b/BTCPayServer/Hosting/BTCPayServerServices.cs @@ -208,6 +208,7 @@ namespace BTCPayServer.Hosting services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs index 8c83dae6e..e87128ead 100644 --- a/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs +++ b/BTCPayServer/Models/AppViewModels/ViewPointOfSaleViewModel.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; @@ -20,6 +20,7 @@ namespace BTCPayServer.Models.AppViewModels public ItemPrice Price { get; set; } public string Title { get; set; } public bool Custom { get; set; } + public int? Inventory { get; set; } = null; } public class CurrencyInfoData diff --git a/BTCPayServer/Services/Apps/AppService.cs b/BTCPayServer/Services/Apps/AppService.cs index 0d13aa1a7..d63e6d645 100644 --- a/BTCPayServer/Services/Apps/AppService.cs +++ b/BTCPayServer/Services/Apps/AppService.cs @@ -17,14 +17,20 @@ using BTCPayServer.Services.Apps; using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Rates; using BTCPayServer.Services.Stores; +using ExchangeSharp; using Ganss.XSS; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using NBitcoin; +using NBitcoin.DataEncoders; using NBitpayClient; +using Newtonsoft.Json.Linq; using YamlDotNet.RepresentationModel; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; using static BTCPayServer.Controllers.AppsController; using static BTCPayServer.Models.AppViewModels.ViewCrowdfundViewModel; @@ -268,7 +274,33 @@ namespace BTCPayServer.Services.Apps return _storeRepository.FindStore(app.StoreDataId); } + public string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items) + { + var mappingNode = new YamlMappingNode(); + foreach (var item in items) + { + var itemNode = new YamlMappingNode(); + itemNode.Add("title", new YamlScalarNode(item.Title)); + itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant())); + if (!string.IsNullOrEmpty(item.Description)) + { + itemNode.Add("description", new YamlScalarNode(item.Description)); + } + if (!string.IsNullOrEmpty(item.Image)) + { + itemNode.Add("image", new YamlScalarNode(item.Image)); + } + itemNode.Add("custom", new YamlScalarNode(item.Custom.ToStringLowerInvariant())); + if (item.Inventory.HasValue) + { + itemNode.Add("inventory", new YamlScalarNode(item.Inventory.ToString())); + } + mappingNode.Add(item.Id, itemNode); + } + var serializer = new SerializerBuilder().Build(); + return serializer.Serialize(mappingNode); + } public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency) { if (string.IsNullOrWhiteSpace(template)) @@ -293,7 +325,8 @@ namespace BTCPayServer.Services.Apps Value = decimal.Parse(cc.Value.Value, CultureInfo.InvariantCulture), Formatted = Currencies.FormatCurrency(cc.Value.Value, currency) }).Single(), - Custom = c.GetDetailString("custom") == "true" + Custom = c.GetDetailString("custom") == "true", + Inventory = string.IsNullOrEmpty(c.GetDetailString("inventory")) ?(int?) null: int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture) }) .ToArray(); } @@ -369,7 +402,6 @@ namespace BTCPayServer.Services.Apps public string GetDetailString(string field) { - return GetDetail(field).FirstOrDefault()?.Value?.Value; } } @@ -396,5 +428,51 @@ namespace BTCPayServer.Services.Apps return app; } } + + public async Task UpdateOrCreateApp(AppData app) + { + using (var ctx = _ContextFactory.CreateContext()) + { + if (string.IsNullOrEmpty(app.Id)) + { + app.Id = Encoders.Base58.EncodeData(RandomUtils.GetBytes(20)); + app.Created = DateTimeOffset.Now; + await ctx.Apps.AddAsync(app); + } + else + { + ctx.Apps.Update(app); + ctx.Entry(app).Property(data => data.Created).IsModified = false; + ctx.Entry(app).Property(data => data.Id).IsModified = false; + ctx.Entry(app).Property(data => data.AppType).IsModified = false; + } + await ctx.SaveChangesAsync(); + } + } + + private static bool TryParseJson(string json, out JObject result) + { + result = null; + try + { + result = JObject.Parse(json); + return true; + } + catch + { + return false; + } + } + + public static bool TryParsePosCartItems(string posData, out Dictionary cartItems) + { + cartItems = null; + if (!TryParseJson(posData, out var posDataObj) || + !posDataObj.TryGetValue("cart", out var cartObject)) return false; + cartItems = cartObject.Select(token => (JObject)token) + .ToDictionary(o => o.GetValue("id", StringComparison.InvariantCulture).ToString(), + o => int.Parse(o.GetValue("count", StringComparison.InvariantCulture).ToString(), CultureInfo.InvariantCulture )); + return true; + } } } diff --git a/BTCPayServer/Views/Apps/UpdateCrowdfund.cshtml b/BTCPayServer/Views/Apps/UpdateCrowdfund.cshtml index bc033e365..54e486f95 100644 --- a/BTCPayServer/Views/Apps/UpdateCrowdfund.cshtml +++ b/BTCPayServer/Views/Apps/UpdateCrowdfund.cshtml @@ -273,6 +273,12 @@ +
+
+ + +
+
} diff --git a/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml b/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml index 27d2452a6..615feec5e 100644 --- a/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml +++ b/BTCPayServer/Views/Apps/UpdatePointOfSale.cshtml @@ -277,6 +277,12 @@ +
+
+ + +
+
diff --git a/BTCPayServer/Views/AppsPublic/Crowdfund/ContributeForm.cshtml b/BTCPayServer/Views/AppsPublic/Crowdfund/ContributeForm.cshtml index 978268372..e9c0ec51a 100644 --- a/BTCPayServer/Views/AppsPublic/Crowdfund/ContributeForm.cshtml +++ b/BTCPayServer/Views/AppsPublic/Crowdfund/ContributeForm.cshtml @@ -1,4 +1,5 @@ @using Microsoft.EntityFrameworkCore.Internal +@using Microsoft.EntityFrameworkCore.Storage @model BTCPayServer.Models.AppViewModels.ContributeToCrowdfund
@@ -43,11 +44,29 @@

@Safe.Raw(item.Description)

- @if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id)) + @if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id) || item.Inventory.HasValue) { } diff --git a/BTCPayServer/Views/AppsPublic/Crowdfund/VueCrowdfund.cshtml b/BTCPayServer/Views/AppsPublic/Crowdfund/VueCrowdfund.cshtml index f66921843..bff5e07fb 100644 --- a/BTCPayServer/Views/AppsPublic/Crowdfund/VueCrowdfund.cshtml +++ b/BTCPayServer/Views/AppsPublic/Crowdfund/VueCrowdfund.cshtml @@ -300,9 +300,11 @@ -
diff --git a/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml b/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml index 55867c416..d2feaae9b 100644 --- a/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml +++ b/BTCPayServer/Views/AppsPublic/ViewPointOfSale.cshtml @@ -6,6 +6,7 @@ ViewData["Title"] = Model.Title; Layout = null; int[] CustomTipPercentages = Model.CustomTipPercentages; + var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue); } @@ -42,10 +43,19 @@ grid-gap: .5rem; } - .card-deck .card:only-of-type { - max-width: 320px; - margin: auto; - } + .card-deck .card:only-of-type { + max-width: 320px; + margin: auto; + } + + .js-cart-item-count::-webkit-inner-spin-button, + .js-cart-item-count::-webkit-outer-spin-button { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + margin: 0; + } + @if (!string.IsNullOrEmpty(Model.EmbeddedCSS)) { @@ -55,7 +65,7 @@ -