Async dashboard (#3916)

* Dashboard: Load Lightning balance async, display default currency

* Simplify approach, improve views and scripts

* Async tiles


Async tiles

* Add period for app sales

* Fix missing keypad view sales

* Fix after rebase

* Fix awaited call

* Fix build

Co-authored-by: Nicolas Dorier <nicolas.dorier@gmail.com>
This commit is contained in:
d11n 2022-07-06 05:40:16 +02:00 committed by GitHub
parent 19aaff2345
commit 657423207b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 459 additions and 265 deletions

View file

@ -1,3 +1,4 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Data;
using BTCPayServer.Models.AppViewModels;
@ -8,26 +9,30 @@ using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Components.AppSales;
public enum AppSalesPeriod
{
Week,
Month
}
public class AppSales : ViewComponent
{
private readonly AppService _appService;
private readonly StoreRepository _storeRepo;
public AppSales(AppService appService, StoreRepository storeRepo)
public AppSales(AppService appService)
{
_appService = appService;
_storeRepo = storeRepo;
}
public async Task<IViewComponentResult> InvokeAsync(AppData app)
public async Task<IViewComponentResult> InvokeAsync(AppSalesViewModel vm)
{
var stats = await _appService.GetSalesStats(app);
var vm = new AppSalesViewModel
{
App = app,
SalesCount = stats.SalesCount,
Series = stats.Series
};
if (vm.App == null) throw new ArgumentNullException(nameof(vm.App));
if (vm.InitialRendering) return View(vm);
var stats = await _appService.GetSalesStats(vm.App);
vm.SalesCount = stats.SalesCount;
vm.Series = stats.Series;
return View(vm);
}

View file

@ -7,6 +7,8 @@ namespace BTCPayServer.Components.AppSales;
public class AppSalesViewModel
{
public AppData App { get; set; }
public AppSalesPeriod Period { get; set; } = AppSalesPeriod.Week;
public int SalesCount { get; set; }
public IEnumerable<SalesStatsItem> Series { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -1,6 +1,7 @@
@using BTCPayServer.Services.Apps
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.AppSales
@model BTCPayServer.Components.AppSales.AppSalesViewModel
@{
@ -13,22 +14,81 @@
<h3>@Model.App.Name @label</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">Manage</a>
</header>
<p>@Model.SalesCount Total @label</p>
<div class="ct-chart"></div>
<script>
(function () {
const id = @Safe.Json($"AppSales-{Model.App.Id}");
const labels = @Safe.Json(Model.Series.Select(i => i.Label));
const series = @Safe.Json(Model.Series.Select(i => i.SalesCount));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
})();
</script>
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppSales-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppSales-${appId} script`);
if (initScript) eval(initScript.innerHTML);
}
})();
</script>
}
else
{
<header class="mb-3">
<span>
<span class="sales-count">@Model.SalesCount</span> Total @label
</span>
<div class="btn-group only-for-js" role="group" aria-label="Filter">
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodWeek-@Model.App.Id" value="@AppSalesPeriod.Week" @(Model.Period == AppSalesPeriod.Week ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodWeek-@Model.App.Id">1W</label>
<input type="radio" class="btn-check" name="AppSalesPeriod-@Model.App.Id" id="AppSalesPeriodMonth-@Model.App.Id" value="@AppSalesPeriod.Month" @(Model.Period == AppSalesPeriod.Month ? "checked" : "")>
<label class="btn btn-link" for="AppSalesPeriodMonth-@Model.App.Id">1M</label>
</div>
</header>
<div class="ct-chart"></div>
<script>
(function () {
const id = @Safe.Json($"AppSales-{Model.App.Id}");
const appId = @Safe.Json(Model.App.Id);
const period = @Safe.Json(Model.Period.ToString());
const baseUrl = @Safe.Json(Url.Action("AppSales", "UIApps", new { appId = Model.App.Id }));
const data = { series: @Safe.Json(Model.Series), salesCount: @Safe.Json(Model.SalesCount) };
const render = (data, period) => {
const series = data.series.map(s => s.salesCount);
const labels = data.series.map((s, i) => period === @Safe.Json(Model.Period.ToString()) ? s.label : (i % 5 === 0 ? s.label : ''));
const min = Math.min(...series);
const max = Math.max(...series);
const low = min === max ? 0 : Math.max(min - ((max - min) / 5), 0);
document.querySelectorAll(`#${id} .sales-count`).innerText = data.salesCount;
new Chartist.Bar(`#${id} .ct-chart`, {
labels,
series: [series]
}, {
low,
});
};
render(data, period);
const update = async period => {
const url = `${baseUrl}/${period}`;
const response = await fetch(url);
if (response.ok) {
const data = await response.json();
render(data, period);
}
};
delegate('change', `#${id} [name="AppSalesPeriod-${appId}"]`, async e => {
const type = e.target.value;
await update(type);
});
})();
</script>
}
</div>

View file

@ -12,24 +12,22 @@ namespace BTCPayServer.Components.AppTopItems;
public class AppTopItems : ViewComponent
{
private readonly AppService _appService;
private readonly StoreRepository _storeRepo;
public AppTopItems(AppService appService, StoreRepository storeRepo)
public AppTopItems(AppService appService)
{
_appService = appService;
_storeRepo = storeRepo;
}
public async Task<IViewComponentResult> InvokeAsync(AppData app)
public async Task<IViewComponentResult> InvokeAsync(AppTopItemsViewModel vm)
{
var entries = Enum.Parse<AppType>(app.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(app)
: await _appService.GetItemStats(app);
var vm = new AppTopItemsViewModel
{
App = app,
Entries = entries.ToList()
};
if (vm.App == null) throw new ArgumentNullException(nameof(vm.App));
if (vm.InitialRendering) return View(vm);
var entries = Enum.Parse<AppType>(vm.App.AppType) == AppType.Crowdfund
? await _appService.GetPerkStats(vm.App)
: await _appService.GetItemStats(vm.App);
vm.Entries = entries.ToList();
return View(vm);
}

View file

@ -8,4 +8,5 @@ public class AppTopItemsViewModel
{
public AppData App { get; set; }
public List<ItemStats> Entries { get; set; }
public bool InitialRendering { get; set; }
}

View file

@ -11,7 +11,27 @@
<h3>Top @(Model.App.AppType == nameof(AppType.Crowdfund) ? "Perks" : "Items")</h3>
<a asp-controller="UIApps" asp-action="@action" asp-route-appId="@Model.App.Id">View All</a>
</header>
@if (Model.Entries.Any())
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
(async () => {
const url = @Safe.Json(Url.Action("AppTopItems", "UIApps", new { appId = Model.App.Id }));
const appId = @Safe.Json(Model.App.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`AppTopItems-${appId}`).outerHTML = await response.text();
const initScript = document.querySelector(`#AppTopItems-${appId} script`);
if (initScript) eval(initScript.innerHTML);
}
})();
</script>
}
else if (Model.Entries.Any())
{
<div class="ct-chart mb-3"></div>
<script>

View file

@ -179,4 +179,3 @@
});
})();
</script>

