Add category feature to the PoS with Cart (#5078)

* Add grouping feature to the PoS with Cart

* Improve UI

* Rename groups to categories

* Make it easier to select categories of the items

* Refactor TemplateEditor, use TomSelect for categories

* Prevent Vue code insertion

* Prevent empty categories

* Add label ids

* Add test case

---------

Co-authored-by: Dennis Reimann <mail@dennisreimann.de>
This commit is contained in:
Nicolas Dorier 2023-06-30 09:13:15 +09:00 committed by GitHub
parent 983b8c1f54
commit 8cde8c01df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 317 additions and 257 deletions

View File

@ -962,11 +962,13 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.CssSelector("label[for='DefaultView_Cart']")).Click();
s.Driver.FindElement(By.CssSelector(".template-item:nth-of-type(1) .btn-primary")).Click();
s.Driver.FindElement(By.Id("BuyButtonText")).SendKeys("Take my money");
s.Driver.FindElement(By.Id("EditorCategories-ts-control")).SendKeys("Drinks");
s.Driver.FindElement(By.Id("SaveItemChanges")).Click();
s.Driver.FindElement(By.Id("ToggleRawEditor")).Click();
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
Assert.Contains("\"buyButtonText\": \"Take my money\"", template);
Assert.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);
@ -980,6 +982,14 @@ namespace BTCPayServer.Tests
Assert.True(s.Driver.PageSource.Contains("Tea shop"), "Unable to create PoS");
Assert.True(s.Driver.PageSource.Contains("Cart"), "PoS not showing correct default view");
Assert.True(s.Driver.PageSource.Contains("Take my money"), "PoS not showing correct default view");
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
var drinks = s.Driver.FindElement(By.CssSelector("label[for='Category-Drinks']"));
Assert.Equal("Drinks", drinks.Text);
drinks.Click();
Assert.Single(s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")));
s.Driver.FindElement(By.CssSelector("label[for='Category-*']")).Click();
Assert.Equal(5, s.Driver.FindElements(By.CssSelector(".card-deck .card:not(.d-none)")).Count);
s.Driver.Url = posBaseUrl + "/static";
Assert.False(s.Driver.PageSource.Contains("Cart"), "Static PoS not showing correct view");

View File

@ -1,7 +1,10 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.JsonConverters;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc.Rendering;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
@ -24,6 +27,8 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
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; }
@ -63,7 +68,35 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public bool EnableTips { get; set; }
public string Step { get; set; }
public string Title { get; set; }
public Item[] Items { get; set; }
Item[] _Items;
public Item[] Items
{
get
{
return _Items;
}
set
{
_Items = value;
UpdateGroups();
}
}
private void UpdateGroups()
{
AllCategories = null;
if (Items is null)
return;
var groups = Items.SelectMany(g => g.Categories ?? Array.Empty<string>())
.ToHashSet()
.Select(o => new KeyValuePair<string, string>(o, o))
.ToList();
if (groups.Count == 0)
return;
groups.Insert(0, new KeyValuePair<string, string>("All items", "*"));
AllCategories = new SelectList(groups, "Value", "Key", "*");
}
public string CurrencyCode { get; set; }
public string CurrencySymbol { get; set; }
public string AppId { get; set; }
@ -76,6 +109,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Models
public string CustomCSSLink { get; set; }
public string CustomLogoLink { get; set; }
public string Description { get; set; }
public SelectList AllCategories { get; set; }
[Display(Name = "Custom CSS Code")]
public string EmbeddedCSS { get; set; }
public RequiresRefundEmail RequiresRefundEmail { get; set; } = RequiresRefundEmail.InheritFromStore;

View File

@ -165,19 +165,8 @@
</div>
</div>
</div>
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<partial name="TemplateEditor" model="@(nameof(Model.PerksTemplate), "Perks", Model.TargetCurrency ?? Model.StoreDefaultCurrency)" />
</div>
</div>
<div class="row collapse" id="RawEditor">
<div class="col-xl-10 col-xxl-constrain">
<div class="form-group pt-3">
<label asp-for="PerksTemplate" class="form-label"></label>
<textarea asp-for="PerksTemplate" rows="10" cols="40" class="form-control"></textarea>
<span asp-validation-for="PerksTemplate" class="text-danger"></span>
</div>
</div>
<div id="perks">
<partial name="TemplateEditor" model="@(nameof(Model.PerksTemplate), Model.PerksTemplate, "Perks", Model.TargetCurrency ?? Model.StoreDefaultCurrency)" />
</div>
<div class="row">
<div class="col-xl-8 col-xxl-constrain">

