Refactoring: Move AppItem to Client lib and use the class for item list (#6258)

* Refactoring: Move AppItem to Client lib and use the class for item list

This makes it available for the app, which would otherwise have to replicate the model. Also uses the proper class for the item/perk list of the app models.

* Remove unused app item payment methods property

* Do not ignore nullable values in JSON

* Revert to use Newtonsoft types
This commit is contained in:
d11n
2024-11-05 03:49:30 +01:00
committed by GitHub
parent 225264a283
commit ff79a31066
22 changed files with 219 additions and 245 deletions

View File

@@ -0,0 +1,35 @@
using System.Collections.Generic;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models;
public enum AppItemPriceType
{
Fixed,
Topup,
Minimum
}
public class AppItem
{
public string Id { get; set; }
public string Title { get; set; }
public bool Disabled { get; set; }
public string Description { get; set; }
public string[] Categories { get; set; }
public string Image { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public AppItemPriceType PriceType { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Price { get; set; }
public string BuyButtonText { get; set; }
public int? Inventory { get; set; }
[JsonExtensionData]
public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@@ -37,7 +37,7 @@ public abstract class CrowdfundBaseData : AppBaseData
public class CrowdfundAppData : CrowdfundBaseData public class CrowdfundAppData : CrowdfundBaseData
{ {
public object? Perks { get; set; } public AppItem[]? Perks { get; set; }
} }
public class CrowdfundAppRequest : CrowdfundBaseData, IAppRequest public class CrowdfundAppRequest : CrowdfundBaseData, IAppRequest

View File

@@ -1,8 +1,6 @@
#nullable enable #nullable enable
using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters; using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Client.Models; namespace BTCPayServer.Client.Models;
@@ -31,7 +29,7 @@ public abstract class PointOfSaleBaseData : AppBaseData
public class PointOfSaleAppData : PointOfSaleBaseData public class PointOfSaleAppData : PointOfSaleBaseData
{ {
public object? Items { get; set; } public AppItem[]? Items { get; set; }
} }
public class PointOfSaleAppRequest : PointOfSaleBaseData, IAppRequest public class PointOfSaleAppRequest : PointOfSaleBaseData, IAppRequest

View File

@@ -3,10 +3,9 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting; using BTCPayServer.Hosting;
using BTCPayServer.Lightning; using BTCPayServer.Lightning;
using BTCPayServer.Models.AppViewModels; using BTCPayServer.Models.AppViewModels;
@@ -25,7 +24,7 @@ using Newtonsoft.Json.Linq;
using OpenQA.Selenium; using OpenQA.Selenium;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using Xunit.Sdk; using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
using WalletSettingsViewModel = BTCPayServer.Models.StoreViewModels.WalletSettingsViewModel; using WalletSettingsViewModel = BTCPayServer.Models.StoreViewModels.WalletSettingsViewModel;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
@@ -759,39 +758,6 @@ noninventoryitem:
AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory); AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory);
}, 10000); }, 10000);
//test payment methods option
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
vmpos.Title = "hello";
vmpos.Currency = "BTC";
vmpos.Template = @"
btconly:
price: 1.0
title: good apple
payment_methods:
- BTC
normal:
price: 1.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "btconly").Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, choiceKey: "normal").Result);
invoices = user.BitPay.GetInvoices();
var normalInvoice = invoices.Single(invoice => invoice.ItemCode == "normal");
var btcOnlyInvoice = invoices.Single(invoice => invoice.ItemCode == "btconly");
Assert.Single(btcOnlyInvoice.CryptoInfo);
Assert.Equal("BTC",
btcOnlyInvoice.CryptoInfo.First().CryptoCode);
Assert.Equal("BTC-CHAIN",
btcOnlyInvoice.CryptoInfo.First().PaymentType);
Assert.Equal(2, normalInvoice.CryptoInfo.Length);
Assert.Contains(
normalInvoice.CryptoInfo,
s => "BTC-CHAIN" == s.PaymentType && new[] { "BTC", "LTC" }.Contains(
s.CryptoCode));
//test topup option //test topup option
vmpos.Template = @" vmpos.Template = @"
a: a:
@@ -821,13 +787,13 @@ g:
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>(); vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.DoesNotContain("custom", vmpos.Template); Assert.DoesNotContain("custom", vmpos.Template);
var items = AppService.Parse(vmpos.Template); var items = AppService.Parse(vmpos.Template);
Assert.Contains(items, item => item.Id == "a" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed); Assert.Contains(items, item => item.Id == "a" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed); Assert.Contains(items, item => item.Id == "b" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum); Assert.Contains(items, item => item.Id == "c" && item.PriceType == AppItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed); Assert.Contains(items, item => item.Id == "d" && item.PriceType == AppItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum); Assert.Contains(items, item => item.Id == "e" && item.PriceType == AppItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup); Assert.Contains(items, item => item.Id == "f" && item.PriceType == AppItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup); Assert.Contains(items, item => item.Id == "g" && item.PriceType == AppItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, choiceKey: "g").Result); .ViewPointOfSale(app.Id, PosViewType.Static, choiceKey: "g").Result);

View File

