Compare commits

...

26 Commits

Author SHA1 Message Date
72623d9792 Improve mirror field editing 2023-07-18 08:42:04 +02:00
d8a36903b9 Minor adjustmentts 2023-07-18 08:42:04 +02:00
ca542e47f8 hide hidden field from ui 2023-07-18 08:40:40 +02:00
aace013109 clarify 2023-07-18 08:40:40 +02:00
98387e09b2 polsih mirror view and name suggestions for fields 2023-07-18 08:40:40 +02:00
3eab36555c Indicate when special field names are used 2023-07-18 08:40:40 +02:00
e69e744fb8 Support mirror in editor 2023-07-18 08:40:40 +02:00
8a72add7e6 Integrate invoice amount adjustment fields for form on pos 2023-07-18 08:40:40 +02:00
c5db390c71 Introduce invoice amount adjustment fields for form 2023-07-18 08:40:40 +02:00
45832bb171 enhance: make mirror field type able to map values 2023-07-18 08:40:40 +02:00
26d62c5a7a fix redirect to checkout if invoice is settled (redirect to receipt instead) 2023-07-18 08:40:40 +02:00
43eb489e25 Fix constant fields being editable on UI 2023-07-18 08:40:40 +02:00
a7def63137 fix pos item topups lnurl (#5172)
fixes #5170
2023-07-17 13:08:41 +02:00
3703a170e7 try fix migration for pos yml 2023-07-13 14:59:18 +02:00
73fbfbd7cb Add support for Monero RPC authentication (#5157)
Co-authored-by: Andrew Camilleri <evilkukka@gmail.com>
2023-07-13 12:24:08 +02:00
acae3b8753 Refactoring 2023-07-13 12:17:41 +02:00
a618f901fc Support NFC on modal 2023-07-13 12:17:41 +02:00
6d4918f0ab Update ViewPullPayment.cshtml 2023-07-13 12:17:01 +02:00
7f2c4d2e7a add extension point for pull payment view 2023-07-13 12:17:01 +02:00
fd6d361e1a CheckoutV2: When WebSocket disconnects, we should continue polling via XHR (#5165)
* When WebSocket disconnects, we should continue polling via XHR

* Update BTCPayServer/wwwroot/checkout-v2/checkout.js

Co-authored-by: d11n <mail@dennisreimann.de>

---------

Co-authored-by: d11n <mail@dennisreimann.de>
2023-07-11 21:56:13 +02:00
b5f0924651 Serialize PosAppCartItem.value as decimal instead of string 2023-07-11 15:49:16 +09:00
1600dd4759 POS: Backwards-compatible price parsing (#5163)
* POS: Backwards-compatible price parsing

Fixes #5159 and a regression introduced in bbff9710bf2f4a66bd6f4cd9e8ee55618d0ca5e0: The price in posData needs to be parsed in a backwards-compatible manner, as the old format of price as an object exists in the invoice metadata.

* Test corner cases

---------

Co-authored-by: nicolas.dorier <nicolas.dorier@gmail.com>
2023-07-11 15:32:01 +09:00
c777746b69 Custom Forms: Allow HTML in labels and help text (#5136)
* Custom Forms: Allow HTML in labels and help text

Fixes #5003.

* Vue: Sanitize labels and helper text input

* Form editor: Fix blur on input for select option values

---------

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
2023-07-11 13:02:02 +09:00
9f5466a41f Make sure CheckJsContent run as part of CI, and ignore end of line differences 2023-07-11 09:41:28 +09:00
4d1e4801bf Dark theme color fix 2023-07-10 11:33:39 +02:00
5e469ff9c0 Improve rates (#5166)
* Removes Chaincoin shitcoin which is so dead even its website is gone
* Add ExchangeRateHost and FreeCurrencyRates as new rate providers
* Add recommended rate providers for UGX and RSD
* Fix BTX rate by switching to graviex
* Fix BTC rate by switching to exmo
* Fix LCAD rate script
2023-07-10 17:31:48 +09:00
34 changed files with 627 additions and 180 deletions

View File

@ -16,7 +16,7 @@ namespace BTCPayServer
DefaultRateRules = new[]
{
"BTG_X = BTG_BTC * BTC_X",
"BTG_BTC = bitfinex(BTG_BTC)",
"BTG_BTC = exmo(BTG_BTC)",
},
CryptoImagePath = "imlegacy/btg.svg",
LightningImagePath = "imlegacy/btg-lightning.svg",

View File

@ -17,7 +17,7 @@ namespace BTCPayServer
DefaultRateRules = new[]
{
"BTX_X = BTX_BTC * BTC_X",
"BTX_BTC = hitbtc(BTX_BTC)"
"BTX_BTC = graviex(BTX_BTC)"
},
CryptoImagePath = "imlegacy/bitcore.svg",
LightningImagePath = "imlegacy/bitcore-lightning.svg",

View File

@ -1,32 +0,0 @@
using NBitcoin;
namespace BTCPayServer
{
public partial class BTCPayNetworkProvider
{
public void InitChaincoin()
{
var nbxplorerNetwork = NBXplorerNetworkProvider.GetFromCryptoCode("CHC");
Add(new BTCPayNetwork()
{
CryptoCode = nbxplorerNetwork.CryptoCode,
DisplayName = "Chaincoin",
BlockExplorerLink = NetworkType == ChainName.Mainnet
? "https://explorer.chaincoin.org/Explorer/Transaction/{0}"
: "https://test.explorer.chaincoin.org/Explorer/Transaction/tx/{0}",
NBXplorerNetwork = nbxplorerNetwork,
DefaultRateRules = new[]
{
"CHC_X = CHC_BTC * BTC_X",
"CHC_BTC = txbit(CHC_X)"
},
CryptoImagePath = "imlegacy/chaincoin.png",
DefaultSettings = BTCPayDefaultSettings.GetDefaultSettings(NetworkType),
//https://github.com/satoshilabs/slips/blob/master/slip-0044.md
CoinType = NetworkType == ChainName.Mainnet ? new KeyPath("711'")
: new KeyPath("1'")
});
}
}
}

View File

@ -63,6 +63,7 @@ namespace BTCPayServer
"LCAD_CAD = 1",
"LCAD_X = CAD_BTC * BTC_X",
"LCAD_BTC = bylls(CAD_BTC)",
"CAD_BTC = LCAD_BTC"
},
AssetId = new uint256("0e99c1a6da379d1f4151fb9df90449d40d0608f6cb33a5bcbfc8c265f42bab0a"),
DisplayName = "Liquid CAD",

View File

@ -45,10 +45,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.RPC
httpRequest.Headers.Authorization = new AuthenticationHeaderValue("Basic",
Convert.ToBase64String(Encoding.Default.GetBytes($"{_username}:{_password}")));
var rawResult = await _httpClient.SendAsync(httpRequest, cts);
var rawJson = await rawResult.Content.ReadAsStringAsync();
HttpResponseMessage rawResult = await _httpClient.SendAsync(httpRequest, cts);
rawResult.EnsureSuccessStatusCode();
var rawJson = await rawResult.Content.ReadAsStringAsync();
JsonRpcResult<TResponse> response;
try
{

View File

@ -56,7 +56,6 @@ namespace BTCPayServer
InitViacoin();
InitMonero();
InitZcash();
InitChaincoin();
// InitArgoneum();//their rate source is down 9/15/20.
// InitMonetaryUnit(); Not supported from Bittrex from 11/23/2022, dead shitcoin

View File

@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Rates;
public class ExchangeRateHostRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("exchangeratehost", "Yadio", "https://api.exchangerate.host/latest?base=BTC");
private readonly HttpClient _httpClient;
public ExchangeRateHostRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(RateSourceInfo.Url, cancellationToken);
response.EnsureSuccessStatusCode();
var jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
if(jobj["success"].Value<bool>() is not true || !jobj["base"].Value<string>().Equals("BTC", StringComparison.InvariantCulture))
throw new Exception("exchangerate.host returned a non success response or the base currency was not the requested one (BTC)");
var results = (JObject) jobj["rates"] ;
//key value is currency code to rate value
var list = new List<PairRate>();
foreach (var item in results)
{
string name = item.Key;
var value = item.Value.Value<decimal>();
list.Add(new PairRate(new CurrencyPair("BTC", name), new BidAsk(value)));
}
return list.ToArray();
}
}

View File

@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Rating;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Rates;
public class FreeCurrencyRatesRateProvider : IRateProvider
{
public RateSourceInfo RateSourceInfo => new("free-currency-rates", "Free Currency Rates", "https://cdn.jsdelivr.net/gh/fawazahmed0/currency-api@1/latest/currencies/btc.min.json");
private readonly HttpClient _httpClient;
public FreeCurrencyRatesRateProvider(HttpClient httpClient)
{
_httpClient = httpClient ?? new HttpClient();
}
public async Task<PairRate[]> GetRatesAsync(CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(RateSourceInfo.Url, cancellationToken);
response.EnsureSuccessStatusCode();
var jobj = await response.Content.ReadAsAsync<JObject>(cancellationToken);
var results = (JObject) jobj["btc"] ;
//key value is currency code to rate value
var list = new List<PairRate>();
foreach (var item in results)
{
string name = item.Key;
var value = item.Value.Value<decimal>();
list.Add(new PairRate(new CurrencyPair("BTC", name), new BidAsk(value)));
}
return list.ToArray();
}
}

View File

@ -1145,6 +1145,45 @@ namespace BTCPayServer.Tests
Assert.Equal("000000161", m.OrderId);
}
[Fact]
public void CanParseOldPosAppData()
{
var data = new JObject()
{
["price"] = 1.64m
}.ToString();
Assert.Equal(1.64m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = 1.65m
}
}.ToString();
Assert.Equal(1.65m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = "1.6305"
}
}.ToString();
Assert.Equal(1.6305m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
data = new JObject()
{
["price"] = new JObject()
{
["value"] = null
}
}.ToString();
Assert.Equal(0.0m, JsonConvert.DeserializeObject<PosAppCartItem>(data).Price);
var o = JObject.Parse(JsonConvert.SerializeObject(new PosAppCartItem() { Price = 1.356m }));
Assert.Equal(1.356m, o["price"].Value<decimal>());
}
[Fact]
public void CanParseCurrencyValue()
{

View File

@ -290,9 +290,9 @@ retry:
}
[Fact]
public void CanGetRateCryptoCurrenciesByDefault()
public async Task CanGetRateCryptoCurrenciesByDefault()
{
string[] brokenShitcoins = { "BTX_USD", "CHC_USD" };
string[] brokenShitcoins = { };
var provider = new BTCPayNetworkProvider(ChainName.Mainnet);
var factory = FastTests.CreateBTCPayRateFactory();
var fetcher = new RateFetcher(factory);
@ -305,17 +305,37 @@ retry:
var result = fetcher.FetchRates(pairs, rules, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
var rateResult = value.GetAwaiter().GetResult();
if (key.ToString() == "BTG_USD")
continue; // shitcoin not supported by bitfinex anymore
var rateResult = await value;
TestLogs.LogInformation($"Testing {key}");
if (brokenShitcoins.Contains(key.ToString()))
continue;
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}");
}
var b = new StoreBlob();
foreach (var k in StoreBlob.RecommendedExchanges)
{
b.DefaultCurrency = k.Key;
rules = b.GetDefaultRateRules(provider);
pairs =
provider.GetAll()
.Select(c => new CurrencyPair(c.CryptoCode, k.Key))
.ToHashSet();
result = fetcher.FetchRates(pairs, rules, default);
foreach ((CurrencyPair key, Task<RateResult> value) in result)
{
var rateResult = await value;
TestLogs.LogInformation($"Testing {key} when default currency is {k.Key}");
if (brokenShitcoins.Contains(key.ToString()))
continue;
Assert.True(rateResult.BidAsk != null, $"Impossible to get the rate {rateResult.EvaluatedRule}");
}
}
}
[Fact]
[Trait("Fast", "Fast")]
public async Task CheckJsContent()
{
// This test verify that no malicious js is added in the minified files.
@ -324,52 +344,63 @@ retry:
var actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap", "bootstrap.bundle.min.js").Trim();
var version = Regex.Match(actual, "Bootstrap v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
var expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/bootstrap@{version}/dist/js/bootstrap.bundle.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "clipboard.js", "clipboard.js");
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/clipboard.js/2.0.8/clipboard.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vuejs", "vue.min.js").Trim();
version = Regex.Match(actual, "Vue\\.js v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/vue/{version}/vue.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18next.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next/22.0.6/i18next.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "i18nextHttpBackend.min.js").Trim();
expected = (await (await client.GetAsync("https://cdnjs.cloudflare.com/ajax/libs/i18next-http-backend/2.0.1/i18nextHttpBackend.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "i18next", "vue-i18next.js").Trim();
expected = (await (await client.GetAsync("https://unpkg.com/@panter/vue-i18next@0.15.2/dist/vue-i18next.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-qrcode", "vue-qrcode.min.js").Trim();
version = Regex.Match(actual, "vue-qrcode v([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://unpkg.com/@chenfengyuan/vue-qrcode@{version}/dist/vue-qrcode.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "tom-select", "tom-select.complete.min.js").Trim();
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/tom-select@2.2.2/dist/js/tom-select.complete.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "dom-confetti", "dom-confetti.min.js").Trim();
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);
EqualJsContent(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);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "bootstrap-vue", "bootstrap-vue.min.js").Trim();
version = Regex.Match(actual, "BootstrapVue ([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdnjs.cloudflare.com/ajax/libs/bootstrap-vue/{version}/bootstrap-vue.min.js")).Content.ReadAsStringAsync()).Trim();
Assert.Equal(expected, actual);
EqualJsContent(expected, actual);
actual = GetFileContent("BTCPayServer", "wwwroot", "vendor", "vue-sanitize-directive", "vue-sanitize-directive.umd.min.js").Trim();
version = Regex.Match(actual, "Original file: /npm/vue-sanitize-directive@([0-9]+.[0-9]+.[0-9]+)").Groups[1].Value;
expected = (await (await client.GetAsync($"https://cdn.jsdelivr.net/npm/vue-sanitize-directive@{version}/dist/vue-sanitize-directive.umd.min.js")).Content.ReadAsStringAsync()).Trim();
EqualJsContent(expected, actual);
}
private void EqualJsContent(string expected, string actual)
{
if (expected != actual)
Assert.Equal(expected, actual.ReplaceLineEndings("\n"));
}
string GetFileContent(params string[] path)

View File

@ -296,7 +296,8 @@ namespace BTCPayServer
var createInvoice = new CreateInvoiceRequest()
{
Amount = item?.Price.Value,
Amount = item?.PriceType == ViewPointOfSaleViewModel.ItemPriceType.Topup? null: item?.Price,
Currency = currencyCode,
Checkout = new InvoiceDataBase.CheckoutOptions()
{

View File

@ -199,7 +199,9 @@ namespace BTCPayServer.Data
{ "GTQ", "bitpay" },
{ "COP", "yadio" },
{ "JPY", "bitbank" },
{ "TRY", "btcturk" }
{ "TRY", "btcturk" },
{ "UGX", "exchangeratehost"},
{ "RSD", "bitpay"}
};
public string GetRecommendedExchange() =>

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using BTCPayServer.Abstractions.Form;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Forms;
@ -10,7 +11,7 @@ public class FieldValueMirror : IFormComponentProvider
{
if (form.GetFieldByFullName(field.Value) is null)
{
field.ValidationErrors = new List<string> { $"{field.Name} requires {field.Value} to be present" };
field.ValidationErrors = new List<string> {$"{field.Name} requires {field.Value} to be present"};
}
}
@ -21,6 +22,13 @@ public class FieldValueMirror : IFormComponentProvider
public string GetValue(Form form, Field field)
{
return form.GetFieldByFullName(field.Value)?.Value;
var rawValue = form.GetFieldByFullName(field.Value)?.Value;
if (rawValue is not null && field.AdditionalData?.TryGetValue("valuemap", out var valueMap) is true &&
valueMap is JObject map && map.TryGetValue(rawValue, out var mappedValue))
{
return mappedValue.Value<string>();
}
return rawValue;
}
}

View File

@ -151,11 +151,32 @@ public class FormDataService
public CreateInvoiceRequest GenerateInvoiceParametersFromForm(Form form)
{
var amt = GetValue(form, $"{InvoiceParameterPrefix}amount");
var amtRaw = GetValue(form, $"{InvoiceParameterPrefix}amount");
var amt = string.IsNullOrEmpty(amtRaw) ? (decimal?) null : decimal.Parse(amtRaw, CultureInfo.InvariantCulture);
var adjustmentAmount = 0m;
foreach (var adjustmentField in form.GetAllFields().Where(f => f.FullName.StartsWith($"{InvoiceParameterPrefix}amount_adjustment")))
{
if (!decimal.TryParse(GetValue(form, adjustmentField.Field), out var adjustment))
{
continue;
}
adjustmentAmount += adjustment;
}
if (amt is null && adjustmentAmount > 0)
{
amt = adjustmentAmount;
}
else if(amt is not null)
{
amt += adjustmentAmount;
amt = Math.Max(0, amt!.Value);
}
return new CreateInvoiceRequest
{
Currency = GetValue(form, $"{InvoiceParameterPrefix}currency"),
Amount = string.IsNullOrEmpty(amt) ? null : decimal.Parse(amt, CultureInfo.InvariantCulture),
Amount = amt,
Metadata = GetValues(form),
};
}

View File

@ -205,7 +205,10 @@ public class UIFormsController : Controller
var request = _formDataService.GenerateInvoiceParametersFromForm(form);
var inv = await invoiceController.CreateInvoiceCoreRaw(request, store, Request.GetAbsoluteRoot());
if (inv.Price == 0 && inv.Type == InvoiceType.Standard && inv.ReceiptOptions?.Enabled is not false)
{
return RedirectToAction("InvoiceReceipt", "UIInvoice", new { invoiceId = inv.Id });
}
return RedirectToAction("Checkout", "UIInvoice", new { invoiceId = inv.Id });
}
}

View File

@ -520,6 +520,8 @@ namespace BTCPayServer.Hosting
services.AddRateProvider<BitflyerRateProvider>();
services.AddRateProvider<YadioRateProvider>();
services.AddRateProvider<BtcTurkRateProvider>();
services.AddRateProvider<FreeCurrencyRatesRateProvider>();
services.AddRateProvider<ExchangeRateHostRateProvider>();
// Broken
// Providers.Add("argoneum", new ArgoneumRateProvider(_httpClientFactory?.CreateClient("EXCHANGE_ARGONEUM")));

View File

@ -341,9 +341,13 @@ namespace BTCPayServer.Hosting
{
var items = new List<ViewPointOfSaleViewModel.Item>();
var stream = new YamlStream();
if (string.IsNullOrEmpty(yaml))
return items.ToArray();
stream.Load(new StringReader(yaml));
var root = stream.Documents.First().RootNode as YamlMappingNode;
if(stream.Documents.FirstOrDefault()?.RootNode is not YamlMappingNode root)
return items.ToArray();
foreach (var posItem in root.Children)
{
var trimmedKey = ((YamlScalarNode)posItem.Key).Value?.Trim();

View File

@ -278,7 +278,28 @@ namespace BTCPayServer.Plugins.PointOfSale.Controllers
return RedirectToAction(nameof(ViewPointOfSale), new { appId, viewType });
}
var amtField = form.GetFieldByFullName($"{FormDataService.InvoiceParameterPrefix}amount");
if (amtField is null && price.HasValue)
{
form.Fields.Add(new Field
{
Name = $"{FormDataService.InvoiceParameterPrefix}amount",
Type = "hidden",
Value = price.ToString(),
Constant = true
});
}
else
{
amtField.Value = price?.ToString();
}
formResponseJObject = FormDataService.GetValues(form);
var invoiceRequest = FormDataService.GenerateInvoiceParametersFromForm(form);
if (invoiceRequest.Amount is not null)
{
price = invoiceRequest.Amount.Value;
}
break;
}
try

View File

@ -15,6 +15,8 @@ namespace BTCPayServer.Services.Altcoins.Monero.Configuration
public Uri DaemonRpcUri { get; set; }
public Uri InternalWalletRpcUri { get; set; }
public string WalletDirectory { get; set; }
public string Username { get; set; }
public string Password { get; set; }
}
}
#endif

