New feature: Apps limited inventory (#961)

This commit is contained in:
Andrew Camilleri 2019-09-02 15:37:52 +02:00 committed by Nicolas Dorier
parent fefc45854e
commit d99beb9811
20 changed files with 544 additions and 123 deletions

View file

@ -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

View file

@ -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<UpdatePointOfSaleViewModel>(Assert.IsType<ViewResult>(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<RedirectToActionResult>(apps.UpdatePointOfSale(appId, vmpos).Result);
//inventoryitem has 1 item available
Assert.IsType<RedirectToActionResult>(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<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "inventoryitem").Result);
//inventoryitem has unlimited items available
Assert.IsType<RedirectToActionResult>(publicApps.ViewPointOfSale(appId, 1, null, null, null, null, "noninventoryitem").Result);
Assert.IsType<RedirectToActionResult>(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<InvoiceController>(user.UserId, user.StoreId);
var appService = tester.PayTester.GetService<AppService>();
var eventAggregator = tester.PayTester.GetService<EventAggregator>();
Assert.IsType<JsonResult>( await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid"));
//check that item is back in stock
TestUtils.Eventually(() =>
{
vmpos = Assert.IsType<UpdatePointOfSaleViewModel>(Assert.IsType<ViewResult>(apps.UpdatePointOfSale(appId).Result).Model);
Assert.Equal(1, appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory);
}, 10000);
}
}

View file

@ -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()
{

View file

@ -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<AppData>(app).State = EntityState.Modified;
ctx.Entry<AppData>(app).Property(a => a.Settings).IsModified = true;
ctx.Entry<AppData>(app).Property(a => a.TagAllInvoices).IsModified = true;
await ctx.SaveChangesAsync();
}
}
private int[] ListSplit(string list, string separator = ",")
{

View file

@ -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]

View file

@ -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<ApplicationUser> 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<ApplicationUser> _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 >

View file

@ -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<InvoiceEvent>();
}
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<string, int> 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<AppType>(data.AppType))
{
case AppType.PointOfSale:
var possettings = data.GetSettings<AppsController.PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _AppService.Parse(possettings.Template, possettings.Currency));
case AppType.Crowdfund:
var cfsettings = data.GetSettings<CrowdfundSettings>();
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<AppType>(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);
}
}
}
}
}
}

View file

@ -208,6 +208,7 @@ namespace BTCPayServer.Hosting
services.AddSingleton<IHostedService, RatesHostedService>();
services.AddSingleton<IHostedService, BackgroundJobSchedulerHostedService>();
services.AddSingleton<IHostedService, AppHubStreamer>();
services.AddSingleton<IHostedService, AppInventoryUpdaterHostedService>();
services.AddSingleton<IHostedService, DynamicDnsHostedService>();
services.AddSingleton<IHostedService, TorServicesHostedService>();
services.AddSingleton<IHostedService, PaymentRequestStreamer>();

View file

@ -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

View file

@ -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<string, int> 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;
}
}
}

View file

@ -273,6 +273,12 @@
<textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea>
</div>
</div>
<div class="form-row">
<div class="col">
<label>Inventory (leave to not use inventory feature)</label>
<input type="number" step="1" class="js-product-inventory form-control mb-2" value="{inventory}"/>
</div>
</div>
</div>
</script>
}

View file

@ -277,6 +277,12 @@
<textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea>
</div>
</div>
<div class="form-row">
<div class="col">
<label>Inventory (leave blank to not use inventory feature)</label>
<input type="number" step="1" class="js-product-inventory form-control mb-2" value="{inventory}"/>
</div>
</div>
</div>
</script>

View file

@ -1,4 +1,5 @@
@using Microsoft.EntityFrameworkCore.Internal
@using Microsoft.EntityFrameworkCore.Storage
@model BTCPayServer.Models.AppViewModels.ContributeToCrowdfund
<form method="post">
@ -43,11 +44,29 @@
<p class="card-text overflow-hidden">@Safe.Raw(item.Description)</p>
</div>
@if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id))
@if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id) || item.Inventory.HasValue)
{
<div class="card-footer d-flex justify-content-between">
<span></span>
<span> @Model.ViewCrowdfundViewModel.PerkCount[item.Id] Contributors</span>
@switch (item.Inventory)
{
case null:
<span></span>
break;
case int i when i <= 0:
<span>Sold out</span>
break;
default:
<span>@item.Inventory left</span>
break;
}
@if (Model.ViewCrowdfundViewModel.PerkCount.ContainsKey(item.Id))
{
<span> @Model.ViewCrowdfundViewModel.PerkCount[item.Id] Contributors</span>
}
else
{
<span></span>
}
</div>
}
</div>

