btcpayserver/BTCPayServer/Views/Shared/TemplateEditor.cshtml
2023-05-23 09:18:57 +09:00

301 lines
15 KiB
Text

@model (string templateId, string title, string currency)
<div id="template-editor-app" v-cloak>
<div class="form-group mb-0">
<h3 class="mt-5 mb-4">@Model.title</h3>
@if (ViewContext.ViewData.ModelState.TryGetValue(Model.templateId, out var errors))
{
foreach (var error in errors.Errors)
{
<br/>
<span class="text-danger" v-pre>@error.ErrorMessage</span>
}
}
<div class="bg-light card">
<div class="card-body " v-bind:class="{ 'card-deck': items.length > 0}">
<div v-for="(item, index) of items" class="card my-2 card-wrapper template-item me-0 ms-0" v-bind:key="item.id">
<div v-if="anyImages" class="card-img-top border-bottom" v-bind:style="getImage(item)"></div>
<div class="card-body">
<h6 class="card-title" v-html="item.title"></h6>
<div class="gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="editItem(index)">Edit</button>
<button type="button" class="btn btn-danger" v-on:click="removeItem(index)">
<i class="fa fa-trash"></i>
</button>
</div>
</div>
</div>
<div v-if="!items || items.length === 0" class="col-12 text-center">
No items.<br/>
<button type="button" class="btn btn-link" v-on:click="editItem(-1)" id="btn-add-first">
Add your first item
</button>
</div>
</div>
<div class="card-footer text-start p-3 gap-3 d-flex">
<button type="button" class="btn btn-primary" v-on:click="editItem(-1)" id="btn-add">
<i class="fa fa-plus fa-fw"></i> Add
</button>
<button type="button" class="btn btn-secondary" id="ToggleRawEditor" data-bs-toggle="collapse" data-bs-target="#RawEditor" aria-expanded="false" aria-controls="RawEditor">
Toggle raw editor
</button>
</div>
</div>
<div class="modal" id="product-modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" v-if="editingItem">{{editingItem.index>=0? "Edit" : "Add"}} Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" ref="close">
<vc:icon symbol="close"/>
</button>
</div>
<div class="modal-body" v-if="editingItem">
<div class="mb-3">
<span class="text-danger row m-2" v-for="error of errors">{{error}}</span>
<div class="form-group">
<label class="form-label" data-required>Title</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.title" autofocus ref="txtTitle" />
</div>
<div class="form-group row">
<div class="col-sm-6">
<label class="form-label">Price</label>
<select class="form-select" v-model="editingItem.priceType">
<option v-for="option in customPriceOptions" :value="option.value">{{option.text}}</option>
</select>
</div>
<div class="col-sm-6" v-show="editingItem.priceType !== 'Topup'">
<label class="form-label">&nbsp;</label>
<div class="input-group mb-2">
<input class="form-control"
inputmode="decimal"
pattern="\d*"
step="any"
min="0"
type="number"
required
v-model="editingItem.price"
ref="txtPrice"
aria-describedby="currency-addon"/>
<span class="input-group-text" id="currency-addon">@Model.currency</span>
</div>
</div>
</div>
<div class="form-group">
<label class="form-label">Image Url</label>
<input type="text" class="form-control mb-2" pattern="[^\*#]+" v-model="editingItem.image" ref="txtImage" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<textarea rows="3" cols="40" class="form-control mb-2" v-model="editingItem.description" ref="txtDescription"></textarea>
</div>
<div class="form-group">
<label class="form-label">Inventory</label>
<input type="number" inputmode="numeric" min="0" step="1" class="form-control mb-2" v-model="editingItem.inventory" ref="txtInventory" />
<div class="form-text">Leave blank to not use this feature.</div>
</div>
<div class="form-group">
<label class="form-label">ID</label>
<input type="text" required pattern="[^\*#]+" class="form-control mb-2" v-model="editingItem.id" ref="txtId" />
<div class="form-text">Leave blank to generate ID from title.</div>
</div>
<div class="form-group">
<label class="form-label">Buy Button Text</label>
<input type="text" id="BuyButtonText" class="form-control mb-2" v-model="editingItem.buyButtonText" ref="txtBuyButtonText" />
</div>
<div class="form-group d-flex align-items-center">
<input type="checkbox" id="Disabled" class="btcpay-toggle me-3" v-model="editingItem.disabled" />
<label class="form-label mb-0">Disabled</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" v-on:click="clearEditingItem()">Close</button>
<button type="button" class="btn btn-primary" v-on:click="saveEditingItem()" id="SaveItemChanges">Save</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener("DOMContentLoaded", function () {
new Vue({
el: '#template-editor-app',
data: {
errors: [],
items: [],
editingItem: null,
customPriceOptions: [
{ text: 'Fixed', value: "Fixed" },
{ text: 'Minimum', value: "Minimum" },
{ text: 'Custom', value: 'Topup' },
],
elementId: "@Model.templateId"
},
computed: {
anyImages: function(){
return !!this.items.find(function(i){ return !!i.image;});
}
},
mounted: function() {
this.load();
this.getInputElement().on("input change", this.load.bind(this));
this.getModalElement().on("hide.bs.modal", this.clearEditingItem.bind(this));
},
methods: {
getImage: function(item){
var image = this.unEscapeKey(item.image) || "~/img/img-placeholder.svg";
var url = image.startsWith("~") ? image.replace('~', window.location.pathname.substring(0, image.indexOf('/apps'))) : image;
return {
"background-image" : "url('" + url +"')",
"opacity": item.image? 1: 0.5
}
},
getInputElement : function(){ return $("#" + this.elementId); },
getModalElement : function(){ return $("#product-modal"); },
load: function(){
const template = this.getInputElement().val().trim();
if (!template){
this.items = [];
} else {
this.items = JSON.parse(template);
}
},
save: function(){
let template = JSON.stringify(this.items);
this.getInputElement().val(template);
},
editItem: function(index){
this.errors = [];
if(index < 0){
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", priceType: "Fixed", inventory: null, disabled: false};
}else{
this.editingItem = {...this.items[index], index};
}
this.editingItem = this.unEscape(this.editingItem);
this.getModalElement().modal("show");
},
removeItem: function(index){
this.items.splice(index,1);
this.save();
},
clearEditingItem: function(){
this.editingItem = null;
this.errors = [];
},
validate: function(){
this.errors = [];
if (this.editingItem.id) {
var matchedId = this.items.findIndex((x)=> { return this.unEscapeKey(x.id) === this.editingItem.id;});
if( matchedId>= 0 && matchedId != this.editingItem.index)
this.errors.push("You cannot have multiple items with the same id");
if (!this.$refs.txtId.checkValidity()) {
this.errors.push("Id is required and cannot have * or #");
}
if(this.editingItem.id.startsWith("- ")){
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.txtImage.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;
},
saveEditingItem: function(){
const fallbackId = this.editingItem.title.toLowerCase().trim();
if(!this.editingItem.id && fallbackId){
this.editingItem.id = fallbackId;
this.$nextTick(this.saveEditingItem.bind(this));
return;
}
if(!this.validate()){
return;
}
this.editingItem = this.escape(this.editingItem);
if(this.editingItem.index < 0){
this.items.push(this.editingItem);
}else{
this.items.splice(this.editingItem.index,1,this.editingItem);
}
this.save();
this.getModalElement().modal("hide");
},
escape: function(item) {
for(var k in item){
if(k !== "paymentMethods" && k!=="id"){
item[k] = $('<div/>').text(item[k]).html();
}
}
return item;
},
unEscape: function(item){
for(var k in item){
if(k !== "paymentMethods" && k!=="id" && k !== "disabled"){
item[k] = this.unEscapeKey(item[k]);
}
}
return item;
},
unEscapeKey : function(k){
// Without this check a `false` boolean value will always be returned as an empty string
if (k === false) {
return "false";
}
return $('<div/>').html(k).text();
}
}
});
});
Number.prototype.noExponents= function(){
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>