@@ -745,9 +745,9 @@ namespace BTCPayServer.Tests
await user.RegisterDerivationSchemeAsync("BTC"); await user.RegisterDerivationSchemeAsync("BTC");
var client = await user.CreateClient(); var client = await user.CreateClient();
var item1 = new ViewPointOfSaleViewModel.Item { Id = "item1", Title = "Item 1", Price = 1, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed }; var item1 = new AppItem { Id = "item1", Title = "Item 1", Price = 1, PriceType = AppItemPriceType.Fixed };
var item2 = new ViewPointOfSaleViewModel.Item { Id = "item2", Title = "Item 2", Price = 2, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed }; var item2 = new AppItem { Id = "item2", Title = "Item 2", Price = 2, PriceType = AppItemPriceType.Fixed };
var item3 = new ViewPointOfSaleViewModel.Item { Id = "item3", Title = "Item 3", Price = 3, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed }; var item3 = new AppItem { Id = "item3", Title = "Item 3", Price = 3, PriceType = AppItemPriceType.Fixed };
var posItems = AppService.SerializeTemplate([item1, item2, item3]); var posItems = AppService.SerializeTemplate([item1, item2, item3]);
var posApp = await client.CreatePointOfSaleApp(user.StoreId, new PointOfSaleAppRequest { AppName = "test pos", Template = posItems, }); var posApp = await client.CreatePointOfSaleApp(user.StoreId, new PointOfSaleAppRequest { AppName = "test pos", Template = posItems, });
var crowdfundApp = await client.CreateCrowdfundApp(user.StoreId, new CrowdfundAppRequest { AppName = "test crowdfund" }); var crowdfundApp = await client.CreateCrowdfundApp(user.StoreId, new CrowdfundAppRequest { AppName = "test crowdfund" });

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers; using BTCPayServer.Controllers;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Hosting; using BTCPayServer.Hosting;
@@ -14,6 +15,7 @@ using Microsoft.AspNetCore.Mvc;
using Xunit; using Xunit;
using Xunit.Abstractions; using Xunit.Abstractions;
using static BTCPayServer.Tests.UnitTest1; using static BTCPayServer.Tests.UnitTest1;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Tests namespace BTCPayServer.Tests
{ {
@@ -76,9 +78,8 @@ fruit tea:
Assert.Null( parsedDefault[0].BuyButtonText); Assert.Null( parsedDefault[0].BuyButtonText);
Assert.Equal( "~/img/pos-sample/green-tea.jpg" ,parsedDefault[0].Image); Assert.Equal( "~/img/pos-sample/green-tea.jpg" ,parsedDefault[0].Image);
Assert.Equal( 1 ,parsedDefault[0].Price); Assert.Equal( 1 ,parsedDefault[0].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Fixed ,parsedDefault[0].PriceType); Assert.Equal( AppItemPriceType.Fixed ,parsedDefault[0].PriceType);
Assert.Null( parsedDefault[0].AdditionalData); Assert.Null( parsedDefault[0].AdditionalData);
Assert.Null( parsedDefault[0].PaymentMethods);
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title); Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
@@ -87,9 +88,8 @@ fruit tea:
Assert.Null( parsedDefault[4].BuyButtonText); Assert.Null( parsedDefault[4].BuyButtonText);
Assert.Equal( "~/img/pos-sample/herbal-tea.jpg" ,parsedDefault[4].Image); Assert.Equal( "~/img/pos-sample/herbal-tea.jpg" ,parsedDefault[4].Image);
Assert.Equal( 1.8m ,parsedDefault[4].Price); Assert.Equal( 1.8m ,parsedDefault[4].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Minimum ,parsedDefault[4].PriceType); Assert.Equal( AppItemPriceType.Minimum ,parsedDefault[4].PriceType);
Assert.Null( parsedDefault[4].AdditionalData); Assert.Null( parsedDefault[4].AdditionalData);
Assert.Null( parsedDefault[4].PaymentMethods);
} }
[Fact] [Fact]

View File

@@ -355,6 +355,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
var settings = appData.GetSettings<PointOfSaleSettings>(); var settings = appData.GetSettings<PointOfSaleSettings>();
Enum.TryParse<PosViewType>(settings.DefaultView.ToString(), true, out var defaultView); Enum.TryParse<PosViewType>(settings.DefaultView.ToString(), true, out var defaultView);
var items = AppService.Parse(settings.Template);
return new PointOfSaleAppData return new PointOfSaleAppData
{ {
@@ -382,16 +383,7 @@ namespace BTCPayServer.Controllers.Greenfield
RedirectUrl = settings.RedirectUrl, RedirectUrl = settings.RedirectUrl,
Description = settings.Description, Description = settings.Description,
RedirectAutomatically = settings.RedirectAutomatically, RedirectAutomatically = settings.RedirectAutomatically,
Items = JsonConvert.DeserializeObject( Items = items
JsonConvert.SerializeObject(
AppService.Parse(settings.Template),
new JsonSerializerSettings
{
ContractResolver =
new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
}
)
)
}; };
} }
@@ -420,6 +412,7 @@ namespace BTCPayServer.Controllers.Greenfield
{ {
var settings = appData.GetSettings<CrowdfundSettings>(); var settings = appData.GetSettings<CrowdfundSettings>();
Enum.TryParse<CrowdfundResetEvery>(settings.ResetEvery.ToString(), true, out var resetEvery); Enum.TryParse<CrowdfundResetEvery>(settings.ResetEvery.ToString(), true, out var resetEvery);
var perks = AppService.Parse(settings.PerksTemplate);
return new CrowdfundAppData return new CrowdfundAppData
{ {
@@ -451,15 +444,7 @@ namespace BTCPayServer.Controllers.Greenfield
SortPerksByPopularity = settings.SortPerksByPopularity, SortPerksByPopularity = settings.SortPerksByPopularity,
Sounds = settings.Sounds, Sounds = settings.Sounds,
AnimationColors = settings.AnimationColors, AnimationColors = settings.AnimationColors,
Perks = JsonConvert.DeserializeObject( Perks = perks
JsonConvert.SerializeObject(
AppService.Parse(settings.PerksTemplate),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
}
)
)
}; };
} }