View File

@ -1,6 +1,8 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using Newtonsoft.Json.Linq;
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
@ -20,6 +22,10 @@
justify-content: center;
align-items: center;
}
.card:not(.d-none:only-of-type) {
max-width: 320px;
margin: auto !important;
}
</style>
}
@section PageFootContent {
@ -232,6 +238,16 @@
{
<div class="lead text-center mt-3">@Safe.Raw(Model.Description)</div>
}
@if (Model.AllCategories != null)
{
<div class="btcpay-pills d-flex flex-wrap align-items-center justify-content-center gap-3 mt-3" data-toggle="buttons" v-pre>
@foreach (var g in Model.AllCategories)
{
<input id="Category-@g.Value" type="radio" name="category" class="js-categories" value="@g.Value" @(g.Selected ? "checked" : "") autocomplete="off">
<label class="btcpay-pill" for="Category-@g.Value">@g.Text</label>
}
</div>
}
</div>
<div id="js-pos-list" class="text-center mx-auto px-2 px-sm-4 py-4 py-sm-2">
<div class="card-deck">
@ -245,7 +261,7 @@
var image = item.Image;
var description = item.Description;
<div class="js-add-cart card px-0 card-wrapper" data-index="@index">
<div class="js-add-cart card px-0 card-wrapper" data-index="@index" data-categories="@(new JArray(item.Categories).ToString())">
@if (!string.IsNullOrWhiteSpace(image))
{
<img class="card-img-top" src="@image" alt="@Safe.Raw(item.Title)" asp-append-version="true">
@ -258,7 +274,6 @@
}
</div>
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
@ -284,7 +299,8 @@
<span>Sold out</span>
}
</div>
} else if (anyInventoryItems)
}
else if (anyInventoryItems)
{
<div class="w-100 pt-2">&nbsp;</div>
}

View File

@ -7,6 +7,13 @@
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
}
<style>
.card:only-of-type {
max-width: 320px;
margin: auto !important;
}
</style>
<div class="container public-page-wrap flex-column">
<partial name="_StatusMessage" />
<partial name="_StoreHeader" model="(string.IsNullOrEmpty(Model.Title) ? Model.StoreName : Model.Title, Model.LogoFileId)" />

View File

@ -50,10 +50,6 @@
.card {
page-break-inside: avoid;
}
.card:only-of-type {
max-width: 320px;
margin: auto !important;
}
</style>
@await RenderSectionAsync("PageHeadContent", false)
</head>

View File

