POS improvements (#4668)

This commit is contained in:
d11n
2023-02-23 09:52:37 +01:00
committed by GitHub
parent ddb125f458
commit 66e1eee010
11 changed files with 92 additions and 56 deletions

View File

@@ -47,10 +47,10 @@ public class Form
public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields() public IEnumerable<(string FullName, List<string> Path, Field Field)> GetAllFields()
{ {
HashSet<string> nameReturned = new HashSet<string>(); HashSet<string> nameReturned = new();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields)) foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{ {
var fullName = String.Join('_', f.Path); var fullName = string.Join('_', f.Path);
if (!nameReturned.Add(fullName)) if (!nameReturned.Add(fullName))
continue; continue;
yield return (fullName, f.Path, f.Field); yield return (fullName, f.Path, f.Field);
@@ -60,10 +60,10 @@ public class Form
public bool ValidateFieldNames(out List<string> errors) public bool ValidateFieldNames(out List<string> errors)
{ {
errors = new List<string>(); errors = new List<string>();
HashSet<string> nameReturned = new HashSet<string>(); HashSet<string> nameReturned = new();
foreach (var f in GetAllFieldsCore(new List<string>(), Fields)) foreach (var f in GetAllFieldsCore(new List<string>(), Fields))
{ {
var fullName = String.Join('_', f.Path); var fullName = string.Join('_', f.Path);
if (!nameReturned.Add(fullName)) if (!nameReturned.Add(fullName))
{ {
errors.Add($"Form contains duplicate field names '{fullName}'"); errors.Add($"Form contains duplicate field names '{fullName}'");
@@ -77,7 +77,7 @@ public class Form
{ {
foreach (var field in fields) foreach (var field in fields)
{ {
List<string> thisPath = new List<string>(path.Count + 1); List<string> thisPath = new(path.Count + 1);
thisPath.AddRange(path); thisPath.AddRange(path);
if (!string.IsNullOrEmpty(field.Name)) if (!string.IsNullOrEmpty(field.Name))
{ {

View File

@@ -35,9 +35,9 @@ namespace BTCPayServer.Filters
var matchedDomainMapping = mapping.FirstOrDefault(item => item.AppId == appId); var matchedDomainMapping = mapping.FirstOrDefault(item => item.AppId == appId);
// App is accessed via path, redirect to canonical domain // App is accessed via path, redirect to canonical domain
if (matchedDomainMapping != null)
{
var req = context.RouteContext.HttpContext.Request; var req = context.RouteContext.HttpContext.Request;
if (matchedDomainMapping != null && req.Method != "POST" && !req.HasFormContentType)
{
var uri = new UriBuilder(req.Scheme, matchedDomainMapping.Domain); var uri = new UriBuilder(req.Scheme, matchedDomainMapping.Domain);
if (req.Host.Port.HasValue) uri.Port = req.Host.Port.Value; if (req.Host.Port.HasValue) uri.Port = req.Host.Port.Value;
context.RouteContext.HttpContext.Response.Redirect(uri.ToString()); context.RouteContext.HttpContext.Response.Redirect(uri.ToString());

View File

@@ -1,5 +1,4 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using BTCPayServer.Abstractions.Form; using BTCPayServer.Abstractions.Form;
using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.AspNetCore.Mvc.ModelBinding;
@@ -9,7 +8,7 @@ public class FormComponentProviders
{ {
private readonly IEnumerable<IFormComponentProvider> _formComponentProviders; private readonly IEnumerable<IFormComponentProvider> _formComponentProviders;
public Dictionary<string, IFormComponentProvider> TypeToComponentProvider = new Dictionary<string, IFormComponentProvider>(); public Dictionary<string, IFormComponentProvider> TypeToComponentProvider = new();
public FormComponentProviders(IEnumerable<IFormComponentProvider> formComponentProviders) public FormComponentProviders(IEnumerable<IFormComponentProvider> formComponentProviders)
{ {

View File

@@ -10,9 +10,10 @@ public class FormViewModel
public string BrandColor { get; set; } public string BrandColor { get; set; }
public string StoreName { get; set; } public string StoreName { get; set; }
public string FormName { get; set; } public string FormName { get; set; }
public string RedirectUrl { get; set; }
public Form Form { get; set; } public Form Form { get; set; }
public string AspController { get; set; } public string AspController { get; set; }
public string AspAction { get; set; } public string AspAction { get; set; }
public Dictionary<string, string> RouteParameters { get; set; } = new(); public Dictionary<string, string> RouteParameters { get; set; } = new();
public MultiValueDictionary<string, string> FormParameters { get; set; } = new();
public string FormParameterPrefix { get; set; }
} }

View File

@@ -9,7 +9,7 @@ namespace BTCPayServer.Models
public string FormUrl { get; set; } public string FormUrl { get; set; }
public bool AllowExternal { get; set; } public bool AllowExternal { get; set; }
public MultiValueDictionary<string, string> FormParameters { get; set; } = new MultiValueDictionary<string, string>(); public MultiValueDictionary<string, string> FormParameters { get; set; } = new ();
public Dictionary<string, string> RouteParameters { get; set; } = new Dictionary<string, string>(); public Dictionary<string, string> RouteParameters { get; set; } = new ();
} }
} }

View File

@@ -26,8 +26,11 @@ using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores; using BTCPayServer.Services.Stores;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient; using NBitpayClient;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using NicolasDorier.RateLimits; using NicolasDorier.RateLimits;
@@ -240,13 +243,18 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
case not null: case not null:
if (formResponse is null) if (formResponse is null)
{ {
return View("PostRedirect", new PostRedirectViewModel var vm = new PostRedirectViewModel
{ {
AspAction = nameof(POSForm), AspAction = nameof(POSForm),
AspController = "UIPointOfSale",
RouteParameters = new Dictionary<string, string> { { "appId", appId } }, RouteParameters = new Dictionary<string, string> { { "appId", appId } },
AspController = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture),
FormParameters = new MultiValueDictionary<string, string>(Request.Form.Select(pair => new KeyValuePair<string, IReadOnlyCollection<string>>(pair.Key, pair.Value))) FormParameters = new MultiValueDictionary<string, string>(Request.Form.Select(pair => new KeyValuePair<string, IReadOnlyCollection<string>>(pair.Key, pair.Value)))
}); };
if (viewType.HasValue)
{
vm.RouteParameters.Add("viewType", viewType.Value.ToString());
}
return View("PostRedirect", vm);
} }
formResponseJObject = JObject.Parse(formResponse); formResponseJObject = JObject.Parse(formResponse);
@@ -255,7 +263,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (!FormDataService.Validate(form, ModelState)) if (!FormDataService.Validate(form, ModelState))
{ {
//someone tried to bypass validation //someone tried to bypass validation
return RedirectToAction(nameof(ViewPointOfSale), new {appId}); return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
} }
formResponseJObject = form.GetValues(); formResponseJObject = form.GetValues();
@@ -276,7 +284,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl, string.IsNullOrEmpty(notificationUrl) ? settings.NotificationUrl : notificationUrl,
RedirectURL = !string.IsNullOrEmpty(redirectUrl) ? redirectUrl RedirectURL = !string.IsNullOrEmpty(redirectUrl) ? redirectUrl
: !string.IsNullOrEmpty(settings.RedirectUrl) ? settings.RedirectUrl : !string.IsNullOrEmpty(settings.RedirectUrl) ? settings.RedirectUrl
: Request.GetDisplayUrl(), : Request.GetAbsoluteUri(Url.Action(nameof(ViewPointOfSale), "UIPointOfSale", new { appId, viewType })),
FullNotifications = true, FullNotifications = true,
ExtendedNotifications = true, ExtendedNotifications = true,
PosData = string.IsNullOrEmpty(posData) ? null : posData, PosData = string.IsNullOrEmpty(posData) ? null : posData,
@@ -294,8 +302,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (formResponseJObject is not null) if (formResponseJObject is not null)
{ {
var meta = entity.Metadata.ToJObject(); var meta = entity.Metadata.ToJObject();
meta.Merge(formResponseJObject); formResponseJObject.Merge(meta);
entity.Metadata = InvoiceMetadata.FromJObject(meta); entity.Metadata = InvoiceMetadata.FromJObject(formResponseJObject);
} }
}); });
return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Data.Id }); return RedirectToAction(nameof(UIInvoiceController.Checkout), "UIInvoice", new { invoiceId = invoice.Data.Id });
@@ -312,8 +320,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
} }
} }
[HttpPost("/apps/{appId}/pos/form")] [HttpPost("/apps/{appId}/pos/form/{viewType?}")]
public async Task<IActionResult> POSForm(string appId) public async Task<IActionResult> POSForm(string appId, PosViewType? viewType = null)
{ {
var app = await _appService.GetApp(appId, AppType.PointOfSale); var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null) if (app == null)
@@ -323,20 +331,18 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
var formData = await FormDataService.GetForm(settings.FormId); var formData = await FormDataService.GetForm(settings.FormId);
if (formData is null) if (formData is null)
{ {
return RedirectToAction(nameof(ViewPointOfSale), new { appId }); return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
} }
var myDictionary = Request.Form var prefix = Encoders.Base58.EncodeData(RandomUtils.GetBytes(16)) + "_";
var formParameters = Request.Form
.Where(pair => pair.Key != "__RequestVerificationToken") .Where(pair => pair.Key != "__RequestVerificationToken")
.ToDictionary(p => p.Key, p => p.Value.ToString()); .ToMultiValueDictionary(p => p.Key, p => p.Value.ToString());
myDictionary.Add("appId", appId); var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);;
var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);
var redirectUrl = Url.Action(nameof(ViewPointOfSale), controller, myDictionary);
var store = await _appService.GetStore(app); var store = await _appService.GetStore(app);
var storeBlob = store.GetStoreBlob(); var storeBlob = store.GetStoreBlob();
var form = Form.Parse(formData.Config); var form = Form.Parse(formData.Config);
var vm = new FormViewModel
return View("Views/UIForms/View", new FormViewModel
{ {
StoreName = store.StoreName, StoreName = store.StoreName,
BrandColor = storeBlob.BrandColor, BrandColor = storeBlob.BrandColor,
@@ -344,39 +350,55 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
LogoFileId = storeBlob.LogoFileId, LogoFileId = storeBlob.LogoFileId,
FormName = formData.Name, FormName = formData.Name,
Form = form, Form = form,
RedirectUrl = redirectUrl,
AspController = controller, AspController = controller,
AspAction = nameof(POSFormSubmit), AspAction = nameof(POSFormSubmit),
RouteParameters = new Dictionary<string, string> { { "appId", appId } }, RouteParameters = new Dictionary<string, string> { { "appId", appId } },
}); FormParameters = formParameters,
FormParameterPrefix = prefix
};
if (viewType.HasValue)
{
vm.RouteParameters.Add("viewType", viewType.Value.ToString());
} }
[HttpPost("/apps/{appId}/pos/form/submit")] return View("Views/UIForms/View", vm);
public async Task<IActionResult> POSFormSubmit(string appId, FormViewModel viewModel) }
[HttpPost("/apps/{appId}/pos/form/submit/{viewType?}")]
public async Task<IActionResult> POSFormSubmit(string appId, FormViewModel viewModel, PosViewType? viewType = null)
{ {
var app = await _appService.GetApp(appId, AppType.PointOfSale); var app = await _appService.GetApp(appId, AppType.PointOfSale);
if (app == null) if (app == null)
return NotFound(); return NotFound();
var settings = app.GetSettings<PointOfSaleSettings>(); var settings = app.GetSettings<PointOfSaleSettings>();
var formData = await FormDataService.GetForm(settings.FormId); var formData = await FormDataService.GetForm(settings.FormId);
if (formData is null || viewModel.RedirectUrl is null) if (formData is null)
{ {
return RedirectToAction(nameof(ViewPointOfSale), new {appId }); return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
} }
var form = Form.Parse(formData.Config); var form = Form.Parse(formData.Config);
if (Request.Method == "POST" && Request.HasFormContentType) var formFieldNames = form.GetAllFields().Select(tuple => tuple.FullName).Distinct().ToArray();
var formParameters = Request.Form
.Where(pair => pair.Key.StartsWith(viewModel.FormParameterPrefix))
.ToDictionary(pair => pair.Key.Replace(viewModel.FormParameterPrefix, string.Empty), pair => pair.Value)
.ToMultiValueDictionary(p => p.Key, p => p.Value.ToString());
if (Request is { Method: "POST", HasFormContentType: true })
{ {
form.ApplyValuesFromForm(Request.Form); form.ApplyValuesFromForm(Request.Form.Where(pair => formFieldNames.Contains(pair.Key)));
if (FormDataService.Validate(form, ModelState)) if (FormDataService.Validate(form, ModelState))
{ {
var controller = nameof(UIPointOfSaleController).TrimEnd("Controller", StringComparison.InvariantCulture);;
var redirectUrl =
Request.GetAbsoluteUri(Url.Action(nameof(ViewPointOfSale), controller, new {appId, viewType}));
formParameters.Add("formResponse", form.GetValues().ToString());
return View("PostRedirect", new PostRedirectViewModel return View("PostRedirect", new PostRedirectViewModel
{ {
FormUrl = viewModel.RedirectUrl, FormUrl = redirectUrl,
FormParameters = FormParameters = formParameters
{
{ "formResponse", form.GetValues().ToString() }
}
}); });
} }
} }
@@ -384,6 +406,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
viewModel.FormName = formData.Name; viewModel.FormName = formData.Name;
viewModel.Form = form; viewModel.Form = form;
viewModel.FormParameters = formParameters;
return View("Views/UIForms/View", viewModel); return View("Views/UIForms/View", viewModel);
} }

View File

@@ -10,6 +10,7 @@
{ {
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial)) if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial))
{ {
field.Name = $"{Model.Name}_{field.Name}";
<partial name="@partial.View" for="@field"></partial> <partial name="@partial.View" for="@field"></partial>
} }
} }