View File

@@ -26,7 +26,6 @@ using BTCPayServer.Payouts;
using BTCPayServer.Plugins; using BTCPayServer.Plugins;
using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@@ -290,7 +289,7 @@ namespace BTCPayServer
return NotFound(); return NotFound();
} }
ViewPointOfSaleViewModel.Item[] items; AppItem[] items;
string currencyCode; string currencyCode;
PointOfSaleSettings posS = null; PointOfSaleSettings posS = null;
switch (app.AppType) switch (app.AppType)
@@ -310,7 +309,7 @@ namespace BTCPayServer
return NotFound(); return NotFound();
} }
ViewPointOfSaleViewModel.Item item = null; AppItem item = null;
if (!string.IsNullOrEmpty(itemCode)) if (!string.IsNullOrEmpty(itemCode))
{ {
var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _); var pmi = GetLNUrlPaymentMethodId(cryptoCode, store, out _);
@@ -321,14 +320,9 @@ namespace BTCPayServer
item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) || item1.Id.Equals(itemCode, StringComparison.InvariantCultureIgnoreCase) ||
item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase)); item1.Id.Equals(escapedItemId, StringComparison.InvariantCultureIgnoreCase));
if (item is null || if (item is null || item.Inventory <= 0)
item.Inventory <= 0 ||
(item.PaymentMethods?.Any() is true &&
item.PaymentMethods?.Any(s => PaymentMethodId.Parse(s) == pmi) is false))
{
return NotFound(); return NotFound();
} }
}
else if (app.AppType == PointOfSaleAppType.AppType && posS?.ShowCustomAmount is not true) else if (app.AppType == PointOfSaleAppType.AppType && posS?.ShowCustomAmount is not true)
{ {
return NotFound(); return NotFound();
@@ -336,7 +330,7 @@ namespace BTCPayServer
var createInvoice = new CreateInvoiceRequest var createInvoice = new CreateInvoiceRequest
{ {
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? null : item?.Price, Amount = item?.PriceType == AppItemPriceType.Topup ? null : item?.Price,
Currency = currencyCode, Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions Checkout = new InvoiceDataBase.CheckoutOptions
{ {
@@ -350,7 +344,7 @@ namespace BTCPayServer
AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) } AdditionalSearchTerms = new[] { AppService.GetAppSearchTerm(app) }
}; };
var allowOverpay = item?.PriceType is not ViewPointOfSaleViewModel.ItemPriceType.Fixed; var allowOverpay = item?.PriceType is not AppItemPriceType.Fixed;
var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() }; var invoiceMetadata = new InvoiceMetadata { OrderId = AppService.GetRandomOrderId() };
if (item != null) if (item != null)
{ {

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Abstractions.Contracts; using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration; using BTCPayServer.Configuration;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Fido2; using BTCPayServer.Fido2;
@@ -12,11 +13,9 @@ using BTCPayServer.Fido2.Models;
using BTCPayServer.Payments; using BTCPayServer.Payments;
using BTCPayServer.Payments.Bitcoin; using BTCPayServer.Payments.Bitcoin;
using BTCPayServer.Payments.Lightning; using BTCPayServer.Payments.Lightning;
using BTCPayServer.PayoutProcessors;
using BTCPayServer.Payouts; using BTCPayServer.Payouts;
using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@@ -398,9 +397,10 @@ namespace BTCPayServer.Hosting
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
} }
public static ViewPointOfSaleViewModel.Item[] ParsePOSYML(string yaml)
public static AppItem[] ParsePOSYML(string yaml)
{ {
var items = new List<ViewPointOfSaleViewModel.Item>(); var items = new List<AppItem>();
var stream = new YamlStream(); var stream = new YamlStream();
if (string.IsNullOrEmpty(yaml)) if (string.IsNullOrEmpty(yaml))
return items.ToArray(); return items.ToArray();
@@ -417,11 +417,11 @@ namespace BTCPayServer.Hosting
continue; continue;
} }
var currentItem = new ViewPointOfSaleViewModel.Item var currentItem = new AppItem
{ {
Id = trimmedKey, Id = trimmedKey,
Title = trimmedKey, Title = trimmedKey,
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed PriceType = AppItemPriceType.Fixed
}; };
var itemSpecs = (YamlMappingNode)posItem.Value; var itemSpecs = (YamlMappingNode)posItem.Value;
foreach (var spec in itemSpecs) foreach (var spec in itemSpecs)
@@ -446,12 +446,6 @@ namespace BTCPayServer.Hosting
case "image": case "image":
currentItem.Image = scalarValue?.Value; currentItem.Image = scalarValue?.Value;
break; break;
case "payment_methods" when spec.Value is YamlSequenceNode pmSequenceNode:
currentItem.PaymentMethods = pmSequenceNode.Children
.Select(node => (node as YamlScalarNode)?.Value?.Trim())
.Where(node => !string.IsNullOrEmpty(node)).ToArray();
break;
case "price_type": case "price_type":
case "custom": case "custom":
if (bool.TryParse(scalarValue?.Value, out var customBoolValue)) if (bool.TryParse(scalarValue?.Value, out var customBoolValue))
@@ -459,15 +453,15 @@ namespace BTCPayServer.Hosting
if (customBoolValue) if (customBoolValue)
{ {
currentItem.PriceType = currentItem.Price is null or 0 currentItem.PriceType = currentItem.Price is null or 0
? ViewPointOfSaleViewModel.ItemPriceType.Topup ? AppItemPriceType.Topup
: ViewPointOfSaleViewModel.ItemPriceType.Minimum; : AppItemPriceType.Minimum;
} }
else else
{ {
currentItem.PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed; currentItem.PriceType = AppItemPriceType.Fixed;
} }
} }
else if (Enum.TryParse<ViewPointOfSaleViewModel.ItemPriceType>(scalarValue?.Value, true, else if (Enum.TryParse<AppItemPriceType>(scalarValue?.Value, true,
out var customPriceType)) out var customPriceType))
{ {
currentItem.PriceType = customPriceType; currentItem.PriceType = customPriceType;

View File

@@ -17,7 +17,6 @@ using BTCPayServer.Forms;
using BTCPayServer.Forms.Models; using BTCPayServer.Forms.Models;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Plugins.Crowdfund.Models; using BTCPayServer.Plugins.Crowdfund.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services; using BTCPayServer.Services;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
@@ -149,38 +148,30 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
decimal? price = request.Amount; decimal? price = request.Amount;
var title = settings.Title; var title = settings.Title;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null; Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(request.ChoiceKey)) if (!string.IsNullOrEmpty(request.ChoiceKey))
{ {
var choices = AppService.Parse(settings.PerksTemplate, false); var choices = AppService.Parse(settings.PerksTemplate, false);
choice = choices?.FirstOrDefault(c => c.Id == request.ChoiceKey); AppItem choice = choices.FirstOrDefault(c => c.Id == request.ChoiceKey);
if (choice == null) if (choice == null)
return NotFound("Incorrect option provided"); return NotFound("Incorrect option provided");
title = choice.Title; title = choice.Title;
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) if (choice.PriceType == AppItemPriceType.Topup)
{ {
price = null; price = null;
} }
else else
{ {
if (choice.Price.HasValue)
price = choice.Price.Value; price = choice.Price.Value;
if (request.Amount > price) if (request.Amount > price)
price = request.Amount; price = request.Amount;
} }
if (choice.Inventory.HasValue) if (choice.Inventory is <= 0)
{
if (choice.Inventory <= 0)
{ {
return NotFound("Option was out of stock"); return NotFound("Option was out of stock");
} }
} }
if (choice?.PaymentMethods?.Any() is true)
{
paymentMethods = choice?.PaymentMethods.ToDictionary(s => s,
s => new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
}
else else
{ {
if (request.Amount < 0) if (request.Amount < 0)
@@ -231,8 +222,6 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
} }
} }
if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price > if (!isAdmin && (settings.EnforceTargetAmount && info.TargetAmount.HasValue && price >
(info.TargetAmount - (info.Info.CurrentAmount + info.Info.CurrentPendingAmount)))) (info.TargetAmount - (info.Info.CurrentAmount + info.Info.CurrentPendingAmount))))
{ {

View File

@@ -1,8 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using BTCPayServer.Client.Models;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
namespace BTCPayServer.Plugins.Crowdfund.Models namespace BTCPayServer.Plugins.Crowdfund.Models
@@ -26,7 +26,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Models
public CrowdfundInfo Info { get; set; } public CrowdfundInfo Info { get; set; }
public string Tagline { get; set; } public string Tagline { get; set; }
public StoreBrandingViewModel StoreBranding { get; set; } public StoreBrandingViewModel StoreBranding { get; set; }
public ViewPointOfSaleViewModel.Item[] Perks { get; set; } public AppItem[] Perks { get; set; }
public bool SimpleDisplay { get; set; } public bool SimpleDisplay { get; set; }
public bool DisqusEnabled { get; set; } public bool DisqusEnabled { get; set; }
public bool SoundsEnabled { get; set; } public bool SoundsEnabled { get; set; }

View File

@@ -176,9 +176,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string title; string title;
decimal? price; decimal? price;
Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null; Dictionary<string, InvoiceSupportedTransactionCurrency> paymentMethods = null;
ViewPointOfSaleViewModel.Item choice = null; AppItem choice = null;
List<PosCartItem> cartItems = null; List<PosCartItem> cartItems = null;
ViewPointOfSaleViewModel.Item[] choices = null; AppItem[] choices = null;
if (!string.IsNullOrEmpty(choiceKey)) if (!string.IsNullOrEmpty(choiceKey))
{ {
choices = AppService.Parse(settings.Template, false); choices = AppService.Parse(settings.Template, false);
@@ -186,7 +186,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (choice == null) if (choice == null)
return NotFound(); return NotFound();
title = choice.Title; title = choice.Title;
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) if (choice.PriceType == AppItemPriceType.Topup)
{ {
price = null; price = null;
} }
@@ -201,12 +201,6 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
{ {
return RedirectToAction(nameof(ViewPointOfSale), new { appId }); return RedirectToAction(nameof(ViewPointOfSale), new { appId });
} }
if (choice?.PaymentMethods?.Any() is true)
{
paymentMethods = choice?.PaymentMethods.ToDictionary(s => s,
s => new InvoiceSupportedTransactionCurrency() { Enabled = true });
}
} }
else else
{ {
@@ -239,7 +233,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
} }
var expectedCartItemPrice = itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup var expectedCartItemPrice = itemChoice.PriceType != AppItemPriceType.Topup
? itemChoice.Price ?? 0 ? itemChoice.Price ?? 0
: 0; : 0;
@@ -373,7 +367,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol); var singlePrice = _displayFormatter.Currency(cartItem.Price, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol); var totalPrice = _displayFormatter.Currency(cartItem.Price * cartItem.Count, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol);
var ident = selectedChoice.Title ?? selectedChoice.Id; var ident = selectedChoice.Title ?? selectedChoice.Id;
var key = selectedChoice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})"; var key = selectedChoice.PriceType == AppItemPriceType.Fixed ? ident : $"{ident} ({singlePrice})";
cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}"); cartData.Add(key, $"{cartItem.Count} x {singlePrice} = {totalPrice}");
} }

