Compare commits

...

19 Commits

Author SHA1 Message Date
Pavlenex
395495fef5
Merge pull request #236 from TChukwuleta/bg/centre_charge_button 2025-07-18 19:35:28 +05:00
Chukwuleta Tobechi
aee8c5f032 Fix charge button position on Bitcoinize POS 2025-07-18 15:23:16 +01:00
Ghander
0785a1ab39
Update build-test.yml
change workflow trigger from pull_request to pull_request_target, which runs in the context of the base repository and can access secrets.
2025-07-01 16:57:04 -05:00
Ghander
ae06fd4404
Merge pull request #225 from TChukwuleta/ft/Include_archive
Include receipt and archive CTA
2025-06-30 11:50:51 -05:00
Ghander
e2d9b4e87c
clean before build-test.yml 2025-06-29 21:28:51 -05:00
Ghander
621adc1804 fixed ambiguous class references for invoice request 2025-06-28 21:49:25 -05:00
Pavlenex
831f1e2c5b
Merge pull request #220 from TChukwuleta/feat/include_supporters
Include supporters to the login and create account view
2025-06-27 11:34:43 +05:00
Pavlenex
9f56451b41
Merge pull request #227 from NicolasDorier/fix-double-charge
Fix: The keypad is charging double of what it should be
2025-06-27 10:50:04 +05:00
nicolas.dorier
427ba10f5e
Fix instructions in README.md 2025-06-26 18:24:36 +09:00
nicolas.dorier
409822d1a2
Fix: The keypad is charging double of what it should be 2025-06-26 17:53:42 +09:00
Chukwuleta Tobechi
1e84841af1 fixing sshd 2025-06-24 13:41:14 +01:00
Chukwuleta Tobechi
29136fe4c4 Include receipt and archive CTA 2025-06-23 19:18:23 +01:00
rockstardev
1b462e81da
Improving NFC scan processing 2025-06-19 11:04:55 +02:00
Chukwuleta Tobechi
e75d011eb4 Include supporters to the login and create account view 2025-06-19 10:04:31 +01:00
Ghander
ef534215f9
Merge pull request #219 from btcpayserver/fix-store-build
Fix: Unable to build on the plugin builder
2025-06-15 16:07:34 -05:00
Ghander
b147ddba3f Running the app in the background is causing the app to lock up on restart. Turning this off for now until we can find a long term fix. 2025-06-13 23:05:23 -05:00
Ghander
fdd3e0fc33 prevent multiple simultaneous nfc scans 2025-06-13 22:49:45 -05:00
Ghander
797e7a33c7 added invoice paid with successful nfc payment since this happens outside the iframe. 2025-06-12 19:47:07 -05:00
Ghander
f4f988b113 lnurl nfc attempt #3 2025-06-11 20:44:28 -05:00
26 changed files with 645 additions and 255 deletions

View File

@ -7,7 +7,7 @@ on:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request:
pull_request_target:
branches:
- master
@ -36,7 +36,9 @@ jobs:
run: dotnet build --configuration Release BTCPayApp.Server
# Setup infrastructure
- name: Start containers
run: docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" up -d dev
run: |
docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" build
docker compose -f "submodules/btcpayserver/BTCPayServer.Tests/docker-compose.yml" up -d dev
- name: Start BTCPay
run: |
./setup.sh
@ -65,6 +67,9 @@ jobs:
dotnet-version: 8.0.x
- name: Install workloads
run: dotnet workload install maui
- name: Clean before build
run: |
dotnet clean BTCPayApp.Maui/BTCPayApp.Maui.csproj
- name: Build
# TODO: Use proper keystore once we switch to real releases
# https://learn.microsoft.com/en-us/dotnet/maui/android/deployment/publish-cli?view=net-maui-8.0#code-try-4

View File