View file

@ -19,7 +19,6 @@ namespace BTCPayServer.Components.StoreLightningBalance;
public class StoreLightningBalance : ViewComponent
{
private string _cryptoCode;
private readonly StoreRepository _storeRepo;
private readonly CurrencyNameTable _currencies;
private readonly BTCPayServerOptions _btcpayServerOptions;
@ -44,14 +43,13 @@ public class StoreLightningBalance : ViewComponent
_externalServiceOptions = externalServiceOptions;
_lightningClientFactory = lightningClientFactory;
_lightningNetworkOptions = lightningNetworkOptions;
_cryptoCode = _networkProvider.DefaultNetwork.CryptoCode;
}
public async Task<IViewComponentResult> InvokeAsync(StoreLightningBalanceViewModel vm)
{
if (vm.Store == null) throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null) throw new ArgumentNullException(nameof(vm.CryptoCode));
vm.CryptoCode = _cryptoCode;
vm.DefaultCurrency = vm.Store.GetStoreBlob().DefaultCurrency;
vm.CurrencyData = _currencies.GetCurrencyData(vm.DefaultCurrency, true);
@ -59,7 +57,7 @@ public class StoreLightningBalance : ViewComponent
try
{
var lightningClient = GetLightningClient(vm.Store);
var lightningClient = GetLightningClient(vm.Store, vm.CryptoCode);
var balance = await lightningClient.GetBalance();
vm.Balance = balance;
vm.TotalOnchain = balance.OnchainBalance != null
@ -84,10 +82,10 @@ public class StoreLightningBalance : ViewComponent
return View(vm);
}
private ILightningClient GetLightningClient(StoreData store)
private ILightningClient GetLightningClient(StoreData store, string cryptoCode)
{
var network = _networkProvider.GetNetwork<BTCPayNetwork>(_cryptoCode);
var id = new PaymentMethodId(_cryptoCode, PaymentTypes.LightningLike);
var network = _networkProvider.GetNetwork<BTCPayNetwork>(cryptoCode);
var id = new PaymentMethodId(cryptoCode, PaymentTypes.LightningLike);
var existing = store.GetSupportedPaymentMethods(_networkProvider)
.OfType<LightningSupportedPaymentMethod>()
.FirstOrDefault(d => d.PaymentId == id);
@ -97,7 +95,7 @@ public class StoreLightningBalance : ViewComponent
{
return _lightningClientFactory.Create(connectionString, network);
}
if (existing.IsInternalNode && _lightningNetworkOptions.Value.InternalLightningByCryptoCode.TryGetValue(_cryptoCode, out var internalLightningNode))
if (existing.IsInternalNode && _lightningNetworkOptions.Value.InternalLightningByCryptoCode.TryGetValue(cryptoCode, out var internalLightningNode))
{
return _lightningClientFactory.Create(internalLightningNode, network);
}

View file

@ -1,8 +1,6 @@
using BTCPayServer.Client.JsonConverters;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Services.Rates;
using Newtonsoft.Json;
namespace BTCPayServer.Components.StoreLightningBalance;

View file

@ -18,7 +18,6 @@ namespace BTCPayServer.Components.StoreLightningServices;
public class StoreLightningServices : ViewComponent
{
private readonly string _cryptoCode;
private readonly BTCPayServerOptions _btcpayServerOptions;
private readonly BTCPayNetworkProvider _networkProvider;
private readonly IOptions<ExternalServicesOptions> _externalServiceOptions;
@ -31,61 +30,55 @@ public class StoreLightningServices : ViewComponent
_networkProvider = networkProvider;
_btcpayServerOptions = btcpayServerOptions;
_externalServiceOptions = externalServiceOptions;
_cryptoCode = _networkProvider.DefaultNetwork.CryptoCode;
}
public IViewComponentResult Invoke(StoreData store)
public IViewComponentResult Invoke(StoreLightningServicesViewModel vm)
{
var vm = new StoreLightningServicesViewModel
{
Store = store,
CryptoCode = _cryptoCode,
};
if (vm.Store == null) throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null) throw new ArgumentNullException(nameof(vm.CryptoCode));
if (vm.LightningNodeType != LightningNodeType.Internal) return View(vm);
if (vm.LightningNodeType == LightningNodeType.Internal)
{
var services = _externalServiceOptions.Value.ExternalServices.ToList()
.Where(service => ExternalServices.LightningServiceTypes.Contains(service.Type))
.Select(async service =>
{
var model = new AdditionalServiceViewModel
{
DisplayName = service.DisplayName,
ServiceName = service.ServiceName,
CryptoCode = service.CryptoCode,
Type = service.Type.ToString()
};
try
{
model.Link = await service.GetLink(Request.GetAbsoluteUriNoPathBase(), _btcpayServerOptions.NetworkType);
}
catch (Exception exception)
{
model.Error = exception.Message;
}
return model;
})
.Select(t => t.Result)
.ToList();
// other services
foreach ((string key, Uri value) in _externalServiceOptions.Value.OtherExternalServices)
var services = _externalServiceOptions.Value.ExternalServices.ToList()
.Where(service => ExternalServices.LightningServiceTypes.Contains(service.Type))
.Select(async service =>
{
if (ExternalServices.LightningServiceNames.Contains(key))
var model = new AdditionalServiceViewModel
{
services.Add(new AdditionalServiceViewModel
{
DisplayName = key,
ServiceName = key,
Type = key.Replace(" ", ""),
Link = Request.GetAbsoluteUriNoPathBase(value).AbsoluteUri
});
DisplayName = service.DisplayName,
ServiceName = service.ServiceName,
CryptoCode = service.CryptoCode,
Type = service.Type.ToString()
};
try
{
model.Link = await service.GetLink(Request.GetAbsoluteUriNoPathBase(), _btcpayServerOptions.NetworkType);
}
catch (Exception exception)
{
model.Error = exception.Message;
}
return model;
})
.Select(t => t.Result)
.ToList();
// other services
foreach ((string key, Uri value) in _externalServiceOptions.Value.OtherExternalServices)
{
if (ExternalServices.LightningServiceNames.Contains(key))
{
services.Add(new AdditionalServiceViewModel
{
DisplayName = key,
ServiceName = key,
Type = key.Replace(" ", ""),
Link = Request.GetAbsoluteUriNoPathBase(value).AbsoluteUri
});
}
vm.Services = services;
}
vm.Services = services;
return View(vm);
}
}