@ -76,20 +76,7 @@
</div>
</div>
<div id="products">
<div class="row">
<div class="col-xxl-constrain">
<partial name="TemplateEditor" model="@(nameof(Model.Template), "Products", Model.Currency ?? Model.StoreDefaultCurrency)" />
</div>
</div>
<div class="row collapse" id="RawEditor">
<div class="col-xxl-constrain">
<div class="form-group pt-3">
<label asp-for="Template" class="form-label"></label>
<textarea asp-for="Template" rows="10" cols="40" class="form-control"></textarea>
<span asp-validation-for="Template" class="text-danger"></span>
</div>
</div>
</div>
<partial name="TemplateEditor" model="@(nameof(Model.Template), Model.Template, "Products", Model.Currency ?? Model.StoreDefaultCurrency)" />
</div>
<div class="row mt-5">
<div class="col-sm-10 col-md-9 col-xl-7 col-xxl-6">
@ -362,7 +349,6 @@
el.removeAttribute('hidden');
}
function updateFormForDefaultView(type) {
console.log(type)
switch (type) {
case 'Static':
case 'Print':

View File

@ -1,231 +1,278 @@
@model (string templateId, string title, string currency)
@model (string templateId, string template, string title, string currency)
<div id="template-editor-app" v-cloak>
<div class="form-group mb-0">
<h3 class="mt-5 mb-4">@Model.title</h3>
@if (ViewContext.ViewData.ModelState.TryGetValue(Model.templateId, out var errors))
{
foreach (var error in errors.Errors)
{
<br/>
<span class="text-danger" v-pre>@error.ErrorMessage</span>
}
}
<div class="bg-light card">
<div class="card-body " v-bind:class="{ 'card-deck': items.length > 0}">
<div v-for="(item, index) of items" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
<div v-if="anyImages" class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
<div class="card-body">
<h6 class="card-title" v-html="item.title"></h6>
<div class="gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="editItem(index)">Edit</button>
<button type="button" class="btn btn-danger" v-on:click="removeItem(index)">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
<div v-if="!items || items.length === 0" class="col-12 text-center">
No items.<br/>
<button type="button" class="btn btn-link" v-on:click="editItem(-1)" id="btn-add-first">
Add your first item
<div class="modal" id="product-modal" tabindex="-1" role="dialog" ref="productModal">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{editingItem && editingItem.id ? "Edit" : "Add"}} Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close" />
</button>
</div>
</div>
<div class="card-footer text-start p-3 gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="editItem(-1)" id="btn-add">
<i class="fa fa-plus fa-fw"></i> Add
</button>
<button type="button" class="btn btn-secondary" id="ToggleRawEditor" data-bs-toggle="collapse" data-bs-target="#RawEditor" aria-expanded="false" aria-controls="RawEditor">
Toggle raw editor
</button>
<div class="modal-body">
<div class="mb-3">
<div class="text-danger mb-3" v-for="error of errors">{{error}}</div>
<div class="form-group">
<label for="EditorTitle" class="form-label" data-required>Title</label>
<input id="EditorTitle" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem && editingItem.title" autofocus ref="txtTitle" />
</div>
<div class="form-group row">
<div class="col-sm-6">
<label for="EditorPrice" class="form-label">Price</label>
<select id="EditorPrice" class="form-select" v-model="editingItem && editingItem.priceType">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-6" v-show="editingItem && editingItem.priceType !== 'Topup'">
<label for="EditorAmount" class="form-label">&nbsp;</label>
<div class="input-group mb-2">
<input class="form-control"
id="EditorAmount"
inputmode="decimal"
pattern="\d*"
step="any"
min="0"
type="number"
required
v-model="editingItem && editingItem.price"
ref="txtPrice"
aria-describedby="currency-addon" />
<span class="input-group-text" id="currency-addon" v-pre>@Model.currency</span>
</div>
</div>
</div>
<div class="form-group">
<label for="EditorImageUrl" class="form-label">Image Url</label>
<input id="EditorImageUrl" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem && editingItem.image" ref="txtImage" />
</div>
<div class="form-group">
<label for="EditorDescription" class="form-label">Description</label>
<textarea id="EditorDescription" rows="3" cols="40" class="form-control mb-2" v-model="editingItem && editingItem.description" ref="txtDescription"></textarea>
</div>
<div class="form-group">
<label for="EditorCategories" class="form-label">Categories</label>
<input id="EditorCategories" class="form-control mb-2" autocomplete="off" ref="editorCategories" />
<div class="form-text">Easily filter the different items using categories, used only in the product list with cart.</div>
</div>
<div class="form-group">
<label for="EditorInventory" class="form-label">Inventory</label>
<input id="EditorInventory" type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" v-model="editingItem && editingItem.inventory" ref="txtInventory" />
<div class="form-text">Leave blank to not use this feature.</div>
</div>
<div class="form-group">
<label for="EditorId" class="form-label">ID</label>
<input id="EditorId" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem && editingItem.id" ref="txtId" />
<div class="form-text">Leave blank to generate ID from title.</div>
</div>
<div class="form-group">
<label for="BuyButtonText" class="form-label">Buy Button Text</label>
<input id="BuyButtonText" type="text" class="form-control mb-2" v-model="editingItem && editingItem.buyButtonText" ref="txtBuyButtonText" />
</div>
<div class="form-group d-flex align-items-center">
<input type="checkbox" id="Disabled" class="btcpay-toggle me-3" v-model="editingItem && editingItem.disabled" />
<label for="Disabled" class="form-label mb-0">Disabled</label>
</div>
<vc:ui-extension-point location="app-template-editor-item-detail" model="Model"></vc:ui-extension-point>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" v-on:click="saveItem()" id="SaveItemChanges">Save</button>
</div>
</div>
</div>
<div class="modal" id="product-modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" v-if="editingItem">{{editingItem.index>=0? "Edit" : "Add"}} Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body" v-if="editingItem">
<div class="mb-3">
<span class="text-danger row m-2" v-for="error of errors">{{error}}</span>
<div class="form-group">
<label class="form-label" data-required>Title</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.title" autofocus ref="txtTitle" />
</div>
<div class="form-group row">
<div class="col-sm-6">
<label class="form-label">Price</label>
<select class="form-select" v-model="editingItem.priceType">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-6" v-show="editingItem.priceType !== 'Topup'">
<label class="form-label">&nbsp;</label>
<div class="input-group mb-2">
<input class="form-control"
inputmode="decimal"
pattern="\d*"
step="any"
min="0"
type="number"
required
v-model="editingItem.price"
ref="txtPrice"
aria-describedby="currency-addon"/>
<span class="input-group-text" id="currency-addon">@Model.currency</span>
</div>
</div>
<div class="row">
<div class="col-xxl-constrain">
<div class="form-group mb-0">
<h3 class="mt-5 mb-4" v-pre>@Model.title</h3>
@if (ViewContext.ViewData.ModelState.TryGetValue(Model.templateId, out var errors))
{
foreach (var error in errors.Errors)
{
<br />
<span class="text-danger" v-pre>@error.ErrorMessage</span>
}
}
<div class="bg-light card">
<div class="card-body " v-bind:class="{ 'card-deck': config.length > 0}">
<div v-if="!config || config.length === 0" class="col-12 text-center">
No items.<br />
<button type="button" class="btn btn-link" v-on:click="addItem()" id="btn-add-first">
Add your first item
</button>
</div>
<div v-else v-for="(item, index) of config" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
<div class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
<div class="card-body">
<h6 class="card-title" v-html="item.title"></h6>
<div class="gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="editItem(index)">Edit</button>
<button type="button" class="btn btn-danger" v-on:click="removeItem(index)">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
<div class="form-group">
<label class="form-label">Image Url</label>
<input type="text" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem.image" ref="txtImage" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea rows="3" cols="40" class="form-control mb-2" v-model="editingItem.description" ref="txtDescription"></textarea>
</div>
<div class="form-group">
<label class="form-label">Inventory</label>
<input type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" v-model="editingItem.inventory" ref="txtInventory" />
<div class="form-text">Leave blank to not use this feature.</div>
</div>
<div class="form-group">
<label class="form-label">ID</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.id" ref="txtId" />
<div class="form-text">Leave blank to generate ID from title.</div>
</div>
<div class="form-group">
<label class="form-label">Buy Button Text</label>
<input type="text" id="BuyButtonText" class="form-control mb-2" v-model="editingItem.buyButtonText" ref="txtBuyButtonText" />
</div>
<div class="form-group d-flex align-items-center">
<input type="checkbox" id="Disabled" class="btcpay-toggle me-3" v-model="editingItem.disabled" />
<label class="form-label mb-0">Disabled</label>
</div>
<vc:ui-extension-point location="app-template-editor-item-detail" model="Model"></vc:ui-extension-point>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" v-on:click="clearEditingItem()">Close</button>
<button type="button" class="btn btn-primary" v-on:click="saveEditingItem()" id="SaveItemChanges">Save</button>
<div class="card-footer text-start p-3 gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="addItem()" id="btn-add">
<i class="fa fa-plus fa-fw"></i> Add
</button>
<button type="button" class="btn btn-secondary" id="ToggleRawEditor" data-bs-toggle="collapse" data-bs-target="#RawEditor" aria-expanded="false" aria-controls="RawEditor">
Toggle raw editor
</button>
</div>
</div>
</div>
</div>
</div>
<div class="row collapse" id="RawEditor">
<div class="col-xxl-constrain">
<div class="form-group pt-3">
<label for="@Model.templateId" class="form-label">Template</label>
<textarea id="@Model.templateId" name="@Model.templateId" rows="10" cols="40" class="form-control" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
</div>
</div>
</div>
</div>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
document.addEventListener("DOMContentLoaded", () => {
const parseConfig = str => {
try {
return JSON.parse(str)
} catch (err) {
console.error('Error deserializing template config:', err)
}
}
const template = @Safe.Json(Model.template)
let config = parseConfig(template) || []
new Vue({
el: '#template-editor-app',
data: {
errors: [],
items: [],
editingItem: null,
customPriceOptions: [
{ text: 'Fixed', value: "Fixed" },
{ text: 'Minimum', value: "Minimum" },
{ text: 'Custom', value: 'Topup' },
],
elementId: "@Model.templateId"
},
computed: {
anyImages: function(){
return !!this.items.find(function(i){ return !!i.image;});
data () {
return {
config,
errors: [],
editingIndex: null,
editingItem: null,
customPriceOptions: [
{ text: 'Fixed', value: "Fixed" },
{ text: 'Minimum', value: "Minimum" },
{ text: 'Custom', value: 'Topup' },
],
categoriesSelect: null,
productModal: null
}
},
mounted: function() {
this.load();
this.getInputElement().on("input change", this.load.bind(this));
this.getModalElement().on("hide.bs.modal", this.clearEditingItem.bind(this));
mounted() {
// modal
const $modalEl = this.$refs.productModal;
$modalEl.addEventListener('hide.bs.modal', () => { this.setEditingItem(null, null); });
this.productModal = new bootstrap.Modal($modalEl, {})
// categories
this.categoriesSelect = new TomSelect(this.$refs.editorCategories, {
persist: false,
createOnBlur: true,
create: true,
options: this.allCategories.map(value => ({ value, text: value })),
});
this.categoriesSelect.on('change', () => {
const value = this.categoriesSelect.getValue();
this.editingItem.categories = Array.from(value.split(',').reduce((res, item) => {
const category = item.trim();
if (category) res.add(category);
return res;
}, new Set()));
});
},
beforeDestroy () {
this.categoriesSelect.destroy();
},
computed: {
allCategories() {
return Array.from(this.config.reduce((res, item) => {
(item.categories || []).forEach(category => { res.add(category); });
return res;
}, new Set()));
},
configJSON() {
return JSON.stringify(this.config, null, 2)
}
},
methods: {
getImage: function(item){
var image = this.unEscapeKey(item.image) || "~/img/img-placeholder.svg";
var url = image.startsWith("~") ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image;
updateFromJSON(event) {
const config = parseConfig(event.target.value)
if (!config) return
this.config = config
},
getImage(item) {
const image = item.image || "~/img/img-placeholder.svg";
const url = image.startsWith("~") ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image;
return {
"background-image" : "url('" + url +"')",
"opacity": item.image? 1: 0.5
}
},
getInputElement : function(){ return $("#" + this.elementId); },
getModalElement : function(){ return $("#product-modal"); },
load: function(){
const template = this.getInputElement().val().trim();
if (!template){
this.items = [];
} else {
this.items = JSON.parse(template);
}
removeItem(index) {
this.config.splice(index, 1);
},
save: function(){
let template = JSON.stringify(this.items, null, 2);
this.getInputElement().val(template);
addItem() {
this.setEditingItem(null, { id: '', title: '', price: 0, image: '', description: '', categories: [], priceType: 'Fixed', inventory: null, disabled: false });
},
editItem: function(index){
this.errors = [];
if(index < 0){
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", priceType: "Fixed", inventory: null, disabled: false};
}else{
this.editingItem = {...this.items[index], index};
}
this.editingItem = this.unEscape(this.editingItem);
this.getModalElement().modal("show");
editItem(index) {
this.setEditingItem(index, Object.assign({}, this.config[index]));
},
removeItem: function(index){
this.items.splice(index,1);
this.save();
saveItem() {
// set id from title if not set
if (!this.editingItem.id) this.editingItem.id = this.editingItem.title.toLowerCase().trim();
// validate
if (!this.validate()) return;
// add or update
const idx = this.editingIndex === null ? this.config.length : this.editingIndex;
this.$set(this.config, idx, this.editingItem);
// update categories
this.categoriesSelect.clearOptions();
this.categoriesSelect.addOptions(this.allCategories.map(value => ({ value, text: value })));
// hide modal
this.productModal.hide();
},
clearEditingItem: function(){
this.editingItem = null;
this.errors = [];
},
validate: function(){
validate () {
this.errors = [];
if (this.editingItem.id) {
var matchedId = this.items.findIndex((x)=> { return this.unEscapeKey(x.id) === this.editingItem.id;});
if( matchedId>= 0 && matchedId != this.editingItem.index)
const matchedId = this.config.findIndex(x => x.id === this.editingItem.id);
if (matchedId >= 0 && matchedId !== this.editingIndex)
this.errors.push("You cannot have multiple items with the same id");
if (!this.$refs.txtId.checkValidity()) {
this.errors.push("Id is required and cannot have * or #");
}
if(this.editingItem.id.startsWith("- ")){
if (this.editingItem.id.startsWith("- "))
this.errors.push("Id cannot start with \"- \"");
}else if(this.editingItem.id.trim() == ""){
else if (this.editingItem.id.trim() === "")
this.errors.push("Id is required");
}
}
if (this.editingItem.description.indexOf("*") >= 0 || this.editingItem.description.indexOf("#") >= 0) {
this.errors.push("Description cannot have * or #");
}
if(this.editingItem.description.startsWith("- ")){
if (this.editingItem.description.startsWith("- ")){
this.errors.push("Description cannot start with \"- \"");
}
if (!this.$refs.txtImage.checkValidity()) {
this.errors.push("Image cannot have * or #");
}
if(this.editingItem.image.startsWith("- ")){
if (this.editingItem.image.startsWith("- ")){
this.errors.push("Image cannot start with \"- \"");
}
if (this.editingItem["priceType"] !== "Topup" && !this.$refs.txtPrice.checkValidity()) {
this.errors.push("Price must be a valid number");
}
if (!this.$refs.txtTitle.checkValidity()) {
this.errors.push("Title is required and cannot have * or #");
}else if(this.editingItem.title.startsWith("- ")){
} else if (this.editingItem.title.startsWith("- ")){
this.errors.push("Title cannot start with \"- \"");
}else if(this.editingItem.title.trim() == ""){
} else if (!this.editingItem.title.trim()){
this.errors.push("Title is required");
}
if (!this.$refs.txtInventory.checkValidity()) {
@ -233,57 +280,22 @@ document.addEventListener("DOMContentLoaded", function () {
}
return this.errors.length === 0;
},
saveEditingItem: function(){
const fallbackId = this.editingItem.title.toLowerCase().trim();
if(!this.editingItem.id && fallbackId){
this.editingItem.id = fallbackId;
this.$nextTick(this.saveEditingItem.bind(this));
return;
setEditingItem(index, item) {
this.errors = [];
this.editingIndex = index;
this.editingItem = item;
if (this.editingItem != null) {
this.categoriesSelect.setValue(this.editingItem.categories);
this.productModal.show();
}
if(!this.validate()){
return;
}
this.editingItem = this.escape(this.editingItem);
if(this.editingItem.index < 0){
this.items.push(this.editingItem);
}else{
this.items.splice(this.editingItem.index,1,this.editingItem);
}
this.save();
this.getModalElement().modal("hide");
},
escape: function(item) {
for(var k in item){
if(k !== "paymentMethods" && k!=="id"){
item[k] = $('<div/>').text(item[k]).html();
}
}
return item;
},
unEscape: function(item){
for(var k in item){
if(k !== "paymentMethods" && k!=="id" && k !== "disabled"){
item[k] = this.unEscapeKey(item[k]);
}
}
return item;
},
unEscapeKey : function(k){
// Without this check a `false` boolean value will always be returned as an empty string
if (k === false) {
return "false";
}
return $('<div/>').html(k).text();
}
}
});
});
Number.prototype.noExponents= function(){
Number.prototype.noExponents = function(){
var data= String(this).split(/[eE]/);
if(data.length== 1) return data[0];
if (data.length== 1) return data[0];
var z= '', sign= this<0? '-':'',
str= data[0].replace('.', ''),
@ -298,5 +310,4 @@ Number.prototype.noExponents= function(){
while(mag--) z += '0';
return str + z;
};
</script>

View File

@ -16,7 +16,7 @@
.card-img-top {
width: 100%;
max-height: 180px;
max-height: 210px;
object-fit: scale-down;
}

View File

@ -15,7 +15,7 @@ function Cart() {
this.$summaryTip = $('.js-cart-summary-tip');
this.$destroy = $('.js-cart-destroy');
this.$confirm = $('#js-cart-confirm');
this.$categories = $('.js-categories');
this.listItems();
this.bindEmptyCart();
@ -421,7 +421,18 @@ Cart.prototype.listItems = function() {
self = this,
list = [],
tableTemplate = '';
this.$categories.on('change', function (event) {
if ($(this).is(':checked')) {
var selectedCategory = $(this).val();
$(".js-add-cart").each(function () {
var categories = JSON.parse(this.getAttribute("data-categories"));
if (selectedCategory === "*" || categories.includes(selectedCategory))
this.classList.remove("d-none");
else
this.classList.add("d-none");
});
}
});
if (this.content.length > 0) {
// Prepare the list of items in the cart
for (var key in this.content) {