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:
d11n 2023-11-02 19:58:03 +01:00 committed by GitHub
parent c979c4774c
commit c16dfb2dcb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 702 additions and 644 deletions

View file

@ -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>

View file

@ -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);

View file

@ -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))

View file

@ -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>

View file

@ -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>

View file

@ -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>
}

View file

@ -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">&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="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">&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="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>

View file

@ -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>

View file

@ -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>

View 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');
}
});
});

View file

@ -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();
};

View file

@ -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) {

View 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 = []
}
})
})

View 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;
}

View file

@ -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();
};

View file

@ -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;
}

View file

@ -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;
};

View 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);
});