Redesign Lightning invoice creation and display with better UX and status handling (#1184)

---------

Co-authored-by: thesimplekid <tsk@thesimplekid.com>
This commit is contained in:
Erik
2025-10-15 10:36:04 +02:00
committed by GitHub
parent 5caa7d58ed
commit db2764c566
8 changed files with 1190 additions and 366 deletions

View File

@@ -140,8 +140,8 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
// Balance Summary as metric cards
div class="card" {
h2 { "Balance Summary" }
div class="metrics-container" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Balance Summary" }
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
div class="metric-label" { "Lightning Balance" }
@@ -209,8 +209,8 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
// Right side - Connections metrics
aside class="node-metrics" {
div class="card" {
h3 { "Connections" }
div class="metrics-container" {
h3 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Connections" }
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format!("{}/{}", num_connected_peers, num_peers)) }
div class="metric-label" { "Connected Peers" }
@@ -224,52 +224,75 @@ pub async fn dashboard(State(state): State<AppState>) -> Result<Html<String>, St
}
}
// Lightning Network Activity as metric cards
// Activity Sections - Side by Side Layout
div class="card" {
h2 { "Lightning Network Activity" }
div class="metrics-container" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) }
div class="metric-label" { "24h LN Inflow" }
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; margin-bottom: 0;" { "Activity Overview" }
div class="activity-grid" {
// Lightning Network Activity
div class="activity-section" {
div class="activity-header" {
div class="activity-icon-box" {
svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" {
path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z" {}
}
}
h3 class="activity-title" { "Lightning Network Activity" }
}
div class="activity-metrics" {
div class="activity-metric-card" {
div class="activity-metric-label" { "24h Inflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_inflow_24h)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "24h Outflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "All-time Inflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "All-time Outflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) }
}
}
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_24h)) }
div class="metric-label" { "24h LN Outflow" }
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.lightning_inflow_all_time)) }
div class="metric-label" { "All-time LN Inflow" }
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.lightning_outflow_all_time)) }
div class="metric-label" { "All-time LN Outflow" }
// On-chain Activity
div class="activity-section" {
div class="activity-header" {
div class="activity-icon-box" {
svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round" {
path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" {}
path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" {}
}
}
h3 class="activity-title" { "On-chain Activity" }
}
div class="activity-metrics" {
div class="activity-metric-card" {
div class="activity-metric-label" { "24h Inflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "24h Outflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "All-time Inflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) }
}
div class="activity-metric-card" {
div class="activity-metric-label" { "All-time Outflow" }
div class="activity-metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) }
}
}
}
}
}
// On-chain Activity as metric cards
div class="card" {
h2 { "On-chain Activity" }
div class="metrics-container" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_24h)) }
div class="metric-label" { "24h On-chain Inflow" }
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_24h)) }
div class="metric-label" { "24h On-chain Outflow" }
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.onchain_inflow_all_time)) }
div class="metric-label" { "All-time On-chain Inflow" }
}
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(metrics.onchain_outflow_all_time)) }
div class="metric-label" { "All-time On-chain Outflow" }
}
}
}
};
let is_running = is_node_running(&state.node.inner);

View File