View File

@ -1,6 +1,8 @@
#if ALTCOINS
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using BTCPayServer.Abstractions.Contracts;
using BTCPayServer.Abstractions.Services;
using BTCPayServer.Configuration;
@ -19,6 +21,19 @@ namespace BTCPayServer.Services.Altcoins.Monero
{
serviceCollection.AddSingleton(provider =>
provider.ConfigureMoneroLikeConfiguration());
serviceCollection.AddHttpClient("XMRclient")
.ConfigurePrimaryHttpMessageHandler(provider =>
{
var configuration = provider.GetRequiredService<MoneroLikeConfiguration>();
if(!configuration.MoneroLikeConfigurationItems.TryGetValue("XMR", out var xmrConfig) || xmrConfig.Username is null || xmrConfig.Password is null){
return new HttpClientHandler();
}
return new HttpClientHandler
{
Credentials = new NetworkCredential(xmrConfig.Username, xmrConfig.Password),
PreAuthenticate = true
};
});
serviceCollection.AddSingleton<MoneroRPCProvider>();
serviceCollection.AddHostedService<MoneroLikeSummaryUpdaterHostedService>();
serviceCollection.AddHostedService<MoneroListener>();
@ -55,6 +70,12 @@ namespace BTCPayServer.Services.Altcoins.Monero
var walletDaemonWalletDirectory =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_wallet_daemon_walletdir", null);
var daemonUsername =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_username", null);
var daemonPassword =
configuration.GetOrDefault<string>(
$"{moneroLikeSpecificBtcPayNetwork.CryptoCode}_daemon_password", null);
if (daemonUri == null || walletDaemonUri == null)
{
throw new ConfigException($"{moneroLikeSpecificBtcPayNetwork.CryptoCode} is misconfigured");
@ -63,6 +84,8 @@ namespace BTCPayServer.Services.Altcoins.Monero
result.MoneroLikeConfigurationItems.Add(moneroLikeSpecificBtcPayNetwork.CryptoCode, new MoneroLikeConfigurationItem()
{
DaemonRpcUri = daemonUri,
Username = daemonUsername,
Password = daemonPassword,
InternalWalletRpcUri = walletDaemonUri,
WalletDirectory = walletDaemonWalletDirectory
});

View File

@ -29,10 +29,10 @@ namespace BTCPayServer.Services.Altcoins.Monero.Services
_eventAggregator = eventAggregator;
DaemonRpcClients =
_moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key,
pair => new JsonRpcClient(pair.Value.DaemonRpcUri, "", "", httpClientFactory.CreateClient()));
pair => new JsonRpcClient(pair.Value.DaemonRpcUri, pair.Value.Username, pair.Value.Password, httpClientFactory.CreateClient($"{pair.Key}client")));
WalletRpcClients =
_moneroLikeConfiguration.MoneroLikeConfigurationItems.ToImmutableDictionary(pair => pair.Key,
pair => new JsonRpcClient(pair.Value.InternalWalletRpcUri, "", "", httpClientFactory.CreateClient()));
pair => new JsonRpcClient(pair.Value.InternalWalletRpcUri, "", "", httpClientFactory.CreateClient($"{pair.Key}client")));
}
public bool IsAvailable(string cryptoCode)