View file

@ -1,30 +1,51 @@
@model BTCPayServer.Components.StoreNumbers.StoreNumbersViewModel
<div class="widget store-numbers">
<div class="store-number">
<header>
<h6>Payouts Pending</h6>
<a asp-controller="UIStorePullPayments" asp-action="Payouts" asp-route-storeId="@Model.Store.Id">Manage</a>
</header>
<div class="h3">@Model.PayoutsPending</div>
</div>
@if (Model.Transactions is not null)
{
<div class="store-number">
<header>
<h6>TXs in the last @Model.TransactionDays days</h6>
@if (Model.Transactions.Value > 0)
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
<div class="h3">@Model.Transactions.Value</div>
</div>
}
<div class="store-number">
<header>
<h6>Refunds Issued</h6>
</header>
<div class="h3">@Model.RefundsIssued</div>
</div>
<div class="widget store-numbers" id="StoreNumbers-@Model.Store.Id">
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
(async () => {
const url = @Safe.Json(Url.Action("StoreNumbers", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode }));
const storeId = @Safe.Json(Model.Store.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`StoreNumbers-${storeId}`).outerHTML = await response.text();
}
})();
</script>
}
else
{
<div class="store-number">
<header>
<h6>Payouts Pending</h6>
<a asp-controller="UIStorePullPayments" asp-action="Payouts" asp-route-storeId="@Model.Store.Id">Manage</a>
</header>
<div class="h3">@Model.PayoutsPending</div>
</div>
@if (Model.Transactions is not null)
{
<div class="store-number">
<header>
<h6>TXs in the last @Model.TransactionDays days</h6>
@if (Model.Transactions.Value > 0)
{
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
<div class="h3">@Model.Transactions.Value</div>
</div>
}
<div class="store-number">
<header>
<h6>Refunds Issued</h6>
</header>
<div class="h3">@Model.RefundsIssued</div>
</div>
}
</div>

View file

@ -19,9 +19,6 @@ namespace BTCPayServer.Components.StoreNumbers;
public class StoreNumbers : ViewComponent
{
private string CryptoCode;
private const int TransactionDays = 7;
private readonly StoreRepository _storeRepo;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayWalletProvider _walletProvider;
@ -40,41 +37,39 @@ public class StoreNumbers : ViewComponent
_nbxConnectionFactory = nbxConnectionFactory;
_networkProvider = networkProvider;
_dbContextFactory = dbContextFactory;
CryptoCode = networkProvider.DefaultNetwork.CryptoCode;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
public async Task<IViewComponentResult> InvokeAsync(StoreNumbersViewModel vm)
{
if (vm.Store == null) throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null) throw new ArgumentNullException(nameof(vm.CryptoCode));
vm.WalletId = new WalletId(vm.Store.Id, vm.CryptoCode);
if (vm.InitialRendering) return View(vm);
await using var ctx = _dbContextFactory.CreateContext();
var payoutsCount = await ctx.Payouts
.Where(p => p.PullPaymentData.StoreId == store.Id && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingApproval)
.Where(p => p.PullPaymentData.StoreId == vm.Store.Id && !p.PullPaymentData.Archived && p.State == PayoutState.AwaitingApproval)
.CountAsync();
var refundsCount = await ctx.Invoices
.Where(i => i.StoreData.Id == store.Id && !i.Archived && i.CurrentRefundId != null)
.Where(i => i.StoreData.Id == vm.Store.Id && !i.Archived && i.CurrentRefundId != null)
.CountAsync();
var walletId = new WalletId(store.Id, CryptoCode);
var derivation = store.GetDerivationSchemeSettings(_networkProvider, walletId.CryptoCode);
var derivation = vm.Store.GetDerivationSchemeSettings(_networkProvider, vm.CryptoCode);
int? transactionsCount = null;
if (derivation != null && _nbxConnectionFactory.Available)
{
await using var conn = await _nbxConnectionFactory.OpenConnection();
var wid = NBXplorer.Client.DBUtils.nbxv1_get_wallet_id(derivation.Network.CryptoCode, derivation.AccountDerivation.ToString());
var afterDate = DateTimeOffset.UtcNow - TimeSpan.FromDays(TransactionDays);
var afterDate = DateTimeOffset.UtcNow - TimeSpan.FromDays(vm.TransactionDays);
var count = await conn.ExecuteScalarAsync<long>("SELECT COUNT(*) FROM wallets_history WHERE code=@code AND wallet_id=@wid AND seen_at > @afterDate", new { code = derivation.Network.CryptoCode, wid, afterDate });
transactionsCount = (int)count;
}
var vm = new StoreNumbersViewModel
{
Store = store,
WalletId = walletId,
PayoutsPending = payoutsCount,
Transactions = transactionsCount,
TransactionDays = TransactionDays,
RefundsIssued = refundsCount
};
vm.PayoutsPending = payoutsCount;
vm.Transactions = transactionsCount;
vm.RefundsIssued = refundsCount;
return View(vm);
}

View file

@ -1,4 +1,3 @@
using System.Collections;
using BTCPayServer.Data;
namespace BTCPayServer.Components.StoreNumbers;
@ -10,5 +9,7 @@ public class StoreNumbersViewModel
public int PayoutsPending { get; set; }
public int? Transactions { get; set; }
public int RefundsIssued { get; set; }
public int TransactionDays { get; set; }
public int TransactionDays { get; set; } = 7;
public bool InitialRendering { get; set; }
public string CryptoCode { get; set; }
}