@ -70,16 +70,20 @@ public class BTCPayAppClient(string baseUri, string? apiKey = null, HttpClient?
return await SendHttpRequest<JObject?>(path, payload, HttpMethod.Post, cancellation);
}
public async Task<JObject?> CreatePosInvoice(CreatePosInvoiceRequest req, CancellationToken cancellation = default)
public async Task<JObject?> CreatePosInvoice(Models.CreatePosInvoiceRequest req, CancellationToken cancellation = default)
{
var query = new Dictionary<string, object>();
if (req.Total != null) query.Add("amount", req.Total.Value.ToString(CultureInfo.InvariantCulture));
if (req.DiscountPercent != null) query.Add("discount", req.DiscountPercent.Value.ToString(CultureInfo.InvariantCulture));
if (req.Tip != null) query.Add("tip", req.Tip.Value.ToString(CultureInfo.InvariantCulture));
if (req.PosData != null) query.Add("posData", req.PosData);
return await SendHttpRequest<JObject?>($"apps/{req.AppId}/pos/light", query, HttpMethod.Post, cancellation);
}
public async Task<string> SubmitLNURLWithdrawForInvoice(SubmitLnUrlRequest req, CancellationToken cancellation = default)
{
return await SendHttpRequest<string>($"plugins/NFC", req, HttpMethod.Post, cancellation);
}
public virtual async Task<T> UploadFileRequest<T>(string apiPath, StreamContent fileContent, string fileName, string mimeType, CancellationToken token = default)
{
using MultipartFormDataContent multipartContent = new();

View File

@ -1,8 +0,0 @@

namespace BTCPayApp.Core.BTCPayServer;
public interface INfcService
{
event EventHandler<string> OnNfcDataReceived;
void StartNfc();
void Dispose();
}

View File

@ -0,0 +1,15 @@
using BTCPayApp.Core.Models;
namespace BTCPayApp.Core.Contracts;
public interface INfcService: IDisposable
{
event EventHandler<NfcCardData> OnNfcDataReceived;
void StartNfc();
void EndNfc();
}
public class NfcCardData
{
public string Message { get; set; }
public byte[] Payload { get; set; }
}

View File

@ -0,0 +1,17 @@
using BTCPayServer.Client.Models;
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
namespace BTCPayApp.Core.Models;
public class CreatePosInvoiceRequest
{
public string? AppId { get; set; }
public List<AppCartItem>? Cart { get; set; }
public int? DiscountPercent { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? DiscountAmount { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal? Tip { get; set; }
public string? PosData { get; set; }
}

View File

@ -0,0 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BTCPayApp.Core.Models
{
public class NfcLnUrlRecord
{
public byte[]? Payload { get; set; }
//
// Summary:
// LnUrl
public string? LnUrl { get; set; }
//
// Summary:
// String formatted payload
public string? Message { get; set; }
//
// Summary:
// Two letters ISO 639-1 Language Code (ex: en, fr, de...)
public string? LanguageCode { get; set; }
}
}

View File

@ -0,0 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace BTCPayApp.Core.Models
{
public class SubmitLnUrlRequest
{
public string? Lnurl { get; set; }
public string? InvoiceId { get; set; }
public long? Amount { get; set; }
}
}

View File

@ -1,10 +1,11 @@
using BTCPayApp.Core.Extensions;
using BTCPayApp.Maui.Services;
using BTCPayApp.UI;
using Microsoft.Extensions.Logging;
using Microsoft.Maui.LifecycleEvents;
using Plugin.Fingerprint;
using BTCPayApp.Core.BTCPayServer;
using BTCPayApp.Core.Contracts;
#if ANDROID
@ -60,7 +61,7 @@ public static class MauiProgram
var context = Android.App.Application.Context;
var intent = new Intent(context, typeof(HubConnectionForegroundService));
context.StartForegroundService(intent); // App is in background — start service
//context.StartForegroundService(intent); // App is in background — start service
})
.OnDestroy(activity =>
{

View File

@ -1,16 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:supportsRtl="true">
<service android:name=".HubConnectionForegroundService" android:enabled="true" android:exported="false"/>
</application>
<service android:name=".HubConnectionForegroundService" android:enabled="true" android:exported="false"/>
</application>
<queries>
<intent>
<action android:name="android.intent.action.SENDTO" />
<data android:scheme="mailto" />
</intent>
</queries>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

View File

@ -1,7 +1,6 @@
using Android.App;
using Android.Content;
using Android.Content.PM;
using Android.Nfc;
using Android.OS;
using Plugin.NFC;
@ -16,6 +15,15 @@ public class MainActivity : MauiAppCompatActivity
{
CrossNFC.Init(this);
base.OnCreate(savedInstanceState);
#if DEBUG
// Enable WebView debugging
if (Build.VERSION.SdkInt >= BuildVersionCodes.Kitkat)
{
Android.Webkit.WebView.SetWebContentsDebuggingEnabled(true); // Fully qualify the WebView class
}
#endif
}
protected override void OnResume()

View File

@ -1,98 +1,48 @@
using BTCPayApp.Core.BTCPayServer;
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Models;
using Plugin.NFC;
public class NfcService : INfcService
public class NfcService : INfcService, IDisposable
{
public event EventHandler<string> OnNfcDataReceived = delegate { };
public event EventHandler<NfcCardData> OnNfcDataReceived = delegate { };
public void StartNfc()
{
if (!CrossNFC.IsSupported)
{
OnNfcDataReceived?.Invoke(this, "NFC not supported on this device");
return;
}
if (!CrossNFC.Current.IsEnabled)
{
OnNfcDataReceived?.Invoke(this, "NFC is disabled. Please enable it.");
return;
}
CrossNFC.Current.OnMessageReceived += Current_OnMessageReceived;
CrossNFC.Current.StartListening();
}
public void EndNfc()
{
CrossNFC.Current.StopListening();
Dispose();
}
private void Current_OnMessageReceived(ITagInfo tagInfo)
{
if (tagInfo == null || tagInfo.Records == null || !tagInfo.Records.Any())
{
OnNfcDataReceived?.Invoke(this, "No NDEF data found on the tag");
//throw new ArgumentException("No NFC records found in the tag info.");
return;
}
var record = tagInfo.Records[0];
foreach (var record in tagInfo.Records)
// Pass the raw tag info up - let the consumer decide what to do with it
OnNfcDataReceived?.Invoke(this, new NfcCardData
{
string data = string.Empty;
// Handle URI records
if (record.TypeFormat == NFCNdefTypeFormat.Uri)
{
data = record.Message; // e.g., "lightning:lnbc..."
data = $"URI: {data}";
}
// Handle Well-Known Text records
else if (record.TypeFormat == NFCNdefTypeFormat.WellKnown && record.TypeFormat == NFCNdefTypeFormat.WellKnown && record.Payload != null)
{
// Decode text payload (Well-Known Text records have a specific format)
data = DecodeTextRecord(record);
data = $"Text: {data}";
}
else
{
// Handle other types (e.g., Unknown, Mime)
data = $"Unsupported record type: {record.TypeFormat}";
}
// Check for Lightning-specific data
if (data.Contains("lightning:") || data.Contains("lnbc"))
{
OnNfcDataReceived?.Invoke(this, data);
}
else
{
OnNfcDataReceived?.Invoke(this, $"Unsupported NFC data: {data}");
}
}
}
// Helper method to decode Well-Known Text records
private string DecodeTextRecord(NFCNdefRecord record)
{
if (record.Payload == null || record.Payload.Length == 0)
return string.Empty;
try
{
// Well-Known Text record format: [Status Byte][Language Code][Text]
byte statusByte = record.Payload[0];
int languageCodeLength = statusByte & 0x3F; // Lower 6 bits indicate language code length
int textStartIndex = 1 + languageCodeLength;
if (textStartIndex >= record.Payload.Length)
return string.Empty;
// Extract text (UTF-8 encoded)
string text = System.Text.Encoding.UTF8.GetString(
record.Payload,
textStartIndex,
record.Payload.Length - textStartIndex);
return text;
}
catch
{
return "Error decoding text record";
}
Message = record.Message,
Payload = record.Payload
});
}
public void Dispose()

View File

@ -12,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Photino.Blazor;
using Photino.NET;
using Size = System.Drawing.Size;
namespace BTCPayApp.Photino;

View File

@ -1,5 +1,7 @@
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Extensions;
using BTCPayApp.Desktop;
using BTCPayApp.Server.Services;
using BTCPayApp.UI;
using Serilog;
@ -17,6 +19,8 @@ builder.Services.ConfigureBTCPayAppCore();
builder.Services.AddDangerousSSLSettingsForDev();
#endif
builder.Services.AddSingleton<INfcService, NfcService>();
// Configure the HTTP request pipeline.
var app = builder.Build();
if (!app.Environment.IsDevelopment())

View File

@ -0,0 +1,23 @@
using BTCPayApp.Core.Contracts;
using BTCPayApp.Core.Models;
namespace BTCPayApp.Server.Services
{
public class NfcService : INfcService
{
public event EventHandler<NfcCardData> OnNfcDataReceived = delegate { };
public void Dispose()
{
}
public void EndNfc()
{
}
public void StartNfc()
{
// NFC for web is supported within the btcpayserver iframe, so we dont need to implement NFC support here.
}
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,4 +1,8 @@
@using BTCPayApp.Core.Auth
@using BTCPayApp.UI.Features
@using BTCPayServer.Client.Models
@inject IAccountManager AccountManager
@inject IDispatcher Dispatcher
<div @attributes="InputAttributes" class="@CssClass">
@if (Invoices is not null)
{
@ -44,4 +48,21 @@
public Dictionary<string, object>? InputAttributes { get; set; }
private string CssClass => $"invoice-list {(InputAttributes?.ContainsKey("class") is true ? InputAttributes["class"] : "")}".Trim();
private string? StoreId => AccountManager.CurrentStore?.Id;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
if (!string.IsNullOrEmpty(StoreId))
{
Dispatcher.Dispatch(new StoreState.FetchInvoices(StoreId));
}
}
}
private async void OnStateChanged(object? sender, EventArgs e)
{
await InvokeAsync(StateHasChanged);
}
}

View File

@ -2,6 +2,7 @@
@using System.Globalization
@using System.Text.Json
@using System.Text.Json.Nodes
@using BTCPayApp.Core.Models
@using BTCPayServer.Client
@using BTCPayServer.Client.Models
@inject IJSRuntime JS
@ -97,18 +98,21 @@
</button>
}
</div>
<button class="btn btn-lg btn-primary mx-3" type="submit" disabled="@(IsSubmitting || amount == 0)" id="pay-button" @onclick="HandleSubmit">
@if (IsSubmitting)
{
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
else
{
<span>Charge</span>
}
</button>
<div class="d-flex justify-content-center">
<button class="btn btn-lg btn-primary mx-3" type="submit" disabled="@(IsSubmitting || amount == 0)" id="pay-button" @onclick="HandleSubmit">
@if (IsSubmitting)
{
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Loading...</span>
</div>
}
else
{
<span>Charge</span>
}
</button>
</div>
@if (CanAccessRecentTransactions)
{
@ -248,7 +252,7 @@
</div>
@code {
#nullable enable
#nullable enable
[Parameter, EditorRequired]
public string StoreId { get; set; } = null!;
[Parameter, EditorRequired]
@ -274,7 +278,7 @@
[Parameter]
public EventCallback LoadRecentTransactions { get; set; }
[Parameter]
public EventCallback<CreatePosInvoiceRequest> CreateInvoice { get; set; }
public EventCallback<Core.Models.CreatePosInvoiceRequest> CreateInvoice { get; set; }
[Parameter]
public IEnumerable<RecentTransaction>? RecentTransactions { get; set; }
[Parameter]
@ -436,8 +440,6 @@
{
var data = new JsonObject
{
["subTotal"] = GetAmount(),
["total"] = GetTotal(),
["cart"] = JsonValue.Create(Model.Cart)
};
@ -667,16 +669,12 @@
StateHasChanged();
if (CreateInvoice.HasDelegate)
{
var req = new CreatePosInvoiceRequest
var req = new Core.Models.CreatePosInvoiceRequest
{
Cart = Model.Cart,
DiscountPercent = Model.DiscountPercent,
TipPercent = Model.TipPercent,
Tip = GetTip(),
Amounts = GetAmounts(),
DiscountAmount = GetDiscount(),
Subtotal = GetAmount(),
Total = GetTotal(),
PosData = GetData()
};
await CreateInvoice.InvokeAsync(req);

View File

@ -1,13 +1,20 @@
@attribute [Route(Routes.Checkout)]
@using BTCPayApp.Core
@using BTCPayApp.Core.Auth
@using BTCPayApp.Core.BTCPayServer
@using BTCPayApp.Core.Contracts
@using BTCPayApp.Core.Models
@inject IJSRuntime JS
@inject IAccountManager AccountManager
@inject INfcService NfcService
@inject NavigationManager NavigationManager
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
<PageTitle>Checkout</PageTitle>
<iframe id="AppCheckout" name="checkout" allowfullscreen src="@CheckoutUrl" @onload="OnIframeLoad"></iframe>
@if (!_iframeLoaded)
{
<section class="loading-container">
@ -15,13 +22,66 @@
<div class="fs-4">Loading</div>
</section>
}
<iframe id="AppCheckout" name="checkout" allow="clipboard-read;clipboard-write;nfc" allowfullscreen src="@CheckoutUrl" @onload="OnIframeLoad"></iframe>
@if (_toast.IsVisible)
{
<div class="toast-container @(_toast.IsError ? "toast-error" : "toast-success")">
<div class="toast-message">@_toast.Message</div>
</div>
}
<style>
.toast-container {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
padding: 12px 24px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-width: 90%;
min-width: 200px;
text-align: center;
animation: slideUp 0.3s ease-out;
}
.toast-success {
background-color: #28a745;
color: white;
border: 1px solid #1e7e34;
}
.toast-error {
background-color: #dc3545;
color: white;
border: 1px solid #c82333;
}
.toast-message {
font-size: 14px;
font-weight: 500;
line-height: 1.4;
}
@@keyframes slideUp {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>
@code {
[Parameter, EditorRequired]
public string? InvoiceId { get; set; }
[Parameter, EditorRequired] public string? InvoiceId { get; set; }
private bool _iframeLoaded;
private bool _scanInProgress;
private Toast _toast = new();
private string BaseUri => AccountManager.Account!.BaseUri;
private string? CheckoutUrl => string.IsNullOrEmpty(InvoiceId) ? null : $"{BaseUri}i/{InvoiceId}";
@ -33,20 +93,63 @@
NfcService.OnNfcDataReceived += OnNfcDataReceived;
NfcService.StartNfc();
_toast.OnUpdated = StateHasChanged;
}
private async void OnNfcDataReceived(object? sender, string data)
private async void OnNfcDataReceived(object? sender, NfcCardData record)
{
if (_scanInProgress)
return;
_scanInProgress = true;
try
{
await JS.InvokeVoidAsync("Interop.sendNfcDataToIframe", "AppCheckout", data);
if (record == null || record.Message == null)
{
_toast.ShowError("No NFC data found");
return;
}
var message = record.Message;
if (string.IsNullOrEmpty(message))
{
_toast.ShowError("Empty NFC message");
return;
}
var btcPayClient = new BTCPayAppClient(BaseUri);
var req = new SubmitLnUrlRequest
{
InvoiceId = InvoiceId,
Lnurl = message
};
_toast.ShowSuccess("Submitting NFC data to Server", 5000);
var result = await btcPayClient.SubmitLNURLWithdrawForInvoice(req);
if (result == null)
{
_toast.Hide();
NfcService.EndNfc();
NfcService.OnNfcDataReceived -= OnNfcDataReceived;
}
}
catch (Exception ex)
{
await JS.InvokeVoidAsync("Interop.sendNfcErrorToIframe", "AppCheckout", ex.Message);
try
{
var errorFromServer = ex.Message.Split("\"")[1].Trim('"');
_toast.ShowError("ERROR: " + errorFromServer);
}
catch
{
_toast.ShowError("NFC call to server ERROR");
}
}
finally
{
_scanInProgress = false;
}
//StateHasChanged();
}
public void Dispose()
@ -54,4 +157,59 @@
NfcService.OnNfcDataReceived -= OnNfcDataReceived;
}
public class Toast
{
private CancellationTokenSource? _cts;
public bool IsVisible { get; private set; }
public bool IsError { get; private set; }
public string Message { get; private set; } = string.Empty;
public Action? OnUpdated { get; set; }
public void ShowSuccess(string message, int delayTime = 3000)
{
Show(message, false);
}
public void ShowError(string message, int delayTime = 3000)
{
Show(message, true);
}
private void Show(string message, bool isError, int delayTime = 3000)
{
_cts?.Cancel();
_cts = new CancellationTokenSource();
Message = message;
IsError = isError;
IsVisible = true;
OnUpdated?.Invoke();
var token = _cts.Token;
_ = HideAfterDelayAsync(token);
}
private async Task HideAfterDelayAsync(CancellationToken token, int delayTime = 3000)
{
try
{
await Task.Delay(delayTime, token);
if (!token.IsCancellationRequested)
{
IsVisible = false;
OnUpdated?.Invoke();
}
}
catch (TaskCanceledException) { }
}
public void Hide()
{
_cts?.Cancel();
IsVisible = false;
OnUpdated?.Invoke();
}
}
}

View File

@ -1,15 +1,28 @@
.container {
max-width: 560px;
padding-top: var(--btcpay-space-l);
padding-bottom: var(--btcpay-space-l);
max-width: 560px;
padding-top: var(--btcpay-space-l);
padding-bottom: var(--btcpay-space-l);
}
iframe {
position: fixed;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%; /* fallback for older webviews */
height: calc(100% - var(--navbar-bottom-height));
position: fixed;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%; /* fallback for older webviews */
height: calc(100% - var(--navbar-bottom-height));
}
.section-complete ::deep .checkout-complete {
width: 110px; /* Increase size */
height: 100px;
fill: var(--btcpay-primary) !important; /* Change fill color */
stroke: var(--btcpay-primary) !important; /* Change stroke color */
/*fill: var(--btcpay-primary);*/
}
.section-complete {
height: 80vh;
}

View File

@ -36,6 +36,10 @@
{
<Alert Type="danger">@Error</Alert>
}
@if (!string.IsNullOrEmpty(_successMessage))
{
<Alert Type="success">@_successMessage</Alert>
}
@if (Invoice is not null)
{
@if (CanCheckout)
@ -56,45 +60,64 @@
<InvoiceStatusDisplay Invoice="@Invoice"/>
</div>
<div class="invoice-actions mt-3 mb-3">
<div class="row g-2">
@if (!string.IsNullOrEmpty(ReceiptUrl))
{
<div class="col-4">
<a class="btn btn-outline-secondary btn-sm w-100" href="@ReceiptUrl" rel="noreferrer noopener" target="_blank">Receipt</a>
</div>
}
@if (!Invoice.Archived)
{
<div class="col-4">
<button class="btn btn-outline-secondary btn-sm w-100" @onclick="ToggleArchive">
Archive
</button>
</div>
}
</div>
</div>
<h4 class="mt-4">General Information</h4>
<div class="box">
<table class="table my-0">
<tbody>
@if (Invoice.Metadata.TryGetValue("orderId", out var orderId))
{
@if (Invoice.Metadata.TryGetValue("orderId", out var orderId))
{
<tr>
<th>Order Id</th>
<td>
@if (Invoice.Metadata.TryGetValue("orderUrl", out var orderUrl))
{
<a href="@orderUrl" rel="noreferrer noopener" target="_blank">@orderId</a>
}
else
{
<span>@orderId</span>
}
</td>
</tr>
}
@if (Invoice.Metadata.TryGetValue("paymentRequestId", out var paymentRequestId))
{
<tr>
<th>Payment Request Id</th>
<td>@paymentRequestId</td>
</tr>
}
<tr>
<th>Order Id</th>
<th>Created</th>
<td>
@if (Invoice.Metadata.TryGetValue("orderUrl", out var orderUrl))
{
<a href="@orderUrl" rel="noreferrer noopener" target="_blank">@orderId</a>
}
else
{
<span>@orderId</span>
}
<DateDisplay DateTimeOffset="@Invoice.CreatedTime"/>
</td>
</tr>
}
@if (Invoice.Metadata.TryGetValue("paymentRequestId", out var paymentRequestId))
{
<tr>
<th>Payment Request Id</th>
<td>@paymentRequestId</td>
<th>Expired</th>
<td>
<DateDisplay DateTimeOffset="@Invoice.ExpirationTime"/>
</td>
</tr>
}
<tr>
<th>Created</th>
<td>
<DateDisplay DateTimeOffset="@Invoice.CreatedTime"/>
</td>
</tr>
<tr>
<th>Expired</th>
<td>
<DateDisplay DateTimeOffset="@Invoice.ExpirationTime"/>
</td>
</tr>
</tbody>
</table>
</div>
@ -111,27 +134,27 @@
<div class="box">
<table class="table my-0">
<tbody>
@if (!string.IsNullOrEmpty(itemCode?.ToString()))
{
<tr>
<th>Item code</th>
<td>@itemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(itemDesc?.ToString()))
{
<tr>
<th>Item Description</th>
<td>@itemDesc</td>
</tr>
}
@if (taxIncluded is not null)
{
<tr>
<th>Tax Included</th>
<td>@taxIncluded</td>
</tr>
}
@if (!string.IsNullOrEmpty(itemCode?.ToString()))
{
<tr>
<th>Item code</th>
<td>@itemCode</td>
</tr>
}
@if (!string.IsNullOrEmpty(itemDesc?.ToString()))
{
<tr>
<th>Item Description</th>
<td>@itemDesc</td>
</tr>
}
@if (taxIncluded is not null)
{
<tr>
<th>Tax Included</th>
<td>@taxIncluded</td>
</tr>
}
</tbody>
</table>
</div>
@ -150,80 +173,80 @@
Invoice.Metadata.TryGetValue("buyerZip", out var buyerZip);
}
@if (!string.IsNullOrEmpty(buyerName?.ToString()) || !string.IsNullOrEmpty(buyerEmail?.ToString()) ||
!string.IsNullOrEmpty(buyerPhone?.ToString()) || !string.IsNullOrEmpty(buyerAddress1?.ToString()) ||
!string.IsNullOrEmpty(buyerAddress2?.ToString()) || !string.IsNullOrEmpty(buyerCity?.ToString()) ||
!string.IsNullOrEmpty(buyerState?.ToString()) || !string.IsNullOrEmpty(buyerCountry?.ToString()) ||
!string.IsNullOrEmpty(buyerZip?.ToString()))
!string.IsNullOrEmpty(buyerPhone?.ToString()) || !string.IsNullOrEmpty(buyerAddress1?.ToString()) ||
!string.IsNullOrEmpty(buyerAddress2?.ToString()) || !string.IsNullOrEmpty(buyerCity?.ToString()) ||
!string.IsNullOrEmpty(buyerState?.ToString()) || !string.IsNullOrEmpty(buyerCountry?.ToString()) ||
!string.IsNullOrEmpty(buyerZip?.ToString()))
{
<h4 class="mt-4">Buyer Information</h4>
<div class="box">
<table class="table my-0">
<tbody>
@if (!string.IsNullOrEmpty(buyerName?.ToString()))
{
<tr>
<th>Name</th>
<td>@buyerName</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerEmail?.ToString()))
{
<tr>
<th>Email</th>
<td>
<a href="mailto:@buyerEmail">@buyerEmail</a>
</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerPhone?.ToString()))
{
<tr>
<th>Phone</th>
<td>@buyerPhone</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerAddress1?.ToString()))
{
<tr>
<th>Address 1</th>
<td>@buyerAddress1</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerAddress2?.ToString()))
{
<tr>
<th>Address 2</th>
<td>@buyerAddress2</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerCity?.ToString()))
{
<tr>
<th>City</th>
<td>@buyerCity</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerState?.ToString()))
{
<tr>
<th>State</th>
<td>@buyerState</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerCountry?.ToString()))
{
<tr>
<th>Country</th>
<td>@buyerCountry</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerZip?.ToString()))
{
<tr>
<th>Zip</th>
<td>@buyerZip</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerName?.ToString()))
{
<tr>
<th>Name</th>
<td>@buyerName</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerEmail?.ToString()))
{
<tr>
<th>Email</th>
<td>
<a href="mailto:@buyerEmail">@buyerEmail</a>
</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerPhone?.ToString()))
{
<tr>
<th>Phone</th>
<td>@buyerPhone</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerAddress1?.ToString()))
{
<tr>
<th>Address 1</th>
<td>@buyerAddress1</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerAddress2?.ToString()))
{
<tr>
<th>Address 2</th>
<td>@buyerAddress2</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerCity?.ToString()))
{
<tr>
<th>City</th>
<td>@buyerCity</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerState?.ToString()))
{
<tr>
<th>State</th>
<td>@buyerState</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerCountry?.ToString()))
{
<tr>
<th>Country</th>
<td>@buyerCountry</td>
</tr>
}
@if (!string.IsNullOrEmpty(buyerZip?.ToString()))
{
<tr>
<th>Zip</th>
<td>@buyerZip</td>
</tr>
}
</tbody>
</table>
</div>
@ -327,13 +350,13 @@
if (!string.IsNullOrEmpty(StoreId) && !string.IsNullOrEmpty(InvoiceId))
{
if (Invoice == null)
Dispatcher.Dispatch(new StoreState.FetchInvoice(StoreId, InvoiceId));
Dispatcher.Dispatch(new StoreState.FetchInvoice(StoreId, InvoiceId));
if (PaymentMethods == null)
Dispatcher.Dispatch(new StoreState.FetchInvoicePaymentMethods(StoreId, InvoiceId));
}
}
private string? _successMessage;
private string? StoreId => AccountManager.CurrentStore?.Id;
private AppUserStoreInfo? StoreInfo => StoreState.Value.StoreInfo;
private InvoiceData? Invoice => !string.IsNullOrEmpty(InvoiceId) ? StoreState.Value.GetInvoice(InvoiceId!)?.Data : null;
@ -353,4 +376,14 @@
private string GetTitle() => $"Invoice {Invoice?.Id}".Trim();
private bool CanCheckout => Invoice is { Status: InvoiceStatus.New };
private async Task ToggleArchive()
{
await AccountManager.GetClient().ArchiveInvoice(StoreId, InvoiceId);
_successMessage = "The invoice has been archived and will no longer appear in the invoice list by default";
if (!string.IsNullOrEmpty(StoreId) && !string.IsNullOrEmpty(InvoiceId))
{
Dispatcher.Dispatch(new StoreState.FetchInvoice(StoreId, InvoiceId));
}
}
}

