btcpayserver/BTCPayServer/Views/Shared/TemplateEditor.cshtml

353 lines
19 KiB
Text

@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>
</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>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" v-on:click="saveItem()" id="SaveItemChanges">Save</button>
</div>
</div>
</div>
</div>
<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>
</div>
</div>
<link href="~/vendor/tom-select/tom-select.bootstrap5.min.css" asp-append-version="true" rel="stylesheet">
<script src="~/vendor/tom-select/tom-select.complete.min.js" asp-append-version="true"></script>
<script>
document.addEventListener("DOMContentLoaded", () => {
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>