View file

@ -3,7 +3,7 @@
@using BTCPayServer.Services.Invoices
@model BTCPayServer.Components.StoreRecentInvoices.StoreRecentInvoicesViewModel
<div class="widget store-recent-transactions">
<div class="widget store-recent-invoices" id="StoreRecentInvoices-@Model.Store.Id">
<header>
<h3>Recent Invoices</h3>
@if (Model.Invoices.Any())
@ -11,7 +11,25 @@
<a asp-controller="UIInvoice" asp-action="ListInvoices" asp-route-storeId="@Model.Store.Id">View All</a>
}
</header>
@if (Model.Invoices.Any())
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
(async () => {
const url = @Safe.Json(Url.Action("RecentInvoices", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode }));
const storeId = @Safe.Json(Model.Store.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`StoreRecentInvoices-${storeId}`).outerHTML = await response.text();
}
})();
</script>
}
else if (Model.Invoices.Any())
{
<table class="table table-hover mt-3 mb-0">
<thead>

View file

@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@ -32,22 +33,25 @@ public class StoreRecentInvoices : ViewComponent
_dbContextFactory = dbContextFactory;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
public async Task<IViewComponentResult> InvokeAsync(StoreRecentInvoicesViewModel vm)
{
if (vm.Store == null) throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null) throw new ArgumentNullException(nameof(vm.CryptoCode));
if (vm.InitialRendering) return View(vm);
var userId = _userManager.GetUserId(UserClaimsPrincipal);
var invoiceEntities = await _invoiceRepo.GetInvoices(new InvoiceQuery
{
UserId = userId,
StoreId = new [] { store.Id },
StoreId = new [] { vm.Store.Id },
IncludeArchived = false,
IncludeRefunds = true,
Take = 5
});
var invoices = new List<StoreRecentInvoiceViewModel>();
foreach (var invoice in invoiceEntities)
{
var state = invoice.GetInvoiceState();
invoices.Add(new StoreRecentInvoiceViewModel
vm.Invoices = (from invoice in invoiceEntities
let state = invoice.GetInvoiceState()
select new StoreRecentInvoiceViewModel
{
Date = invoice.InvoiceTime,
Status = state,
@ -55,13 +59,7 @@ public class StoreRecentInvoices : ViewComponent
InvoiceId = invoice.Id,
OrderId = invoice.Metadata.OrderId ?? string.Empty,
AmountCurrency = _currencyNameTable.DisplayFormatCurrency(invoice.Price, invoice.Currency),
});
}
var vm = new StoreRecentInvoicesViewModel
{
Store = store,
Invoices = invoices
};
}).ToList();
return View(vm);
}

