Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
72623d9792 | |||
d8a36903b9 | |||
ca542e47f8 | |||
aace013109 | |||
98387e09b2 | |||
3eab36555c | |||
e69e744fb8 | |||
8a72add7e6 | |||
c5db390c71 | |||
45832bb171 | |||
26d62c5a7a | |||
43eb489e25 | |||
a7def63137 | |||
3703a170e7 | |||
73fbfbd7cb | |||
acae3b8753 | |||
a618f901fc | |||
6d4918f0ab | |||
7f2c4d2e7a | |||
fd6d361e1a | |||
b5f0924651 | |||
1600dd4759 | |||
c777746b69 | |||
9f5466a41f | |||
4d1e4801bf | |||
5e469ff9c0 |
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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'")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
{
|
||||
|
@ -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
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
@ -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()
|
||||
{
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
{
|
||||
|
@ -199,7 +199,9 @@ namespace BTCPayServer.Data
|
||||
{ "GTQ", "bitpay" },
|
||||
{ "COP", "yadio" },
|
||||
{ "JPY", "bitbank" },
|
||||
{ "TRY", "btcturk" }
|
||||
{ "TRY", "btcturk" },
|
||||
{ "UGX", "exchangeratehost"},
|
||||
{ "RSD", "bitpay"}
|
||||
};
|
||||
|
||||
public string GetRecommendedExchange() =>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
@ -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")));
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
});
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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}\"")
|
||||
|
@ -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>
|
||||
|
@ -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"
|
||||
|
@ -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';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
|
@ -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 || []
|
||||
},
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
})();
|
||||
|
52
BTCPayServer/wwwroot/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js
vendored
Normal file
52
BTCPayServer/wwwroot/vendor/vue-sanitize-directive/vue-sanitize-directive.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user