Compare commits

...

12 Commits

Author SHA1 Message Date
9a5f1ff712 Merge branch 'master' into qtioqtqotn 2023-05-25 08:51:27 +02:00
3589417b58 Form Editor: Minor wording adjustments (#4998) 2023-05-25 08:51:03 +02:00
55203e0b64 Dashboard: Fix SATS denomination display (#4994)
When the default currency of the store is SATS, the display was broken.
2023-05-25 10:08:00 +09:00
745c0cdeba [Bug] If a altcoins is disabled from BTCPay and payout processor is used, it would crash at restart 2023-05-25 09:52:43 +09:00
a918288e3b Fix codeql config to not scan vendor js, add it to solution 2023-05-23 10:38:59 +09:00
e183138d2c Remove vendor js from codeql scan 2023-05-23 10:07:08 +09:00
d3e42862ed Create codeql.yml (#4821)
Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2023-05-23 09:23:24 +09:00
8860eec254 Switch Apps to json not YML (#4792) 2023-05-23 09:18:57 +09:00
97e7e60cea Add minrelayfee to payjoin request
fixes #4689
2023-05-22 14:56:08 +02:00
44aaf7acbb Form editor (#4968)
Co-authored-by: dstrukt <gfxdsign@gmail.com>
2023-05-22 13:30:28 +02:00
9b721fae27 Better handle postgres requests for wallet objects (#4985) 2023-05-20 23:26:16 +09:00
c3f412e3bb Bump tests to Bitcoin Core 24.1 (#4988) 2023-05-20 21:38:39 +09:00
42 changed files with 1056 additions and 535 deletions

2
.github/codeql/codeql-config.yml vendored Normal file
View File

@ -0,0 +1,2 @@
paths-ignore:
- 'BTCPayServer/wwwroot/vendor/**/*.js'

80
.github/workflows/codeql.yml vendored Normal file
View File

@ -0,0 +1,80 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
# Allow running tests manually. Usefull if scan failure, or need to rescan before next scheduled date.
workflow_dispatch:
# We scan only on a schedule for now, can uncomment the following to scan on commit or PR merge later on if deemed appropriate.
# push:
# branches: [ "master" ]
# pull_request:
# branches: [ "master" ]
schedule:
# Scan every Monday 06:00 UTC.
- cron: '0 6 * * 1'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript', 'csharp' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Use only 'java' to analyze code written in Java, Kotlin or both
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
# - run: |
# echo "Run, Build Application using script"
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@ -69,7 +69,6 @@ public class Form
if (!nameReturned.Add(fullName))
{
errors.Add($"Form contains duplicate field names '{fullName}'");
continue;
}
}
return errors.Count == 0;
@ -86,15 +85,10 @@ public class Form
thisPath.Add(field.Name);
yield return (thisPath, field);
}
foreach (var child in field.Fields)
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
{
if (field.Constant)
child.Constant = true;
foreach (var descendant in GetAllFieldsCore(thisPath, field.Fields))
{
yield return descendant;
}
descendant.Field.Constant = field.Constant || descendant.Field.Constant;
yield return descendant;
}
}
}

View File

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Data;
@ -41,4 +43,7 @@ public class LightningAddressDataBlob
public decimal? Max { get; set; }
public JObject InvoiceMetadata { get; set; }
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}

View File

@ -6,6 +6,7 @@ using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.HostedServices;
using BTCPayServer.Hosting;
using BTCPayServer.Lightning;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Models.StoreViewModels;
@ -651,6 +652,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.Equal("hello", vmpos.Title);
@ -662,7 +664,6 @@ donation:
Assert.Equal("good apple", vmview.Items[0].Title);
Assert.Equal("orange", vmview.Items[1].Title);
Assert.Equal(10.0m, vmview.Items[1].Price.Value);
Assert.Equal("$5.00", vmview.Items[0].Price.Formatted);
Assert.Equal("{0} Purchase", vmview.ButtonText);
Assert.Equal("Nicolas Sexy Hair", vmview.CustomButtonText);
Assert.Equal("Wanna tip?", vmview.CustomTipText);
@ -723,6 +724,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
publicApps = user.GetController<UIPointOfSaleController>();
vmview = await publicApps.ViewPointOfSale(app.Id, PosViewType.Cart).AssertViewModelAsync<ViewPointOfSaleViewModel>();
@ -750,6 +752,8 @@ inventoryitem:
inventory: 1
noninventoryitem:
price: 10.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
//inventoryitem has 1 item available
@ -780,15 +784,13 @@ noninventoryitem:
//let's mark the inventoryitem invoice as invalid, this should return the item to back in stock
var controller = tester.PayTester.GetController<UIInvoiceController>(user.UserId, user.StoreId);
var appService = tester.PayTester.GetService<AppService>();
var eventAggregator = tester.PayTester.GetService<EventAggregator>();
Assert.IsType<JsonResult>(await controller.ChangeInvoiceState(inventoryItemInvoice.Id, "invalid"));
//check that item is back in stock
await TestUtils.EventuallyAsync(async () =>
{
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.Equal(1,
appService.Parse(vmpos.Template, "BTC").Single(item => item.Id == "inventoryitem").Inventory);
AppService.Parse(vmpos.Template).Single(item => item.Id == "inventoryitem").Inventory);
}, 10000);
//test payment methods option
@ -803,6 +805,8 @@ btconly:
- BTC
normal:
price: 1.0";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Cart, 1, null, null, null, null, "btconly").Result);
@ -847,18 +851,19 @@ g:
custom: topup
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
vmpos = await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
Assert.DoesNotContain("custom", vmpos.Template);
var items = appService.Parse(vmpos.Template, vmpos.Currency);
Assert.Contains(items, item => item.Id == "a" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup);
var items = AppService.Parse(vmpos.Template);
Assert.Contains(items, item => item.Id == "a" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "b" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "c" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "d" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed);
Assert.Contains(items, item => item.Id == "e" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum);
Assert.Contains(items, item => item.Id == "f" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.Contains(items, item => item.Id == "g" && item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup);
Assert.IsType<RedirectToActionResult>(publicApps
.ViewPointOfSale(app.Id, PosViewType.Static, null, null, null, null, null, "g").Result);
invoices = user.BitPay.GetInvoices();

View File

@ -1,10 +1,12 @@
using System.Threading.Tasks;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Hosting;
using BTCPayServer.Models.AppViewModels;
using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
using Microsoft.AspNetCore.Mvc;
using Xunit;
using Xunit.Abstractions;
@ -19,6 +21,74 @@ namespace BTCPayServer.Tests
{
}
[Fact]
[Trait("Fast", "Fast")]
public void CanParseOldYmlCorrectly()
{
var testOriginalDefaultYmlTemplate = @"
green tea:
price: 1
title: Green Tea
description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.
image: ~/img/pos-sample/green-tea.jpg
black tea:
price: 1
title: Black Tea
description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.
image: ~/img/pos-sample/black-tea.jpg
rooibos:
price: 1.2
title: Rooibos
description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.
image: ~/img/pos-sample/rooibos.jpg
pu erh:
price: 2
title: Pu Erh
description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.
image: ~/img/pos-sample/pu-erh.jpg
herbal tea:
price: 1.8
title: Herbal Tea
description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!
image: ~/img/pos-sample/herbal-tea.jpg
custom: true
fruit tea:
price: 1.5
title: Fruit Tea
description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!
image: ~/img/pos-sample/fruit-tea.jpg
inventory: 5
custom: true
";
var parsedDefault = MigrationStartupTask.ParsePOSYML(testOriginalDefaultYmlTemplate);
Assert.Equal(6, parsedDefault.Length);
Assert.Equal( "Green Tea" ,parsedDefault[0].Title);
Assert.Equal( "green tea" ,parsedDefault[0].Id);
Assert.Equal( "Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years." ,parsedDefault[0].Description);
Assert.Null( parsedDefault[0].BuyButtonText);
Assert.Equal( "~/img/pos-sample/green-tea.jpg" ,parsedDefault[0].Image);
Assert.Equal( 1 ,parsedDefault[0].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Fixed ,parsedDefault[0].PriceType);
Assert.Null( parsedDefault[0].AdditionalData);
Assert.Null( parsedDefault[0].PaymentMethods);
Assert.Equal( "Herbal Tea" ,parsedDefault[4].Title);
Assert.Equal( "herbal tea" ,parsedDefault[4].Id);
Assert.Equal( "Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!" ,parsedDefault[4].Description);
Assert.Null( parsedDefault[4].BuyButtonText);
Assert.Equal( "~/img/pos-sample/herbal-tea.jpg" ,parsedDefault[4].Image);
Assert.Equal( 1.8m ,parsedDefault[4].Price);
Assert.Equal( ViewPointOfSaleViewModel.ItemPriceType.Minimum ,parsedDefault[4].PriceType);
Assert.Null( parsedDefault[4].AdditionalData);
Assert.Null( parsedDefault[4].PaymentMethods);
}
[Fact(Timeout = LongRunningTestTimeout)]
[Trait("Integration", "Integration")]
public async Task CanUsePoSApp1()
@ -53,6 +123,7 @@ donation:
price: 1.02
custom: true
";
vmpos.Template = AppService.SerializeTemplate(MigrationStartupTask.ParsePOSYML(vmpos.Template));
Assert.IsType<RedirectToActionResult>(pos.UpdatePointOfSale(app.Id, vmpos).Result);
await pos.UpdatePointOfSale(app.Id).AssertViewModelAsync<UpdatePointOfSaleViewModel>();
var publicApps = user.GetController<UIPointOfSaleController>();

View File

@ -129,16 +129,20 @@ namespace BTCPayServer.Tests
Assert.Contains("There are no forms yet.", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateForm")).Click();
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 1");
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
var emailtemplate = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
Assert.Contains("buyerEmail", emailtemplate);
s.Driver.FindElement(By.Id("ApplyEmailTemplate")).Click();
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
s.Driver.WaitForElement(By.Id("CodeTabPane"));
var config = s.Driver.FindElement(By.Name("FormConfig")).GetAttribute("value");
Assert.Contains("buyerEmail", config);
s.Driver.FindElement(By.Name("FormConfig")).Clear();
s.Driver.FindElement(By.Name("FormConfig"))
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest"));
.SendKeys(config.Replace("Enter your email", "CustomFormInputTest"));
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.Id("ViewForm")).Click();
var formurl = s.Driver.Url;
Assert.Contains("CustomFormInputTest", s.Driver.PageSource);
s.Driver.FindElement(By.Name("buyerEmail")).SendKeys("aa@aa.com");
@ -157,12 +161,16 @@ namespace BTCPayServer.Tests
Assert.DoesNotContain("Custom Form 1", s.Driver.PageSource);
s.Driver.FindElement(By.Id("CreateForm")).Click();
s.Driver.FindElement(By.Name("Name")).SendKeys("Custom Form 2");
s.Driver.FindElement((By.CssSelector("[data-form-template='email']"))).Click();
s.Driver.FindElement(By.Id("ApplyEmailTemplate")).Click();
s.Driver.FindElement(By.Id("CodeTabButton")).Click();
s.Driver.WaitForElement(By.Id("CodeTabPane"));
s.Driver.SetCheckbox(By.Name("Public"), true);
s.Driver.FindElement(By.Name("FormConfig")).Clear();
s.Driver.FindElement(By.Name("FormConfig"))
.SendKeys(emailtemplate.Replace("Enter your email", "CustomFormInputTest2"));
.SendKeys(config.Replace("Enter your email", "CustomFormInputTest2"));
s.Driver.FindElement(By.Id("SaveButton")).Click();
s.Driver.FindElement(By.Id("ViewForm")).Click();
formurl = s.Driver.Url;
@ -956,7 +964,7 @@ namespace BTCPayServer.Tests
s.Driver.FindElement(By.Id("ToggleRawEditor")).Click();
var template = s.Driver.FindElement(By.Id("Template")).GetAttribute("value");
Assert.Contains("buyButtonText: Take my money", template);
Assert.Contains("\"buyButtonText\":\"Take my money\"", template);
s.Driver.FindElement(By.Id("SaveSettings")).Click();
Assert.Contains("App updated", s.FindAlertMessage().Text);

View File

@ -358,6 +358,11 @@ retry:
version = Regex.Match(actual, "Original file: /npm/dom-confetti@([0-9]+.[0-9]+.[0-9]+)/lib/main.js").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/dom-confetti@{version}/lib/main.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sortable", "sortable.min.js").Trim();
version = Regex.Match(actual, "Sortable ([0-9]+.[0-9]+.[0-9]+) ").Groups[1].Value;
expected = (await (await client.GetAsync($"https://unpkg.com/sortablejs@{version}/Sortable.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
}
string GetFileContent(params string[] path)

View File

@ -73,7 +73,7 @@ services:
- "sshd_datadir:/root/.ssh"
devlnd:
image: btcpayserver/bitcoin:24.0
image: btcpayserver/bitcoin:24.1-1
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"
@ -135,7 +135,7 @@ services:
bitcoind:
restart: unless-stopped
image: btcpayserver/bitcoin:24.0
image: btcpayserver/bitcoin:24.1-1
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"

View File

@ -70,7 +70,7 @@ services:
- "sshd_datadir:/root/.ssh"
devlnd:
image: btcpayserver/bitcoin:24.0
image: btcpayserver/bitcoin:24.1-1
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"
@ -121,7 +121,7 @@ services:
bitcoind:
restart: unless-stopped
image: btcpayserver/bitcoin:24.0
image: btcpayserver/bitcoin:24.1-1
environment:
BITCOIN_NETWORK: regtest
BITCOIN_WALLETDIR: "/data/wallets"

View File

@ -45,6 +45,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="BIP78.Sender" Version="0.2.2" />
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.2" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.4.24" />
@ -75,7 +76,6 @@
<PackageReference Include="TwentyTwenty.Storage.Azure" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Google" Version="2.12.1" />
<PackageReference Include="TwentyTwenty.Storage.Local" Version="2.12.1" />
<PackageReference Include="YamlDotNet" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="6.0.9" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="6.0.9" />
</ItemGroup>
@ -111,9 +111,6 @@
<None Include="wwwroot\vendor\font-awesome\scss\_screen-reader.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_stacked.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_variables.scss" />
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.compatibility.js" />
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.js" />
<None Include="wwwroot\vendor\jquery-easing\jquery.easing.min.js" />
<None Include="wwwroot\vendor\jquery\jquery.js" />
<None Include="wwwroot\vendor\jquery\jquery.min.js" />
</ItemGroup>

View File

@ -92,8 +92,7 @@
window.setTimeout(() => {
const yLabels = [...document.querySelectorAll('.ct-label.ct-vertical.ct-start')];
if (yLabels) {
const factor = rate ? 6 : 8;
const width = Math.max(...(yLabels.map(l => l.innerText.length * factor)));
const width = Math.max(...(yLabels.map(l => l.innerText.length * 7.5)));
const opts = Object.assign({}, renderOpts, {
axisY: Object.assign({}, renderOpts.axisY, { offset: width })
});

View File

@ -245,7 +245,7 @@ namespace BTCPayServer.Controllers.Greenfield
EmbeddedCSS = request.EmbeddedCSS?.Trim(),
NotificationUrl = request.NotificationUrl?.Trim(),
Tagline = request.Tagline?.Trim(),
PerksTemplate = request.PerksTemplate is not null ? _appService.SerializeTemplate(_appService.Parse(request.PerksTemplate.Trim(), request.TargetCurrency!)) : null,
PerksTemplate = request.PerksTemplate is not null ? AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate.Trim())) : null,
// If Disqus shortname is not null or empty we assume that Disqus should be enabled
DisqusEnabled = !string.IsNullOrEmpty(request.DisqusShortname?.Trim()),
DisqusShortname = request.DisqusShortname?.Trim(),
@ -272,7 +272,7 @@ namespace BTCPayServer.Controllers.Greenfield
ShowDiscount = request.ShowDiscount,
EnableTips = request.EnableTips,
Currency = request.Currency,
Template = request.Template != null ? _appService.SerializeTemplate(_appService.Parse(request.Template, request.Currency)) : null,
Template = request.Template != null ? AppService.SerializeTemplate(AppService.Parse(request.Template)) : null,
ButtonText = request.FixedAmountPayButtonText ?? PointOfSaleSettings.BUTTON_TEXT_DEF,
CustomButtonText = request.CustomAmountPayButtonText ?? PointOfSaleSettings.CUSTOM_BUTTON_TEXT_DEF,
CustomTipText = request.TipText ?? PointOfSaleSettings.CUSTOM_TIP_TEXT_DEF,
@ -331,7 +331,7 @@ namespace BTCPayServer.Controllers.Greenfield
Currency = settings.Currency,
Items = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
_appService.Parse(settings.Template, settings.Currency),
AppService.Parse(settings.Template),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
@ -363,8 +363,8 @@ namespace BTCPayServer.Controllers.Greenfield
{
try
{
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.Template, "USD"));
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.Template));
}
catch
{
@ -406,7 +406,7 @@ namespace BTCPayServer.Controllers.Greenfield
Tagline = settings.Tagline,
Perks = JsonConvert.DeserializeObject(
JsonConvert.SerializeObject(
_appService.Parse(settings.PerksTemplate, settings.TargetCurrency),
AppService.Parse(settings.PerksTemplate),
new JsonSerializerSettings
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver()
@ -453,8 +453,8 @@ namespace BTCPayServer.Controllers.Greenfield
try
{
// Just checking if we can serialize, we don't care about the currency
_appService.SerializeTemplate(_appService.Parse(request.PerksTemplate, "USD"));
// Just checking if we can serialize
AppService.SerializeTemplate(AppService.Parse(request.PerksTemplate));
}
catch
{

View File

@ -585,6 +585,7 @@ namespace BTCPayServer.Controllers.Greenfield
{
await _delayedTransactionBroadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0),
transaction, network);
_payjoinClient.MinimumFeeRate = minRelayFee;
var payjoinPSBT = await _payjoinClient.RequestPayjoin(
new BitcoinUrlBuilder(signingContext.PayJoinBIP21, network.NBitcoinNetwork),
new PayjoinWallet(derivationScheme),

View File

@ -257,12 +257,12 @@ namespace BTCPayServer
case CrowdfundAppType.AppType:
var cfS = app.GetSettings<CrowdfundSettings>();
currencyCode = cfS.TargetCurrency;
items = _appService.Parse(cfS.PerksTemplate, cfS.TargetCurrency);
items = AppService.Parse(cfS.PerksTemplate);
break;
case PointOfSaleAppType.AppType:
posS = app.GetSettings<PointOfSaleSettings>();
currencyCode = posS.Currency;
items = _appService.Parse(posS.Template, posS.Currency);
items = AppService.Parse(posS.Template);
break;
default:
//TODO: Allow other apps to define lnurl support

View File

@ -318,6 +318,8 @@ namespace BTCPayServer.Controllers
await _broadcaster.Schedule(DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0), cloned.ExtractTransaction(), btcPayNetwork);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(30));
var minRelayFee = _dashboard.Get(btcPayNetwork.CryptoCode).Status.BitcoinStatus?.MinRelayTxFee;
_payjoinClient.MinimumFeeRate = minRelayFee;
return await _payjoinClient.RequestPayjoin(bip21, new PayjoinWallet(derivationSchemeSettings), psbt, cts.Token);
}

View File

@ -77,7 +77,6 @@ public class UIFormsController : Controller
if (!_formDataService.IsFormSchemaValid(modifyForm.FormConfig, out var form, out var error))
{
ModelState.AddModelError(nameof(modifyForm.FormConfig),
$"Form config was invalid: {error})");
}
@ -86,7 +85,6 @@ public class UIFormsController : Controller
modifyForm.FormConfig = form.ToString();
}
if (!ModelState.IsValid)
{
return View(modifyForm);

View File

@ -40,11 +40,11 @@ namespace BTCPayServer.HostedServices
case PointOfSaleAppType.AppType:
var possettings = data.GetSettings<PointOfSaleSettings>();
return (Data: data, Settings: (object)possettings,
Items: _appService.Parse(possettings.Template, possettings.Currency));
Items: AppService.Parse(possettings.Template));
case CrowdfundAppType.AppType:
var cfsettings = data.GetSettings<CrowdfundSettings>();
return (Data: data, Settings: (object)cfsettings,
Items: _appService.Parse(cfsettings.PerksTemplate, cfsettings.TargetCurrency));
Items: AppService.Parse(cfsettings.PerksTemplate));
default:
return (null, null, null);
}
@ -70,11 +70,11 @@ namespace BTCPayServer.HostedServices
{
case PointOfSaleAppType.AppType:
((PointOfSaleSettings)valueTuple.Settings).Template =
_appService.SerializeTemplate(valueTuple.Items);
AppService.SerializeTemplate(valueTuple.Items);
break;
case CrowdfundAppType.AppType:
((CrowdfundSettings)valueTuple.Settings).PerksTemplate =
_appService.SerializeTemplate(valueTuple.Items);
AppService.SerializeTemplate(valueTuple.Items);
break;
default:
throw new InvalidOperationException();

View File

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@ -26,11 +27,14 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using NBXplorer;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using PeterO.Cbor;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using LightningAddressData = BTCPayServer.Data.LightningAddressData;
using Serializer = NBXplorer.Serializer;
namespace BTCPayServer.Hosting
{
@ -246,6 +250,11 @@ namespace BTCPayServer.Hosting
{
await FixMappedDomainAppType();
settings.FixMappedDomainAppType = true;
}
if (!settings.MigrateAppYmlToJson)
{
await MigrateAppYmlToJson();
settings.MigrateAppYmlToJson = true;
await _Settings.UpdateSetting(settings);
}
}
@ -304,6 +313,134 @@ namespace BTCPayServer.Hosting
return;
await ToPostgresMigrationStartupTask.UpdateSequenceInvoiceSearch(ctx);
}
private async Task MigrateAppYmlToJson()
{
await using var ctx = _DBContextFactory.CreateContext();
var apps = await ctx.Apps.Where(data => CrowdfundAppType.AppType == data.AppType || PointOfSaleAppType.AppType == data.AppType)
.ToListAsync();
foreach (var app in apps)
{
switch (app.AppType)
{
case CrowdfundAppType.AppType :
var cfSettings = app.GetSettings<CrowdfundSettings>();
if (!string.IsNullOrEmpty(cfSettings?.PerksTemplate))
{
cfSettings.PerksTemplate = AppService.SerializeTemplate(ParsePOSYML(cfSettings?.PerksTemplate));
app.SetSettings(cfSettings);
}
break;
case PointOfSaleAppType.AppType:
var pSettings = app.GetSettings<PointOfSaleSettings>();
if (!string.IsNullOrEmpty(pSettings?.Template))
{
pSettings.Template = AppService.SerializeTemplate(ParsePOSYML(pSettings?.Template));
app.SetSettings(pSettings);
}
break;
}
}
await ctx.SaveChangesAsync();
}
public static ViewPointOfSaleViewModel.Item[] ParsePOSYML(string yaml)
{
var items = new List<ViewPointOfSaleViewModel.Item>();
var stream = new YamlStream();
stream.Load(new StringReader(yaml));
var root = stream.Documents.First().RootNode as YamlMappingNode;
foreach (var posItem in root.Children)
{
var trimmedKey = ((YamlScalarNode)posItem.Key).Value?.Trim();
if (string.IsNullOrEmpty(trimmedKey))
{
continue;
}
var currentItem = new ViewPointOfSaleViewModel.Item
{
Id = trimmedKey, Title = trimmedKey, PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed
};
var itemSpecs = (YamlMappingNode)posItem.Value;
foreach (var spec in itemSpecs)
{
if (spec.Key is not YamlScalarNode {Value: string keyString} || string.IsNullOrEmpty(keyString))
continue;
var scalarValue = spec.Value as YamlScalarNode;
switch (keyString)
{
case "title":
currentItem.Title = scalarValue?.Value ?? trimmedKey;
break;
case "inventory":
if (int.TryParse(scalarValue?.Value, out var inv))
{
currentItem.Inventory = inv;
}
break;
case "description":
currentItem.Description = scalarValue?.Value;
break;
case "image":
currentItem.Image = scalarValue?.Value;
break;
case "payment_methods" when spec.Value is YamlSequenceNode pmSequenceNode:
currentItem.PaymentMethods = pmSequenceNode.Children
.Select(node => (node as YamlScalarNode)?.Value?.Trim())
.Where(node => !string.IsNullOrEmpty(node)).ToArray();
break;
case "price_type":
case "custom":
if (bool.TryParse(scalarValue?.Value, out var customBoolValue))
{
if (customBoolValue)
{
currentItem.PriceType = currentItem.Price is null or 0
? ViewPointOfSaleViewModel.ItemPriceType.Topup
: ViewPointOfSaleViewModel.ItemPriceType.Minimum;
}
else
{
currentItem.PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed;
}
}
else if (Enum.TryParse<ViewPointOfSaleViewModel.ItemPriceType>(scalarValue?.Value, true,
out var customPriceType))
{
currentItem.PriceType = customPriceType;
}
break;
case "price":
if (decimal.TryParse(scalarValue?.Value, out var price))
{
currentItem.Price = price;
}
break;
case "buybuttontext":
currentItem.BuyButtonText = scalarValue?.Value;
break;
case "disabled":
if (bool.TryParse(scalarValue?.Value, out var disabled))
{
currentItem.Disabled = disabled;
}
break;
}
}
items.Add(currentItem);
}
return items.ToArray();
}
#pragma warning disable CS0612 // Type or member is obsolete
@ -521,8 +658,8 @@ WHERE cte.""Id""=p.""Id""
settings1.TargetCurrency = app.StoreData.GetStoreBlob().DefaultCurrency;
app.SetSettings(settings1);
}
items = _appService.Parse(settings1.PerksTemplate, settings1.TargetCurrency);
newTemplate = _appService.SerializeTemplate(items);
items = AppService.Parse(settings1.PerksTemplate);
newTemplate = AppService.SerializeTemplate(items);
if (settings1.PerksTemplate != newTemplate)
{
settings1.PerksTemplate = newTemplate;
@ -538,8 +675,8 @@ WHERE cte.""Id""=p.""Id""
settings2.Currency = app.StoreData.GetStoreBlob().DefaultCurrency;
app.SetSettings(settings2);
}
items = _appService.Parse(settings2.Template, settings2.Currency);
newTemplate = _appService.SerializeTemplate(items);
items = AppService.Parse(settings2.Template);
newTemplate = AppService.SerializeTemplate(items);
if (settings2.Template != newTemplate)
{
settings2.Template = newTemplate;

View File

@ -136,7 +136,16 @@ public class PayoutProcessorService : EventHostedServiceBase
if (matchedProcessor is not null)
{
await StopProcessor(data.Id, cancellationToken);
var processor = await matchedProcessor.ConstructProcessor(data);
IHostedService processor = null;
try
{
processor = await matchedProcessor.ConstructProcessor(data);
}
catch(Exception ex)
{
Logs.PayServer.LogWarning(ex, $"Payout processor ({data.PaymentMethod}) failed to start. Skipping...");
return;
}
await processor.StartAsync(cancellationToken);
Services.TryAdd(data.Id, processor);
}

View File

@ -127,13 +127,13 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
ViewPointOfSaleViewModel.Item choice = null;
if (!string.IsNullOrEmpty(request.ChoiceKey))
{
var choices = _appService.GetPOSItems(settings.PerksTemplate, settings.TargetCurrency);
var choices = AppService.Parse(settings.PerksTemplate, false);
choice = choices?.FirstOrDefault(c => c.Id == request.ChoiceKey);
if (choice == null)
return NotFound("Incorrect option provided");
title = choice.Title;
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
price = null;
}
@ -273,7 +273,7 @@ namespace BTCPayServer.Plugins.Crowdfund.Controllers
try
{
vm.PerksTemplate = _appService.SerializeTemplate(_appService.Parse(vm.PerksTemplate, vm.TargetCurrency));
vm.PerksTemplate = AppService.SerializeTemplate(AppService.Parse(vm.PerksTemplate));
}
catch
{

View File

@ -74,14 +74,14 @@ namespace BTCPayServer.Plugins.Crowdfund
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var cfS = app.GetSettings<CrowdfundSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, cfS.PerksTemplate, cfS.TargetCurrency);
var items = AppService.Parse( cfS.PerksTemplate);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<CrowdfundSettings>();
var perks = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
var perks = AppService.Parse( settings.PerksTemplate);
var perkCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.TargetCurrency, StringComparison.OrdinalIgnoreCase) &&
// we need the item code to know which perk it is and group by that
@ -176,7 +176,7 @@ namespace BTCPayServer.Plugins.Crowdfund
})));
}
var perks = AppService.GetPOSItems(_htmlSanitizer, _displayFormatter, settings.PerksTemplate, settings.TargetCurrency);
var perks = AppService.Parse( settings.PerksTemplate, false);
if (settings.SortPerksByPopularity)
{
var ordered = perkCount.OrderByDescending(pair => pair.Value);

View File

@ -106,7 +106,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
Prefixed = new[] { 0, 2 }.Contains(numberFormatInfo.CurrencyPositivePattern),
SymbolSpace = new[] { 2, 3 }.Contains(numberFormatInfo.CurrencyPositivePattern)
},
Items = _appService.GetPOSItems(settings.Template, settings.Currency),
Items = AppService.Parse(settings.Template, false),
ButtonText = settings.ButtonText,
CustomButtonText = settings.CustomButtonText,
CustomTipText = settings.CustomTipText,
@ -165,12 +165,12 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ViewPointOfSaleViewModel.Item[] choices = null;
if (!string.IsNullOrEmpty(choiceKey))
{
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
choices = AppService.Parse(settings.Template, false);
choice = choices.FirstOrDefault(c => c.Id == choiceKey);
if (choice == null)
return NotFound();
title = choice.Title;
if (choice.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (choice.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
price = null;
}
@ -204,7 +204,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (currentView == PosViewType.Cart &&
AppService.TryParsePosCartItems(jposData, out cartItems))
{
choices = _appService.GetPOSItems(settings.Template, settings.Currency);
choices = AppService.Parse(settings.Template, false);
var expectedMinimumAmount = 0m;
foreach (var cartItem in cartItems)
{
@ -224,9 +224,9 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
decimal expectedCartItemPrice = 0;
if (itemChoice.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
if (itemChoice.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
expectedCartItemPrice = itemChoice.Price.Value ?? 0;
expectedCartItemPrice = itemChoice.Price ?? 0;
}
expectedMinimumAmount += expectedCartItemPrice * cartItem.Value;
@ -327,7 +327,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
if (selectedChoices.TryGetValue(cartItem.Key, out var selectedChoice))
{
cartData.Add(selectedChoice.Title ?? selectedChoice.Id,
$"{(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price.Value is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}");
$"{(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency((decimal)selectedChoice.Price.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")} x {cartItem.Value} = {(selectedChoice.Price is null ? "Any price" : $"{_displayFormatter.Currency(((decimal)selectedChoice.Price.Value) * cartItem.Value, settings.Currency, DisplayFormatter.CurrencyFormat.Symbol)}")}");
}
}
@ -533,7 +533,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
}
try
{
var items = _appService.Parse(settings.Template, settings.Currency);
var items = AppService.Parse(settings.Template);
var builder = new StringBuilder();
builder.AppendLine(CultureInfo.InvariantCulture, $"<form method=\"POST\" action=\"{encoder.Encode(appUrl)}\">");
builder.AppendLine($" <input type=\"hidden\" name=\"email\" value=\"customer@example.com\" />");
@ -568,7 +568,7 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
ModelState.AddModelError(nameof(vm.Currency), "Invalid currency");
try
{
vm.Template = _appService.SerializeTemplate(_appService.Parse(vm.Template, vm.Currency));
vm.Template = AppService.SerializeTemplate(AppService.Parse(vm.Template));
}
catch
{

View File

@ -1,34 +1,45 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.JsonConverters;
using BTCPayServer.Services.Apps;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.PointOfSale.Models
{
public class ViewPointOfSaleViewModel
{
public enum ItemPriceType
{
Topup,
Minimum,
Fixed
}
public class Item
{
public class ItemPrice
{
public enum ItemPriceType
{
Topup,
Minimum,
Fixed
}
public ItemPriceType Type { get; set; }
public string Formatted { get; set; }
public decimal? Value { get; set; }
}
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Description { get; set; }
public string Id { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string Image { get; set; }
public ItemPrice Price { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public ItemPriceType PriceType { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Price { get; set; }
public string Title { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string BuyButtonText { get; set; }
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public int? Inventory { get; set; } = null;
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
public string[] PaymentMethods { get; set; }
public bool Disabled { get; set; } = false;
[JsonExtensionData] public Dictionary<string, JToken> AdditionalData { get; set; }
}
public class CurrencyInfoData

View File

@ -82,14 +82,14 @@ namespace BTCPayServer.Plugins.PointOfSale
public Task<SalesStats> GetSalesStats(AppData app, InvoiceEntity[] paidInvoices, int numberOfDays)
{
var posS = app.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, posS.Template, posS.Currency);
var items = AppService.Parse(posS.Template);
return AppService.GetSalesStatswithPOSItems(items, paidInvoices, numberOfDays);
}
public Task<IEnumerable<ItemStats>> GetItemStats(AppData appData, InvoiceEntity[] paidInvoices)
{
var settings = appData.GetSettings<PointOfSaleSettings>();
var items = AppService.Parse(_htmlSanitizer, _displayFormatter, settings.Template, settings.Currency);
var items = AppService.Parse( settings.Template);
var itemCount = paidInvoices
.Where(entity => entity.Currency.Equals(settings.Currency, StringComparison.OrdinalIgnoreCase) && (
// The POS data is present for the cart view, where multiple items can be bought

View File

@ -13,18 +13,12 @@ using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using BTCPayServer.Services.Stores;
using ExchangeSharp;
using Ganss.XSS;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using NBitcoin.DataEncoders;
using NBitpayClient;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using YamlDotNet.Core;
using YamlDotNet.RepresentationModel;
using YamlDotNet.Serialization;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Services.Apps
@ -32,6 +26,17 @@ namespace BTCPayServer.Services.Apps
public class AppService
{
private readonly Dictionary<string, AppBaseType> _appTypes;
static AppService()
{
_defaultSerializer = new JsonSerializerSettings()
{
ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver(),
Formatting = Formatting.None
};
}
private static JsonSerializerSettings _defaultSerializer;
readonly ApplicationDbContextFactory _ContextFactory;
private readonly InvoiceRepository _InvoiceRepository;
readonly CurrencyNameTable _Currencies;
@ -342,169 +347,20 @@ namespace BTCPayServer.Services.Apps
{
return _storeRepository.FindStore(app.StoreDataId);
}
public string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
public static string SerializeTemplate(ViewPointOfSaleViewModel.Item[] items)
{
var mappingNode = new YamlMappingNode();
foreach (var item in items)
{
var itemNode = new YamlMappingNode();
itemNode.Add("title", new YamlScalarNode(item.Title));
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup && item.Price.Value is not null)
itemNode.Add("price", new YamlScalarNode(item.Price.Value.ToStringInvariant()));
if (!string.IsNullOrEmpty(item.Description))
{
itemNode.Add("description", new YamlScalarNode(item.Description)
{
Style = ScalarStyle.DoubleQuoted
});
}
if (!string.IsNullOrEmpty(item.Image))
{
itemNode.Add("image", new YamlScalarNode(item.Image));
}
itemNode.Add("price_type", new YamlScalarNode(item.Price.Type.ToStringLowerInvariant()));
itemNode.Add("disabled", new YamlScalarNode(item.Disabled.ToStringLowerInvariant()));
if (item.Inventory.HasValue)
{
itemNode.Add("inventory", new YamlScalarNode(item.Inventory.ToString()));
}
if (!string.IsNullOrEmpty(item.BuyButtonText))
{
itemNode.Add("buyButtonText", new YamlScalarNode(item.BuyButtonText));
}
if (item.PaymentMethods?.Any() is true)
{
itemNode.Add("payment_methods", new YamlSequenceNode(item.PaymentMethods.Select(s => new YamlScalarNode(s))));
}
mappingNode.Add(item.Id, itemNode);
}
var serializer = new SerializerBuilder().Build();
return serializer.Serialize(mappingNode);
return JsonConvert.SerializeObject(items, Formatting.Indented, _defaultSerializer);
}
public ViewPointOfSaleViewModel.Item[] Parse(string template, string currency)
{
return Parse(_HtmlSanitizer, _displayFormatter, template, currency);
}
public ViewPointOfSaleViewModel.Item[] GetPOSItems(string template, string currency)
{
return GetPOSItems(_HtmlSanitizer, _displayFormatter, template, currency);
}
public static ViewPointOfSaleViewModel.Item[] Parse(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
public static ViewPointOfSaleViewModel.Item[] Parse(string template, bool includeDisabled = true)
{
if (string.IsNullOrWhiteSpace(template))
return Array.Empty<ViewPointOfSaleViewModel.Item>();
using var input = new StringReader(template);
YamlStream stream = new();
stream.Load(input);
var root = (YamlMappingNode)stream.Documents[0].RootNode;
return root
.Children
.Select(kv => new PosHolder(htmlSanitizer) { Key = htmlSanitizer.Sanitize((kv.Key as YamlScalarNode)?.Value), Value = kv.Value as YamlMappingNode })
.Where(kv => kv.Value != null)
.Select(c =>
{
ViewPointOfSaleViewModel.Item.ItemPrice price = new();
var pValue = c.GetDetail("price")?.FirstOrDefault();
switch (c.GetDetailString("custom") ?? c.GetDetailString("price_type")?.ToLowerInvariant())
{
case "topup":
case null when pValue is null:
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup;
break;
case "true":
case "minimum":
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum;
if (pValue != null && !string.IsNullOrEmpty(pValue.Value?.Value))
{
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
}
break;
case "fixed":
case "false":
case null:
price.Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed;
if (pValue?.Value.Value is not null)
{
price.Value = decimal.Parse(pValue.Value.Value, CultureInfo.InvariantCulture);
price.Formatted = displayFormatter.Currency(price.Value.Value, currency, DisplayFormatter.CurrencyFormat.Symbol);
}
break;
}
return new ViewPointOfSaleViewModel.Item
{
Description = c.GetDetailString("description"),
Id = c.Key,
Image = c.GetDetailString("image"),
Title = c.GetDetailString("title") ?? c.Key,
Price = price,
BuyButtonText = c.GetDetailString("buyButtonText"),
Inventory =
string.IsNullOrEmpty(c.GetDetailString("inventory"))
? null
: int.Parse(c.GetDetailString("inventory"), CultureInfo.InvariantCulture),
PaymentMethods = c.GetDetailStringList("payment_methods"),
Disabled = c.GetDetailString("disabled") == "true"
};
})
.ToArray();
}
public static ViewPointOfSaleViewModel.Item[] GetPOSItems(HtmlSanitizer htmlSanitizer, DisplayFormatter displayFormatter, string template, string currency)
{
return Parse(htmlSanitizer, displayFormatter, template, currency).Where(c => !c.Disabled).ToArray();
return JsonConvert.DeserializeObject<ViewPointOfSaleViewModel.Item[]>(template, _defaultSerializer)!.Where(item => includeDisabled || !item.Disabled).ToArray();
}
#nullable restore
private class PosHolder
{
private readonly HtmlSanitizer _htmlSanitizer;
public PosHolder(
HtmlSanitizer htmlSanitizer)
{
_htmlSanitizer = htmlSanitizer;
}
public string Key { get; set; }
public YamlMappingNode Value { get; set; }
public IEnumerable<PosScalar> GetDetail(string field)
{
var res = Value.Children
.Where(kv => kv.Value != null)
.Select(kv => new PosScalar { Key = (kv.Key as YamlScalarNode)?.Value, Value = kv.Value as YamlScalarNode })
.Where(cc => cc.Key == field);
return res;
}
public string GetDetailString(string field)
{
var raw = GetDetail(field).FirstOrDefault()?.Value?.Value;
return raw is null ? null : _htmlSanitizer.Sanitize(raw);
}
public string[] GetDetailStringList(string field)
{
if (!Value.Children.ContainsKey(field) || !(Value.Children[field] is YamlSequenceNode sequenceNode))
{
return null;
}
return sequenceNode.Children.Select(node => (node as YamlScalarNode)?.Value).Where(s => s != null).Select(s => _htmlSanitizer.Sanitize(s)).ToArray();
}
}
private class PosScalar
{
public string Key { get; set; }
public YamlScalarNode Value { get; set; }
}
#nullable enable
public async Task<AppData?> GetAppDataIfOwner(string userId, string appId, string? type = null)
{

View File

@ -1,5 +1,5 @@
using BTCPayServer.Client.Models;
using BTCPayServer.Services.Stores;
using BTCPayServer.Plugins.PointOfSale.Models;
using PosViewType = BTCPayServer.Plugins.PointOfSale.PosViewType;
namespace BTCPayServer.Services.Apps
@ -9,40 +9,71 @@ namespace BTCPayServer.Services.Apps
public PointOfSaleSettings()
{
Title = "Tea shop";
Template =
"green tea:\n" +
" price: 1\n" +
" title: Green Tea\n" +
" description: Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.\n" +
" image: ~/img/pos-sample/green-tea.jpg\n\n" +
"black tea:\n" +
" price: 1\n" +
" title: Black Tea\n" +
" description: Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.\n" +
" image: ~/img/pos-sample/black-tea.jpg\n\n" +
"rooibos:\n" +
" price: 1.2\n" +
" title: Rooibos\n" +
" description: Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.\n" +
" image: ~/img/pos-sample/rooibos.jpg\n\n" +
"pu erh:\n" +
" price: 2\n" +
" title: Pu Erh\n" +
" description: This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.\n" +
" image: ~/img/pos-sample/pu-erh.jpg\n\n" +
"herbal tea:\n" +
" price: 1.8\n" +
" title: Herbal Tea\n" +
" description: Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!\n" +
" image: ~/img/pos-sample/herbal-tea.jpg\n" +
" custom: true\n\n" +
"fruit tea:\n" +
" price: 1.5\n" +
" title: Fruit Tea\n" +
" description: The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!\n" +
" image: ~/img/pos-sample/fruit-tea.jpg\n" +
" inventory: 5\n" +
" custom: true";
Template = AppService.SerializeTemplate(new ViewPointOfSaleViewModel.Item[]
{
new()
{
Id = "green-tea",
Title = "Green Tea",
Description =
"Lovely, fresh and tender, Meng Ding Gan Lu ('sweet dew') is grown in the lush Meng Ding Mountains of the southwestern province of Sichuan where it has been cultivated for over a thousand years.",
Image = "~/img/pos-sample/green-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
Price = 1
},
new()
{
Id = "black-tea",
Title = "Black Tea",
Description =
"Tian Jian Tian Jian means 'heavenly tippy tea' in Chinese, and it describes the finest grade of dark tea. Our Tian Jian dark tea is from Hunan province which is famous for making some of the best dark teas available.",
Image = "~/img/pos-sample/black-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
Price = 1
},
new()
{
Id = "rooibos",
Title = "Rooibos",
Description =
"Rooibos is a dramatic red tea made from a South African herb that contains polyphenols and flavonoids. Often called 'African redbush tea', Rooibos herbal tea delights the senses and delivers potential health benefits with each caffeine-free sip.",
Image = "~/img/pos-sample/rooibos.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
Price = 1.2m
},
new()
{
Id = "pu-erh",
Title = "Pu Erh",
Description =
"This loose pur-erh tea is produced in Yunnan Province, China. The process in a relatively high humidity environment has mellowed the elemental character of the tea when compared to young Pu-erh.",
Image = "~/img/pos-sample/pu-erh.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Fixed,
Price = 2
},
new()
{
Id = "herbal-tea",
Title = "Herbal Tea",
Description =
"Chamomile tea is made from the flower heads of the chamomile plant. The medicinal use of chamomile dates back to the ancient Egyptians, Romans and Greeks. Pay us what you want!",
Image = "~/img/pos-sample/herbal-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Minimum,
Price = 1.8m,
Disabled = true
},
new()
{
Id = "fruit-tea",
Title = "Fruit Tea",
Description =
"The Tibetan Himalayas, the land is majestic and beautiful—a spiritual place where, despite the perilous environment, many journey seeking enlightenment. Pay us what you want!",
Image = "~/img/pos-sample/fruit-tea.jpg",
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup,
Inventory = 5,
Disabled = true
}
});
DefaultView = PosViewType.Static;
ShowCustomAmount = false;
ShowDiscount = true;

View File

@ -58,5 +58,5 @@ public class PosAppCartItemPrice
public decimal Value { get; set; }
[JsonProperty(PropertyName = "type")]
public ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType Type { get; set; }
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
}

View File

@ -37,5 +37,6 @@ namespace BTCPayServer.Services
public bool FileSystemStorageAsDefault { get; set; }
public bool FixSeqAfterSqliteMigration { get; set; }
public bool FixMappedDomainAppType { get; set; }
public bool MigrateAppYmlToJson { get; set; }
}
}

View File

@ -7,10 +7,12 @@ using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Services.Wallets;
using Dapper;
using Microsoft.EntityFrameworkCore;
using NBitcoin;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
namespace BTCPayServer.Services
{
@ -365,6 +367,11 @@ namespace BTCPayServer.Services
{
SortWalletObjectLinks(ref a, ref b);
await using var ctx = _ContextFactory.CreateContext();
await UpdateWalletObjectLink(a, b, data, ctx, true);
}
private static async Task UpdateWalletObjectLink(WalletObjectId a, WalletObjectId b, JObject? data, ApplicationDbContext ctx, bool doNothingIfExists)
{
var l = new WalletObjectLinkData()
{
WalletId = a.WalletId.ToString(),
@ -374,13 +381,34 @@ namespace BTCPayServer.Services
BId = b.Id,
Data = data?.ToString(Formatting.None)
};
ctx.WalletObjectLinks.Add(l);
try
if (!ctx.Database.IsNpgsql())
{
await ctx.SaveChangesAsync();
var e = ctx.WalletObjectLinks.Add(l);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
if (!doNothingIfExists)
{
e.State = EntityState.Modified;
await ctx.SaveChangesAsync();
}
}
}
catch (DbUpdateException) // already exists
else
{
var connection = ctx.Database.GetDbConnection();
var conflict = doNothingIfExists ? "ON CONFLICT DO NOTHING" : "ON CONFLICT ON CONSTRAINT \"PK_WalletObjectLinks\" DO UPDATE SET \"Data\"=EXCLUDED.\"Data\"";
try
{
await connection.ExecuteAsync("INSERT INTO \"WalletObjectLinks\" VALUES (@WalletId, @AType, @AId, @BType, @BId, @Data::JSONB) " + conflict, l);
}
catch (PostgresException ex) when (ex.SqlState == PostgresErrorCodes.ForeignKeyViolation)
{
throw new DbUpdateException();
}
}
}
@ -411,25 +439,7 @@ namespace BTCPayServer.Services
await using var ctx = _ContextFactory.CreateContext();
var l = new WalletObjectLinkData()
{
WalletId = a.WalletId.ToString(),
AType = a.Type,
AId = a.Id,
BType = b.Type,
BId = b.Id,
Data = data?.ToString(Formatting.None)
};
var e = ctx.WalletObjectLinks.Add(l);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
e.State = EntityState.Modified;
await ctx.SaveChangesAsync();
}
await UpdateWalletObjectLink(a, b, data, ctx, false);
}
public static int MaxCommentSize = 200;
@ -558,30 +568,47 @@ namespace BTCPayServer.Services
{
ArgumentNullException.ThrowIfNull(id);
await using var ctx = _ContextFactory.CreateContext();
var o = NewWalletObjectData(id, data);
ctx.WalletObjects.Add(o);
try
var wo = NewWalletObjectData(id, data);
if (!ctx.Database.IsNpgsql())
{
await ctx.SaveChangesAsync();
ctx.WalletObjects.Add(wo);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
ctx.Entry(wo).State = EntityState.Modified;
await ctx.SaveChangesAsync();
}
}
catch (DbUpdateException) // already exists
else
{
ctx.Entry(o).State = EntityState.Modified;
await ctx.SaveChangesAsync();
var connection = ctx.Database.GetDbConnection();
await connection.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT ON CONSTRAINT \"PK_WalletObjects\" DO UPDATE SET \"Data\"=EXCLUDED.\"Data\"", wo);
}
}
public async Task EnsureWalletObject(WalletObjectId id, JObject? data = null)
{
ArgumentNullException.ThrowIfNull(id);
var wo = NewWalletObjectData(id, data);
await using var ctx = _ContextFactory.CreateContext();
ctx.WalletObjects.Add(NewWalletObjectData(id, data));
try
if (!ctx.Database.IsNpgsql())
{
await ctx.SaveChangesAsync();
ctx.WalletObjects.Add(wo);
try
{
await ctx.SaveChangesAsync();
}
catch (DbUpdateException) // already exists
{
}
}
catch (DbUpdateException) // already exists
else
{
var connection = ctx.Database.GetDbConnection();
await connection.ExecuteAsync("INSERT INTO \"WalletObjects\" VALUES (@WalletId, @Type, @Id, @Data::JSONB) ON CONFLICT DO NOTHING", wo);
}
}
#nullable restore

View File

@ -33,15 +33,15 @@
<span>@item.Price.Value</span>
<span>@vm.TargetCurrency</span>
if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum)
if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Minimum)
{
@Safe.Raw("or more")
}
}
else if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed )
else if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed )
{
@Safe.Raw("Any amount")
}else if (item.Price.Type == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)
}else if (item.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)
{
@Safe.Raw("Free")
}
@ -56,7 +56,7 @@
{
case null:
break;
case var i when i <= 0:
case <= 0:
<span>Sold out</span>
break;
default:

View File

@ -1,5 +1,7 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var customTipPercentages = Model.CustomTipPercentages;
@ -253,11 +255,12 @@
<span class="text-muted small">
@{
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
buttonText = buttonText.Replace("{0}",item.Price.Formatted)
?.Replace("{Price}",item.Price.Formatted);
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
buttonText = buttonText.Replace("{0}",formatted)
?.Replace("{Price}",formatted);
}
}
@Safe.Raw(buttonText)

View File

@ -1,11 +1,12 @@
@using BTCPayServer.Payments.Lightning
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@using BTCPayServer.Services.Stores
@using LNURL
@inject BTCPayNetworkProvider BTCPayNetworkProvider
@inject StoreRepository StoreRepository
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
var store = await StoreRepository.FindStore(Model.StoreId);
Layout = "PointOfSale/Public/_Layout";
@ -68,11 +69,7 @@ else
Description = "Create invoice to pay custom amount",
Title = "Custom amount",
BuyButtonText = Model.CustomButtonText,
Price = new ViewPointOfSaleViewModel.Item.ItemPrice()
{
Type = ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup,
}
PriceType = ViewPointOfSaleViewModel.ItemPriceType.Topup
}
}).ToArray();
}
@ -90,16 +87,19 @@ else
<p class="card-title text-center">@Safe.Raw(item.Description)</p>
}
<div class="w-100 mb-3 fs-5 text-center">
@switch (item.Price.Type)
@{
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
}
@switch (item.PriceType)
{
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup:
case ViewPointOfSaleViewModel.ItemPriceType.Topup:
<span>Any amount</span>
break;
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum:
<span>@item.Price.Formatted minimum</span>
case ViewPointOfSaleViewModel.ItemPriceType.Minimum:
<span>@formatted minimum</span>
break;
case ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed:
@item.Price.Formatted
case ViewPointOfSaleViewModel.ItemPriceType.Fixed:
@formatted
break;
default:
throw new ArgumentOutOfRangeException();

View File

@ -1,5 +1,7 @@
@using BTCPayServer.Plugins.PointOfSale.Models
@using BTCPayServer.Services
@model BTCPayServer.Plugins.PointOfSale.Models.ViewPointOfSaleViewModel
@inject DisplayFormatter DisplayFormatter
@{
Layout = "PointOfSale/Public/_Layout";
var anyInventoryItems = Model.Items.Any(item => item.Inventory.HasValue);
@ -17,8 +19,9 @@
@for (var x = 0; x < Model.Items.Length; x++)
{
var item = Model.Items[x];
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", item.Price.Formatted).Replace("{Price}", item.Price.Formatted);
var formatted = DisplayFormatter.Currency(item.Price ?? 0, Model.CurrencyCode, DisplayFormatter.CurrencyFormat.Symbol);
var buttonText = string.IsNullOrEmpty(item.BuyButtonText) ? item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Fixed ? Model.CustomButtonText : Model.ButtonText : item.BuyButtonText;
buttonText = buttonText.Replace("{0}", formatted).Replace("{Price}", formatted);
<div class="card px-0" data-id="@x">
@if (!string.IsNullOrWhiteSpace(item.Image))
@ -29,11 +32,11 @@
<div class="card-footer bg-transparent border-0 pb-3">
@if (!item.Inventory.HasValue || item.Inventory.Value > 0)
{
@if (item.Price.Type != ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Topup)
@if (item.PriceType != ViewPointOfSaleViewModel.ItemPriceType.Topup)
{
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy autocomplete="off">
<input type="hidden" name="choiceKey" value="@item.Id" />
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.Price.Type, item.Price.Value, item.Price.Value);}
@{PayFormInputContent(item.BuyButtonText ?? Model.CustomButtonText, item.PriceType, item.Price.Value, item.Price.Value);}
</form>
}
else
@ -72,7 +75,7 @@
@{CardBody("Custom Amount", "Create invoice to pay custom amount");}
<div class="card-footer bg-transparent border-0 pb-3">
<form method="post" asp-action="ViewPointOfSale" asp-route-appId="@Model.AppId" asp-antiforgery="false" data-buy>
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Minimum);}
@{PayFormInputContent(Model.CustomButtonText, ViewPointOfSaleViewModel.ItemPriceType.Minimum);}
</form>
@if (anyInventoryItems)
{
@ -91,9 +94,9 @@
</div>
@functions {
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
private void PayFormInputContent(string buttonText,ViewPointOfSaleViewModel.ItemPriceType itemPriceType, decimal? minPriceValue = null, decimal? priceValue = null)
{
if (itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed && priceValue == 0)
if (itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed && priceValue == 0)
{
<div class="input-group">
<input class="form-control" type="text" readonly value="Free"/>
@ -105,7 +108,7 @@
<div class="input-group">
<span class="input-group-text">@Model.CurrencySymbol</span>
<input type="hidden" name="requiresRefundEmail" value="@Model.RequiresRefundEmail.ToString()" />
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.Item.ItemPrice.ItemPriceType.Fixed)">
<input class="form-control" type="number" min="@(minPriceValue ?? 0)" step="@Model.Step" name="amount" placeholder="Amount" value="@priceValue" readonly="@(itemPriceType == ViewPointOfSaleViewModel.ItemPriceType.Fixed)">
<button class="btn btn-primary text-nowrap" type="submit">@buttonText</button>
</div>
}

View File

@ -61,11 +61,11 @@
<div class="form-group row">
<div class="col-sm-6">
<label class="form-label">Price</label>
<select class="form-select" v-model="editingItem.custom">
<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.custom !== 'topup'">
<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"
@ -129,9 +129,9 @@ document.addEventListener("DOMContentLoaded", function () {
items: [],
editingItem: null,
customPriceOptions: [
{ text: 'Fixed', value: "fixed" },
{ text: 'Minimum', value: "minimum" },
{ text: 'Custom', value: 'topup' },
{ text: 'Fixed', value: "Fixed" },
{ text: 'Minimum', value: "Minimum" },
{ text: 'Custom', value: 'Topup' },
],
elementId: "@Model.templateId"
},
@ -141,8 +141,8 @@ document.addEventListener("DOMContentLoaded", function () {
}
},
mounted: function() {
this.loadYml();
this.getInputElement().on("input change", this.loadYml.bind(this));
this.load();
this.getInputElement().on("input change", this.load.bind(this));
this.getModalElement().on("hide.bs.modal", this.clearEditingItem.bind(this));
},
methods: {
@ -156,148 +156,22 @@ document.addEventListener("DOMContentLoaded", function () {
},
getInputElement : function(){ return $("#" + this.elementId); },
getModalElement : function(){ return $("#product-modal"); },
loadYml: function(){
var result = [];
var template = this.getInputElement().val().trim();
var lines = [];
var items = template.split("\n");
for (var i = 0; i < items.length; i++) {
if (items[i] === ""){
continue;
}
if (items[i].startsWith(" ")){
lines[lines.length-1]+=items[i] + "\n";
} else {
lines.push(items[i] + "\n");
}
load: function(){
const template = this.getInputElement().val().trim();
if (!template){
this.items = [];
} else {
this.items = JSON.parse(template);
}
// Split products from the template
for (var kl in lines) {
var line = lines[kl], product = line.split("\n"), goingThroughMethods = false,
id = null, price = null, title = null, description = null, image = null,
custom = null, buyButtonText = null, inventory = null, paymentMethods = [],
disabled = false;
for (var kp in product) {
var productProperty = product[kp].trim();
if (kp == 0) {
id = productProperty.replace(":", "");
}
if (productProperty.startsWith("-") && goingThroughMethods) {
paymentMethods.push(productProperty.substr(1));
} else {
goingThroughMethods = false;
}
if (productProperty.indexOf('price:') !== -1) {
price = parseFloat(productProperty.replace('price:', '').trim()).noExponents();
}
if (productProperty.indexOf('title:') !== -1) {
title = productProperty.replace('title:', '').trim();
}
if (productProperty.indexOf('description:') !== -1) {
description =productProperty.replace('description:', '').trim();
if (description.startsWith('"') && description.endsWith('"')){
description = description.substr(1, description.length-2);
}
description = description
.replaceAll("<br>", "\n")
.replaceAll("<br/>", "\n")
.replaceAll('\\"', '"')
}
if (productProperty.indexOf('image:') !== -1) {
image = productProperty.replace('image:', '').trim();
}
if (productProperty.indexOf('price_type:') !== -1) {
custom = productProperty.replace('price_type:', '').trim();
}
if (productProperty.indexOf('buyButtonText:') !== -1) {
buyButtonText = productProperty.replace('buyButtonText:', '').trim();
}
if (productProperty.indexOf('inventory:') !== -1) {
inventory = parseInt(productProperty.replace('inventory:', '').trim(),10);
}
if (productProperty.indexOf('payment_methods:') !== -1) {
goingThroughMethods = true;
}
if (productProperty.indexOf('disabled:') !== -1) {
disabled = productProperty.replace('disabled:', '').trim() === "true";
}
}
if (title != null) {
// Add product to the list
result.push({
id: id,
title: title,
price: price,
image: image || null,
description: description || '',
custom: custom || "fixed",
buyButtonText: buyButtonText,
inventory: isNaN(inventory)? null: inventory,
paymentMethods: paymentMethods,
disabled: disabled
});
}
}
this.items = result;
},
toYml: function(){
let template = '';
// Construct template from the product list
for (const key in this.items) {
const product = this.items[key],
id = product.id,
title = product.title,
price = product.custom === 'topup'? null : product.price??0,
image = product.image,
description = product.description,
custom = product.custom,
buyButtonText = product.buyButtonText,
inventory = product.inventory,
paymentMethods = product.paymentMethods,
disabled = product.disabled;
let itemTemplate = id+":\n";
itemTemplate += ( product.custom === 'topup'? '' : (' price: ' + parseFloat(price).noExponents() + '\n'));
itemTemplate+= ' title: ' + title + '\n';
if (description) {
itemTemplate += ' description: "' + description.replaceAll("\n", "<br/>").replaceAll('"', '\\"') + '"\n';
}
if (image) {
itemTemplate += ' image: ' + image + '\n';
}
if (inventory) {
itemTemplate += ' inventory: ' + inventory + '\n';
}
if (custom != null) {
itemTemplate += ' price_type: "' + custom + '" \n';
}
if (buyButtonText != null && buyButtonText.length > 0) {
itemTemplate += ' buyButtonText: ' + buyButtonText + '\n';
}
if (disabled != null) {
itemTemplate += ' disabled: ' + disabled.toString() + '\n';
}
if(paymentMethods != null && paymentMethods.length > 0){
itemTemplate+= ' payment_methods:\n';
for (var method of paymentMethods){
itemTemplate+= ' - '+method+'\n';
}
}
itemTemplate += '\n';
template+=itemTemplate;
}
this.getInputElement().val(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: "", custom: "fixed", inventory: null, paymentMethods: [], disabled: false};
this.editingItem = {index:-1, id:"", title: "", price: 0, image: "", description: "", priceType: "Fixed", inventory: null, disabled: false};
}else{
this.editingItem = {...this.items[index], index};
}
@ -307,7 +181,7 @@ document.addEventListener("DOMContentLoaded", function () {
},
removeItem: function(index){
this.items.splice(index,1);
this.toYml();
this.save();
},
clearEditingItem: function(){
this.editingItem = null;
@ -343,7 +217,7 @@ document.addEventListener("DOMContentLoaded", function () {
this.errors.push("Image cannot start with \"- \"");
}
if (this.editingItem.custom !== "topup" && !this.$refs.txtPrice.checkValidity()) {
if (this.editingItem["priceType"] !== "Topup" && !this.$refs.txtPrice.checkValidity()) {
this.errors.push("Price must be a valid number");
}
if (!this.$refs.txtTitle.checkValidity()) {
@ -375,7 +249,7 @@ document.addEventListener("DOMContentLoaded", function () {
}else{
this.items.splice(this.editingItem.index,1,this.editingItem);
}
this.toYml();
this.save();
this.getModalElement().modal("hide");
},
escape: function(item) {

View File

@ -1,43 +1,164 @@
@using BTCPayServer.Forms
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Abstractions.TagHelpers
@using Newtonsoft.Json
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@model BTCPayServer.Forms.ModifyForm
@{
Csp.UnsafeEval();
var formId = Context.GetRouteValue("id");
var isNew = formId is null;
Layout = "../Shared/_NavLayout.cshtml";
ViewData["NavPartialName"] = "../UIStores/_Nav";
ViewData.SetActivePage(StoreNavPages.Forms, $"{(isNew ? "Create" : "Edit")} Form", Model.Name);
var storeId = Context.GetCurrentStoreId();
}
@section PageFootContent {
<partial name="_ValidationScriptsPartial"/>
<script>
document.addEventListener("DOMContentLoaded", () => {
const $config = document.getElementById("FormConfig");
delegate("click", "[data-form-template]", e => {
const { formTemplate: id } = e.target.dataset
const $template = document.getElementById(`form-template-${id}`)
$config.value = $template.innerHTML.trim()
})
})
</script>
@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>
}
<template id="form-template-email">
@FormDataService.StaticFormEmail
</template>
<template id="form-template-address">
@FormDataService.StaticFormAddress
</template>
@section PageFootContent {
<template id="form-template-email">
@FormDataService.StaticFormEmail
</template>
<template id="form-template-address">
@FormDataService.StaticFormAddress
</template>
<template id="field-editor">
<div class="field" v-if="field">
<div class="form-group">
<label for="field-editor-field-type" class="form-label" data-required>Type</label>
<select id="field-editor-field-type" class="form-select" required v-model="field.type">
<option v-for="option in fieldTypeOptions" :key="option" :value="option" v-text="option.charAt(0).toUpperCase() + option.slice(1)"></option>
</select>
</div>
<div class="form-group">
<label for="field-editor-field-label" class="form-label" data-required>Label</label>
<input id="field-editor-field-label" class="form-control" required v-model="field.label" />
</div>
<div class="form-group">
<label for="field-editor-field-name" class="form-label" data-required>Name</label>
<input id="field-editor-field-name" class="form-control" required v-model="field.name" />
<div class="form-text">The name of the field in the invoice's metadata</div>
</div>
<div class="form-group" v-if="field.type === 'select'">
<h5 class="mt-2">Options</h5>
<div class="options" v-sortable="{ handle: '.drag', onUpdate: sortOptions }">
<div v-for="(option, index) in field.options" :key="option.value" class="d-flex align-items-start gap-2 pt-3">
<button type="button" class="btn b-0 control drag">
<vc:icon symbol="drag" />
</button>
<div class="field flex-grow-1">
<label :for="`field-option-value-${index}`" class="form-label">Value</label>
<input :for="`field-option-value-${index}`" class="form-control" v-model="option.value" />
</div>
<div class="field flex-grow-1">
<label :for="`field-option-text-${index}`" class="form-label">Text</label>
<input :for="`field-option-text-${index}`" class="form-control" v-model="option.text" />
</div>
<button type="button" class="btn b-0 control remove" v-on:click="removeOption($event, index)">
<vc:icon symbol="trash" />
</button>
</div>
</div>
<button type="button" class="btn btn-link px-1 py-2 gap-1 add fw-semibold d-inline-flex align-items-center" v-on:click.stop="addOption($event)">
<vc:icon symbol="new" />
Add Option
</button>
</div>
<div class="form-group" v-if="field.type !== 'fieldset'">
<label for="field-editor-field-value" class="form-label">Default Value</label>
<input id="field-editor-field-value" class="form-control" v-model="field.value" />
</div>
<div class="form-group" v-if="field.type !== 'fieldset'">
<label for="field-editor-field-helpText" class="form-label">Helper Text</label>
<input id="field-editor-field-helpText" class="form-control" v-model="field.helpText" />
<div class="form-text">Additional text to provide an explanation for the field</div>
</div>
<div class="form-group form-check" v-if="field.type !== 'fieldset'">
<input id="field-editor-field-required" type="checkbox" class="form-check-input" v-model="field.required" />
<label for="field-editor-field-required" class="form-check-label">Required Field</label>
</div>
<div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'select'">
<input id="field-editor-field-constant" type="checkbox" class="form-check-input" v-model="field.constant" />
<label for="field-editor-field-constant" class="form-check-label">Constant</label>
<div class="form-text">The user will not be able to change the field's value</div>
</div>
</div>
<div v-else>Select a field to edit</div>
</template>
<template id="fields-editor">
<div>
<div class="fields list-group" :class="{ 'list-group-flush': path.length }" :data-path="path.join(',')" v-sortable="{ handle: '.drag', onUpdate (event) { const { path } = this.el.dataset; $emit('sort-fields', event, (path.indexOf(',') !== -1 ? path.split(',') : [])) } }">
<div v-for="(field, index) in fields" :key="field.name" class="d-flex align-items-start gap-2 list-group-item" :class="{ active: field === selectedField }" v-on:click.stop="$emit('select-field', $event, path, index)">
<button type="button" class="btn b-0 control drag" :disabled="fields.length === 1">
<vc:icon symbol="drag" />
</button>
<div class="field flex-grow-1">
<component :is="getFieldComponent(field.type)" v-bind="field" :path="path.concat(field.name)" :selected-field="selectedField" v-on="$listeners" />
</div>
<button type="button" class="btn b-0 control remove" v-on:click="$emit('remove-field', $event, path, 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-field', $event, path)">
<vc:icon symbol="new" />
Add Form Element
</button>
</div>
</template>
<template id="field-type-input">
<div class="form-group mb-0">
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
<input class="form-control" :id="name" :name="name" :type="type" v-model="value" />
<div v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-text="helpText"></div>
</div>
</template>
<template id="field-type-textarea">
<div class="form-group mb-0">
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
<textarea class="form-control" :id="name" :name="name" v-model="value"></textarea>
<div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-text="helpText"></div>
</div>
</template>
<template id="field-type-select">
<div class="form-group mb-0">
<label class="form-label" :for="name" :data-required="required" v-text="label"></label>
<select class="form-select" :id="name" :name="name">
<option v-for="option in options" :key="option.value" :value="option.value" :selected="option.value === value" v-text="option.text"></option>
</select>
<div v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-text="helpText"></div>
</div>
</template>
<template id="field-type-fieldset">
<fieldset>
<legend class="h5 mt-1 mb-2" v-text="label"></legend>
<fields-editor :path="path" :fields="fields" :selected-field="selectedField" v-on="$listeners" class="nested-fields" />
</fieldset>
</template>
<script src="~/vendor/vuejs/vue.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/form-editor.js" asp-append-version="true"></script>
<partial name="_ValidationScriptsPartial" />
}
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
<div class="row">
<div class="col-xl-10 col-xxl-constrain">
<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>
@ -54,31 +175,63 @@
</div>
</div>
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group">
<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/>
<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" style="max-width:27rem">
<div class="form-text">
Standalone mode, which can be used to generate invoices
independent of payment requests or apps.
</div>
</div>
</div>
<div class="form-group">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="FormConfig" class="form-label" data-required></label>
<div class="d-flex align-items-center gap-2 mb-2">
<span>Templates:</span>
<button type="button" class="btn btn-link p-0" data-form-template="email">Email</button>
<button type="button" class="btn btn-link p-0" data-form-template="address">Address</button>
</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>
<textarea asp-for="FormConfig" class="form-control" rows="10" cols="21"></textarea>
</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>

View File

@ -7,7 +7,6 @@
@using BTCPayServer.Components.AppSales
@using BTCPayServer.Components.AppTopItems
@using BTCPayServer.Services.Apps
@inject AppService AppService
@model StoreDashboardViewModel
@{
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
@ -63,8 +62,11 @@
displayDefaultCurrency(amount, rate, currency, divisibility) {
const value = DashboardUtils.toDefaultCurrency(amount, rate);
const locale = currency === 'USD' ? 'en-US' : navigator.language;
const isSats = currency === 'SATS';
if (isSats) currency = 'BTC';
const opts = { currency, style: 'decimal', minimumFractionDigits: divisibility };
return new Intl.NumberFormat(locale, opts).format(value);
const val = new Intl.NumberFormat(locale, opts).format(value);
return isSats ? val.replace(/[\\.,]/g, ' ') : val;
},
async fetchRate(currencyPair) {
const storeId = @Safe.Json(Context.GetRouteValue("storeId"));

View File

@ -12,7 +12,13 @@
<symbol id="docs" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M8 16A8 8 0 1 0 7.998-.002 8 8 0 0 0 8 16Zm-.336-8.032.773-.616c.258-.203.437-.414.546-.624a1.43 1.43 0 0 0 .172-.71c0-.32-.094-.578-.297-.758-.202-.18-.483-.273-.85-.273s-.664.094-.882.289c-.203.195-.312.46-.312.804 0 .255-.194.489-.449.47l-.728-.053a.458.458 0 0 1-.435-.4 2.521 2.521 0 0 1-.012-.244c0-.655.258-1.178.765-1.568.523-.383 1.21-.578 2.076-.578.913 0 1.616.187 2.115.57.492.374.742.905.742 1.592 0 .765-.367 1.452-1.093 2.068l-.679.57a1.375 1.375 0 0 0-.28.312.738.738 0 0 0-.071.351.36.36 0 0 1-.36.36h-.78a.5.5 0 0 1-.5-.5v-.023c0-.235.048-.43.133-.586a1.82 1.82 0 0 1 .406-.453Zm-.406 4.036a.97.97 0 0 0 .726.288c.305 0 .547-.093.734-.288a.988.988 0 0 0 .289-.734c0-.304-.094-.546-.289-.742-.187-.195-.43-.288-.734-.288a.97.97 0 0 0-.726.288 1.019 1.019 0 0 0-.28.742c0 .296.093.539.28.734Z" fill="currentColor"/></symbol>
<symbol id="donate" viewBox="0 0 16 16" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.65 14.91a.75.75 0 0 0 .7 0L8 14.26l.35.66h.02c1.33-.74 2.59-1.6 3.75-2.6C13.96 10.74 16 8.36 16 5.5 16 2.84 13.91 1 11.75 1 10.2 1 8.85 1.8 8 3.02A4.57 4.57 0 0 0 4.25 1 4.38 4.38 0 0 0 0 5.5c0 2.85 2.04 5.23 3.88 6.82a22.08 22.08 0 0 0 3.75 2.58l.02.01Z" fill="currentColor"/></symbol>
<symbol id="done" viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="12" fill="#51B13E"/><path d="m7 12.14 3.55 3.54L17.5 9" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></symbol>
<symbol id="drag" viewBox="0 0 16 16" fill="none"><rect x="4.5" width="2.5" height="2.5" fill="currentColor"/><rect x="9" width="2.5" height="2.5" fill="currentColor"/><rect x="4.5" y="4.5" width="2.5" height="2.5" fill="currentColor"/><rect x="9" y="4.5" width="2.5" height="2.5" fill="currentColor"/><rect x="4.5" y="9" width="2.5" height="2.5" fill="currentColor"/><rect x="9" y="9" width="2.5" height="2.5" fill="currentColor"/><rect x="4.5" y="13.5" width="2.5" height="2.5" fill="currentColor"/><rect x="9" y="13.5" width="2.5" height="2.5" fill="currentColor"/></symbol>
<symbol id="existing-wallet" viewBox="0 0 32 32" fill="none"><path d="M26.5362 7.08746H25.9614V3.25512C25.9614 2.10542 25.3865 1.14734 24.6201 0.572488C23.8536 -0.00236247 22.7039 -0.193979 21.7458 -0.00236246L4.11707 5.36291C2.00929 5.93776 0.667969 7.85392 0.667969 9.96171V12.0695V12.836V27.3988C0.667969 30.0815 2.77575 32.1893 5.45839 32.1893H26.5362C29.2189 32.1893 31.3267 30.0815 31.3267 27.3988V12.0695C31.3267 9.38686 29.2189 7.08746 26.5362 7.08746ZM4.69192 7.08746L22.129 1.91381C22.5123 1.72219 23.0871 1.91381 23.4704 2.10542C23.8536 2.29704 24.0452 2.87189 24.0452 3.25512V7.08746H5.45839C4.88354 7.08746 4.5003 7.27908 3.92545 7.47069C4.11707 7.27908 4.5003 7.08746 4.69192 7.08746ZM29.4105 27.2072C29.4105 28.7402 28.0692 30.0815 26.5362 30.0815H5.45839C3.92545 30.0815 2.58414 28.7402 2.58414 27.2072V12.836V11.8779C2.58414 10.3449 3.92545 9.00362 5.45839 9.00362H26.5362C28.0692 9.00362 29.4105 10.3449 29.4105 11.8779V27.2072Z" fill="currentColor"/><path d="M25.9591 21.6487C27.0174 21.6487 27.8753 20.7908 27.8753 19.7326C27.8753 18.6743 27.0174 17.8164 25.9591 17.8164C24.9009 17.8164 24.043 18.6743 24.043 19.7326C24.043 20.7908 24.9009 21.6487 25.9591 21.6487Z" fill="currentColor"/></symbol>
<symbol id="forms-checkbox" viewBox="0 0 24 24" fill="none"><rect x="1" y="1" width="22" height="22" rx="3" stroke="currentColor" stroke-width="2"/><path d="M7.5 12.5L10.9297 15.4397C10.9694 15.4737 11.0285 15.4715 11.0655 15.4345L17.5 9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="forms-date" viewBox="0 0 24 24" fill="none"><rect x="1" y="4" width="22" height="19" rx="3" stroke="currentColor" stroke-width="2"/><path d="M6 1L6 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M18 1L18 3" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M5 9H19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="forms-number" viewBox="0 0 24 24" fill="none"><rect x="1" y="1" width="22" height="22" rx="3" stroke="currentColor" stroke-width="2"/><path d="M15.5684 17H8.31543V15.2227L10.7559 12.7549C11.2617 12.2262 11.6536 11.8024 11.9316 11.4834C12.2142 11.1644 12.4124 10.8932 12.5264 10.6699C12.6403 10.4466 12.6973 10.2142 12.6973 9.97266C12.6973 9.67643 12.6016 9.46224 12.4102 9.33008C12.2188 9.19792 11.9932 9.13184 11.7334 9.13184C11.4189 9.13184 11.0999 9.22298 10.7764 9.40527C10.4574 9.58301 10.0951 9.84733 9.68945 10.1982L8.20605 8.46191C8.50684 8.19303 8.82585 7.93783 9.16309 7.69629C9.50033 7.4502 9.89909 7.24967 10.3594 7.09473C10.8197 6.93978 11.3802 6.8623 12.041 6.8623C12.7201 6.8623 13.3079 6.98079 13.8047 7.21777C14.306 7.45475 14.6934 7.7806 14.9668 8.19531C15.2402 8.60547 15.377 9.07259 15.377 9.59668C15.377 10.1755 15.2699 10.6927 15.0557 11.1484C14.846 11.5996 14.5293 12.0531 14.1055 12.5088C13.6816 12.96 13.153 13.4727 12.5195 14.0469L11.7881 14.7031V14.7715H15.5684V17Z" fill="currentColor"/></symbol>
<symbol id="forms-select" viewBox="0 0 24 24" fill="none"><path d="M1 4C1 2.34315 2.34315 1 4 1H17V20C17 21.6569 15.6569 23 14 23H4C2.34315 23 1 21.6569 1 20V4Z" stroke="currentColor" stroke-width="2"/><path d="M1 2C1 1.44772 1.44772 1 2 1H22C22.5523 1 23 1.44771 23 2V6C23 6.55228 22.5523 7 22 7H1V2Z" stroke="currentColor" stroke-width="2"/><path d="M6 12H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M6 17H12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="forms-text" viewBox="0 0 24 24" fill="none"><rect x="1" y="1" width="22" height="22" rx="3" stroke="currentColor" stroke-width="2"/><path d="M7 7.75H17" stroke="currentColor" stroke-width="2" stroke-linecap="round"/><path d="M12 8V16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></symbol>
<symbol id="github" viewBox="0 0 25 24" fill="none"><path clip-rule="evenodd" d="M12.75.3c-6.6 0-12 5.4-12 12 0 5.325 3.45 9.825 8.175 11.4.6.075.825-.225.825-.6v-2.025C6.375 21.825 5.7 19.5 5.7 19.5c-.525-1.35-1.35-1.725-1.35-1.725-1.125-.75.075-.75.075-.75 1.2.075 1.875 1.2 1.875 1.2 1.05 1.8 2.775 1.275 3.525.975a2.59 2.59 0 0 1 .75-1.575c-2.7-.3-5.475-1.35-5.475-5.925 0-1.275.45-2.4 1.2-3.225-.15-.3-.525-1.5.15-3.15 0 0 .975-.3 3.3 1.2.975-.3 1.95-.375 3-.375s2.025.15 3 .375c2.325-1.575 3.3-1.275 3.3-1.275.675 1.65.225 2.85.15 3.15.75.825 1.2 1.875 1.2 3.225 0 4.575-2.775 5.625-5.475 5.925.45.375.825 1.125.825 2.25v3.3c0 .3.225.675.825.6a12.015 12.015 0 0 0 8.175-11.4c0-6.6-5.4-12-12-12z" fill="currentColor" fill-rule="evenodd"/></symbol>
<symbol id="hardware-wallet" viewBox="0 0 32 32" fill="none"><rect x="18.9767" y="6.57031" width="6" height="8" rx="1" transform="rotate(-45 18.9767 6.57031)" fill="none" stroke="currentColor" stroke-width="2"/><path d="M3.8871 21.1057C2.71552 19.9341 2.71552 18.0346 3.8871 16.8631L15.888 4.86213C16.2785 4.4716 16.9117 4.4716 17.3022 4.86212L25.7898 13.3497C26.1804 13.7402 26.1804 14.3734 25.7898 14.7639L13.7889 26.7649C12.6173 27.9364 10.7178 27.9364 9.54626 26.7649L3.8871 21.1057Z" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="home" viewBox="0 0 24 24" fill="none"><path d="M15.6923 19.3845H8.30766C6.27689 19.3845 4.61536 17.7229 4.61536 15.6922V8.30754C4.61536 6.27677 6.27689 4.61523 8.30766 4.61523H15.6923C17.723 4.61523 19.3846 6.27677 19.3846 8.30754V15.6922C19.3846 17.7229 17.723 19.3845 15.6923 19.3845Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M7.56921 11.938H9.04614L10.5846 14.1534L13.3538 9.72266L14.8923 11.938H16.2461" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10" stroke-linecap="round" stroke-linejoin="round"/></symbol>
@ -56,6 +62,7 @@
<symbol id="spark" viewBox="0 0 24 24" fill="none"><path d="M17.57 10.7c-.1-.23-.27-.34-.5-.34h-4.3l.5-3.76a.48.48 0 0 0-.33-.55.52.52 0 0 0-.66.17l-5.45 6.54a.59.59 0 0 0-.05.6c.1.17.27.28.49.28h4.3l-.49 3.76c-.05.22.11.5.33.55.06.05.17.05.22.05a.5.5 0 0 0 .44-.22l5.45-6.54c.1-.17.16-.39.05-.55Z" fill="currentColor"/></symbol>
<symbol id="store" viewBox="0 0 24 24" fill="none"><path d="M19.049 10.2637V16.5294C19.049 17.7602 18.042 18.7672 16.8112 18.7672H7.24478C6.01401 18.7672 5.00702 17.7602 5.00702 16.5294V10.2637" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M9.45456 5.25649V9.08866C9.45456 10.2635 8.50351 11.2425 7.32868 11.2425H6.9091C5.00701 11.2425 3.74826 9.31243 4.50351 7.57817L5.06295 6.26348C5.34267 5.62012 5.95805 5.22852 6.62938 5.22852L9.45456 5.25649Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M14.5455 5.25781V9.08998C14.5455 10.2648 15.4965 11.2438 16.6713 11.2438H17.0909C18.993 11.2438 20.2518 9.31376 19.4965 7.57949L18.9371 6.26481C18.6574 5.64942 18.042 5.25781 17.3706 5.25781H14.5455Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/><path d="M12 11.4949C10.6014 11.4949 9.48254 10.3481 9.48254 8.97746V5.28516H14.5455V8.97746C14.5455 10.3761 13.3986 11.4949 12 11.4949Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-miterlimit="10"/></symbol>
<symbol id="telegram" viewBox="0 0 496 512" fill="none"><path fill="currentColor" d="M248,8C111.033,8,0,119.033,0,256S111.033,504,248,504,496,392.967,496,256,384.967,8,248,8ZM362.952,176.66c-3.732,39.215-19.881,134.378-28.1,178.3-3.476,18.584-10.322,24.816-16.948,25.425-14.4,1.326-25.338-9.517-39.287-18.661-21.827-14.308-34.158-23.215-55.346-37.177-24.485-16.135-8.612-25,5.342-39.5,3.652-3.793,67.107-61.51,68.335-66.746.153-.655.3-3.1-1.154-4.384s-3.59-.849-5.135-.5q-3.283.746-104.608,69.142-14.845,10.194-26.894,9.934c-8.855-.191-25.888-5.006-38.551-9.123-15.531-5.048-27.875-7.717-26.8-16.291q.84-6.7,18.45-13.7,108.446-47.248,144.628-62.3c68.872-28.647,83.183-33.623,92.511-33.789,2.052-.034,6.639.474,9.61,2.885a10.452,10.452,0,0,1,3.53,6.716A43.765,43.765,0,0,1,362.952,176.66Z"/></symbol>
<symbol id="trash" viewBox="0 0 16 16" fill="none"><path d="M11 1.75V3H13.25C13.4489 3 13.6397 3.07902 13.7803 3.21967C13.921 3.36032 14 3.55109 14 3.75C14 3.94891 13.921 4.13968 13.7803 4.28033C13.6397 4.42098 13.4489 4.5 13.25 4.5H2.75C2.55109 4.5 2.36032 4.42098 2.21967 4.28033C2.07902 4.13968 2 3.94891 2 3.75C2 3.55109 2.07902 3.36032 2.21967 3.21967C2.36032 3.07902 2.55109 3 2.75 3H5V1.75C5 0.784 5.784 0 6.75 0H9.25C10.216 0 11 0.784 11 1.75ZM4.496 6.675L5.156 13.275C5.1622 13.3367 5.19112 13.3939 5.23714 13.4355C5.28315 13.4771 5.34298 13.5001 5.405 13.5H10.595C10.657 13.5001 10.7168 13.4771 10.7629 13.4355C10.8089 13.3939 10.8378 13.3367 10.844 13.275L11.504 6.675C11.5288 6.48127 11.6282 6.30486 11.781 6.18328C11.9339 6.06169 12.1281 6.00453 12.3225 6.02394C12.5168 6.04334 12.6959 6.13779 12.8217 6.2872C12.9475 6.43661 13.01 6.6292 12.996 6.824L12.336 13.424C12.2933 13.856 12.0914 14.2566 11.7696 14.5479C11.4478 14.8392 11.0291 15.0004 10.595 15H5.405C4.97121 14.9999 4.5529 14.8388 4.23121 14.5478C3.90952 14.2567 3.70738 13.8566 3.664 13.425L3.004 6.825C2.99148 6.7257 2.99895 6.6249 3.02599 6.52853C3.05303 6.43217 3.09908 6.34219 3.16144 6.2639C3.22379 6.18561 3.30118 6.12059 3.38905 6.07268C3.47692 6.02476 3.5735 5.99492 3.67308 5.98491C3.77266 5.9749 3.87325 5.98492 3.9689 6.01438C4.06455 6.04385 4.15333 6.09216 4.23002 6.15647C4.30671 6.22078 4.36975 6.29979 4.41543 6.38884C4.46111 6.4779 4.48851 6.57519 4.496 6.675ZM6.5 1.75V3H9.5V1.75C9.5 1.6837 9.47366 1.62011 9.42678 1.57322C9.37989 1.52634 9.3163 1.5 9.25 1.5H6.75C6.6837 1.5 6.62011 1.52634 6.57322 1.57322C6.52634 1.62011 6.5 1.6837 6.5 1.75Z" fill="currentColor"/></symbol>
<symbol id="twitter" viewBox="0 0 37 37" fill="none"><path d="M36 18c0 9.945-8.055 18-18 18S0 27.945 0 18 8.055 0 18 0s18 8.055 18 18zm-21.294 9.495c7.983 0 12.348-6.615 12.348-12.348 0-.189 0-.378-.009-.558a8.891 8.891 0 0 0 2.169-2.25 8.808 8.808 0 0 1-2.493.684 4.337 4.337 0 0 0 1.908-2.403 8.788 8.788 0 0 1-2.754 1.053 4.319 4.319 0 0 0-3.168-1.368 4.34 4.34 0 0 0-4.338 4.338c0 .342.036.675.117.99a12.311 12.311 0 0 1-8.946-4.536 4.353 4.353 0 0 0-.585 2.178 4.32 4.32 0 0 0 1.935 3.609 4.263 4.263 0 0 1-1.962-.54v.054a4.345 4.345 0 0 0 3.483 4.257 4.326 4.326 0 0 1-1.962.072 4.333 4.333 0 0 0 4.05 3.015 8.724 8.724 0 0 1-6.426 1.791 12.091 12.091 0 0 0 6.633 1.962z" fill="currentColor"/></symbol>
<symbol id="wallet-file" viewBox="0 0 32 32" fill="none"><path d="M5 1H20.8479L27 6.90258V31H5V1Z" fill="none" stroke="currentColor" stroke-width="2"/></symbol>
<symbol id="wallet-lightning" viewBox="0 0 24 24" fill="none"><path d="M17.57 10.7c-.1-.23-.27-.34-.5-.34h-4.3l.5-3.76a.48.48 0 0 0-.33-.55.52.52 0 0 0-.66.17l-5.45 6.54a.59.59 0 0 0-.05.6c.1.17.27.28.49.28h4.3l-.49 3.76c-.05.22.11.5.33.55.06.05.17.05.22.05a.5.5 0 0 0 .44-.22l5.45-6.54c.1-.17.16-.39.05-.55Z" fill="currentColor"/></symbol>
@ -63,4 +70,4 @@
<symbol id="warning" viewBox="0 0 24 24" fill="none"><path d="M12.337 3.101a.383.383 0 00-.674 0l-9.32 17.434a.383.383 0 00.338.564h18.638a.384.384 0 00.337-.564L12.337 3.101zM9.636 2.018c1.01-1.89 3.719-1.89 4.728 0l9.32 17.434a2.681 2.681 0 01-2.365 3.945H2.681a2.68 2.68 0 01-2.364-3.945L9.636 2.018zm3.896 15.25a1.532 1.532 0 11-3.064 0 1.532 1.532 0 013.064 0zm-.383-8.044a1.15 1.15 0 00-2.298 0v3.83a1.15 1.15 0 002.298 0v-3.83z" fill="currentColor"/></symbol>
<symbol id="watchonly-wallet" viewBox="0 0 32 32" fill="none"><path d="M26.5362 7.08746H25.9614V3.25512C25.9614 2.10542 25.3865 1.14734 24.6201 0.572488C23.8536 -0.00236247 22.7039 -0.193979 21.7458 -0.00236246L4.11707 5.36291C2.00929 5.93776 0.667969 7.85392 0.667969 9.96171V12.0695V12.836V27.3988C0.667969 30.0815 2.77575 32.1893 5.45839 32.1893H26.5362C29.2189 32.1893 31.3267 30.0815 31.3267 27.3988V12.0695C31.3267 9.38686 29.2189 7.08746 26.5362 7.08746ZM4.69192 7.08746L22.129 1.91381C22.5123 1.72219 23.0871 1.91381 23.4704 2.10542C23.8536 2.29704 24.0452 2.87189 24.0452 3.25512V7.08746H5.45839C4.88354 7.08746 4.5003 7.27908 3.92545 7.47069C4.11707 7.27908 4.5003 7.08746 4.69192 7.08746ZM29.4105 27.2072C29.4105 28.7402 28.0692 30.0815 26.5362 30.0815H5.45839C3.92545 30.0815 2.58414 28.7402 2.58414 27.2072V12.836V11.8779C2.58414 10.3449 3.92545 9.00362 5.45839 9.00362H26.5362C28.0692 9.00362 29.4105 10.3449 29.4105 11.8779V27.2072Z" fill="currentColor"/><path d="M25.9591 21.6487C27.0174 21.6487 27.8753 20.7908 27.8753 19.7326C27.8753 18.6743 27.0174 17.8164 25.9591 17.8164C24.9009 17.8164 24.043 18.6743 24.043 19.7326C24.043 20.7908 24.9009 21.6487 25.9591 21.6487Z" fill="currentColor"/></symbol>
<symbol id="xpub" viewBox="0 0 32 32" fill="none"><path d="M21.3911 14.0298C20.4238 14.0396 19.4831 13.713 18.73 13.1059C17.9769 12.4988 17.4581 11.649 17.2622 10.7017C17.0664 9.75436 17.2057 8.76844 17.6564 7.91249C18.1071 7.05655 18.8412 6.38377 19.733 6.00919C20.6249 5.6346 21.6192 5.58148 22.5459 5.85891C23.4726 6.13634 24.2742 6.72709 24.8134 7.53015C25.3528 8.33319 25.5964 9.29866 25.5026 10.2614C25.4088 11.2242 24.9834 12.1246 24.2992 12.8084C23.5288 13.5829 22.4836 14.022 21.3911 14.0298ZM21.3911 7.5228C20.9277 7.52249 20.4746 7.65927 20.0888 7.91592C19.703 8.17258 19.4017 8.53764 19.223 8.96514C19.0442 9.39264 18.9959 9.86347 19.0842 10.3184C19.1724 10.7733 19.3933 11.1919 19.7189 11.5215C20.1653 11.9482 20.759 12.1863 21.3765 12.1863C21.9941 12.1863 22.5878 11.9482 23.0342 11.5215C23.359 11.1928 23.5796 10.7755 23.6683 10.3219C23.7571 9.86838 23.71 9.39874 23.5329 8.97182C23.356 8.54491 23.057 8.1797 22.6734 7.92194C22.2898 7.66419 21.8387 7.52534 21.3765 7.5228H21.3911Z" fill="currentColor"/><path d="M11.3293 29.9927C10.6744 29.9903 10.0472 29.7289 9.58436 29.2657L7.81038 27.4844L7.71586 27.608C7.18174 28.1431 6.45693 28.444 5.70089 28.4448C4.94485 28.4454 4.2195 28.1458 3.68441 27.6117C3.14933 27.0776 2.84834 26.3527 2.84766 25.5967C2.84698 24.8406 3.14666 24.1153 3.68078 23.5802L14.172 13.0672C13.4303 11.3826 13.301 9.49181 13.8065 7.722C14.312 5.9522 15.4204 4.41487 16.9399 3.37617C18.4594 2.33747 20.2942 1.8628 22.1268 2.03435C23.9594 2.20589 25.6743 3.01285 26.9746 4.31551C28.2749 5.61816 29.0787 7.3345 29.2469 9.16737C29.4152 11.0002 28.9372 12.8343 27.8957 14.3519C26.8543 15.8695 25.315 16.9751 23.5443 17.4774C21.7736 17.9797 19.883 17.847 18.1998 17.1023L15.0954 20.2067L16.3241 21.4354C16.5544 21.6639 16.7373 21.9357 16.8621 22.2352C16.9868 22.5346 17.0511 22.8559 17.0511 23.1803C17.0511 23.5048 16.9868 23.826 16.8621 24.1255C16.7373 24.425 16.5544 24.6968 16.3241 24.9252C15.8548 25.3728 15.2312 25.6225 14.5828 25.6225C13.9343 25.6225 13.3107 25.3728 12.8415 24.9252L11.6128 23.6893L11.2929 24.0092L13.0742 25.7904C13.4162 26.1364 13.6484 26.5757 13.742 27.0532C13.8354 27.5307 13.7859 28.0252 13.5996 28.4746C13.4132 28.9241 13.0984 29.3086 12.6946 29.5799C12.2908 29.8512 11.8158 29.9974 11.3293 30V29.9927ZM7.81038 25.296C7.92899 25.2954 8.04656 25.3182 8.15636 25.3631C8.26615 25.408 8.36599 25.4742 8.45017 25.5578L10.8712 27.9861C10.9961 28.1011 11.1596 28.1649 11.3293 28.1649C11.4989 28.1649 11.6624 28.1011 11.7873 27.9861C11.8474 27.9259 11.8949 27.8545 11.9274 27.7759C11.9598 27.6973 11.9764 27.613 11.9763 27.5281C11.9769 27.443 11.9604 27.3587 11.928 27.28C11.8955 27.2013 11.8477 27.1299 11.7873 27.07L9.36624 24.649C9.27688 24.5611 9.2068 24.4557 9.16049 24.3393C9.11417 24.2228 9.09263 24.098 9.09724 23.9728C9.09677 23.8536 9.12035 23.7354 9.16656 23.6255C9.21278 23.5156 9.2807 23.4161 9.36624 23.333L10.9948 21.7917C11.0792 21.707 11.1795 21.6399 11.2899 21.594C11.4003 21.5482 11.5187 21.5247 11.6383 21.5247C11.7578 21.5247 11.8762 21.5482 11.9865 21.594C12.0969 21.6399 12.1973 21.707 12.2817 21.7917L14.1575 23.6675C14.2802 23.7835 14.4428 23.8481 14.6119 23.8481C14.7808 23.8481 14.9434 23.7835 15.0663 23.6675C15.1276 23.6078 15.1766 23.5367 15.2102 23.4581C15.2439 23.3795 15.2618 23.2949 15.2626 23.2094C15.2605 23.0381 15.1929 22.8742 15.0735 22.7514L13.176 20.8465C13.0041 20.675 12.9073 20.4423 12.907 20.1995C12.9065 20.0802 12.93 19.9621 12.9763 19.8521C13.0225 19.7423 13.0904 19.6427 13.176 19.5597L17.3855 15.3501C17.5244 15.2094 17.7056 15.1183 17.9014 15.0906C18.0971 15.063 18.2965 15.1006 18.4688 15.1974C19.7515 15.9077 21.2475 16.131 22.6816 15.8261C24.1158 15.5214 25.3917 14.7091 26.2747 13.5387C27.1577 12.3681 27.5884 10.9182 27.4877 9.45553C27.3869 7.99281 26.7614 6.61566 25.7262 5.57732C24.691 4.539 23.3158 3.90933 21.8534 3.8041C20.391 3.69889 18.9398 4.1252 17.7666 5.00464C16.5935 5.88408 15.7773 7.15751 15.4681 8.59074C15.1591 10.024 15.3777 11.5206 16.0841 12.8055C16.1792 12.977 16.2157 13.1749 16.1881 13.369C16.1606 13.5632 16.0705 13.7432 15.9314 13.8815L4.96764 24.8307C4.8026 25.0286 4.71754 25.281 4.72917 25.5385C4.74081 25.796 4.84829 26.0397 5.0305 26.2219C5.21272 26.4041 5.4565 26.5117 5.71392 26.5233C5.97135 26.5349 6.22383 26.4499 6.42173 26.2848L7.14877 25.5578C7.32593 25.3863 7.56388 25.2922 7.81038 25.296Z" fill="currentColor"/></symbol>
</svg>
</svg>

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -0,0 +1,190 @@
document.addEventListener('DOMContentLoaded', () => {
const parseConfig = str => {
try {
return JSON.parse(str)
} catch (err) {
console.error('Error deserializing form config:', err)
}
}
const $config = document.getElementById('FormConfig')
let config = parseConfig($config.value) || {}
const specialFieldTypeOptions = ['fieldset', 'textarea', 'select']
const inputFieldTypeOptions = ['text', 'number', 'password', 'email', 'url', 'tel', 'date', 'hidden']
const fieldTypeOptions = inputFieldTypeOptions.concat(specialFieldTypeOptions)
const getFieldComponent = type => `field-type-${specialFieldTypeOptions.includes(type) ? type : 'input'}`
const fieldProps = {
type: String,
name: String,
label: String,
value: String,
helpText: String,
required: Boolean,
constant: Boolean,
options: Array,
fields: Array,
validationErrors: Array
}
const fieldTypeBase = {
props: {
// internal
path: Array,
// field config
...fieldProps
}
}
const FieldTypeInput = Vue.extend({
mixins: [fieldTypeBase],
name: 'field-type-input',
template: '#field-type-input'
})
const FieldTypeTextarea = Vue.extend({
mixins: [fieldTypeBase],
name: 'field-type-textarea',
template: '#field-type-textarea'
})
const FieldTypeSelect = Vue.extend({
mixins: [fieldTypeBase],
name: 'field-type-select',
template: '#field-type-select',
props: {
options: Array
}
})
const components = {
FieldTypeInput,
FieldTypeSelect,
FieldTypeTextarea
}
// register fields-editor and field-type-fieldset globally in order to use them recursively
Vue.component('field-type-fieldset', {
mixins: [fieldTypeBase],
template: '#field-type-fieldset',
components,
props: {
fields: Array,
selectedField: fieldProps
}
})
Vue.component('fields-editor', {
template: '#fields-editor',
components,
props: {
path: Array,
fields: Array,
selectedField: fieldProps
},
methods: {
getFieldComponent
}
})
Vue.component('field-editor', {
template: '#field-editor',
components,
data () {
return {
fieldTypeOptions
}
},
props: {
path: Array,
field: fieldProps
},
methods: {
getFieldComponent,
addOption (event) {
if (!this.field.options) this.$set(this.field, 'options', [])
const index = this.field.options.length + 1
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) {
const { newIndex, oldIndex } = event
this.field.options.splice(newIndex, 0, this.field.options.splice(oldIndex, 1)[0])
}
}
})
Vue.use(vSortable)
new Vue({
el: '#FormEditor',
name: 'form-editor',
data () {
return {
config,
selectedField: null
}
},
computed: {
fields() {
return this.config.fields || []
},
configJSON() {
return JSON.stringify(this.config, null, 2)
}
},
methods: {
applyTemplate(id) {
const $template = document.getElementById(`form-template-${id}`)
this.config = JSON.parse($template.innerHTML.trim())
this.selectedField = null
},
updateFromJSON(event) {
const config = parseConfig(event.target.value)
if (!config) return
this.config = config
this.selectedField = null
},
addField(event, path) {
const fields = this.getFieldsForPath(path)
const index = fields.length + 1
const length = fields.push({ type: 'text', name: `newField${index}`, label: `New field ${index}`, fields: [], options: [] })
this.selectedField = fields[length - 1]
},
selectField(event, path, index) {
const fields = this.getFieldsForPath(path)
this.selectedField = fields[index]
},
removeField(event, path, index) {
const fields = this.getFieldsForPath(path)
fields.splice(index, 1)
this.selectedField = null
},
sortFields(event, path) {
const { newIndex, oldIndex } = event
const fields = this.getFieldsForPath(path)
fields.splice(newIndex, 0, fields.splice(oldIndex, 1)[0])
},
getFieldsForPath (path) {
if (!this.config.fields) this.$set(this.config, 'fields', [])
let fields = this.config.fields
while (path.length) {
const name = path.shift()
const field = fields.find(field => field.name === name)
if (!field.fields) this.$set(field, 'fields', [])
fields = field.fields
}
return fields
}
},
mounted () {
if (!this.config.fields || this.config.fields.length === 0) {
this.addField(null,[])
}
}
})
})

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,47 @@
// modified version that's compatible with Vue 2, see https://github.com/sagalbot/vue-sortable/issues/32
; (function () {
var vSortable = {}
var Sortable = typeof require === 'function'
? require('sortablejs')
: window.Sortable
if (!Sortable) {
throw new Error('[vue-sortable] cannot locate Sortable.js.')
}
// exposed global options
vSortable.config = {}
vSortable.install = function (Vue) {
Vue.directive('sortable', {
inserted: function (el) {
var sortable = new Sortable(el, options)
if (this.arg && !this.vm.sortable) {
this.vm.sortable = {}
}
// Throw an error if the given ID is not unique
if (this.arg && this.vm.sortable[this.arg]) {
console.warn('[vue-sortable] cannot set already defined sortable id: \'' + this.arg + '\'')
} else if (this.arg) {
this.vm.sortable[this.arg] = sortable
}
},
bind: function (el, binding) {
this.options = binding.value || {};
}
})
}
if (typeof exports == "object") {
module.exports = vSortable
} else if (typeof define == "function" && define.amd) {
define([], function () {
return vSortable
})
} else if (window.Vue) {
window.vSortable = vSortable
Vue.use(vSortable)
}
})()

View File

@ -14,6 +14,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{29290EC7-0
arm32v7.Dockerfile = arm32v7.Dockerfile
arm64v8.Dockerfile = arm64v8.Dockerfile
Changelog.md = Changelog.md
.github\codeql\codeql-config.yml = .github\codeql\codeql-config.yml
Build\Common.csproj = Build\Common.csproj
.circleci\config.yml = .circleci\config.yml
docker-entrypoint.sh = docker-entrypoint.sh