View file

@ -6,5 +6,7 @@ namespace BTCPayServer.Components.StoreRecentInvoices;
public class StoreRecentInvoicesViewModel
{
public StoreData Store { get; set; }
public IEnumerable<StoreRecentInvoiceViewModel> Invoices { get; set; }
public IList<StoreRecentInvoiceViewModel> Invoices { get; set; } = new List<StoreRecentInvoiceViewModel>();
public bool InitialRendering { get; set; }
public string CryptoCode { get; set; }
}

View file

@ -1,7 +1,7 @@
@using BTCPayServer.Abstractions.Extensions
@model BTCPayServer.Components.StoreRecentTransactions.StoreRecentTransactionsViewModel
<div class="widget store-recent-transactions">
<div class="widget store-recent-transactions" id="StoreRecentTransactions-@Model.Store.Id">
<header>
<h3>Recent Transactions</h3>
@if (Model.Transactions.Any())
@ -9,7 +9,25 @@
<a asp-controller="UIWallets" asp-action="WalletTransactions" asp-route-walletId="@Model.WalletId">View All</a>
}
</header>
@if (Model.Transactions.Any())
@if (Model.InitialRendering)
{
<div class="loading d-flex justify-content-center p-3">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<script>
(async () => {
const url = @Safe.Json(Url.Action("RecentTransactions", "UIStores", new { storeId = Model.Store.Id, cryptoCode = Model.CryptoCode }));
const storeId = @Safe.Json(Model.Store.Id);
const response = await fetch(url);
if (response.ok) {
document.getElementById(`StoreRecentTransactions-${storeId}`).outerHTML = await response.text();
}
})();
</script>
}
else if (Model.Transactions.Any())
{
<table class="table table-hover mt-3 mb-0">
<thead>

View file

@ -21,32 +21,27 @@ namespace BTCPayServer.Components.StoreRecentTransactions;
public class StoreRecentTransactions : ViewComponent
{
private string CryptoCode;
private readonly StoreRepository _storeRepo;
private readonly ApplicationDbContextFactory _dbContextFactory;
private readonly BTCPayWalletProvider _walletProvider;
public BTCPayNetworkProvider NetworkProvider { get; }
public StoreRecentTransactions(
StoreRepository storeRepo,
BTCPayNetworkProvider networkProvider,
NBXplorerConnectionFactory connectionFactory,
BTCPayWalletProvider walletProvider,
ApplicationDbContextFactory dbContextFactory)
BTCPayWalletProvider walletProvider)
{
_storeRepo = storeRepo;
NetworkProvider = networkProvider;
_walletProvider = walletProvider;
_dbContextFactory = dbContextFactory;
CryptoCode = networkProvider.DefaultNetwork.CryptoCode;
}
public async Task<IViewComponentResult> InvokeAsync(StoreData store)
public async Task<IViewComponentResult> InvokeAsync(StoreRecentTransactionsViewModel vm)
{
var walletId = new WalletId(store.Id, CryptoCode);
var derivationSettings = store.GetDerivationSchemeSettings(NetworkProvider, walletId.CryptoCode);
if (vm.Store == null) throw new ArgumentNullException(nameof(vm.Store));
if (vm.CryptoCode == null) throw new ArgumentNullException(nameof(vm.CryptoCode));
vm.WalletId = new WalletId(vm.Store.Id, vm.CryptoCode);
if (vm.InitialRendering) return View(vm);
var derivationSettings = vm.Store.GetDerivationSchemeSettings(NetworkProvider, vm.CryptoCode);
var transactions = new List<StoreRecentTransactionViewModel>();
if (derivationSettings?.AccountDerivation is not null)
{
@ -66,13 +61,8 @@ public class StoreRecentTransactions : ViewComponent
.ToList();
}
var vm = new StoreRecentTransactionsViewModel
{
Store = store,
WalletId = walletId,
Transactions = transactions
};
vm.Transactions = transactions;
return View(vm);
}
}