View File

@ -82,7 +82,7 @@ else
return ValueTask.CompletedTask;
}
private async Task CreateInvoice(CreatePosInvoiceRequest req)
private async Task CreateInvoice(Core.Models.CreatePosInvoiceRequest req)
{
_errorMessage = null;
req.AppId = AppId;

View File

@ -79,6 +79,10 @@
<NavLink href="@Routes.Connect">Connect to a BTCPay Server</NavLink>
</p>
}
<div class="mt-5">
<BTCPaySupporters />
</div>
</ValidationEditContext>
<QrScanModal OnScan="@OnQrCodeScan"/>
</NotAuthorized>

View File

@ -58,6 +58,10 @@
<p class="mt-4 text-center">
<NavLink href="@GetLoginUrl()">Back to login</NavLink>
</p>
<div class="mt-5">
<BTCPaySupporters />
</div>
</ValidationEditContext>
</NotAuthorized>
<Authorized>

View File

@ -0,0 +1,34 @@
using System;
using System.Threading.Tasks;
namespace BTCPayApp.UI.Services
{
public class ToastService
{
public event Action<string, bool>? OnToastShow;
public event Action? OnToastHide;
public void ShowToast(string message, bool isError = false)
{
OnToastShow?.Invoke(message, isError);
// Auto-hide after 3 seconds
_ = Task.Delay(3000).ContinueWith(_ => OnToastHide?.Invoke());
}
public void ShowSuccess(string message)
{
ShowToast(message, false);
}
public void ShowError(string message)
{
ShowToast(message, true);
}
public void HideToast()
{
OnToastHide?.Invoke();
}
}
}