@@ -10,7 +10,7 @@ use serde::Deserialize;
use crate::web::handlers::utils::{deserialize_optional_f64, deserialize_optional_u32};
use crate::web::handlers::AppState;
use crate::web::templates::{
error_message, form_card, format_sats_as_btc, info_card, is_node_running, layout_with_status,
error_message, format_sats_as_btc, invoice_display_card, is_node_running, layout_with_status,
success_message,
};
@@ -34,54 +34,101 @@ pub struct CreateBolt12Form {
pub async fn invoices_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Invoices" }
div class="grid" {
(form_card(
"Create BOLT11 Invoice",
html! {
form method="post" action="/invoices/bolt11" {
div class="form-group" {
label for="amount_btc" { "Amount" }
input type="number" id="amount_btc" name="amount_btc" required placeholder="₿0" step="0.00000001" {}
}
div class="form-group" {
label for="description" { "Description (optional)" }
input type="text" id="description" name="description" placeholder="Payment for..." {}
}
div class="form-group" {
label for="expiry_seconds" { "Expiry (seconds, optional)" }
input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
}
button type="submit" { "Create BOLT11 Invoice" }
}
}
))
(form_card(
"Create BOLT12 Offer",
html! {
form method="post" action="/invoices/bolt12" {
div class="form-group" {
label for="amount_btc" { "Amount (optional for variable amount)" }
input type="number" id="amount_btc" name="amount_btc" placeholder="₿0" step="0.00000001" {}
}
div class="form-group" {
label for="description" { "Description (optional)" }
input type="text" id="description" name="description" placeholder="Payment for..." {}
}
div class="form-group" {
label for="expiry_seconds" { "Expiry (seconds, optional)" }
input type="number" id="expiry_seconds" name="expiry_seconds" placeholder="3600" {}
}
button type="submit" { "Create BOLT12 Offer" }
div class="card" {
// Tab navigation
div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
button type="button" class="payment-tab active" onclick="switchInvoiceTab('bolt11')" data-tab="bolt11" {
"BOLT11 Invoice"
}
button type="button" class="payment-tab" onclick="switchInvoiceTab('bolt12')" data-tab="bolt12" {
"BOLT12 Offer"
}
}
// BOLT11 tab content
div id="bolt11-content" class="tab-content active" {
form method="post" action="/invoices/bolt11" {
div class="form-group" {
label for="amount_btc_bolt11" { "Amount" }
input type="number" id="amount_btc_bolt11" name="amount_btc" required placeholder="₿0" step="0.00000001" {}
}
div class="form-group" {
label for="description_bolt11" { "Description (optional)" }
input type="text" id="description_bolt11" name="description" placeholder="Payment for..." {}
}
div class="form-group" {
label for="expiry_seconds_bolt11" { "Expiry (seconds, optional)" }
input type="number" id="expiry_seconds_bolt11" name="expiry_seconds" placeholder="3600" {}
}
div class="form-actions" {
a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
button type="submit" class="button-primary" { "Create BOLT11 Invoice" }
}
}
))
}
// BOLT12 tab content
div id="bolt12-content" class="tab-content" {
form method="post" action="/invoices/bolt12" {
div class="form-group" {
label for="amount_btc_bolt12" { "Amount (optional for variable amount)" }
input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="₿0" step="0.00000001" {}
p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
"Leave empty for variable amount offers, specify amount for fixed offers"
}
}
div class="form-group" {
label for="description_bolt12" { "Description (optional)" }
input type="text" id="description_bolt12" name="description" placeholder="Payment for..." {}
}
div class="form-group" {
label for="expiry_seconds_bolt12" { "Expiry (seconds, optional)" }
input type="number" id="expiry_seconds_bolt12" name="expiry_seconds" placeholder="3600" {}
}
div class="form-actions" {
a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
button type="submit" class="button-primary" { "Create BOLT12 Offer" }
}
}
}
}
// Tab switching script
script type="text/javascript" {
(maud::PreEscaped(r#"
function switchInvoiceTab(tabName) {
console.log('Switching to invoice tab:', tabName);
// Hide all tab contents
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
// Remove active class from all tabs
const tabs = document.querySelectorAll('.payment-tab');
tabs.forEach(tab => tab.classList.remove('active'));
// Show selected tab content
const tabContent = document.getElementById(tabName + '-content');
if (tabContent) {
tabContent.classList.add('active');
console.log('Activated invoice tab content:', tabName);
}
// Add active class to selected tab
const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
if (tabButton) {
tabButton.classList.add('active');
console.log('Activated invoice tab button:', tabName);
}
}
"#))
}
};
let is_running = is_node_running(&state.node.inner);
Ok(Html(
layout_with_status("Create Invoices", content, is_running).into_string(),
layout_with_status("s", content, is_running).into_string(),
))
}
@@ -126,7 +173,7 @@ pub async fn post_create_bolt11(
.status(StatusCode::BAD_REQUEST)
.header("content-type", "text/html")
.body(Body::from(
layout_with_status("Create Invoice Error", content, true).into_string(),
layout_with_status(" Error", content, true).into_string(),
))
.unwrap());
}
@@ -161,32 +208,25 @@ pub async fn post_create_bolt11(
description_text.clone()
};
let invoice_details = vec![
("Payment Hash", invoice.payment_hash().to_string()),
("Amount", format_sats_as_btc(form.amount_btc)),
("Description", description_display),
(
"Expires At",
format!("{}", current_time + expiry_seconds as u64),
),
];
html! {
(success_message("BOLT11 Invoice created successfully!"))
(info_card(
"Invoice Details",
vec![
("Payment Hash", invoice.payment_hash().to_string()),
("Amount", format_sats_as_btc(form.amount_btc)),
("Description", description_display),
("Expires At", format!("{}", current_time + expiry_seconds as u64)),
]
))
div class="card" {
h3 { "Invoice (copy this to share)" }
textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
(invoice.to_string())
}
}
div class="card" {
a href="/invoices" { button { "← Create Another Invoice" } }
}
(invoice_display_card(&invoice.to_string(), &format_sats_as_btc(form.amount_btc), invoice_details, "/invoices"))
}
}
Err(e) => {
tracing::error!("Web interface: Failed to create BOLT11 invoice: {}", e);
html! {
(error_message(&format!("Failed to create invoice: {e}")))
(error_message(&format!("Failed to : {e}")))
div class="card" {
a href="/invoices" { button { "← Try Again" } }
}
@@ -210,15 +250,15 @@ pub async fn post_create_bolt12(
let description_text = form.description.unwrap_or_else(|| "".to_string());
tracing::info!(
"Web interface: Creating BOLT12 offer for amount={:?} btc, description={:?}, expiry={}s",
"Web interface: Creating BOLT12 offer for amount={:?} sats, description={:?}, expiry={}s",
form.amount_btc,
description_text,
expiry_seconds
);
let offer_result = if let Some(amount_btc) = form.amount_btc {
// Convert Bitcoin to millisatoshis (1 BTC = 100,000,000,000 msats)
let amount_msats = (amount_btc * 100_000_000_000.0) as u64;
// Convert satoshis to millisatoshis (1 sat = 1,000 msats)
let amount_msats = (amount_btc * 1_000.0) as u64;
state.node.inner.bolt12_payment().receive(
amount_msats,
&description_text,
@@ -246,7 +286,7 @@ pub async fn post_create_bolt12(
let amount_display = form
.amount_btc
.map(|a| format_sats_as_btc((a * 100_000_000.0) as u64))
.map(|a| format_sats_as_btc(a as u64))
.unwrap_or_else(|| "Variable amount".to_string());
let description_display = if description_text.is_empty() {
@@ -255,26 +295,19 @@ pub async fn post_create_bolt12(
description_text
};
let offer_details = vec![
("Offer ID", offer.id().to_string()),
("Amount", amount_display.clone()),
("Description", description_display),
(
"Expires At",
format!("{}", current_time + expiry_seconds as u64),
),
];
html! {
(success_message("BOLT12 Offer created successfully!"))
(info_card(
"Offer Details",
vec![
("Offer ID", offer.id().to_string()),
("Amount", amount_display),
("Description", description_display),
("Expires At", format!("{}", current_time + expiry_seconds as u64)),
]
))
div class="card" {
h3 { "Offer (copy this to share)" }
textarea readonly style="width: 100%; height: 150px; font-family: monospace; font-size: 0.8rem;" {
(offer.to_string())
}
}
div class="card" {
a href="/invoices" { button { "← Create Another Offer" } }
}
(invoice_display_card(&offer.to_string(), &amount_display, offer_details, "/invoices"))
}
}
Err(e) => {

View File

@@ -26,43 +26,33 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
// Quick Actions section - individual cards
div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
// Open Channel Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning Network channel to connect with another node." }
a href="/channels/open" style="text-decoration: none;" {
button class="button-outline" { "Open Channel" }
}
}
// Create Invoice Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments from other users or services." }
a href="/invoices" style="text-decoration: none;" {
button class="button-outline" { "Create Invoice" }
}
}
// Make Payment Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices. BOLT 11 & 12 supported." }
a href="/invoices" style="text-decoration: none;" {
button class="button-outline" { "Make Payment" }
}
// Inactive channels warning (only show if > 0)
@if num_inactive_channels > 0 {
div class="card" style="background-color: #fef3c7; border: 1px solid #f59e0b; margin-bottom: 2rem;" {
h3 style="color: #92400e; margin-bottom: 0.5rem;" { "⚠️ Inactive Channels Detected" }
p style="color: #78350f; margin: 0;" {
"You have " (num_inactive_channels) " inactive channel(s). This may indicate a connectivity issue that requires attention."
}
}
}
// Balance Information as metric cards
// Balance Information with action buttons in header
div class="card" {
h2 { "Balance Information" }
div class="metrics-container" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Balance Information" }
div style="display: flex; gap: 0.5rem;" {
a href="/payments/send" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
}
a href="/invoices" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
}
a href="/channels/open" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Open Channel" }
}
}
}
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
div class="metric-label" { "Lightning Balance" }
@@ -75,9 +65,11 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
div class="metric-value" { (format!("{}", num_active_channels)) }
div class="metric-label" { "Active Channels" }
}
div class="metric-card" {
div class="metric-value" { (format!("{}", num_inactive_channels)) }
div class="metric-label" { "Inactive Channels" }
@if num_inactive_channels > 0 {
div class="metric-card" {
div class="metric-value" style="color: #f59e0b;" { (format!("{}", num_inactive_channels)) }
div class="metric-label" { "Inactive Channels" }
}
}
}
}
@@ -90,43 +82,33 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Lightning" }
// Quick Actions section - individual cards
div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
// Open Channel Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Open Channel" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Create a new Lightning channel by connecting with another node." }
a href="/channels/open" style="text-decoration: none;" {
button class="button-outline" { "Open Channel" }
}
}
// Create Invoice Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Create Invoice" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a Lightning invoice to receive payments." }
a href="/invoices" style="text-decoration: none;" {
button class="button-outline" { "Create Invoice" }
}
}
// Make Payment Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Make Lightning Payment" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Lightning payments to other users using invoices." }
a href="/payments/send" style="text-decoration: none;" {
button class="button-outline" { "Make Payment" }
}
// Inactive channels warning (only show if > 0)
@if num_inactive_channels > 0 {
div class="card" style="background-color: #fef3c7; border: 1px solid #f59e0b; margin-bottom: 2rem;" {
h3 style="color: #92400e; margin-bottom: 0.5rem;" { "⚠️ Inactive Channels Detected" }
p style="color: #78350f; margin: 0;" {
"You have " (num_inactive_channels) " inactive channel(s). This may indicate a connectivity issue that requires attention."
}
}
}
// Balance Information as metric cards
// Balance Information with action buttons in header
div class="card" {
h2 { "Balance Information" }
div class="metrics-container" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Balance Information" }
div style="display: flex; gap: 0.5rem;" {
a href="/payments/send" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
}
a href="/invoices" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
}
a href="/channels/open" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Open Channel" }
}
}
}
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_lightning_balance_sats)) }
div class="metric-label" { "Lightning Balance" }
@@ -139,47 +121,48 @@ pub async fn balance_page(State(state): State<AppState>) -> Result<Html<String>,
div class="metric-value" { (format!("{}", num_active_channels)) }
div class="metric-label" { "Active Channels" }
}
div class="metric-card" {
div class="metric-value" { (format!("{}", num_inactive_channels)) }
div class="metric-label" { "Inactive Channels" }
@if num_inactive_channels > 0 {
div class="metric-card" {
div class="metric-value" style="color: #f59e0b;" { (format!("{}", num_inactive_channels)) }
div class="metric-label" { "Inactive Channels" }
}
}
}
}
// Channel Details header (outside card)
h2 class="section-header" { "Channel Details" }
h2 class="section-header" style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5;" { "Channel Details" }
// Channels list
@for (index, channel) in channels.iter().enumerate() {
// Channels list
@for (index, channel) in channels.iter().enumerate() {
@let node_id = channel.counterparty_node_id.to_string();
@let channel_number = index + 1;
div class="channel-box" {
// Channel number as prominent header
div class="channel-alias" { (format!("Channel {}", channel_number)) }
// Channel header with number on left and status badge on right
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 1.5rem;" {
div class="channel-alias" style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { (format!("Channel {}", channel_number)) }
@if channel.is_usable {
span class="status-badge status-active" { "Active" }
} @else {
span class="status-badge status-inactive" { "Inactive" }
}
}
// Channel details in left-aligned format
// Channel details - ordered by label length
div class="channel-details" {
div class="detail-row" {
span class="detail-label" { "Node ID" }
span class="detail-value" { (node_id) }
}
div class="detail-row" {
span class="detail-label" { "Channel ID" }
span class="detail-value-amount" { (channel.channel_id.to_string()) }
span class="detail-value" { (channel.channel_id.to_string()) }
}
@if let Some(short_channel_id) = channel.short_channel_id {
div class="detail-row" {
span class="detail-label" { "Short Channel ID" }
span class="detail-value-amount" { (short_channel_id.to_string()) }
}
}
div class="detail-row" {
span class="detail-label" { "Node ID" }
span class="detail-value-amount" { (node_id) }
}
div class="detail-row" {
span class="detail-label" { "Status" }
@if channel.is_usable {
span class="status-badge status-active" { "Active" }
} @else {
span class="status-badge status-inactive" { "Inactive" }
span class="detail-value" { (short_channel_id.to_string()) }
}
}
}

View File

@@ -33,33 +33,6 @@ pub struct ConfirmOnchainForm {
confirmed: Option<String>,
}
fn quick_actions_section() -> maud::Markup {
html! {
div class="card" style="margin-bottom: 2rem;" {
h2 { "Quick Actions" }
div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-top: 1.5rem;" {
// Receive Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Receive Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Generate a new Bitcoin address to receive on-chain payments from other users or services." }
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" { "Receive Bitcoin" }
}
}
// Send Bitcoin Card
div class="quick-action-card" {
h3 style="font-size: 1.125rem; font-weight: 600; margin-bottom: 0.5rem; color: var(--text-primary);" { "Send Bitcoin" }
p style="font-size: 0.875rem; color: var(--text-muted); margin-bottom: 1rem; line-height: 1.4;" { "Send Bitcoin to another address on the blockchain. Standard on-chain transactions." }
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" { "Send Bitcoin" }
}
}
}
}
}
}
pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let address_result = state.node.inner.onchain_payment().new_address();
@@ -67,8 +40,8 @@ pub async fn get_new_address(State(state): State<AppState>) -> Result<Html<Strin
Ok(address) => {
html! {
div class="card" {
h2 { "Bitcoin Address" }
div class="address-display" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Bitcoin Address" }
div class="address-display" style="margin-top: 1.5rem;" {
div class="address-container" {
span class="address-text" { (address.to_string()) }
}
@@ -113,13 +86,20 @@ pub async fn onchain_page(
let mut content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "On-chain" }
// Quick Actions section - only show on overview
(quick_actions_section())
// On-chain Balance as metric cards
// On-chain Balance with action buttons in header
div class="card" {
h2 { "On-chain Balance" }
div class="metrics-container" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
div style="display: flex; gap: 0.5rem;" {
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
}
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
}
}
}
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
div class="metric-label" { "Total Balance" }
@@ -162,10 +142,20 @@ pub async fn onchain_page(
}
))
// On-chain Balance as metric cards
// On-chain Balance with action buttons in header
div class="card" {
h2 { "On-chain Balance" }
div class="metrics-container" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
div style="display: flex; gap: 0.5rem;" {
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
}
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
}
}
}
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
div class="metric-label" { "Total Balance" }
@@ -196,10 +186,20 @@ pub async fn onchain_page(
}
))
// On-chain Balance as metric cards
// On-chain Balance with action buttons in header
div class="card" {
h2 { "On-chain Balance" }
div class="metrics-container" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "On-chain Balance" }
div style="display: flex; gap: 0.5rem;" {
a href="/onchain?action=send" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Send" }
}
a href="/onchain?action=receive" style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Receive" }
}
}
}
div class="metrics-container" style="margin-top: 1.5rem;" {
div class="metric-card" {
div class="metric-value" { (format_sats_as_btc(balances.total_onchain_balance_sats)) }
div class="metric-label" { "Total Balance" }
@@ -324,8 +324,8 @@ pub async fn onchain_confirm_page(
// Transaction Details Card
div class="card" {
h2 { "Transaction Details" }
div class="transaction-details" {
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Transaction Details" }
div class="transaction-details" style="margin-top: 1.5rem;" {
div class="detail-row" {
span class="detail-label" { "Recipient Address:" }
span class="detail-value" { (form.address.clone()) }

View File

@@ -15,7 +15,7 @@ use serde::Deserialize;
use crate::web::handlers::utils::{deserialize_optional_u64, get_paginated_payments_streaming};
use crate::web::handlers::AppState;
use crate::web::templates::{
error_message, form_card, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
error_message, format_msats_as_btc, format_sats_as_btc, info_card, is_node_running,
layout_with_status, payment_list_item, success_message,
};
@@ -86,7 +86,7 @@ pub async fn payments_page(
div class="card" {
div class="payment-list-header" {
div {
h2 { "Payment History" }
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { "Payment History" }
@if total_count > 0 {
p style="margin: 0.25rem 0 0 0; color: #666; font-size: 0.9rem;" {
"Showing " (start_index + 1) " to " (end_index) " of " (total_count) " payments"
@@ -116,14 +116,6 @@ pub async fn payments_page(
PaymentDirection::Outbound => "Outbound",
};
@let status_str = match payment.status {
PaymentStatus::Pending => "Pending",
PaymentStatus::Succeeded => "Succeeded",
PaymentStatus::Failed => "Failed",
};
@let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
@let (payment_hash, description, payment_type, preimage) = match &payment.kind {
PaymentKind::Bolt11 { hash, preimage, .. } => {
(Some(hash.to_string()), None::<String>, "BOLT11", preimage.map(|p| p.to_string()))
@@ -147,6 +139,27 @@ pub async fn payments_page(
},
};
@let status_str = {
// Helper function to determine invoice status
fn get_invoice_status(status: PaymentStatus, direction: PaymentDirection, payment_type: &str) -> &'static str {
match status {
PaymentStatus::Succeeded => "Succeeded",
PaymentStatus::Failed => "Failed",
PaymentStatus::Pending => {
// For inbound BOLT11 payments, show "Unpaid" instead of "Pending"
if direction == PaymentDirection::Inbound && payment_type == "BOLT11" {
"Unpaid"
} else {
"Pending"
}
}
}
}
get_invoice_status(payment.status, payment.direction, payment_type)
};
@let amount_str = payment.amount_msat.map(format_msats_as_btc).unwrap_or_else(|| "Unknown".to_string());
(payment_list_item(
&payment.id.to_string(),
direction_str,
@@ -245,42 +258,91 @@ pub async fn payments_page(
pub async fn send_payments_page(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
let content = html! {
h2 style="text-align: center; margin-bottom: 3rem;" { "Send Payment" }
div class="grid" {
(form_card(
"Pay BOLT11 Invoice",
html! {
form method="post" action="/payments/bolt11" {
div class="form-group" {
label for="invoice" { "BOLT11 Invoice" }
textarea id="invoice" name="invoice" required placeholder="lnbc..." style="height: 120px;" {}
}
div class="form-group" {
label for="amount_btc" { "Amount Override (optional)" }
input type="number" id="amount_btc" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
}
button type="submit" { "Pay BOLT11 Invoice" }
}
}
))
(form_card(
"Pay BOLT12 Offer",
html! {
form method="post" action="/payments/bolt12" {
div class="form-group" {
label for="offer" { "BOLT12 Offer" }
textarea id="offer" name="offer" required placeholder="lno..." style="height: 120px;" {}
div class="card" {
// Tab navigation
div class="payment-tabs" style="display: flex; gap: 0.5rem; margin-bottom: 1.5rem; border-bottom: 1px solid hsl(var(--border)); padding-bottom: 0;" {
button type="button" class="payment-tab active" onclick="switchTab('bolt11')" data-tab="bolt11" {
"BOLT11 Invoice"
}
button type="button" class="payment-tab" onclick="switchTab('bolt12')" data-tab="bolt12" {
"BOLT12 Offer"
}
}
// BOLT11 tab content
div id="bolt11-content" class="tab-content active" {
form method="post" action="/payments/bolt11" {
div class="form-group" {
label for="invoice" { "BOLT11 Invoice" }
textarea id="invoice" name="invoice" required placeholder="lnbc..." rows="4" {}
}
div class="form-group" {
label for="amount_btc_bolt11" { "Amount Override (optional)" }
input type="number" id="amount_btc_bolt11" name="amount_btc" placeholder="Leave empty to use invoice amount" step="1" {}
p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
"Only specify an amount if you want to override the invoice amount"
}
div class="form-group" {
label for="amount_btc" { "Amount (required for variable amount offers)" }
input type="number" id="amount_btc" name="amount_btc" placeholder="Required for variable amount offers, ignored for fixed amount offers" step="1" {}
}
button type="submit" { "Pay BOLT12 Offer" }
}
div class="form-actions" {
a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
button type="submit" class="button-primary" { "Pay Invoice" }
}
}
))
}
// BOLT12 tab content
div id="bolt12-content" class="tab-content" {
form method="post" action="/payments/bolt12" {
div class="form-group" {
label for="offer" { "BOLT12 Offer" }
textarea id="offer" name="offer" required placeholder="lno..." rows="4" {}
}
div class="form-group" {
label for="amount_btc_bolt12" { "Amount" }
input type="number" id="amount_btc_bolt12" name="amount_btc" placeholder="Amount in satoshis" step="1" {}
p style="font-size: 0.8125rem; color: hsl(var(--muted-foreground)); margin-top: 0.5rem;" {
"Required for variable amount offers, ignored for fixed amount offers"
}
}
div class="form-actions" {
a href="/balance" { button type="button" class="button-secondary" { "Cancel" } }
button type="submit" class="button-primary" { "Pay Offer" }
}
}
}
}
// Tab switching script
script type="text/javascript" {
(maud::PreEscaped(r#"
function switchTab(tabName) {
console.log('Switching to tab:', tabName);
// Hide all tab contents
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
// Remove active class from all tabs
const tabs = document.querySelectorAll('.payment-tab');
tabs.forEach(tab => tab.classList.remove('active'));
// Show selected tab content
const tabContent = document.getElementById(tabName + '-content');
if (tabContent) {
tabContent.classList.add('active');
console.log('Activated tab content:', tabName);
}
// Add active class to selected tab
const tabButton = document.querySelector('[data-tab="' + tabName + '"]');
if (tabButton) {
tabButton.classList.add('active');
console.log('Activated tab button:', tabName);
}
}
"#))
}
};
let is_running = is_node_running(&state.node.inner);

View File

@@ -3,11 +3,13 @@ use maud::{html, Markup};
pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup {
html! {
div class="card" {
h2 { (title) }
@for (label, value) in items {
div class="info-item" {
span class="info-label" { (label) ":" }
span class="info-value" { (value) }
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { (title) }
div style="margin-top: 1.5rem;" {
@for (label, value) in items {
div class="info-item" {
span class="info-label" { (label) ":" }
span class="info-value" { (value) }
}
}
}
}
@@ -17,8 +19,10 @@ pub fn info_card(title: &str, items: Vec<(&str, String)>) -> Markup {
pub fn form_card(title: &str, form_content: Markup) -> Markup {
html! {
div class="card" {
h2 { (title) }
(form_content)
h2 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 0;" { (title) }
div style="margin-top: 1.5rem;" {
(form_content)
}
}
}
}
@@ -34,3 +38,50 @@ pub fn error_message(message: &str) -> Markup {
div class="error" { (message) }
}
}
pub fn invoice_display_card(
invoice_text: &str,
amount: &str,
details: Vec<(&str, String)>,
back_url: &str,
) -> Markup {
html! {
div class="card" {
div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 1rem; border-bottom: 1px solid hsl(var(--border)); margin-bottom: 1.5rem;" {
h3 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0;" { "Invoice Details" }
}
// Amount highlight section at the top
div class="invoice-amount-section" {
div class="invoice-amount-label" { "Amount" }
div class="invoice-amount-value" { (amount) }
}
// Invoice display section - under the amount
div class="invoice-display-section" {
div class="invoice-label" { "Invoice" }
div class="invoice-display-container" {
textarea readonly class="invoice-textarea" { (invoice_text) }
}
}
// Invoice details section - after the invoice with increased spacing
div class="invoice-details-section" style="margin-top: 2.5rem;" {
h4 style="font-size: 0.875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; opacity: 0.5; margin: 0 0 1rem 0;" { "Details" }
@for (label, value) in details {
div class="info-item" {
span class="info-label" { (label) ":" }
span class="info-value" { (value) }
}
}
}
// Back button at bottom left - no border lines
div style="margin-top: 2rem;" {
a href=(back_url) style="text-decoration: none;" {
button class="button-outline" style="padding: 0.5rem 1rem; font-size: 0.875rem;" { "Back" }
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -18,6 +18,7 @@ pub fn payment_list_item(
"Succeeded" => "status-active",
"Failed" => "status-inactive",
"Pending" => "status-pending",
"Unpaid" => "status-pending", // Use pending styling for unpaid
_ => "status-badge",
};