View File

@@ -2,52 +2,14 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using BTCPayServer.JsonConverters; using BTCPayServer.Client.Models;
using BTCPayServer.Models; using BTCPayServer.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.PointOfSale.Models namespace BTCPayServer.Plugins.PointOfSale.Models
{ {
public class ViewPointOfSaleViewModel public class ViewPointOfSaleViewModel
{ {
public enum ItemPriceType
{
Topup,
Minimum,
Fixed
}
public class Item
{
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Description { get; set; }
public string Id { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string[] Categories { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Image { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public ItemPriceType PriceType { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Price { get; set; }
public string Title { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string BuyButtonText { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int? Inventory { get; set; } = null;
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string[] PaymentMethods { get; set; }
public bool Disabled { get; set; } = false;
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}
public class CurrencyInfoData public class CurrencyInfoData
{ {
public bool Prefixed { get; set; } public bool Prefixed { get; set; }
@@ -70,8 +32,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public bool EnableTips { get; set; } public bool EnableTips { get; set; }
public string Step { get; set; } public string Step { get; set; }
public string Title { get; set; } public string Title { get; set; }
Item[] _Items; AppItem[] _Items;
public Item[] Items public AppItem[] Items
{ {
get get
{ {

View File

@@ -10,7 +10,6 @@ using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels; using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.Crowdfund; using BTCPayServer.Plugins.Crowdfund;
using BTCPayServer.Plugins.PointOfSale; using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Invoices; using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates; using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
@@ -95,7 +94,7 @@ namespace BTCPayServer.Services.Apps
return await salesType.GetItemStats(appData, paidInvoices); return await salesType.GetItemStats(appData, paidInvoices);
} }
public static Task<AppSalesStats> GetSalesStatswithPOSItems(ViewPointOfSaleViewModel.Item[] items, public static Task<AppSalesStats> GetSalesStatswithPOSItems(AppItem[] items,
InvoiceEntity[] paidInvoices, int numberOfDays) InvoiceEntity[] paidInvoices, int numberOfDays)
{ {
var series = paidInvoices var series = paidInvoices
@@ -145,7 +144,7 @@ namespace BTCPayServer.Services.Apps
public DateTime Date { get; set; } public DateTime Date { get; set; }
} }
public static Func<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> AggregateInvoiceEntitiesForStats(ViewPointOfSaleViewModel.Item[] items) public static Func<List<InvoiceStatsItem>, InvoiceEntity, List<InvoiceStatsItem>> AggregateInvoiceEntitiesForStats(AppItem[] items)
{ {
return (res, e) => return (res, e) =>
{ {
@@ -344,14 +343,14 @@ namespace BTCPayServer.Services.Apps
return _storeRepository.FindStore(app.StoreDataId); return _storeRepository.FindStore(app.StoreDataId);
} }
public static string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items) public static string SerializeTemplate(AppItem[] items)
{ {
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer); return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
} }
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true, bool throws = false) public static AppItem[] Parse(string template, bool includeDisabled = true, bool throws = false)
{ {
if (string.IsNullOrWhiteSpace(template)) return []; if (string.IsNullOrWhiteSpace(template)) return [];
var allItems = JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!; var allItems = JsonConvert.DeserializeObject<AppItem[]>(template, _defaultSerializer)!;
// ensure all items have an id, which is also unique // ensure all items have an id, which is also unique
var itemsWithoutId = allItems.Where(i => string.IsNullOrEmpty(i.Id)).ToList(); var itemsWithoutId = allItems.Where(i => string.IsNullOrEmpty(i.Id)).ToList();
if (itemsWithoutId.Any() && throws) throw new ArgumentException($"Missing ID for item \"{itemsWithoutId.First().Title}\"."); if (itemsWithoutId.Any() && throws) throw new ArgumentException($"Missing ID for item \"{itemsWithoutId.First().Title}\".");

View File

@@ -1,4 +1,4 @@
using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Client.Models;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType; using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps namespace BTCPayServer.Services.Apps
@@ -8,71 +8,70 @@ namespace BTCPayServer.Services.Apps
public PointOfSaleSettings() public PointOfSaleSettings()
{ {
Title = "Tea shop"; Title = "Tea shop";
Template = AppService.SerializeTemplate(new ViewPointOfSaleViewModel.Item[] Template = AppService.SerializeTemplate([
{ new AppItem
new()
{ {
Id = "green-tea", Id = "green-tea",
Title = "Green Tea", Title = "Green Tea",
Description = Description =
"Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.", "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
Image = "~/img/pos-sample/green-tea.jpg", Image = "~/img/pos-sample/green-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed, PriceType = AppItemPriceType.Fixed,
Price = 1 Price = 1
}, },
new() new AppItem
{ {
Id = "black-tea", Id = "black-tea",
Title = "Black Tea", Title = "Black Tea",
Description = Description =
"Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.", "Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
Image = "~/img/pos-sample/black-tea.jpg", Image = "~/img/pos-sample/black-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed, PriceType = AppItemPriceType.Fixed,
Price = 1 Price = 1
}, },
new() new AppItem
{ {
Id = "rooibos", Id = "rooibos",
Title = "Rooibos (limited)", Title = "Rooibos (limited)",
Description = Description =
"Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.", "Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.",
Image = "~/img/pos-sample/rooibos.jpg", Image = "~/img/pos-sample/rooibos.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed, PriceType = AppItemPriceType.Fixed,
Price = 1.2m, Price = 1.2m,
Inventory = 5, Inventory = 5,
}, },
new() new AppItem
{ {
Id = "pu-erh", Id = "pu-erh",
Title = "Pu Erh (free)", Title = "Pu Erh (free)",
Description = Description =
"This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.", "This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.",
Image = "~/img/pos-sample/pu-erh.jpg", Image = "~/img/pos-sample/pu-erh.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed, PriceType = AppItemPriceType.Fixed,
Price = 0 Price = 0
}, },
new() new AppItem
{ {
Id = "herbal-tea", Id = "herbal-tea",
Title = "Herbal Tea (minimum)", Title = "Herbal Tea (minimum)",
Description = Description =
"Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!", "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!",
Image = "~/img/pos-sample/herbal-tea.jpg", Image = "~/img/pos-sample/herbal-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Minimum, PriceType = AppItemPriceType.Minimum,
Price = 1.8m, Price = 1.8m,
Disabled = false Disabled = false
}, },
new() new AppItem
{ {
Id = "fruit-tea", Id = "fruit-tea",
Title = "Fruit Tea (any amount)", Title = "Fruit Tea (any amount)",
Description = 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!", "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!",
Image = "~/img/pos-sample/fruit-tea.jpg", Image = "~/img/pos-sample/fruit-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup, PriceType = AppItemPriceType.Topup,
Disabled = false Disabled = false
} }
}); ]);
DefaultView = PosViewType.Static; DefaultView = PosViewType.Static;
ShowCustomAmount = false; ShowCustomAmount = false;
ShowDiscount = false; ShowDiscount = false;

View File

@@ -1,6 +1,5 @@
using System; using System;
using System.Globalization; using System.Globalization;
using BTCPayServer.Plugins.PointOfSale.Models;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;

View File

@@ -1,4 +1,5 @@
@using BTCPayServer.Plugins.PointOfSale.Models @using BTCPayServer.Client.Models
@using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.Crowdfund.Models.ContributeToCrowdfund @model BTCPayServer.Plugins.Crowdfund.Models.ContributeToCrowdfund
@{ var vm = Model.ViewCrowdfundViewModel; } @{ var vm = Model.ViewCrowdfundViewModel; }
@@ -32,16 +33,16 @@
<span>@item.Price.Value</span> <span>@item.Price.Value</span>
<span>@vm.TargetCurrency</span> <span>@vm.TargetCurrency</span>
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum) if (item.PriceType == AppItemPriceType.Minimum)
{ {
@Safe.Raw(StringLocalizer["or more"]) @Safe.Raw(StringLocalizer["or more"])
} }
} }
else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ) else if (item.PriceType == AppItemPriceType.Topup)
{ {
@Safe.Raw(StringLocalizer["Any amount"]) @Safe.Raw(StringLocalizer["Any amount"])
} }
else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed) else if (item.PriceType == AppItemPriceType.Fixed)
{ {
@Safe.Raw(StringLocalizer["Free"]) @Safe.Raw(StringLocalizer["Free"])
} }

View File

@@ -1,9 +1,9 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services @using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq @using Newtonsoft.Json.Linq
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.Abstractions.TagHelpers @using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp @inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@@ -21,12 +21,12 @@
<script src="~/pos/cart.js" asp-append-version="true"></script> <script src="~/pos/cart.js" asp-append-version="true"></script>
} }
@functions { @functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item) private string GetItemPriceFormatted(AppItem item)
{ {
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount"; if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free"; if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol); var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted; return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
} }
} }
@@ -73,7 +73,7 @@
var formatted = GetItemPriceFormatted(item); var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0; var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText ? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText; : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted); buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
var categories = new JArray(item.Categories ?? new object[] { }); var categories = new JArray(item.Categories ?? new object[] { });
@@ -86,7 +86,7 @@
<div class="card-body d-flex flex-column gap-2 mb-auto"> <div class="card-body d-flex flex-column gap-2 mb-auto">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5> <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0) @if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{ {
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span> <span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
} }
@@ -116,7 +116,7 @@
@if (inStock) @if (inStock)
{ {
<form class="card-footer"> <form class="card-footer">
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed) @if (item.PriceType != AppItemPriceType.Fixed)
{ {
<div class="input-group mb-2"> <div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span> <span class="input-group-text">@Model.CurrencySymbol</span>

View File

@@ -1,15 +1,17 @@
@using BTCPayServer.Abstractions.TagHelpers
@using BTCPayServer.Client.Models
@using BTCPayServer.Components.QRCode
@using BTCPayServer.Payments @using BTCPayServer.Payments
@using BTCPayServer.Payments.Lightning @using BTCPayServer.Payments.Lightning
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services @using BTCPayServer.Services
@using BTCPayServer.Services.Invoices @using BTCPayServer.Services.Invoices
@using BTCPayServer.Services.Stores @using BTCPayServer.Services.Stores
@using LNURL @using LNURL
@inject BTCPayNetworkProvider BTCPayNetworkProvider @using Microsoft.AspNetCore.Mvc.TagHelpers
@inject StoreRepository StoreRepository @inject StoreRepository StoreRepository
@inject PaymentMethodHandlerDictionary Handlers @inject PaymentMethodHandlerDictionary Handlers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{ @{
var store = await StoreRepository.FindStore(Model.StoreId); var store = await StoreRepository.FindStore(Model.StoreId);
Layout = "PointOfSale/Public/_Layout"; Layout = "PointOfSale/Public/_Layout";
@@ -58,16 +60,15 @@ else
{ {
if (Model.ShowCustomAmount) if (Model.ShowCustomAmount)
{ {
Model.Items = Model.Items.Concat(new[] Model.Items = Model.Items.Concat([
{ new AppItem
new ViewPointOfSaleViewModel.Item()
{ {
Description = "Create invoice to pay custom amount", Description = "Create invoice to pay custom amount",
Title = "Custom amount", Title = "Custom amount",
BuyButtonText = Model.CustomButtonText, BuyButtonText = Model.CustomButtonText,
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup PriceType = AppItemPriceType.Topup
} }
}).ToArray(); ]).ToArray();
} }
} }
@@ -76,7 +77,7 @@ else
{ {
var item = Model.Items[x]; var item = Model.Items[x];
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol); var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && item.Price == 0) continue; if (item.PriceType == AppItemPriceType.Fixed && item.Price == 0) continue;
<div class="d-flex flex-wrap"> <div class="d-flex flex-wrap">
<div class="tile card w-100" data-id="@x"> <div class="tile card w-100" data-id="@x">
<div class="card-body pt-0 d-flex flex-column gap-2"> <div class="card-body pt-0 d-flex flex-column gap-2">
@@ -85,13 +86,13 @@ else
<span class="fw-semibold"> <span class="fw-semibold">
@switch (item.PriceType) @switch (item.PriceType)
{ {
case ViewPointOfSaleViewModel.ItemPriceType.Topup: case AppItemPriceType.Topup:
<span>Any amount</span> <span>Any amount</span>
break; break;
case ViewPointOfSaleViewModel.ItemPriceType.Minimum: case AppItemPriceType.Minimum:
<span>@formatted minimum</span> <span>@formatted minimum</span>
break; break;
case ViewPointOfSaleViewModel.ItemPriceType.Fixed: case AppItemPriceType.Fixed:
@formatted @formatted
break; break;
default: default:

View File

@@ -1,18 +1,18 @@
@using BTCPayServer.Plugins.PointOfSale.Models @using BTCPayServer.Client.Models
@using BTCPayServer.Services @using BTCPayServer.Services
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@{ @{
Layout = "PointOfSale/Public/_Layout"; Layout = "PointOfSale/Public/_Layout";
} }
@functions { @functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item) private string GetItemPriceFormatted(AppItem item)
{ {
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount"; if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free"; if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol); var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted; return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
} }
} }
@@ -31,7 +31,7 @@
var formatted = GetItemPriceFormatted(item); var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0; var inStock = item.Inventory is null or > 0;
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) var buttonText = string.IsNullOrEmpty(item.BuyButtonText)
? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText ? item.PriceType == AppItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText
: item.BuyButtonText; : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted); buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
@@ -44,7 +44,7 @@
<div class="card-body d-flex flex-column gap-2 mb-auto"> <div class="card-body d-flex flex-column gap-2 mb-auto">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5> <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0) @if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{ {
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span> <span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
} }
@@ -76,7 +76,7 @@
{ {
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off"> <form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" autocomplete="off">
<input type="hidden" name="choiceKey" value="@item.Id" /> <input type="hidden" name="choiceKey" value="@item.Id" />
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum) @if (item.PriceType == AppItemPriceType.Minimum)
{ {
<div class="input-group mb-2"> <div class="input-group mb-2">
<span class="input-group-text">@Model.CurrencySymbol</span> <span class="input-group-text">@Model.CurrencySymbol</span>

View File

@@ -3,15 +3,16 @@
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using Newtonsoft.Json.Linq @using Newtonsoft.Json.Linq
@using BTCPayServer.Client @using BTCPayServer.Client
@using BTCPayServer.Client.Models
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@functions { @functions {
private string GetItemPriceFormatted(ViewPointOfSaleViewModel.Item item) private string GetItemPriceFormatted(AppItem item)
{ {
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup) return "any amount"; if (item.PriceType == AppItemPriceType.Topup) return "any amount";
if (item.Price == 0) return "free"; if (item.Price == 0) return "free";
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol); var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
return item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum ? $"{formatted} minimum" : formatted; return item.PriceType == AppItemPriceType.Minimum ? $"{formatted} minimum" : formatted;
} }
} }
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak> <form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 my-auto" v-cloak>
@@ -115,7 +116,7 @@
var item = Model.Items[index]; var item = Model.Items[index];
var formatted = GetItemPriceFormatted(item); var formatted = GetItemPriceFormatted(item);
var inStock = item.Inventory is null or > 0; var inStock = item.Inventory is null or > 0;
var displayed = item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && inStock ? "true" : "false"; var displayed = item.PriceType == AppItemPriceType.Fixed && inStock ? "true" : "false";
var categories = new JArray(item.Categories ?? new object[] { }); var categories = new JArray(item.Categories ?? new object[] { });
<div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed"> <div class="posItem p-3" :class="{ 'posItem--inStock': inStock(@index), 'posItem--displayed': @displayed }" data-index="@index" data-search="@Safe.RawEncode(item.Title + " " + item.Description)" data-categories='@Safe.Json(categories)' v-show="@displayed">
<div class="d-flex align-items-start w-100 gap-3"> <div class="d-flex align-items-start w-100 gap-3">
@@ -128,7 +129,7 @@
<div class="d-flex flex-column gap-2"> <div class="d-flex flex-column gap-2">
<h5 class="card-title m-0">@Safe.Raw(item.Title)</h5> <h5 class="card-title m-0">@Safe.Raw(item.Title)</h5>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
@if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup || item.Price == 0) @if (item.PriceType == AppItemPriceType.Topup || item.Price == 0)
{ {
<span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span> <span class="fw-semibold badge text-bg-info">@Safe.Raw(char.ToUpper(formatted[0]) + formatted[1..])</span>
} }

View File

@@ -747,34 +747,31 @@
"type": "object", "type": "object",
"properties": { "properties": {
"items": { "items": {
"type": "object", "type": "array",
"items": {
"$ref": "#/components/schemas/AppItem"
},
"description": "JSON object of app items", "description": "JSON object of app items",
"example": [ "example": [
{ {
"description": "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
"id": "green tea", "id": "green tea",
"image": "~/img/pos-sample/green-tea.jpg",
"price": {
"type": 2,
"formatted": "$1.00",
"value": 1.0
},
"title": "Green Tea", "title": "Green Tea",
"description": "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
"image": "~/img/pos-sample/green-tea.jpg",
"price": "1.0",
"priceType": "Fixed",
"buyButtonText": null, "buyButtonText": null,
"inventory": 5, "inventory": 5,
"paymentMethods": null, "paymentMethods": null,
"disabled": false "disabled": false
}, },
{ {
"description": "Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
"id": "black tea", "id": "black tea",
"image": "~/img/pos-sample/black-tea.jpg",
"price": {
"type": 2,
"formatted": "$1.00",
"value": 1.0
},
"title": "Black Tea", "title": "Black Tea",
"description": "Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
"image": "~/img/pos-sample/black-tea.jpg",
"price": "2.0",
"priceType": "Fixed",
"buyButtonText": "Test Buy Button Text", "buyButtonText": "Test Buy Button Text",
"inventory": null, "inventory": null,
"paymentMethods": null, "paymentMethods": null,
@@ -1012,6 +1009,66 @@
} }
] ]
}, },
"AppItem": {
"type": "object",
"properties": {
"id": {
"type": "string",
"example": "green-tea",
"description": "Unique ID of the item"
},
"title": {
"type": "string",
"example": "Green Tea",
"description": "The display name of the item"
},
"description": {
"type": "string",
"example": "Lovely, fresh and tender.",
"description": "A description text for the item"
},
"image": {
"type": "string",
"example": "http://teashop.com/img/green-tea.jpg",
"description": "An image URL for the item"
},
"price": {
"type": "string",
"format": "decimal",
"nullable": true,
"example": "21.0"
},
"priceType": {
"type": "string",
"x-enumNames": [
"Fixed",
"Topup",
"Minimum"
],
"enum": [
"Fixed",
"Topup",
"Minimum"
]
},
"buyButtonText": {
"type": "string",
"example": "Buy me!",
"description": "A custom text for the buy button for the item"
},
"inventory": {
"type": "integer",
"nullable": true,
"example": 21,
"description": "The remaining stock the item"
},
"disabled": {
"type": "boolean",
"description": "If true, the item does not appear in the list by default.",
"default": false
}
}
},
"AppSalesStats": { "AppSalesStats": {
"type": "object", "type": "object",
"properties": { "properties": {