View File

@ -1,5 +1,8 @@
using System;
using System.Globalization;
using BTCPayServer.Plugins.PointOfSale.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Services.Invoices;
@ -33,6 +36,7 @@ public class PosAppCartItem
public string Id { get; set; }
[JsonProperty(PropertyName = "price")]
[JsonConverter(typeof(PosAppCartItemPriceJsonConverter))]
public decimal Price { get; set; }
[JsonProperty(PropertyName = "title")]
@ -48,11 +52,40 @@ public class PosAppCartItem
public string Image { get; set; }
}
public class PosAppCartItemPrice
public class PosAppCartItemPriceJsonConverter : JsonConverter
{
[JsonProperty(PropertyName = "formatted")]
public string Formatted { get; set; }
public override bool CanConvert(Type objectType)
{
return objectType == typeof(decimal) || objectType == typeof(object);
}
[JsonProperty(PropertyName = "type")]
public ViewPointOfSaleViewModel.ItemPriceType Type { get; set; }
public override object ReadJson(JsonReader reader, Type objectType, object existingValue,
JsonSerializer serializer)
{
JToken token = JToken.Load(reader);
switch (token.Type)
{
case JTokenType.Float:
if (objectType == typeof(decimal))
return token.Value<decimal>();
throw new JsonSerializationException($"Unexpected object type: {objectType}");
case JTokenType.Integer:
case JTokenType.String:
if (objectType == typeof(decimal))
return decimal.Parse(token.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture);
throw new JsonSerializationException($"Unexpected object type: {objectType}");
case JTokenType.Null:
return null;
case JTokenType.Object:
return token.ToObject<JObject>()?["value"]?.Value<decimal?>();
default:
throw new JsonSerializationException($"Unexpected token type: {token.Type}");
}
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
if (value is decimal x)
writer.WriteValue(x);
}
}