View File

@ -10,24 +10,26 @@ Interop = {
if (!$el) return console.warn('Selector does not exist:', selector);
$el.contentWindow.postMessage(JSON.stringify({ context: 'btcpayapp' }), origin);
},
sendNfcDataToIframe: function (iframeId, data) {
sendNfcDataToIframe: function (iframeId, data, origin) {
const iframe = document.getElementById(iframeId);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({
action: 'nfc:data',
data: data
}, window.location.origin);
data: data,
origin: origin
}, origin);
} else {
console.error('Iframe not found or inaccessible');
}
},
sendNfcErrorToIframe: function (iframeId, error) {
sendNfcErrorToIframe: function (iframeId, error, origin) {
const iframe = document.getElementById(iframeId);
if (iframe && iframe.contentWindow) {
iframe.contentWindow.postMessage({
action: 'nfc:error',
data: error
}, window.location.origin);
data: error,
origin: origin
}, origin);
}
},
openModal(selector) {

View File

@ -24,7 +24,7 @@ docker-compose up dev
Now you can open up the IDE and run `DEV ALL` profile which builds both the App and the BTCPay Server.
The app should open in the browser and you should see the Welcome screen.
Click the Connect button, use `http://localhost:14142` as the server URL and an existing account for the server.
Click the Connect button, use `https://localhost:14142` as the server URL and an existing account for the server.
## Lightning Channels