View file

@ -300,9 +300,11 @@
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-between" v-if="perk.sold">
<span ></span>
<span x >{{perk.sold}} Contributor{{perk.sold > 1? "s": ""}}</span>
<div class="card-footer d-flex justify-content-between" v-if="perk.sold || perk.inventory != null">
<span v-if="perk.inventory != null && perk.inventory > 0" class="text-center text-muted">{{perk.inventory}} left</span>
<span v-if="perk.inventory != null && perk.inventory <= 0" class="text-center text-muted">Sold out</span>
<span v-if="perk.sold" >{{perk.sold}} Contributor{{perk.sold > 1? "s": ""}}</span>
</div>
</form>
</div>

View file

@ -6,6 +6,7 @@
ViewData["Title"] = Model.Title;
Layout = null;
int[] CustomTipPercentages = Model.CustomTipPercentages;
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
}
<!DOCTYPE html>
@ -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;
}
</style>
@if (!string.IsNullOrEmpty(Model.EmbeddedCSS))
{
@ -55,7 +65,7 @@
</head>
<body class="h-100">
<script id="template-cart-item" type="text/template">
<script id="template-cart-item" type="text/template">
<tr data-id="{id}">
{image}
<td class="align-middle pr-0 pl-2"><b>{title}</b></td>
@ -67,7 +77,7 @@
<div class="input-group-prepend">
<a class="js-cart-item-minus btn btn-link px-2" href="#"><i class="fa fa-minus-circle fa-fw text-danger"></i></a>
</div>
<input class="js-cart-item-count form-control form-control-sm pull-left" type="text" name="count" placeholder="Qty" value="{count}" data-prev="{count}">
<input class="js-cart-item-count form-control form-control-sm pull-left" type="number" step="1" name="count" placeholder="Qty" max="{inventory}" value="{count}" data-prev="{count}">
<div class="input-group-append">
<a class="js-cart-item-plus btn btn-link px-2" href="#">
<i class="fa fa-plus-circle fa-fw text-success"></i>
@ -287,7 +297,7 @@
var image = item.Image;
var description = item.Description;
<div class="js-add-cart card my-2 card-wrapper" data-id="@index">
<div class="js-add-cart card my-2 card-wrapper" data-index="@index">
@if (!String.IsNullOrWhiteSpace(image))
{
@:<img class="card-img-top" src="@image" alt="Card image cap">
@ -302,6 +312,23 @@
<div class="card-footer pt-0 bg-transparent border-0">
<span class="text-muted small">@String.Format(Model.ButtonText, @item.Price.Formatted)</span>
@if (item.Inventory.HasValue)
{
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
{
<span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
}
</div>
</div>
}
@ -378,29 +405,51 @@
</div>
<div class="card-footer bg-transparent border-0">
@if (item.Custom)
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id" />
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">@Model.CurrencySymbol</span>
</div>
<input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount"
value="@item.Price.Value" placeholder="Amount">
<div class="input-group-append">
<button class="btn btn-primary" type="submit">@Model.CustomButtonText</button>
</div>
</div>
</form>
<div class="w-100">
@if (item.Custom)
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
<input type="hidden" name="choicekey" value="@item.Id"/>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">@Model.CurrencySymbol</span>
</div>
<input class="form-control" type="number" min="@item.Price.Value" step="@Model.Step" name="amount"
value="@item.Price.Value" placeholder="Amount">
<div class="input-group-append">
<button class="btn btn-primary" type="submit">@Model.CustomButtonText</button>
</div>
</div>
</form>
}
else
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@String.Format(Model.ButtonText, @item.Price.Formatted)
</button>
</form>
}
</div>
}
else
@if (item.Inventory.HasValue)
{
<form method="post" asp-controller="AppsPublic" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false">
<button type="submit" name="choiceKey" class="js-add-cart btn btn-primary" value="@item.Id">
@String.Format(Model.ButtonText, @item.Price.Formatted)
</button>
</form>
<div class="w-100 pt-2 text-center text-muted">
@if (item.Inventory > 0)
{
<span>@item.Inventory left</span>
}
else
{
<span>Sold out</span>
}
</div>
}else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
}
</div>
</div>
@ -425,6 +474,10 @@
</div>
</div>
</form>
@if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp</div>
}
</div>
</div>
}