View File

@ -5,7 +5,7 @@
@if (!Model.Constant)
{
<fieldset>
<legend class="h3 mt-4 mb-3">@Model.Label</legend>
<legend class="h3 mt-4 mb-3">@Safe.Raw(Model.Label)</legend>
@foreach (var field in Model.Fields)
{
if (FormComponentProviders.TypeToComponentProvider.TryGetValue(field.Type, out var partial) && !string.IsNullOrEmpty(partial.View))

View File

@ -3,12 +3,17 @@
var isInvalid = ViewContext.ModelState[Model.Name]?.ValidationState is Microsoft.AspNetCore.Mvc.ModelBinding.ModelValidationState.Invalid;
var errors = isInvalid ? ViewContext.ModelState[Model.Name].Errors : null;
}
@if (Model.Type == "hidden")
{
<input id="@Model.Name" type="@Model.Type" name="@Model.Name" value="@Model.Value" />
return;
}
<div class="form-group">
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Model.Label
@Safe.Raw(Model.Label)
</label>
<input id="@Model.Name" type="@Model.Type" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" value="@Model.Value" data-val="true"
name="@Model.Name" value="@Model.Value" data-val="true" readonly="@Model.Constant"
@if (!string.IsNullOrEmpty(Model.HelpText))
{
@Safe.Raw($" aria-describedby=\"HelpText-{Model.Name}\"")

View File

@ -12,17 +12,15 @@
}
<div class="form-group">
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Model.Label
@Safe.Raw(Model.Label)
</label>
<select id="@selectField.Name" asp-items="selectField.Options" class="form-select @(errors is null ? "" : "is-invalid")"
name="@selectField.Name" data-val="true" aria-describedby="HelpText-@selectField.Name" required="@selectField.Required"
data-val-required="@selectField.Label is required.">
</select>
<span class="text-danger" data-valmsg-for="@selectField.Name" data-valmsg-replace="true">@(isInvalid && errors.Any() ? errors.First().ErrorMessage : string.Empty)</span>
@if (!string.IsNullOrEmpty(selectField.HelpText))
{
<div id="@($"HelpText-{selectField.Name}")" class="form-text">@selectField.HelpText</div>
<div id="@($"HelpText-{selectField.Name}")" class="form-text">@Safe.Raw(selectField.HelpText)</div>
}
</div>

View File

@ -5,7 +5,7 @@
}
<div class="form-group">
<label class="form-label" for="@Model.Name"@(Model.Required ? " data-required" : "")>
@Model.Label
@Safe.Raw(Model.Label)
</label>
<textarea id="@Model.Name" class="form-control @(errors is null ? "" : "is-invalid")"
name="@Model.Name" data-val="true"

