mirror of
https://github.com/btcpayserver/btcpayserver.git
synced 2025-02-20 13:34:37 +01:00
POS and Crowdfund: Improve item editor (#5418)
* POS and Crowdfund: Improve item editor Makes it work the same way as the form editor: Drag and drop for reordering and inline editing without modal. * Upload component
This commit is contained in:
parent
c979c4774c
commit
c16dfb2dcb
18 changed files with 702 additions and 644 deletions
|
@ -23,7 +23,7 @@
|
|||
<PackageReference Include="Newtonsoft.Json.Schema" Version="3.0.14" />
|
||||
<PackageReference Include="Selenium.Support" Version="4.1.1" />
|
||||
<PackageReference Include="Selenium.WebDriver" Version="4.1.1" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="116.0.5845.9600" />
|
||||
<PackageReference Include="Selenium.WebDriver.ChromeDriver" Version="118.0.5993.7000" />
|
||||
<PackageReference Include="xunit" Version="2.4.2" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
|
|
|
@ -983,13 +983,12 @@ namespace BTCPayServer.Tests
|
|||
Assert.Contains("App successfully created", s.FindAlertMessage().Text);
|
||||
|
||||
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.CssSelector(".template-item:nth-of-type(1)")).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();
|
||||
s.Driver.FindElement(By.Id("ApplyItemChanges")).Click();
|
||||
|
||||
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
|
||||
var template = s.Driver.FindElement(By.Id("TemplateConfig")).GetAttribute("value");
|
||||
Assert.Contains("\"buyButtonText\": \"Take my money\"", template);
|
||||
Assert.Matches("\"categories\": \\[\n\\s+\"Drinks\"\n\\s+\\]", template);
|
||||
|
||||
|
|
|
@ -228,13 +228,17 @@ namespace BTCPayServer.Controllers
|
|||
if (app is null || userId is null)
|
||||
return NotFound();
|
||||
|
||||
if (!file.FileName.IsValidFileName())
|
||||
{
|
||||
return Json(new { error = "Invalid file name" });
|
||||
}
|
||||
if (!file.ContentType.StartsWith("image/", StringComparison.InvariantCulture))
|
||||
{
|
||||
return Json(new { error = "The file needs to be an image" });
|
||||
}
|
||||
if (file.Length > 500_000)
|
||||
{
|
||||
return Json(new { error = "The image file size should be less than 0.5MB" });
|
||||
return Json(new { error = "The file size should be less than 0.5MB" });
|
||||
}
|
||||
var formFile = await file.Bufferize();
|
||||
if (!FileTypeDetector.IsPicture(formFile.Buffer, formFile.FileName))
|
||||
|
|
|
@ -394,7 +394,6 @@
|
|||
<script src="~/vendor/signalr/signalr.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/animejs/anime.min.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/app.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/helpers/math.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/services/audioplayer.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/services/fireworks.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/services/listener.js" asp-append-version="true"></script>
|
||||
|
|
|
@ -13,16 +13,13 @@
|
|||
|
||||
@section PageHeadContent {
|
||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||
<link href="~/main/template-editor.css" rel="stylesheet" asp-append-version="true" />
|
||||
<style>
|
||||
.flatpickr-wrapper { flex-grow: 1; }
|
||||
</style>
|
||||
<style>.flatpickr-wrapper { flex-grow: 1; }</style>
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||
<script src="~/crowdfund/admin.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
|
@ -351,31 +348,3 @@
|
|||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))" />
|
||||
|
||||
<script>
|
||||
const resetRow = document.getElementById('ResetRow');
|
||||
const startDateInputId = "StartDate";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeout(() => {
|
||||
flatpickrInstances.forEach((instance) => {
|
||||
if (instance.element.id === startDateInputId) {
|
||||
instance.config.onChange.push((selectedDates) => {
|
||||
if (selectedDates.length) {
|
||||
// Show the reset row if start date is selected.
|
||||
// Since start date must be selected in order for the reset options to be set
|
||||
// we don't need to show it by default and can show it only when start date is selected
|
||||
resetRow.removeAttribute('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
document.addEventListener('input-group-clear-input-value-cleared', ({ detail }) => {
|
||||
const input = detail[0];
|
||||
if (input.id === startDateInputId) {
|
||||
resetRow.setAttribute('hidden', 'hidden');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
@ -12,6 +12,17 @@
|
|||
var checkoutFormOptions = await FormDataService.GetSelect(Model.StoreId, Model.FormId);
|
||||
}
|
||||
|
||||
@section PageHeadContent {
|
||||
<link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true">
|
||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||
<script src="~/pos/admin.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
||||
<form method="post">
|
||||
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
|
@ -289,125 +300,3 @@
|
|||
</div>
|
||||
|
||||
<partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))" />
|
||||
|
||||
@section PageHeadContent {
|
||||
<link href="~/vendor/highlightjs/default.min.css" rel="stylesheet" asp-append-version="true">
|
||||
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||
<link href="~/main/template-editor.css" rel="stylesheet" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
<script id="template-product-item" type="text/template">
|
||||
<div class="col-sm-4 col-md-3 mb-3">
|
||||
<div class="card">
|
||||
{image}
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{title}</h6>
|
||||
<a href="#" class="js-product-edit btn btn-primary" data-bs-toggle="modal" data-bs-target="#product-modal">Edit</a>
|
||||
<a href="#" class="js-product-remove btn btn-danger"><i class="fa fa-trash"></i></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script id="template-product-content" type="text/template">
|
||||
<div class="mb-3">
|
||||
<input class="js-product-id" type="hidden" name="id" value="{id}">
|
||||
<input class="js-product-index" type="hidden" name="index" value="{index}">
|
||||
<div class="form-group row">
|
||||
<div class="col-sm-6">
|
||||
<label class="form-label" data-required>Title</label>
|
||||
<input type="text" class="js-product-title form-control mb-2" value="{title}" autofocus required />
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label" data-required>Price</label>
|
||||
<input class="js-product-price form-control mb-2"
|
||||
inputmode="decimal"
|
||||
pattern="\d*"
|
||||
type="number"
|
||||
value="{price}"
|
||||
required />
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label class="form-label">Custom price</label>
|
||||
<select class="js-product-custom form-select">
|
||||
{custom}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Image</label>
|
||||
<input type="text" class="js-product-image form-control mb-2" value="{image}" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description</label>
|
||||
<textarea rows="3" cols="40" class="js-product-description form-control mb-2">{description}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Inventory (leave blank to not use inventory feature)</label>
|
||||
<input type="number" inputmode="numeric" step="1" class="js-product-inventory form-control mb-2" value="{inventory}" />
|
||||
</div>
|
||||
<div class="form-group d-flex align-items-center">
|
||||
<input type="checkbox" class="btcpay-toggle me-2" value="{disabled}" />
|
||||
<label class="form-label mb-0">Disabled</label>
|
||||
</div>
|
||||
</div>
|
||||
</script>
|
||||
<script>
|
||||
const description = document.getElementById('description');
|
||||
const products = document.getElementById('products');
|
||||
const tips = document.getElementById('tips');
|
||||
const discounts = document.getElementById('discounts');
|
||||
const buttonPriceText = document.getElementById('button-price-text');
|
||||
const customPayments = document.getElementById('custom-payments');
|
||||
|
||||
function hide(el) {
|
||||
el.setAttribute('hidden', true);
|
||||
}
|
||||
function show(el) {
|
||||
el.removeAttribute('hidden');
|
||||
}
|
||||
function updateFormForDefaultView(type) {
|
||||
switch (type) {
|
||||
case 'Static':
|
||||
case 'Print':
|
||||
hide(tips);
|
||||
hide(discounts);
|
||||
hide(buttonPriceText);
|
||||
show(description);
|
||||
show(products);
|
||||
show(customPayments);
|
||||
break;
|
||||
case 'Cart':
|
||||
show(tips);
|
||||
show(products);
|
||||
show(discounts);
|
||||
show(description);
|
||||
show(buttonPriceText);
|
||||
hide(customPayments);
|
||||
break;
|
||||
case 'Light':
|
||||
show(tips);
|
||||
show(discounts);
|
||||
hide(products);
|
||||
hide(description);
|
||||
hide(buttonPriceText);
|
||||
hide(customPayments);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const defaultView = document.querySelector('input[name="DefaultView"]:checked');
|
||||
if (defaultView) {
|
||||
updateFormForDefaultView(defaultView.value);
|
||||
}
|
||||
});
|
||||
|
||||
delegate('change', 'input[name="DefaultView"]', e => {
|
||||
updateFormForDefaultView(e.target.value);
|
||||
});
|
||||
</script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||
}
|
||||
|
|
|
@ -1,353 +1,171 @@
|
|||
@model (string templateId, string template, string title, string currency)
|
||||
|
||||
<div id="template-editor-app" v-cloak>
|
||||
<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>
|
||||
<template id="item-editor-upload">
|
||||
<div class="form-group">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="file" class="form-control" ref="input" v-on:change="fileChanged">
|
||||
<button class="btn btn-primary" type="button" v-on:click="upload" :disabled="disabled">Upload</button>
|
||||
</div>
|
||||
<div v-if="error" class="form-text text-danger" v-text="error"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="item-editor">
|
||||
<div>
|
||||
<div id="item-form" class="item" v-show="!!editingItem">
|
||||
<div class="form-group">
|
||||
<label for="EditorTitle" class="form-label" data-required>Title</label>
|
||||
<input id="EditorTitle" required class="form-control mb-2" v-model="editingItem && editingItem.title" ref="inputTitle" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="EditorId" class="form-label" data-required>ID</label>
|
||||
<input id="EditorId" required class="form-control mb-2" v-model="editingItem && editingItem.id" ref="inputtId" />
|
||||
<div class="form-text">Leave blank to generate ID from title.</div>
|
||||
</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="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"> </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="EditorImage" class="form-label">Image Url</label>
|
||||
<input id="EditorImageUrl" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem && editingItem.image" ref="txtImage" />
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input id="EditorImage" type="file" class="form-control" ref="editorImage" v-on:change="uploadFileChanged">
|
||||
<button class="btn btn-primary" type="button" id="EditorUploadButton" v-on:click="uploadFile" :disabled="uploadDisabled">Upload</button>
|
||||
</div>
|
||||
<span v-if="uploadError" v-text="uploadError" class="text-danger"></span>
|
||||
</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 class="col-sm-6" v-show="editingItem && editingItem.priceType !== 'Topup'">
|
||||
<label for="EditorAmount" class="form-label"> </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="inputPrice"
|
||||
aria-describedby="currency-addon" />
|
||||
<span class="input-group-text" id="currency-addon" v-pre>@Model.currency</span>
|
||||
</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 class="form-group">
|
||||
<label for="EditorImageUrl" class="form-label">Image Url</label>
|
||||
<input id="EditorImageUrl" class="form-control mb-2" v-model="editingItem && editingItem.image" ref="txtImage" />
|
||||
<item-editor-upload upload-url=@Safe.Json(Url.Action("FileUpload", "UIApps", new { appId = Context.GetRouteValue("appId") })) v-on:uploaded="url => editingItem.image = url" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="EditorDescription" class="form-label">Description</label>
|
||||
<textarea id="EditorDescription" rows="3" cols="40" class="form-control" v-model="editingItem && editingItem.description"></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="inputInventory" />
|
||||
<div class="form-text">Leave blank to not use this feature.</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" />
|
||||
</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 class="text-danger mb-3" v-for="error of errors">{{error}}</div>
|
||||
<button class="btn btn-primary" type="button" id="ApplyItemChanges" v-on:click="apply">Apply</button>
|
||||
</div>
|
||||
<div v-if="!editingItem">Select an item to edit</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="items-editor">
|
||||
<div>
|
||||
<div class="items list-group list-group-horizontal flex-wrap" v-sortable="{ handle: '.drag', onUpdate (event) { $emit('sort-items', event) } }">
|
||||
<div v-for="(item, index) of items" class="d-inline-flex align-items-start gap-2 list-group-item" style="flex: 0 1 375px" v-bind:key="item.id" :class="{ active: item === selectedItem }" v-on:click.stop="$emit('select-item', $event, index)">
|
||||
<button type="button" class="btn b-0 control drag" :disabled="items.length === 1">
|
||||
<vc:icon symbol="drag" />
|
||||
</button>
|
||||
<div class="card template-item bg-transparent w-100 h-100">
|
||||
<img class="card-img-top" :src="getImage(item)" :alt="item.title" :style="(item.image ? null : { opacity: 0.5 })">
|
||||
<div class="card-body p-3 d-flex flex-column gap-2">
|
||||
<h5 class="card-title m-0" v-html="item.title"></h5>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="fw-semibold badge text-bg-info" v-if="item.priceType === 'Topup' || item.price == 0">{{ item.priceType === 'Topup' ? 'Any amount' : 'Free' }}</span>
|
||||
<span class="fw-semibold" v-else>{{ item.price }} @Model.currency{{ item.priceType === 'Minimum' ? ' minimum' : '' }}</span>
|
||||
<span class="badge text-bg-warning" v-if="item.inventory">
|
||||
{{ item.inventory > 0 ? `${item.inventory} left` : 'Sold out' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text" v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn b-0 control remove" v-on:click="$emit('remove-item', $event, index)">
|
||||
<vc:icon symbol="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link py-0 px-2 mt-2 mb-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="$emit('add-item', $event)">
|
||||
<vc:icon symbol="new" />
|
||||
Add Item
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div id="TemplateEditor" class="editor" v-cloak>
|
||||
<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="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
|
||||
<ul class="nav nav-pills gap-4" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false">Code</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-lg-7 mb-4 mb-lg-0">
|
||||
<items-editor :items="items"
|
||||
:selected-item="selectedItem"
|
||||
v-on:add-item="addItem"
|
||||
v-on:sort-items="sortItems"
|
||||
v-on:select-item="selectItem"
|
||||
v-on:remove-item="removeItem"
|
||||
:class="{ 'pt-2': (!items || items.length === 0) }"
|
||||
class="bg-tile pb-2 rounded" />
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<item-editor :item="selectedItem" class="bg-tile p-4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</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-tile 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 template-item me-0 ms-0" v-bind:key="item.id">
|
||||
<img class="card-img-top" :src="getImage(item)" :alt="item.title" :style="(item.image ? null : { opacity: 0.5 })">
|
||||
<div class="card-body p-3 d-flex flex-column flex-grow-0 gap-2">
|
||||
<h5 class="card-title m-0" v-html="item.title"></h5>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="fw-semibold badge text-bg-info" v-if="item.priceType === 'Topup' || item.price == 0">{{ item.priceType === 'Topup' ? 'Any amount' : 'Free' }}</span>
|
||||
<span class="fw-semibold" v-else>{{ item.price }} @Model.currency{{ item.priceType === 'Minimum' ? ' minimum' : '' }}</span>
|
||||
<span class="badge text-bg-warning" v-if="item.inventory">
|
||||
{{ item.inventory > 0 ? `${item.inventory} left` : 'Sold out' }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="card-text" v-if="item.description">{{ item.description }}</p>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent border-0 pt-0 pb-3">
|
||||
<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>
|
||||
<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 class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
|
||||
<label for="TemplateConfig" class="form-label">Template JSON</label>
|
||||
<textarea id="TemplateConfig" name="@Model.templateId" rows="21" cols="21" class="form-control font-monospace" style="font-size:.85rem" v-model="itemsJSON" v-on:change="updateFromJSON">@Model.template</textarea>
|
||||
<span asp-validation-for="@Model.templateId" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
|
||||
<link href="~/main/editor.css" rel="stylesheet" asp-append-version="true" />
|
||||
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const fileUploadUrl = @Safe.Json(Url.Action("FileUpload", "UIApps", new { appId = Context.GetRouteValue("appId") }));
|
||||
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 () {
|
||||
return {
|
||||
config,
|
||||
errors: [],
|
||||
editingIndex: null,
|
||||
editingItem: null,
|
||||
customPriceOptions: [
|
||||
{ text: 'Fixed', value: "Fixed" },
|
||||
{ text: 'Minimum', value: "Minimum" },
|
||||
{ text: 'Custom', value: 'Topup' },
|
||||
],
|
||||
categoriesSelect: null,
|
||||
productModal: null,
|
||||
uploadDisabled: true,
|
||||
uploadError: null
|
||||
}
|
||||
},
|
||||
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: {
|
||||
updateFromJSON(event) {
|
||||
const config = parseConfig(event.target.value)
|
||||
if (!config) return
|
||||
this.config = config
|
||||
},
|
||||
getImage(item) {
|
||||
const image = item.image || "~/img/img-placeholder.svg";
|
||||
return image.startsWith("~") ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image
|
||||
},
|
||||
removeItem(index) {
|
||||
this.config.splice(index, 1);
|
||||
},
|
||||
addItem() {
|
||||
this.setEditingItem(null, { id: '', title: '', price: 0, image: '', description: '', categories: [], priceType: 'Fixed', inventory: null, disabled: false });
|
||||
},
|
||||
editItem(index) {
|
||||
this.setEditingItem(index, Object.assign({ id: '', title: '', price: 0, image: '', description: '', categories: [], priceType: 'Fixed', inventory: null, disabled: false }, this.config[index]));
|
||||
},
|
||||
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();
|
||||
},
|
||||
validate () {
|
||||
this.errors = [];
|
||||
if (this.editingItem.id) {
|
||||
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.editingItem.id.startsWith("- "))
|
||||
this.errors.push("Id cannot start with \"- \"");
|
||||
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("- ")){
|
||||
this.errors.push("Description cannot start with \"- \"");
|
||||
}
|
||||
if (!this.$refs.editorImage.checkValidity()) {
|
||||
this.errors.push("Image cannot have * or #");
|
||||
}
|
||||
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("- ")){
|
||||
this.errors.push("Title cannot start with \"- \"");
|
||||
} else if (!this.editingItem.title.trim()){
|
||||
this.errors.push("Title is required");
|
||||
}
|
||||
if (!this.$refs.txtInventory.checkValidity()) {
|
||||
this.errors.push("Inventory must be blank or a a valid number (>=0)");
|
||||
}
|
||||
return this.errors.length === 0;
|
||||
},
|
||||
setEditingItem(index, item) {
|
||||
this.errors = [];
|
||||
this.editingIndex = index;
|
||||
this.editingItem = item;
|
||||
if (this.editingItem != null) {
|
||||
this.categoriesSelect.setValue(this.editingItem.categories);
|
||||
this.productModal.show();
|
||||
}
|
||||
},
|
||||
uploadFileChanged () {
|
||||
this.uploadDisabled = !this.$refs.editorImage || this.$refs.editorImage.files.length === 0;
|
||||
},
|
||||
async uploadFile() {
|
||||
const file = this.$refs.editorImage.files[0];
|
||||
if (!file) return this.uploadError = 'No file selected';
|
||||
|
||||
this.uploadError = null;
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const response = await fetch(fileUploadUrl, { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
const { error, fileUrl } = await response.json();
|
||||
if (error) return this.uploadError = error;
|
||||
|
||||
this.editingItem.image = fileUrl;
|
||||
this.$refs.editorImage.value = null;
|
||||
this.uploadDisabled = true;
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.uploadError = 'Upload failed';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Number.prototype.noExponents = function(){
|
||||
var data= String(this).split(/[eE]/);
|
||||
if (data.length== 1) return data[0];
|
||||
|
||||
var z= '', sign= this<0? '-':'',
|
||||
str= data[0].replace('.', ''),
|
||||
mag= Number(data[1])+ 1;
|
||||
|
||||
if(mag<0){
|
||||
z= sign + '0.';
|
||||
while(mag++) z += '0';
|
||||
return z + str.replace(/^\-/,'');
|
||||
}
|
||||
mag -= str.length;
|
||||
while(mag--) z += '0';
|
||||
return str + z;
|
||||
};
|
||||
</script>
|
||||
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vue-sortable/sortable.min.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/vue-sortable/vue-sortable.js" asp-append-version="true"></script>
|
||||
<script src="~/js/template-editor.js" asp-append-version="true"></script>
|
||||
|
|
|
@ -12,21 +12,8 @@
|
|||
var storeId = Context.GetCurrentStoreId();
|
||||
}
|
||||
|
||||
@section PageHeadCOntent {
|
||||
<style>
|
||||
#FormEditor .nav-link { background: none; padding: 0; font-weight: var(--btcpay-font-weight-semibold); font-size: 1.125rem; }
|
||||
#FormEditor .nav-link.active { color: var(--btcpay-primary); }
|
||||
#FormEditor .list-group-item:not(.active) { background: none; }
|
||||
#FormEditor .list-group-item { border: none !important; margin-top: 0 !important; padding: var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-s) !important; }
|
||||
#FormEditor fieldset .list-group-item { padding: var(--btcpay-space-m) 0 var(--btcpay-space-m) !important; }
|
||||
#FormEditor .control { color: var(--btcpay-body-text-muted); border: none !important; padding: 0 var(--btcpay-space-xs); }
|
||||
#FormEditor .control.drag[disabled] { visibility: hidden; }
|
||||
#FormEditor .control.drag:hover { color: var(--btcpay-primary); }
|
||||
#FormEditor .control.remove:hover { color: var(--btcpay-danger); }
|
||||
#FormEditor .field .form-group:last-child { margin-bottom: 0; }
|
||||
#FormEditor .nested-fields { margin: 0 -3.1rem 0 -.5rem; }
|
||||
#FormEditor .nested-fields .list-group-item { padding-right: 1rem !important; }
|
||||
</style>
|
||||
@section PageHeadContent {
|
||||
<link href="~/main/editor.css" rel="stylesheet" asp-append-version="true" />
|
||||
}
|
||||
|
||||
@section PageFootContent {
|
||||
|
@ -204,84 +191,83 @@
|
|||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
|
||||
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">
|
||||
<span>@ViewData["Title"]</span>
|
||||
<a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="More information...">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
|
||||
@if (!isNew)
|
||||
{
|
||||
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
|
||||
}
|
||||
</div>
|
||||
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h3 class="mb-0">
|
||||
<span>@ViewData["Title"]</span>
|
||||
<a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="More information...">
|
||||
<vc:icon symbol="info" />
|
||||
</a>
|
||||
</h3>
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
|
||||
@if (!isNew)
|
||||
{
|
||||
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
|
||||
}
|
||||
</div>
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group" style="max-width: 27rem;">
|
||||
<label asp-for="Name" class="form-label" data-required></label>
|
||||
<input asp-for="Name" class="form-control" required />
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-4 gap-3">
|
||||
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
|
||||
<div>
|
||||
<label asp-for="Public"></label>
|
||||
<div class="form-text">
|
||||
Standalone mode, which can be used to generate invoices
|
||||
independent of payment requests or apps.
|
||||
</div>
|
||||
</div>
|
||||
<div asp-validation-summary="All" class="text-danger"></div>
|
||||
<div class="form-group" style="max-width: 27rem;">
|
||||
<label asp-for="Name" class="form-label" data-required></label>
|
||||
<input asp-for="Name" class="form-control" required />
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="d-flex align-items-center mb-4 gap-3">
|
||||
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
|
||||
<div>
|
||||
<label asp-for="Public"></label>
|
||||
<div class="form-text">
|
||||
Standalone mode, which can be used to generate invoices
|
||||
independent of payment requests or apps.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="FormEditor">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
|
||||
<ul class="nav nav-pills gap-4" id="form-editor-tab" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false">Code</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="fw-semibold">Templates</span>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('email')" id="ApplyEmailTemplate">Email</button>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('address')" id="ApplyAddressTemplate">Address</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-lg-7 mb-4 mb-lg-0">
|
||||
<fields-editor :path="[]"
|
||||
:fields="fields"
|
||||
:selected-field="selectedField"
|
||||
v-on:add-field="addField"
|
||||
v-on:sort-fields="sortFields"
|
||||
v-on:select-field="selectField"
|
||||
v-on:remove-field="removeField"
|
||||
class="bg-tile pb-2 rounded" />
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<field-editor :field="selectedField" class="bg-tile p-4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
|
||||
<div class="d-flex align-items-center justify-content-between gap-3">
|
||||
<label asp-for="FormConfig" class="form-label" data-required>Form JSON</label>
|
||||
</div>
|
||||
<textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
|
||||
<span asp-validation-for="FormConfig" class="text-danger"></span>
|
||||
</div>
|
||||
<div id="FormEditor" class="editor">
|
||||
<div class="d-flex flex-wrap align-items-end justify-content-between gap-3 mb-3">
|
||||
<ul class="nav nav-pills gap-4" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="EditorTabButton" data-bs-toggle="pill" data-bs-target="#EditorTabPane" type="button" role="tab" aria-controls="EditorTabPane" aria-selected="true">Editor</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="CodeTabButton" data-bs-toggle="pill" data-bs-target="#CodeTabPane" type="button" role="tab" aria-controls="CodeTabPane" aria-selected="false">Code</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="fw-semibold">Templates</span>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('email')" id="ApplyEmailTemplate">Email</button>
|
||||
<button type="button" class="btn btn-link p-0 fw-semibold" v-on:click="applyTemplate('address')" id="ApplyAddressTemplate">Address</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
|
||||
<div class="row align-items-start">
|
||||
<div class="col-lg-7 mb-4 mb-lg-0">
|
||||
<fields-editor :path="[]"
|
||||
:fields="fields"
|
||||
:selected-field="selectedField"
|
||||
v-on:add-field="addField"
|
||||
v-on:sort-fields="sortFields"
|
||||
v-on:select-field="selectField"
|
||||
v-on:remove-field="removeField"
|
||||
:class="{ 'pt-2': (!fields || fields.length === 0) }"
|
||||
class="bg-tile pb-2 rounded" />
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
<field-editor :field="selectedField" class="bg-tile p-4 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
|
||||
<label asp-for="FormConfig" class="form-label" data-required>Form JSON</label>
|
||||
<textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
|
||||
<span asp-validation-for="FormConfig" class="text-danger"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
@ -45,7 +45,6 @@
|
|||
<script src="~/vendor/signalr/signalr.js" asp-append-version="true"></script>
|
||||
<script src="~/vendor/animejs/anime.min.js" asp-append-version="true"></script>
|
||||
<script src="~/payment-request/app.js" asp-append-version="true"></script>
|
||||
<script src="~/payment-request/helpers/math.js" asp-append-version="true"></script>
|
||||
<script src="~/payment-request/services/listener.js" asp-append-version="true"></script>
|
||||
<script src="~/modal/btcpay.js" asp-append-version="true"></script>
|
||||
<style>
|
||||
|
|
26
BTCPayServer/wwwroot/crowdfund/admin.js
Normal file
26
BTCPayServer/wwwroot/crowdfund/admin.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
const resetRow = document.getElementById('ResetRow');
|
||||
const startDateInputId = "StartDate";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
setTimeout(() => {
|
||||
flatpickrInstances.forEach((instance) => {
|
||||
if (instance.element.id === startDateInputId) {
|
||||
instance.config.onChange.push((selectedDates) => {
|
||||
if (selectedDates.length) {
|
||||
// Show the reset row if start date is selected.
|
||||
// Since start date must be selected in order for the reset options to be set
|
||||
// we don't need to show it by default and can show it only when start date is selected
|
||||
resetRow.removeAttribute('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 0);
|
||||
|
||||
document.addEventListener('input-group-clear-input-value-cleared', ({ detail }) => {
|
||||
const input = detail[0];
|
||||
if (input.id === startDateInputId) {
|
||||
resetRow.setAttribute('hidden', 'hidden');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,21 +0,0 @@
|
|||
String.prototype.noExponents= function(){
|
||||
const data = String(this).split(/[eE]/);
|
||||
if(data.length== 1) return data[0];
|
||||
|
||||
var z= '', sign= this<0? '-':'',
|
||||
str= data[0].replace('.', ''),
|
||||
mag= Number(data[1])+ 1;
|
||||
|
||||
if(mag<0){
|
||||
z= sign + '0.';
|
||||
while(mag++) z += '0';
|
||||
return z + str.replace(/^\-/,'');
|
||||
}
|
||||
mag -= str.length;
|
||||
while(mag--) z += '0';
|
||||
return str + z;
|
||||
}
|
||||
|
||||
Number.prototype.noExponents= function(){
|
||||
return String(this).noExponents();
|
||||
};
|
|
@ -120,7 +120,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||
this.field.options.push({ value: `newOption${index}`, text: `New option ${index}` })
|
||||
},
|
||||
removeOption(event, index) {
|
||||
console.log(this.field.options, index)
|
||||
this.field.options.splice(index, 1)
|
||||
},
|
||||
sortOptions (event) {
|
||||
|
|
283
BTCPayServer/wwwroot/js/template-editor.js
Normal file
283
BTCPayServer/wwwroot/js/template-editor.js
Normal file
|
@ -0,0 +1,283 @@
|
|||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const parseConfig = str => {
|
||||
try {
|
||||
return JSON.parse(str)
|
||||
} catch (err) {
|
||||
console.error('Error deserializing form config:', err)
|
||||
}
|
||||
}
|
||||
const $config = document.getElementById('TemplateConfig')
|
||||
let items = parseConfig($config.value) || []
|
||||
|
||||
const itemProps = {
|
||||
id: String,
|
||||
title: String,
|
||||
image: String,
|
||||
description: String,
|
||||
priceType: String,
|
||||
price: Number,
|
||||
inventory: Number,
|
||||
disabled: Boolean,
|
||||
categories: Array
|
||||
}
|
||||
|
||||
const Item = Vue.extend({
|
||||
name: 'item',
|
||||
template: '#item',
|
||||
props: {
|
||||
...itemProps
|
||||
}
|
||||
})
|
||||
|
||||
const ItemEditorUpload = Vue.component('item-editor-upload', {
|
||||
template: '#item-editor-upload',
|
||||
props: {
|
||||
uploadUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
error: null,
|
||||
disabled: true
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
fileChanged () {
|
||||
this.disabled = !this.$refs.input || this.$refs.input.files.length === 0;
|
||||
},
|
||||
reportError(error) {
|
||||
this.error = error;
|
||||
this.$refs.input.classList.add('is-invalid');
|
||||
this.$emit('error', error);
|
||||
},
|
||||
async upload() {
|
||||
const file = this.$refs.input.files[0];
|
||||
if (!file) return this.reportError('No file selected');
|
||||
this.error = null;
|
||||
this.$refs.input.classList.remove('is-invalid');
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
try {
|
||||
const response = await fetch(this.uploadUrl, { method: 'POST', body: formData });
|
||||
if (response.ok) {
|
||||
const { error, fileUrl } = await response.json();
|
||||
if (error) {
|
||||
this.reportError(error)
|
||||
} else {
|
||||
this.$refs.input.value = null;
|
||||
this.disabled = true;
|
||||
this.$emit('uploaded', fileUrl);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.reportError('Upload failed');
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const ItemEditor = Vue.component('item-editor', {
|
||||
template: '#item-editor',
|
||||
components: {
|
||||
Item,
|
||||
ItemEditorUpload
|
||||
},
|
||||
props: {
|
||||
item: itemProps
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
errors: [],
|
||||
editingItem: null,
|
||||
categoriesSelect: null,
|
||||
customPriceOptions: [
|
||||
{ text: 'Fixed', value: 'Fixed' },
|
||||
{ text: 'Minimum', value: 'Minimum' },
|
||||
{ text: 'Custom', value: 'Topup' },
|
||||
]
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
allCategories() {
|
||||
return this.$parent.allCategories;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
validate () {
|
||||
this.errors = [];
|
||||
|
||||
if (this.editingItem.id) {
|
||||
const existingItem = this.$parent.items.find(i => i.id === this.editingItem.id);
|
||||
if (existingItem && existingItem.id !== this.item.id)
|
||||
this.errors.push(`An item with the ID "${this.editingItem.id}" already exists`);
|
||||
if (this.editingItem.id.startsWith('-'))
|
||||
this.errors.push('ID cannot start with "-"');
|
||||
else if (this.editingItem.id.trim() === '')
|
||||
this.errors.push('ID is required');
|
||||
} else {
|
||||
this.errors.push('ID is required');
|
||||
}
|
||||
|
||||
const { inputTitle, inputPrice, inputInventory } = this.$refs
|
||||
Object.keys(this.$refs).forEach(ref => {
|
||||
if (ref.startsWith('input')) {
|
||||
const $ref = this.$refs[ref];
|
||||
$ref.classList.toggle('is-invalid', !$ref.checkValidity())
|
||||
}
|
||||
})
|
||||
|
||||
if (this.editingItem.priceType !== 'Topup' && !inputPrice.checkValidity()) {
|
||||
this.errors.push('Price must be a valid number');
|
||||
}
|
||||
|
||||
if (!inputTitle.checkValidity()) {
|
||||
this.errors.push('Title is required');
|
||||
} else if (this.editingItem.title.startsWith('-')){
|
||||
this.errors.push('Title cannot start with "-"');
|
||||
} else if (!this.editingItem.title.trim()){
|
||||
this.errors.push('Title is required');
|
||||
}
|
||||
|
||||
if (!inputInventory.checkValidity()) {
|
||||
this.errors.push('Inventory must not be set or be a valid number (>=0)');
|
||||
}
|
||||
|
||||
return this.errors.length === 0;
|
||||
},
|
||||
apply() {
|
||||
// set id from title if not set
|
||||
if (!this.editingItem.id) this.editingItem.id = this.editingItem.title.toLowerCase().trim();
|
||||
// validate
|
||||
if (!this.validate()) return;
|
||||
// set item props
|
||||
Object.keys(this.editingItem).forEach(prop => {
|
||||
const value = this.editingItem[prop];
|
||||
Vue.set(this.$parent.selectedItem, prop, value);
|
||||
})
|
||||
// remove empty/non-existing props on item
|
||||
Object.keys(this.item).forEach(prop => {
|
||||
const value = this.editingItem[prop];
|
||||
if (typeof value === 'undefined' || value === null) {
|
||||
Vue.delete(this.$parent.selectedItem, prop);
|
||||
}
|
||||
})
|
||||
// update categories
|
||||
this.categoriesSelect.clearOptions();
|
||||
this.categoriesSelect.addOptions(this.allCategories.map(value => ({ value, text: value })));
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
item: function (newItem) {
|
||||
this.errors = [];
|
||||
this.editingItem = newItem ? { ...newItem } : null;
|
||||
if (this.editingItem != null) {
|
||||
this.categoriesSelect.setValue(this.editingItem.categories);
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
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();
|
||||
}
|
||||
})
|
||||
|
||||
const ItemsEditor = Vue.component('items-editor', {
|
||||
template: '#items-editor',
|
||||
components: {
|
||||
Item
|
||||
},
|
||||
props: {
|
||||
items: Array,
|
||||
selectedItem: itemProps
|
||||
},
|
||||
methods: {
|
||||
getImage(item) {
|
||||
const image = item.image || '~/img/img-placeholder.svg';
|
||||
return image.startsWith('~') ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Vue.use(vSortable)
|
||||
Vue.use(VueSanitizeDirective.default)
|
||||
|
||||
new Vue({
|
||||
el: '#TemplateEditor',
|
||||
name: 'template-editor',
|
||||
components: {
|
||||
ItemsEditor,
|
||||
ItemEditor
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
items,
|
||||
selectedItem: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
itemsJSON() {
|
||||
return JSON.stringify(this.items, null, 2)
|
||||
},
|
||||
allCategories() {
|
||||
return Array.from(this.items.reduce((res, item) => {
|
||||
(item.categories || []).forEach(category => { res.add(category); });
|
||||
return res;
|
||||
}, new Set()));
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateFromJSON(event) {
|
||||
const items = parseConfig(event.target.value)
|
||||
if (!items) return
|
||||
this.items = items
|
||||
this.selectedItem = null
|
||||
},
|
||||
addItem(event) {
|
||||
const length = this.items.push({
|
||||
id: '',
|
||||
title: '',
|
||||
priceType: 'Fixed',
|
||||
price: 0,
|
||||
image: '',
|
||||
description: '',
|
||||
categories: [],
|
||||
inventory: null,
|
||||
disabled: false
|
||||
})
|
||||
this.selectedItem = this.items[length - 1]
|
||||
},
|
||||
selectItem(event, index) {
|
||||
this.selectedItem = this.items[index]
|
||||
},
|
||||
removeItem(event, index) {
|
||||
this.items.splice(index, 1)
|
||||
this.selectedItem = null
|
||||
},
|
||||
sortItems(event) {
|
||||
const { newIndex, oldIndex } = event
|
||||
this.items.splice(newIndex, 0, this.items.splice(oldIndex, 1)[0])
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (!this.items) this.items = []
|
||||
}
|
||||
})
|
||||
})
|
65
BTCPayServer/wwwroot/main/editor.css
Normal file
65
BTCPayServer/wwwroot/main/editor.css
Normal file
|
@ -0,0 +1,65 @@
|
|||
.editor .card-img-top {
|
||||
max-height: 210px;
|
||||
object-fit: scale-down;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.editor .nav-pills .nav-link {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: var(--btcpay-font-weight-semibold);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.editor .nav-pills .nav-link.active {
|
||||
color: var(--btcpay-primary);
|
||||
}
|
||||
|
||||
.editor .list-group-item:not(.active) {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.editor .list-group-item {
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-m) var(--btcpay-space-s) !important;
|
||||
}
|
||||
|
||||
.editor .list-group-item + .list-group-item.active {
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.editor fieldset .list-group-item {
|
||||
padding: var(--btcpay-space-m) 0 var(--btcpay-space-m);
|
||||
}
|
||||
|
||||
.editor .control {
|
||||
color: var(--btcpay-body-text-muted);
|
||||
border: none;
|
||||
padding: 0 var(--btcpay-space-xs);
|
||||
}
|
||||
|
||||
.editor .control.drag[disabled] {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.editor .control.drag:hover {
|
||||
color: var(--btcpay-primary);
|
||||
}
|
||||
|
||||
.editor .control.remove:hover {
|
||||
color: var(--btcpay-danger);
|
||||
}
|
||||
|
||||
.editor .field .form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor .nested-fields {
|
||||
margin: 0 -3.1rem 0 -.5rem;
|
||||
}
|
||||
|
||||
.editor .nested-fields .list-group-item {
|
||||
padding-right: 1rem;
|
||||
}
|
|
@ -495,3 +495,25 @@ if (window.Blazor) {
|
|||
reconnectionHandler: handler
|
||||
});
|
||||
}
|
||||
|
||||
String.prototype.noExponents= function(){
|
||||
const data = String(this).split(/[eE]/);
|
||||
if(data.length== 1) return data[0];
|
||||
|
||||
var z= '', sign= this<0? '-':'',
|
||||
str= data[0].replace('.', ''),
|
||||
mag= Number(data[1])+ 1;
|
||||
|
||||
if(mag<0){
|
||||
z= sign + '0.';
|
||||
while(mag++) z += '0';
|
||||
return z + str.replace(/^\-/,'');
|
||||
}
|
||||
mag -= str.length;
|
||||
while(mag--) z += '0';
|
||||
return str + z;
|
||||
}
|
||||
|
||||
Number.prototype.noExponents= function(){
|
||||
return String(this).noExponents();
|
||||
};
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
#template-editor-app .card-deck {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
grid-gap: .5rem;
|
||||
}
|
||||
|
||||
#template-editor-app .card-deck .card{
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
#template-editor-app .card-img-top {
|
||||
max-height: 210px;
|
||||
object-fit: scale-down;
|
||||
margin-bottom: auto;
|
||||
}
|
|
@ -1,17 +0,0 @@
|
|||
Number.prototype.noExponents= function(){
|
||||
var data= String(this).split(/[eE]/);
|
||||
if(data.length== 1) return data[0];
|
||||
|
||||
var z= '', sign= this<0? '-':'',
|
||||
str= data[0].replace('.', ''),
|
||||
mag= Number(data[1])+ 1;
|
||||
|
||||
if(mag<0){
|
||||
z= sign + '0.';
|
||||
while(mag++) z += '0';
|
||||
return z + str.replace(/^\-/,'');
|
||||
}
|
||||
mag -= str.length;
|
||||
while(mag--) z += '0';
|
||||
return str + z;
|
||||
};
|
53
BTCPayServer/wwwroot/pos/admin.js
Normal file
53
BTCPayServer/wwwroot/pos/admin.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
const description = document.getElementById('description');
|
||||
const products = document.getElementById('products');
|
||||
const tips = document.getElementById('tips');
|
||||
const discounts = document.getElementById('discounts');
|
||||
const buttonPriceText = document.getElementById('button-price-text');
|
||||
const customPayments = document.getElementById('custom-payments');
|
||||
|
||||
function hide(el) {
|
||||
el.setAttribute('hidden', true);
|
||||
}
|
||||
function show(el) {
|
||||
el.removeAttribute('hidden');
|
||||
}
|
||||
function updateFormForDefaultView(type) {
|
||||
switch (type) {
|
||||
case 'Static':
|
||||
case 'Print':
|
||||
hide(tips);
|
||||
hide(discounts);
|
||||
hide(buttonPriceText);
|
||||
show(description);
|
||||
show(products);
|
||||
show(customPayments);
|
||||
break;
|
||||
case 'Cart':
|
||||
show(tips);
|
||||
show(products);
|
||||
show(discounts);
|
||||
show(description);
|
||||
show(buttonPriceText);
|
||||
hide(customPayments);
|
||||
break;
|
||||
case 'Light':
|
||||
show(tips);
|
||||
show(discounts);
|
||||
hide(products);
|
||||
hide(description);
|
||||
hide(buttonPriceText);
|
||||
hide(customPayments);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const defaultView = document.querySelector('input[name="DefaultView"]:checked');
|
||||
if (defaultView) {
|
||||
updateFormForDefaultView(defaultView.value);
|
||||
}
|
||||
});
|
||||
|
||||
delegate('change', 'input[name="DefaultView"]', e => {
|
||||
updateFormForDefaultView(e.target.value);
|
||||
});
|
Loading…
Add table
Reference in a new issue