mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
New feature: Apps limited inventory (#961)
This commit is contained in:
parent
fefc45854e
commit
d99beb9811
20 changed files with 544 additions and 123 deletions
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
{
|
||||
|
|
|
@ -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 = ",")
|
||||
{
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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 >
|
||||
|
|
134
BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs
Normal file
134
BTCPayServer/HostedServices/AppInventoryUpdaterHostedService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"> </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"> </div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -425,6 +474,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@if (anyInventoryItems)
|
||||
{
|
||||
<div class="w-100 pt-2"> </div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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'));
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Reference in a new issue