View file

@ -52,8 +52,8 @@ $(document).ready(function(){
var $btn = $(event.target),
self = this;
id = $btn.closest('.card').data('id'),
item = srvModel.items[id],
index = $btn.closest('.card').data('index'),
item = srvModel.items[index],
items = cart.items;
// Is event catching disabled?
@ -68,10 +68,11 @@ $(document).ready(function(){
});
cart.addItem({
id: id,
id: item.id,
title: item.title,
price: item.price,
image: typeof item.image != 'underfined' ? item.image : null
image: typeof item.image != 'undefined' ? item.image : null,
inventory: item.inventory
});
cart.listItems();
}

View file

@ -150,7 +150,7 @@ Cart.prototype.addItem = function(item) {
// Add new item because it doesn't exist yet
if (!result.length) {
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image});
this.content.push({id: id, title: item.title, price: item.price, count: 0, image: item.image, inventory: item.inventory});
this.emptyCartToggle();
}
@ -159,21 +159,30 @@ Cart.prototype.addItem = function(item) {
}
Cart.prototype.incrementItem = function(id) {
var self = this;
var oldItemsCount = this.items;
this.items = 0; // Calculate total # of items from scratch just to make sure
this.content.filter(function(obj){
// Increment the item count
var result = true;
for (var i = 0; i < this.content.length; i++) {
var obj = this.content[i];
if (obj.id === id){
if(obj.inventory != null && obj.inventory <= obj.count){
result = false;
continue;
}
obj.count++;
delete(obj.disabled);
}
// Increment the total # of items
self.items += obj.count;
});
this.items += obj.count;
}
if(!result){
this.items = oldItemsCount;
}
this.updateAll();
return result;
}
// Disable cart item so it doesn't count towards total amount
@ -425,6 +434,7 @@ Cart.prototype.listItems = function() {
}) : '',
'title': this.escape(item.title),
'count': this.escape(item.count),
'inventory': this.escape(item.inventory < 0? 99999: item.inventory),
'price': this.escape(item.price.formatted)
});
list.push($(tableTemplate));
@ -499,14 +509,14 @@ Cart.prototype.listItems = function() {
// Increment item
$('.js-cart-item-plus').off().on('click', function(event){
event.preventDefault();
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val);
$val.data('prev', val);
self.resetTip();
self.incrementItem($(this).closest('tr').data('id'));
if(self.incrementItem($(this).closest('tr').data('id'))){
var $val = $(this).parents('.input-group').find('.js-cart-item-count'),
val = parseInt($val.val() || $val.data('prev')) + 1;
$val.val(val);
$val.data('prev', val);
self.resetTip();
}
});
// Decrement item
@ -625,15 +635,39 @@ Cart.prototype.saveLocalStorage = function() {
Cart.prototype.loadLocalStorage = function() {
this.content = $.parseJSON(localStorage.getItem(this.getStorageKey('cart'))) || [];
var self = this;
// Get number of cart items
for (var key in this.content) {
if (this.content.hasOwnProperty(key) && typeof this.content[key] != 'undefined' && this.content[key] != null) {
this.items += this.content[key].count;
// Delete the disabled flag if any
delete(this.content[key].disabled);
for (var i = this.content.length-1; i >= 0; i--) {
if (!this.content[i]) {
this.content.splice(i,1);
continue;
}
//check if the pos items still has the cached cart items
var matchedItem = srvModel.items.find(function(item){
return item.id === self.content[i].id;
});
if(!matchedItem){
//remove if no longer available
this.content.splice(i,1);
continue;
}else{
if(matchedItem.inventory != null && matchedItem.inventory <= 0){
//item is out of stock
this.content.splice(i,1);
}else if(matchedItem.inventory != null && matchedItem.inventory < this.content[i].count){
//not enough stock for original cart amount, reduce to available stock
this.content[i].count = matchedItem.inventory;
}
//update its stock
this.content[i].inventory = matchedItem.inventory;
}
this.items += this.content[i].count;
// Delete the disabled flag if any
delete(this.content[i].disabled);
}
this.discount = localStorage.getItem(this.getStorageKey('cartDiscount'));

View file

@ -38,7 +38,7 @@ addLoadEvent(function (ev) {
},
computed: {
canExpand: function(){
return !this.expanded && this.active && (this.perk.price.value || this.perk.custom)
return !this.expanded && this.active && (this.perk.price.value || this.perk.custom) && (this.perk.inventory==null || this.perk.inventory > 0)
}
},
methods: {

View file

@ -35,7 +35,8 @@ $(document).ready(function(){
var index = $('.js-product-index').val(),
description = $('.js-product-description').val(),
image = $('.js-product-image').val(),
custom = $('.js-product-custom').val();
custom = $('.js-product-custom').val(),
inventory = parseInt($('.js-product-inventory').val(), 10);
obj = {
id: products.escape($('.js-product-id').val()),
price: products.escape($('.js-product-price').val()),
@ -58,7 +59,9 @@ $(document).ready(function(){
if (!Boolean(index)) {
obj.id = products.escape(obj.title.toLowerCase() + ':');
}
if(inventory != null && !isNaN(inventory ))
obj.inventory = inventory;
products.saveItem(obj, index);
}
});
@ -66,4 +69,4 @@ $(document).ready(function(){
$('.js-product-add').click(function(){
products.itemContent();
});
});
});

View file

@ -9,8 +9,21 @@ function Products() {
}
Products.prototype.loadFromTemplate = function() {
var template = $('.js-product-template').val().trim(),
lines = template.split("\n\n");
var template = $('.js-product-template').val().trim();
var lines = [];
var items = template.split("\n");
for (var i = 0; i < items.length; i++) {
if(items[i] === ""){
continue;
}
if(items[i].startsWith(" ")){
lines[lines.length-1]+=items[i] + "\n";
}else{
lines.push(items[i] + "\n");
}
}
this.products = [];
@ -19,7 +32,7 @@ Products.prototype.loadFromTemplate = function() {
var line = lines[kl],
product = line.split("\n"),
id, price, title, description, image = null,
custom;
custom, inventory=null;
for (var kp in product) {
var productProperty = product[kp].trim();
@ -43,6 +56,9 @@ Products.prototype.loadFromTemplate = function() {
if (productProperty.indexOf('custom:') !== -1) {
custom = productProperty.replace('custom:', '').trim();
}
if (productProperty.indexOf('inventory:') !== -1) {
inventory = parseInt(productProperty.replace('inventory:', '').trim(),10);
}
}
if (price != null || title != null) {
@ -53,12 +69,13 @@ Products.prototype.loadFromTemplate = function() {
'price': price,
'image': image || null,
'description': description || null,
'custom': Boolean(custom)
'custom': Boolean(custom),
'inventory': isNaN(inventory)? null: inventory
});
}
}
}
};
Products.prototype.saveTemplate = function() {
var template = '';
@ -69,9 +86,10 @@ Products.prototype.saveTemplate = function() {
id = product.id,
title = product.title,
price = product.price? product.price : 0,
image = product.image
image = product.image,
description = product.description,
custom = product.custom;
custom = product.custom,
inventory = product.inventory;
template += id + '\n' +
' price: ' + parseFloat(price).noExponents() + '\n' +
@ -86,11 +104,14 @@ Products.prototype.saveTemplate = function() {
if (custom) {
template += ' custom: true\n';
}
if(inventory != null){
template+= ' inventory: ' + inventory + '\n';
}
template += '\n';
}
$('.js-product-template').val(template);
}
};
Products.prototype.showAll = function() {
var list = [];
@ -106,7 +127,7 @@ Products.prototype.showAll = function() {
}
$('.js-products').html(list);
}
};
// Load the template
Products.prototype.template = function($template, obj) {
@ -118,7 +139,7 @@ Products.prototype.template = function($template, obj) {
}
return template;
}
};
Products.prototype.saveItem = function(obj, index) {
// Edit product
@ -143,7 +164,7 @@ Products.prototype.removeItem = function(index) {
}
this.saveTemplate();
}
};
Products.prototype.itemContent = function(index) {
var product = null,
@ -162,11 +183,12 @@ Products.prototype.itemContent = function(index) {
'title': product != null ? this.escape(product.title) : '',
'description': product != null ? this.escape(product.description) : '',
'image': product != null ? this.escape(product.image) : '',
'inventory': product != null ? parseInt(this.escape(product.inventory),10) : '',
'custom': '<option value="true"' + (custom ? ' selected' : '') + '>Yes</option><option value="false"' + (!custom ? ' selected' : '') + '>No</option>'
});
$('#product-modal').find('.modal-body').html(template);
}
};
Products.prototype.modalEmpty = function() {
var $modal = $('#product-modal');