View File

@@ -2,6 +2,7 @@
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel @model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
<form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 flex-fill" v-cloak> <form id="app" method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy v-on:submit="handleFormSubmit" class="d-flex flex-column gap-4 flex-fill" v-cloak>
<input id="posdata" type="hidden" name="posdata" v-model="posdata">
<div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto"> <div ref="display" class="d-flex flex-column align-items-center px-4 mb-auto">
<div class="fw-semibold text-muted">{{srvModel.currencyCode}}</div> <div class="fw-semibold text-muted">{{srvModel.currencyCode}}</div>
<div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }">{{ formatCurrency(total, false) }}</div> <div class="fw-bold lh-sm" ref="amount" v-bind:style="{ fontSize: `${fontSize}px` }">{{ formatCurrency(total, false) }}</div>

View File

@@ -0,0 +1,11 @@
@model BTCPayServer.Forms.Models.FormViewModel
<input type="hidden" asp-for="FormParameterPrefix"/>
@foreach (var o in Model.FormParameters)
{
foreach (var v in o.Value)
{
<input type="hidden" name="@(Model.FormParameterPrefix??string.Empty)@o.Key" value="@v" />
}
}
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />

View File

@@ -34,23 +34,13 @@
@if (string.IsNullOrEmpty(Model.AspAction)) @if (string.IsNullOrEmpty(Model.AspAction))
{ {
<form method="post" novalidate="novalidate"> <form method="post" novalidate="novalidate">
@if (!string.IsNullOrEmpty(Model.RedirectUrl)) <partial name="_FormWrap" model="@Model" />
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
}
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form> </form>
} }
else else
{ {
<form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters"> <form method="post" asp-action="@Model.AspAction" asp-controller="@Model.AspController" asp-all-route-data="Model.RouteParameters">
@if (!string.IsNullOrEmpty(Model.RedirectUrl)) <partial name="_FormWrap" model="@Model" />
{
<input type="hidden" asp-for="RedirectUrl" value="@Model.RedirectUrl" />
}
<partial name="_Form" model="@Model.Form" />
<input type="submit" class="btn btn-primary" name="command" value="Submit" />
</form> </form>
} }
</div> </div>

View File

@@ -71,6 +71,16 @@ document.addEventListener("DOMContentLoaded",function () {
}, },
totalNumeric () { totalNumeric () {
return parseFloat(this.total); return parseFloat(this.total);
},
posdata () {
const data = {
subTotal: this.formatCurrency(this.amountNumeric),
total: this.formatCurrency(this.totalNumeric)
}
if (this.tipNumeric > 0) data.tip = this.formatCurrency(this.tipNumeric)
if (this.discountNumeric > 0) data.discountAmount = this.formatCurrency(this.discountNumeric)
if (this.discountPercentNumeric > 0) data.discountPercentage = this.discountPercentNumeric
return JSON.stringify(data)
} }
}, },
watch: { watch: {