View File

@ -21,8 +21,23 @@
</div>
</template>
</template>
<script type="text/javascript">
// https://developer.chrome.com/articles/nfc/
<script>
class NDEFReaderWrapper {
constructor() {
this.onreading = null;
this.onreadingerror = null;
}
async scan(opts) {
if (opts && opts.signal){
opts.signal.addEventListener('abort', () => {
window.parent.postMessage('nfc:abort', '*');
});
}
window.parent.postMessage('nfc:startScan', '*');
}
}
Vue.component("lnurl-withdraw-checkout", {
template: "#lnurl-withdraw-template",
props: {
@ -69,7 +84,7 @@ Vue.component("lnurl-withdraw-checkout", {
data () {
return {
url: @Safe.Json(Context.Request.GetAbsoluteUri(Url.Action("SubmitLNURLWithdrawForInvoice", "NFC"))),
supported: 'NDEFReader' in window && window.self === window.top,
supported: 'NDEFReader' in window,
scanning: false,
submitting: false,
permissionGranted: false,
@ -135,7 +150,8 @@ Vue.component("lnurl-withdraw-checkout", {
this.submitting = false;
this.scanning = true;
try {
const ndef = new NDEFReader()
const inModal = window.self !== window.top;
const ndef = inModal ? new NDEFReaderWrapper() : new NDEFReader();
this.readerAbortController = new AbortController()
this.readerAbortController.signal.onabort = () => {
this.scanning = false;
@ -143,17 +159,33 @@ Vue.component("lnurl-withdraw-checkout", {
await ndef.scan({ signal: this.readerAbortController.signal })
ndef.onreadingerror = () => {
this.errorMessage = "Could not read NFC tag";
}
ndef.onreadingerror = this.reportNfcError
ndef.onreading = async ({ message, serialNumber }) => {
ndef.onreading = async ({ message }) => {
const record = message.records[0]
const textDecoder = new TextDecoder('utf-8')
const lnurl = textDecoder.decode(record.data)
await this.sendData(lnurl)
}
if (inModal) {
// receive messages from iframe
window.addEventListener('message', async event => {
// deny messages from other origins
if (event.origin !== window.location.origin) return
const { action, data } = event.data
switch (action) {
case 'nfc:data':
await this.sendData(data)
break;
case 'nfc:error':
this.reportNfcError()
break;
}
});
}
// we came here, so the user must have allowed NFC access
this.permissionGranted = true;
} catch (error) {
@ -182,6 +214,9 @@ Vue.component("lnurl-withdraw-checkout", {
this.errorMessage = error;
}
this.submitting = false;
},
reportNfcError() {
this.errorMessage = 'Could not read NFC tag';
}
}
});

View File

@ -30,6 +30,11 @@
}
@section PageFootContent {
<datalist id="special-field-names">
<option value="invoice_amount">Determine the generated invoice amount</option>
<option value="invoice_currency">Determine the generated invoice currency</option>
<option value="invoice_amount_adjustment">Adjusts the generated invoice amount — use as a prefix to have multiple adjustment fields</option>
</datalist>
<template id="form-template-email">
@FormDataService.StaticFormEmail
</template>
@ -50,8 +55,11 @@
</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>
<input id="field-editor-field-name" class="form-control" list="special-field-names" required v-model="field.name" />
<div class="form-text">The name of the field in the invoice's metadata.</div>
<div class="form-text text-info" v-if="field.name === 'invoice_currency'">The configured name means the value of this field will determine the invoice currency for public forms.</div>
<div class="form-text text-info" v-if="field.name === 'invoice_amount'">The configured name means the value of this field will determine the invoice amount for public forms.</div>
<div class="form-text text-info" v-if="field.name && field.name.startsWith('invoice_amount_adjustment')">The configured name means the value of this field will adjust the invoice amount for public forms and the point of sale app.</div>
</div>
<div class="form-group" v-if="field.type === 'select'">
<h5 class="mt-2">Options</h5>
@ -62,11 +70,11 @@
</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" />
<input :id="`field-option-value-${index}`" class="form-control" v-model.lazy="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" />
<input :id="`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" />
@ -78,20 +86,53 @@
Add Option
</button>
</div>
<div class="form-group" v-if="field.type !== 'fieldset'">
<div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
<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'">
<div class="form-group" v-if="field.type === 'mirror'">
<label for="field-editor-field-mirror" class="form-label">Field to mirror</label>
<select id="field-editor-field-mirror" class="form-select" v-model="field.value">
<option v-for="option in $root.allFields" v-if="option.name && option.name !== field.name" :key="option.name" :value="option.name" :selected="option.name === field.value" v-text="option.label || option.name"></option>
</select>
<div class="form-text">The chosen field's selected value will be copied to this field upon submission.</div>
</div>
<div class="form-group" v-if="field.type === 'mirror'">
<h5 class="mt-2">Value Mapper</h5>
<div class="form-text">The values being mirrored from another field will be mapped to another value if configured.</div>
<div class="options">
<div v-if="field.valuemap" v-for="(v, k, index) in field.valuemap" :key="k" class="d-flex align-items-start gap-2 pt-3">
<div class="field flex-grow-1">
<label :for="`field-valuemap-value-${index}`" class="form-label">Original Value</label>
<select v-if="mirroredField && mirroredField.type === 'select'" :id="`field-valuemap-value-${index}`" class="form-select" v-on:change="updateValueMap(k, $event.target.value, v)">
<option v-for="option in mirroredField.options" v-if="option.text && option.value" :key="option.value" :value="option.value" :selected="k === option.value" v-text="`${option.value} (${option.text})`"></option>
</select>
<input v-else :id="`field-valuemap-value-${index}`" class="form-control" placeholder="Value to match" :value="k" v-on:change="updateValueMap(k, $event.target.value, v)" />
</div>
<div class="field flex-grow-1">
<label :for="`field-valuemap-mapped-${index}`" class="form-label">Mapped Value</label>
<input :id="`field-valuemap-mapped-${index}`" class="form-control" placeholder="Value to set" :value="v" v-on:change="updateValueMap(k, k, $event.target.value)" />
</div>
<button type="button" class="btn b-0 control remove" v-on:click="removeValueMap($event, k)">
<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="addValueMap($event)">
<vc:icon symbol="new" />
Add mapped value
</button>
</div>
<div class="form-group" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
<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'">
<div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'mirror'">
<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'">
<div class="form-group form-check" v-if="field.type !== 'fieldset' && field.type !== 'select' && field.type !== 'mirror'">
<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>
@ -122,25 +163,31 @@
</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>
<label class="form-label" :for="name" :data-required="required" v-sanitize="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 v-if="helpText" :id="`HelpText-{name}`" class="form-text" v-sanitize="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>
<label class="form-label" :for="name" :data-required="required" v-sanitize="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 v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="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>
<label class="form-label" :for="name" :data-required="required" v-sanitize="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 v-if="helpText" :id="`HelpText-${name}`" class="form-text" v-sanitize="helpText"></div>
</div>
</template>
<template id="field-type-mirror">
<div class="form-group mb-0">
<label class="form-label" v-text="label" v-if="label"></label>
<div class="form-text">Mirror of {{value}}</div>
</div>
</template>
<template id="field-type-fieldset">
@ -150,90 +197,91 @@
</fieldset>
</template>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-sortable/sortable.min.js" asp-append-version="true"></script>
<script src="~/vendor/vue-sortable/vue-sortable.js" asp-append-version="true"></script>
<script src="~/js/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 mb-4">
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">
<span>@ViewData["Title"]</span>
<a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
@if (!isNew)
{
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
}
<form method="post" asp-action="Modify" asp-route-id="@formId" asp-route-storeId="@storeId">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex align-items-center justify-content-between mb-3">
<h3 class="mb-0">
<span>@ViewData["Title"]</span>
<a href="https://docs.btcpayserver.org/Forms" target="_blank" rel="noreferrer noopener" title="More information...">
<vc:icon symbol="info" />
</a>
</h3>
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" class="btn btn-primary order-sm-1" id="SaveButton">Save</button>
@if (!isNew)
{
<a class="btn btn-secondary" asp-action="ViewPublicForm" asp-route-formId="@formId" id="ViewForm">View</a>
}
</div>
</div>
</div>
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group" style="max-width: 27rem;">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="d-flex align-items-center mb-4 gap-3">
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
<div>
<label asp-for="Public"></label>
<div class="form-text">
Standalone mode, which can be used to generate invoices
independent of payment requests or apps.
<div asp-validation-summary="All" class="text-danger"></div>
<div class="form-group" style="max-width: 27rem;">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="d-flex align-items-center mb-4 gap-3">
<input asp-for="Public" type="checkbox" class="btcpay-toggle" />
<div>
<label asp-for="Public"></label>
<div class="form-text">
Standalone mode, which can be used to generate invoices
independent of payment requests or apps.
</div>
</div>
</div>
</div>
</div>
</div>
<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 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-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 class="tab-content">
<div class="tab-pane fade show active" id="EditorTabPane" role="tabpanel" aria-labelledby="EditorTabButton" tabindex="0">
<div class="row align-items-start">
<div class="col-lg-7 mb-4 mb-lg-0">
<fields-editor :path="[]"
:fields="fields"
:selected-field="selectedField"
v-on:add-field="addField"
v-on:sort-fields="sortFields"
v-on:select-field="selectField"
v-on:remove-field="removeField"
class="bg-tile pb-2 rounded" />
</div>
<div class="col-lg-5">
<field-editor :field="selectedField" class="bg-tile p-4 rounded" />
</div>
</div>
</div>
<div class="tab-pane fade" id="CodeTabPane" role="tabpanel" aria-labelledby="CodeTabButton" tabindex="0">
<div class="d-flex align-items-center justify-content-between gap-3">
<label asp-for="FormConfig" class="form-label" data-required>Form JSON</label>
</div>
<textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
<span asp-validation-for="FormConfig" class="text-danger"></span>
</div>
<textarea asp-for="FormConfig" class="form-control font-monospace" style="font-size:.85rem" rows="21" cols="21" v-model="configJSON" v-on:change="updateFromJSON"></textarea>
<span asp-validation-for="FormConfig" class="text-danger"></span>
</div>
</div>
</div>
</form>
</form>

View File

@ -227,10 +227,11 @@
});
</script>
}
<script>
<script>
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("copyLink").addEventListener("click", window.copyUrlToClipboard);
});
</script>
<vc:ui-extension-point location="pullpayment-foot" model="@Model"></vc:ui-extension-point>
</body>
</html>

View File

@ -265,6 +265,7 @@ function initApp() {
}
},
listenIn () {
const self = this;
let socket = null;
const updateFn = this.fetchData;
const supportsWebSockets = 'WebSocket' in window && window.WebSocket.CLOSING === 2;
@ -279,6 +280,9 @@ function initApp() {
socket.onerror = function (e) {
console.error('Error while connecting to websocket for invoice notifications (callback):', e);
};
socket.onclose = function () {
self.pollUpdates(2000, socket);
};
}
catch (e) {
console.error('Error while connecting to websocket for invoice notifications', e);

View File

@ -9,7 +9,7 @@ document.addEventListener('DOMContentLoaded', () => {
const $config = document.getElementById('FormConfig')
let config = parseConfig($config.value) || {}
const specialFieldTypeOptions = ['fieldset', 'textarea', 'select']
const specialFieldTypeOptions = ['fieldset', 'textarea', 'select', 'mirror']
const inputFieldTypeOptions = ['text', 'number', 'password', 'email', 'url', 'tel', 'date', 'hidden']
const fieldTypeOptions = inputFieldTypeOptions.concat(specialFieldTypeOptions)
@ -57,11 +57,17 @@ document.addEventListener('DOMContentLoaded', () => {
options: Array
}
})
const FieldTypeMirror = Vue.extend({
mixins: [fieldTypeBase],
name: 'field-type-mirror',
template: '#field-type-mirror'
})
const components = {
FieldTypeInput,
FieldTypeSelect,
FieldTypeTextarea
FieldTypeTextarea,
FieldTypeMirror
}
// register fields-editor and field-type-fieldset globally in order to use them recursively
@ -100,6 +106,12 @@ document.addEventListener('DOMContentLoaded', () => {
path: Array,
field: fieldProps
},
computed: {
mirroredField() {
return this.field.type === 'mirror' &&
this.$root.allFields.find(f => f.name === this.field.value)
}
},
methods: {
getFieldComponent,
addOption (event) {
@ -114,11 +126,26 @@ document.addEventListener('DOMContentLoaded', () => {
sortOptions (event) {
const { newIndex, oldIndex } = event
this.field.options.splice(newIndex, 0, this.field.options.splice(oldIndex, 1)[0])
}
},
addValueMap (event) {
if (!this.field.valuemap) this.$set(this.field, 'valuemap', {})
const index = Object.keys(this.field.valuemap).length + 1;
this.$set(this.field.valuemap, `valuemap_${index}`, '')
},
updateValueMap(oldK, newK, newV) {
if (oldK !== newK) {
Vue.delete(this.field.valuemap, oldK);
}
Vue.set(this.field.valuemap, newK, newV);
},
removeValueMap(event, k) {
Vue.delete(this.field.valuemap, k);
},
}
})
Vue.use(vSortable)
Vue.use(VueSanitizeDirective.default)
new Vue({
el: '#FormEditor',
@ -130,6 +157,18 @@ document.addEventListener('DOMContentLoaded', () => {
}
},
computed: {
allFields() {
const getFields = (fields, path) => {
let result = [];
for (const field of fields) {
result.push(field)
if (field.fields && field.fields.length > 0)
result= result.concat(getFields(field.fields, path + field.name));
}
return result;
}
return getFields(this.fields, "")
},
fields() {
return this.config.fields || []
},

View File

@ -29,7 +29,7 @@
--btcpay-footer-link-accent: var(--btcpay-neutral-800);
--btcpay-pre-bg: var(--btcpay-bg-dark);
--btcpay-primary-accent: rgb(var(--btcpay-primary-accent-rgb));
--btcpay-primary-accent-rgb: var(--btcpay-primary-300);
--btcpay-primary-accent-rgb: 30, 122, 68;
--btcpay-secondary: transparent;
--btcpay-secondary-text-active: var(--btcpay-primary);
--btcpay-secondary-rgb: 22, 27, 34;

View File

@ -48,7 +48,7 @@
var scriptMatch = thisScript.match(scriptSrcRegex)
if (scriptMatch) {
// We can't just take the domain as btcpay can run under a sub path with RootPath
origin = thisScript.substr(0, thisScript.length - scriptMatch[0].length);
origin = thisScript.slice(0, thisScript.length - scriptMatch[0].length);
}
// urlPrefix should be site root without trailing slash
function setApiUrlPrefix(urlPrefix) {
@ -88,10 +88,36 @@
function onModalReceiveMessage(customOnModalReceiveMessage) {
onModalReceiveMessageMethod = customOnModalReceiveMessage;
}
var readerAbortController = null;
function startNfcScan() {
const ndef = new NDEFReader();
readerAbortController = new AbortController()
readerAbortController.signal.onabort = () => {
this.scanning = false;
};
ndef.scan({ signal:readerAbortController.signal }).then(() => {
ndef.onreading = event => {
const message = event.message;
const record = message.records[0];
const textDecoder = new TextDecoder('utf-8');
const data = textDecoder.decode(record.data);
// Send NFC data back to the iframe
if (iframe) {
iframe.contentWindow.postMessage({ action: 'nfc:data', data }, '*');
}
};
ndef.onreadingerror = () => {
// Send error message back to the iframe
if (iframe) {
iframe.contentWindow.postMessage({ action: 'nfc:error' }, '*');
}
};
}).catch(console.error);
}
function receiveMessage(event) {
var uri;
if (!origin.startsWith(event.origin) || !showingInvoice) {
return;
}
@ -99,8 +125,14 @@
hideFrame();
} else if (event.data === 'loaded') {
showFrame();
} else if (event.data === 'nfc:startScan') {
startNfcScan();
} else if (event.data === 'nfc:abort') {
if (readerAbortController) {
readerAbortController.abort()
}
} else if (event.data && event.data.open) {
uri = event.data.open;
const uri = event.data.open;
if (uri.indexOf('bitcoin:') === 0) {
window.location = uri;
}
@ -149,5 +181,4 @@
setApiUrlPrefix: setApiUrlPrefix,
onModalReceiveMessage: onModalReceiveMessage
};
})();

File diff suppressed because one or more lines are too long