View file

@ -1,4 +1,3 @@
using System.Collections;
using System.Collections.Generic;
using BTCPayServer.Data;
@ -9,4 +8,6 @@ public class StoreRecentTransactionsViewModel
public StoreData Store { get; set; }
public IList<StoreRecentTransactionViewModel> Transactions { get; set; } = new List<StoreRecentTransactionViewModel>();
public WalletId WalletId { get; set; }
public bool InitialRendering { get; set; }
public string CryptoCode { get; set; }
}

View file

@ -24,13 +24,13 @@
}
@if (Model.Series != null)
{
<div class="btn-group mt-1" role="group" aria-label="Filter">
<input type="radio" class="btn-check" name="filter" id="filter-week" value="week" @(Model.Type == WalletHistogramType.Week ? "checked" : "")>
<label class="btn btn-link" for="filter-week">1W</label>
<input type="radio" class="btn-check" name="filter" id="filter-month" value="month" @(Model.Type == WalletHistogramType.Month ? "checked" : "")>
<label class="btn btn-link" for="filter-month">1M</label>
<input type="radio" class="btn-check" name="filter" id="filter-year" value="year" @(Model.Type == WalletHistogramType.Year ? "checked" : "")>
<label class="btn btn-link" for="filter-year">1Y</label>
<div class="btn-group only-for-js mt-1" role="group" aria-label="Period">
<input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.Store.Id" id="StoreWalletBalancePeriodWeek-@Model.Store.Id" value="@WalletHistogramType.Week" @(Model.Type == WalletHistogramType.Week ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodWeek-@Model.Store.Id">1W</label>
<input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.Store.Id" id="StoreWalletBalancePeriodMonth-@Model.Store.Id" value="@WalletHistogramType.Month" @(Model.Type == WalletHistogramType.Month ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodMonth-@Model.Store.Id">1M</label>
<input type="radio" class="btn-check" name="StoreWalletBalancePeriod-@Model.Store.Id" id="StoreWalletBalancePeriodYear-@Model.Store.Id" value="@WalletHistogramType.Year" @(Model.Type == WalletHistogramType.Year ? "checked" : "")>
<label class="btn btn-link" for="StoreWalletBalancePeriodYear-@Model.Store.Id">1Y</label>
</div>
}
</header>
@ -114,7 +114,7 @@
render(data);
document.addEventListener('DOMContentLoaded', () => {
delegate('change', `#${id} [name="filter"]`, async e => {
delegate('change', `#${id} [name="StoreWalletBalancePeriod-${storeId}"]`, async e => {
const type = e.target.value;
await update(type);
})

View file

@ -0,0 +1,59 @@
using System;
using System.Threading.Tasks;
using BTCPayServer.Components.AppSales;
using BTCPayServer.Components.AppTopItems;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Controllers
{
public partial class UIAppsController
{
[HttpGet("{appId}/dashboard/app-top-items")]
public IActionResult AppTopItems(string appId)
{
var app = HttpContext.GetAppData();
if (app == null)
return NotFound();
app.StoreData = GetCurrentStore();
var vm = new AppTopItemsViewModel { App = app };
return ViewComponent("AppTopItems", new { vm });
}
[HttpGet("{appId}/dashboard/app-sales")]
public IActionResult AppSales(string appId)
{
var app = HttpContext.GetAppData();
if (app == null)
return NotFound();
app.StoreData = GetCurrentStore();
var vm = new AppSalesViewModel { App = app };
return ViewComponent("AppSales", new { vm });
}
[HttpGet("{appId}/dashboard/app-sales/{period}")]
public async Task<IActionResult> AppSales(string appId, AppSalesPeriod period)
{
var app = HttpContext.GetAppData();
if (app == null)
return NotFound();
app.StoreData = GetCurrentStore();
var days = period switch
{
AppSalesPeriod.Week => 7,
AppSalesPeriod.Month => 30,
_ => throw new ArgumentException($"AppSalesPeriod {period} does not exist.")
};
var stats = await _appService.GetSalesStats(app, days);
return stats == null
? NotFound()
: Json(stats);
}
}
}

View file

@ -1,22 +1,13 @@
#nullable enable
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Components.StoreLightningBalance;
using BTCPayServer.Configuration;
using BTCPayServer.Components.StoreNumbers;
using BTCPayServer.Components.StoreRecentInvoices;
using BTCPayServer.Components.StoreRecentTransactions;
using BTCPayServer.Data;
using BTCPayServer.Lightning;
using BTCPayServer.Logging;
using BTCPayServer.Models;
using BTCPayServer.Models.StoreViewModels;
using BTCPayServer.Payments;
using BTCPayServer.Payments.Lightning;
using BTCPayServer.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
namespace BTCPayServer.Controllers
{
@ -39,36 +30,66 @@ namespace BTCPayServer.Controllers
LightningEnabled = lightningEnabled,
StoreId = CurrentStore.Id,
StoreName = CurrentStore.StoreName,
CryptoCode = _NetworkProvider.DefaultNetwork.CryptoCode,
IsSetUp = walletEnabled || lightningEnabled
};
// Widget data
if (vm.WalletEnabled || vm.LightningEnabled)
{
var userId = GetUserId();
var apps = await _appService.GetAllApps(userId, false, store.Id);
vm.Apps = apps
.Select(a =>
{
var appData = _appService.GetAppDataIfOwner(userId, a.Id).Result;
appData.StoreData = store;
return appData;
})
.ToList();
}
if (!vm.WalletEnabled && !vm.LightningEnabled) return View(vm);
var userId = GetUserId();
var apps = await _appService.GetAllApps(userId, false, store.Id);
foreach (var app in apps)
{
var appData = await _appService.GetAppDataIfOwner(userId, app.Id);
vm.Apps.Add(appData);
}
return View(vm);
}
[HttpGet("{storeId}/lightning/{cryptoCode}/balance")]
[HttpGet("{storeId}/dashboard/{cryptoCode}/lightning/balance")]
public IActionResult LightningBalance(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var vm = new StoreLightningBalanceViewModel { Store = store };
var vm = new StoreLightningBalanceViewModel { Store = store, CryptoCode = cryptoCode };
return ViewComponent("StoreLightningBalance", new { vm });
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/numbers")]
public IActionResult StoreNumbers(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var vm = new StoreNumbersViewModel { Store = store, CryptoCode = cryptoCode };
return ViewComponent("StoreNumbers", new { vm });
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/recent-transactions")]
public IActionResult RecentTransactions(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var vm = new StoreRecentTransactionsViewModel { Store = store, CryptoCode = cryptoCode };
return ViewComponent("StoreRecentTransactions", new { vm });
}
[HttpGet("{storeId}/dashboard/{cryptoCode}/recent-invoices")]
public IActionResult RecentInvoices(string storeId, string cryptoCode)
{
var store = HttpContext.GetStoreData();
if (store == null)
return NotFound();
var vm = new StoreRecentInvoicesViewModel { Store = store, CryptoCode = cryptoCode };
return ViewComponent("StoreRecentInvoices", new { vm });
}
}
}

View file

@ -6,9 +6,10 @@ namespace BTCPayServer.Models.StoreViewModels;
public class StoreDashboardViewModel
{
public string StoreId { get; set; }
public string CryptoCode { get; set; }
public string StoreName { get; set; }
public bool WalletEnabled { get; set; }
public bool LightningEnabled { get; set; }
public bool IsSetUp { get; set; }
public List<AppData> Apps { get; set; }
public List<AppData> Apps { get; set; } = new();
}

View file

@ -283,12 +283,7 @@ namespace BTCPayServer.Services.Apps
var invoices = await GetInvoicesForApp(app);
var paidInvoices = invoices.Where(IsPaid).ToArray();
var series = paidInvoices
.Where(entity => entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays) && (
// The POS data is present for the cart view, where multiple items can be bought
!string.IsNullOrEmpty(entity.Metadata.PosData) ||
// The item code should be present for all types other than the cart and keypad
!string.IsNullOrEmpty(entity.Metadata.ItemCode)
))
.Where(entity => entity.InvoiceTime > DateTimeOffset.UtcNow - TimeSpan.FromDays(numberOfDays))
.Aggregate(new List<InvoiceStatsItem>(), AggregateInvoiceEntitiesForStats(items))
.GroupBy(entity => entity.Date)
.Select(entities => new SalesStatsItem
@ -330,26 +325,7 @@ namespace BTCPayServer.Services.Apps
{
return (res, e) =>
{
if (!string.IsNullOrEmpty(e.Metadata.ItemCode))
{
var item = items.FirstOrDefault(p => p.Id == e.Metadata.ItemCode);
if (item == null) return res;
var fiatPrice = e.GetPayments(true).Sum(pay =>
{
var paymentMethodId = pay.GetPaymentMethodId();
var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee;
var rate = e.GetPaymentMethod(paymentMethodId).Rate;
return rate * value;
});
res.Add(new InvoiceStatsItem
{
ItemCode = e.Metadata.ItemCode,
FiatPrice = fiatPrice,
Date = e.InvoiceTime.Date
});
}
else if (!string.IsNullOrEmpty(e.Metadata.PosData))
if (!string.IsNullOrEmpty(e.Metadata.PosData))
{
// flatten single items from POS data
var data = JsonConvert.DeserializeObject<PosAppData>(e.Metadata.PosData);
@ -370,6 +346,22 @@ namespace BTCPayServer.Services.Apps
}
}
}
else
{
var fiatPrice = e.GetPayments(true).Sum(pay =>
{
var paymentMethodId = pay.GetPaymentMethodId();
var value = pay.GetCryptoPaymentData().GetValue() - pay.NetworkFee;
var rate = e.GetPaymentMethod(paymentMethodId).Rate;
return rate * value;
});
res.Add(new InvoiceStatsItem
{
ItemCode = e.Metadata.ItemCode,
FiatPrice = fiatPrice,
Date = e.InvoiceTime.Date
});
}
return res;
};
}

View file

@ -1,14 +1,17 @@
@using BTCPayServer.Components.StoreLightningBalance
@using BTCPayServer.Components.StoreLightningServices
@using BTCPayServer.Components.StoreNumbers
@using BTCPayServer.Components.StoreRecentInvoices
@using BTCPayServer.Components.StoreRecentTransactions
@using BTCPayServer.Components.StoreWalletBalance
@using BTCPayServer.TagHelpers
@using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Components.AppSales
@using BTCPayServer.Components.AppTopItems
@model StoreDashboardViewModel;
@inject BTCPayNetworkProvider networkProvider
@{
ViewData.SetActivePage(StoreNavPages.Dashboard, Model.StoreName, Model.StoreId);
var defaultCryptoCode = networkProvider.DefaultNetwork.CryptoCode;
var store = ViewContext.HttpContext.GetStoreData();
}
@ -101,7 +104,7 @@
<h5 class="mb-0 text-success">Set up a Lightning node</h5>
</div>
</div>
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center">
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a wallet</h5>
@ -111,21 +114,21 @@
</div>
</div>
}
<vc:store-numbers store="@store"/>
<vc:store-numbers vm="@(new StoreNumbersViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
@if (Model.LightningEnabled)
{
<vc:store-lightning-balance vm="@(new StoreLightningBalanceViewModel { Store = store, InitialRendering = true })"/>
<vc:store-lightning-services store="@store"/>
<vc:store-lightning-balance vm="@(new StoreLightningBalanceViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
<vc:store-lightning-services vm="@(new StoreLightningServicesViewModel { Store = store, CryptoCode = Model.CryptoCode })"/>
}
@if (Model.WalletEnabled)
{
<vc:store-recent-transactions store="@store"/>
<vc:store-recent-transactions vm="@(new StoreRecentTransactionsViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
}
<vc:store-recent-invoices store="@store"/>
<vc:store-recent-invoices vm="@(new StoreRecentInvoicesViewModel { Store = store, CryptoCode = Model.CryptoCode, InitialRendering = true })"/>
@foreach (var app in Model.Apps)
{
<vc:app-sales app="@app"/>
<vc:app-top-items app="@app"/>
<vc:app-sales vm="@(new AppSalesViewModel { App = app, InitialRendering = true })"/>
<vc:app-top-items vm="@(new AppTopItemsViewModel { App = app, InitialRendering = true })"/>
}
</div>
}
@ -142,7 +145,7 @@ else
</div>
@if (!Model.WalletEnabled)
{
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<a asp-controller="UIStores" asp-action="SetupWallet" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" id="SetupGuide-Wallet" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a wallet</h5>
@ -161,7 +164,7 @@ else
}
@if (!Model.LightningEnabled)
{
<a asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@defaultCryptoCode" id="SetupGuide-Lightning" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<a asp-controller="UIStores" asp-action="SetupLightningNode" asp-route-storeId="@Model.StoreId" asp-route-cryptoCode="@Model.CryptoCode" id="SetupGuide-Lightning" class="list-group-item list-group-item-action d-flex align-items-center order-1">
<vc:icon symbol="new-wallet"/>
<div class="content">
<h5 class="mb-0">Set up a Lightning node</h5>