Skip to main content

wallet_dashboard/
wallet_dashboard.rs

1//! Example: Dynamic page generation based on Rust parameters
2//!
3//! This example demonstrates how to generate static HTML pages that vary
4//! based on runtime data and conditionals - perfect for SSG (Static Site Generation).
5//!
6//! Inspired by: <https://github.com/LeakIX/zcash-web-wallet/>
7//!
8//! Run with: `cargo run --example wallet_dashboard`
9
10use ironhtml::typed::{Document, Element};
11use ironhtml_bootstrap::{alerts, buttons, cards, grid, Breakpoint, Color};
12use ironhtml_elements::{
13    Body, Br, Button, Code, Div, Footer, Head, Html, Input, Label, Link, Main, Meta, Nav, Script,
14    Small, Span, Table, Tbody, Td, Th, Thead, Title, Tr, A, H2, H5, H6, I, P,
15};
16
17extern crate alloc;
18use alloc::format;
19use alloc::string::String;
20use alloc::vec::Vec;
21
22// ============================================================================
23// DATA MODELS - These would come from your application
24// ============================================================================
25
26#[derive(Clone)]
27struct WalletConfig {
28    name: String,
29    network: Network,
30    currency_symbol: String,
31    theme: Theme,
32}
33
34#[derive(Clone, Copy, PartialEq)]
35enum Network {
36    Mainnet,
37    Testnet,
38}
39
40impl Network {
41    const fn as_str(self) -> &'static str {
42        match self {
43            Self::Mainnet => "mainnet",
44            Self::Testnet => "testnet",
45        }
46    }
47
48    const fn badge_color(self) -> Color {
49        match self {
50            Self::Mainnet => Color::Success,
51            Self::Testnet => Color::Warning,
52        }
53    }
54}
55
56#[derive(Clone, Copy)]
57enum Theme {
58    Light,
59    Dark,
60}
61
62struct Transaction {
63    tx_id: String,
64    amount: f64,
65    is_incoming: bool,
66    confirmations: u32,
67    timestamp: String,
68    address: String,
69}
70
71struct WalletBalance {
72    total: f64,
73    available: f64,
74    pending: f64,
75    locked: f64,
76}
77
78struct WalletState {
79    config: WalletConfig,
80    address: String,
81    balance: WalletBalance,
82    transactions: Vec<Transaction>,
83    is_syncing: bool,
84    sync_progress: u8,
85    has_pending_tx: bool,
86}
87
88// ============================================================================
89// REUSABLE COMPONENTS WITH CONDITIONAL RENDERING
90// ============================================================================
91
92/// Network badge - different color based on network type
93fn network_badge(network: Network) -> Element<Span> {
94    let class = format!("badge bg-{}", network.badge_color().as_str());
95    Element::<Span>::new().class(&class).text(network.as_str())
96}
97
98/// Sync status indicator - shows spinner when syncing
99fn sync_status(is_syncing: bool, progress: u8) -> Element<Div> {
100    if is_syncing {
101        Element::<Div>::new()
102            .class("d-flex align-items-center text-warning")
103            .child::<Div, _>(|d| {
104                d.class("spinner-border spinner-border-sm me-2")
105                    .attr("role", "status")
106            })
107            .child::<Span, _>(|s| {
108                let text = format!("Syncing... {progress}%");
109                s.text(&text)
110            })
111    } else {
112        Element::<Div>::new()
113            .class("text-success")
114            .child::<I, _>(|i| i.class("bi bi-check-circle-fill me-2"))
115            .child::<Span, _>(|s| s.text("Synced"))
116    }
117}
118
119/// Balance card with conditional pending indicator
120fn balance_card(balance: &WalletBalance, symbol: &str, has_pending: bool) -> Element<Div> {
121    cards::card(|body| {
122        let body = body
123            .class("text-center")
124            .child::<H6, _>(|h| h.class("text-muted mb-3").text("Total Balance"))
125            .child::<H2, _>(|h| {
126                let amount = format!("{symbol} {:.8}", balance.total);
127                h.class("mb-3").text(&amount)
128            });
129
130        // Conditional: Show pending badge if there are pending transactions
131        let body = if has_pending {
132            body.child::<Div, _>(|d| {
133                d.class("mb-3").child::<Span, _>(|s| {
134                    s.class("badge bg-warning text-dark")
135                        .child::<I, _>(|i| i.class("bi bi-clock me-1"))
136                        .text("Pending transactions")
137                })
138            })
139        } else {
140            body
141        };
142
143        // Show balance breakdown
144        body.child::<Div, _>(|d| {
145            d.class("row text-start small")
146                .child::<Div, _>(|c| {
147                    c.class("col-6")
148                        .child::<Div, _>(|row| {
149                            let available = format!("{:.8}", balance.available);
150                            row.class("d-flex justify-content-between")
151                                .child::<Span, _>(|s| s.class("text-muted").text("Available"))
152                                .child::<Span, _>(|s| s.text(&available))
153                        })
154                        .child::<Div, _>(|row| {
155                            let pending = format!("{:.8}", balance.pending);
156                            row.class("d-flex justify-content-between")
157                                .child::<Span, _>(|s| s.class("text-muted").text("Pending"))
158                                .child::<Span, _>(|s| {
159                                    if balance.pending > 0.0 {
160                                        s.class("text-warning").text(&pending)
161                                    } else {
162                                        s.text(&pending)
163                                    }
164                                })
165                        })
166                })
167                .child::<Div, _>(|c| {
168                    c.class("col-6").child::<Div, _>(|row| {
169                        let locked = format!("{:.8}", balance.locked);
170                        row.class("d-flex justify-content-between")
171                            .child::<Span, _>(|s| s.class("text-muted").text("Locked"))
172                            .child::<Span, _>(|s| {
173                                if balance.locked > 0.0 {
174                                    s.class("text-info").text(&locked)
175                                } else {
176                                    s.text(&locked)
177                                }
178                            })
179                    })
180                })
181        })
182    })
183}
184
185/// Transaction row - styling changes based on transaction properties
186fn transaction_row(tx: &Transaction, symbol: &str) -> Element<Tr> {
187    let amount_class = if tx.is_incoming {
188        "text-success"
189    } else {
190        "text-danger"
191    };
192
193    let amount_prefix = if tx.is_incoming { "+" } else { "-" };
194    let amount_text = format!("{amount_prefix}{:.8} {symbol}", tx.amount.abs());
195
196    let status = match tx.confirmations {
197        0 => ("Pending", "warning"),
198        1..=5 => ("Confirming", "info"),
199        _ => ("Confirmed", "success"),
200    };
201
202    Element::<Tr>::new()
203        .child::<Td, _>(|td| {
204            td.child::<Div, _>(|d| {
205                d.class("d-flex align-items-center")
206                    .child::<I, _>(|i| {
207                        let icon = if tx.is_incoming {
208                            "bi bi-arrow-down-circle-fill text-success me-2"
209                        } else {
210                            "bi bi-arrow-up-circle-fill text-danger me-2"
211                        };
212                        i.class(icon)
213                    })
214                    .child::<Div, _>(|inner| {
215                        inner
216                            .child::<Code, _>(|c| c.class("small").text(&tx.tx_id[..16]))
217                            .child::<Br, _>(|br| br)
218                            .child::<Small, _>(|s| s.class("text-muted").text(&tx.timestamp))
219                    })
220            })
221        })
222        .child::<Td, _>(|td| td.child::<Code, _>(|c| c.class("small").text(&tx.address[..20])))
223        .child::<Td, _>(|td| {
224            td.class("text-end")
225                .child::<Span, _>(|s| s.class(amount_class).text(&amount_text))
226        })
227        .child::<Td, _>(|td| {
228            let badge_class = format!("badge bg-{}", status.1);
229            td.class("text-end")
230                .child::<Span, _>(|s| s.class(&badge_class).text(status.0))
231                .child::<Br, _>(|br| br)
232                .child::<Small, _>(|s| {
233                    let conf_text = format!("{} confirmations", tx.confirmations);
234                    s.class("text-muted").text(&conf_text)
235                })
236        })
237}
238
239/// Transaction list - conditionally shows "no transactions" message
240fn transaction_list(transactions: &[Transaction], symbol: &str) -> Element<Div> {
241    cards::card(|body| {
242        let body = body.child::<Div, _>(|d| {
243            d.class("d-flex justify-content-between align-items-center mb-3")
244                .child::<H5, _>(|h| h.class("mb-0").text("Recent Transactions"))
245                .child::<A, _>(|a| {
246                    a.class("btn btn-sm btn-outline-primary")
247                        .attr("href", "#history")
248                        .text("View All")
249                })
250        });
251
252        if transactions.is_empty() {
253            // Conditional: Show empty state when no transactions
254            body.child::<Div, _>(|d| {
255                d.class("text-center py-5 text-muted")
256                    .child::<I, _>(|i| i.class("bi bi-inbox fs-1 mb-3 d-block"))
257                    .child::<P, _>(|p| p.text("No transactions yet"))
258                    .child::<P, _>(|p| {
259                        p.class("small")
260                            .text("Your transaction history will appear here")
261                    })
262            })
263        } else {
264            body.child::<Div, _>(|d| {
265                d.class("table-responsive").child::<Table, _>(|table| {
266                    table
267                        .class("table table-hover mb-0")
268                        .child::<Thead, _>(|thead| {
269                            thead.child::<Tr, _>(|tr| {
270                                tr.child::<Th, _>(|th| th.text("Transaction"))
271                                    .child::<Th, _>(|th| th.text("Address"))
272                                    .child::<Th, _>(|th| th.class("text-end").text("Amount"))
273                                    .child::<Th, _>(|th| th.class("text-end").text("Status"))
274                            })
275                        })
276                        .child::<Tbody, _>(|tbody| {
277                            transactions.iter().fold(tbody, |tbody, tx| {
278                                tbody.child::<Tr, _>(|_| transaction_row(tx, symbol))
279                            })
280                        })
281                })
282            })
283        }
284    })
285}
286
287/// Action buttons - different actions available based on wallet state
288fn action_buttons(can_send: bool, can_receive: bool) -> Element<Div> {
289    Element::<Div>::new()
290        .class("d-flex gap-2 justify-content-center mb-4")
291        .child::<Button, _>(|_| {
292            let mut btn = buttons::btn(Color::Primary, "Send");
293            if !can_send {
294                btn = btn.bool_attr("disabled");
295            }
296            btn.child::<I, _>(|i| i.class("bi bi-send me-2"))
297        })
298        .child::<Button, _>(|_| {
299            if can_receive {
300                buttons::btn(Color::Success, "Receive")
301                    .child::<I, _>(|i| i.class("bi bi-qr-code me-2"))
302            } else {
303                buttons::btn_disabled(Color::Success, "Receive")
304            }
305        })
306        .child::<Button, _>(|_| {
307            buttons::btn_outline(Color::Secondary, "History")
308                .child::<I, _>(|i| i.class("bi bi-clock-history me-2"))
309        })
310}
311
312/// Address display with copy button
313fn address_display(address: &str) -> Element<Div> {
314    Element::<Div>::new()
315        .class("input-group mb-3")
316        .child::<Span, _>(|s| {
317            s.class("input-group-text")
318                .child::<I, _>(|i| i.class("bi bi-wallet2"))
319        })
320        .child::<Input, _>(|input| {
321            input
322                .attr("type", "text")
323                .class("form-control font-monospace")
324                .attr("value", address)
325                .bool_attr("readonly")
326        })
327        .child::<Button, _>(|btn| {
328            btn.class("btn btn-outline-secondary")
329                .attr("type", "button")
330                .attr(
331                    "onclick",
332                    "navigator.clipboard.writeText(this.previousElementSibling.value)",
333                )
334                .child::<I, _>(|i| i.class("bi bi-clipboard"))
335        })
336}
337
338// ============================================================================
339// PAGE GENERATION - The main function that builds the page from state
340// ============================================================================
341
342/// Wallet page navbar with network and sync indicators
343fn wallet_navbar(state: &WalletState) -> Element<Nav> {
344    Element::<Nav>::new()
345        .class("navbar navbar-expand-lg bg-body-tertiary mb-4")
346        .child::<Div, _>(|_| {
347            grid::container(|c| {
348                c.class("d-flex justify-content-between align-items-center")
349                    .child::<A, _>(|a| {
350                        a.class("navbar-brand fw-bold")
351                            .attr("href", "#")
352                            .text(&state.config.name)
353                    })
354                    .child::<Div, _>(|d| {
355                        d.class("d-flex align-items-center gap-3")
356                            .child::<Span, _>(|_| network_badge(state.config.network))
357                            .child::<Div, _>(|_| sync_status(state.is_syncing, state.sync_progress))
358                    })
359            })
360        })
361}
362
363/// Generate the complete wallet dashboard based on wallet state
364fn generate_wallet_page(state: &WalletState) -> Document {
365    let can_send = state.balance.available > 0.0 && !state.is_syncing;
366    let can_receive = !state.is_syncing;
367
368    Document::new()
369        .doctype()
370        .root::<Html, _>(|html| {
371            let mut html = html.attr("lang", "en");
372            if matches!(state.config.theme, Theme::Dark) {
373                html = html.attr("data-bs-theme", "dark");
374            }
375
376            html.child::<Head, _>(|head| {
377                    head.child::<Meta, _>(|m| m.attr("charset", "UTF-8"))
378                        .child::<Meta, _>(|m| {
379                            m.attr("name", "viewport")
380                                .attr("content", "width=device-width, initial-scale=1")
381                        })
382                        .child::<Title, _>(|t| {
383                            let title = format!("{} Wallet", state.config.name);
384                            t.text(&title)
385                        })
386                        .child::<Link, _>(|l| {
387                            l.attr("href", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css")
388                                .attr("rel", "stylesheet")
389                        })
390                        .child::<Link, _>(|l| {
391                            l.attr("href", "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css")
392                                .attr("rel", "stylesheet")
393                        })
394                })
395                .child::<Body, _>(|body| {
396                    let body_class = match state.config.theme {
397                        Theme::Light => "bg-light",
398                        Theme::Dark => "bg-dark",
399                    };
400
401                    body.class(body_class)
402                        .child::<Nav, _>(|_| wallet_navbar(state))
403                        // Main content
404                        .child::<Main, _>(|main| {
405                            main.child::<Div, _>(|_| {
406                                grid::container(|c| {
407                                    c.child::<Div, _>(|_| {
408                                        grid::row_gutter(4, |r| {
409                                            // Left column: Balance and actions
410                                            r.child::<Div, _>(|_| {
411                                                grid::col_bp(Breakpoint::Md, 4, |col| {
412                                                    col.child::<Div, _>(|_| balance_card(&state.balance, &state.config.currency_symbol, state.has_pending_tx))
413                                                        .child::<Div, _>(|d| {
414                                                            d.class("mt-4")
415                                                                .child::<Div, _>(|_| action_buttons(can_send, can_receive))
416                                                        })
417                                                        .child::<Div, _>(|d| {
418                                                            d.class("mt-4")
419                                                                .child::<Label, _>(|l| l.class("form-label small text-muted").text("Your Address"))
420                                                                .child::<Div, _>(|_| address_display(&state.address))
421                                                        })
422                                                        // Conditional: Show testnet warning
423                                                        .when(state.config.network == Network::Testnet, |col| {
424                                                            col.child::<Div, _>(|_| {
425                                                                alerts::alert(Color::Warning, "You are on testnet. Coins have no real value.")
426                                                            })
427                                                        })
428                                                })
429                                            })
430                                            // Right column: Transactions
431                                            .child::<Div, _>(|_| {
432                                                grid::col_bp(Breakpoint::Md, 8, |col| {
433                                                    col.child::<Div, _>(|_| transaction_list(&state.transactions, &state.config.currency_symbol))
434                                                })
435                                            })
436                                        })
437                                    })
438                                })
439                            })
440                        })
441                        // Footer
442                        .child::<Footer, _>(|f| {
443                            f.class("py-3 mt-4")
444                                .child::<Div, _>(|_| {
445                                    grid::container(|c| {
446                                        c.class("text-center text-muted small")
447                                            .child::<P, _>(|p| {
448                                                let version = format!("{} Wallet v1.0.0", state.config.name);
449                                                p.class("mb-0").text(&version)
450                                            })
451                                    })
452                                })
453                        })
454                        // Bootstrap JS
455                        .child::<Script, _>(|s| {
456                            s.attr("src", "https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js")
457                        })
458                })
459        })
460}
461
462// ============================================================================
463// MAIN: Generate pages for different wallet states
464// ============================================================================
465
466fn main() {
467    // Example 1: Active mainnet wallet with transactions
468    let mainnet_wallet = WalletState {
469        config: WalletConfig {
470            name: "Zcash".into(),
471            network: Network::Mainnet,
472            currency_symbol: "ZEC".into(),
473            theme: Theme::Light,
474        },
475        address: "t1Rv4exT7bqhZqi2j7xz8bUHDMxwosrjADU".into(),
476        balance: WalletBalance {
477            total: 12.456_789_01,
478            available: 10.123_456_78,
479            pending: 2.333_332_23,
480            locked: 0.0,
481        },
482        transactions: vec![
483            Transaction {
484                tx_id: "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6".into(),
485                amount: 5.5,
486                is_incoming: true,
487                confirmations: 142,
488                timestamp: "2024-01-15 14:32".into(),
489                address: "t1KzLcPzUnkA8GqXEPrLBLf4bRFzRYLZmCk".into(),
490            },
491            Transaction {
492                tx_id: "b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7".into(),
493                amount: 2.333_332_23,
494                is_incoming: true,
495                confirmations: 2,
496                timestamp: "2024-01-15 13:15".into(),
497                address: "t1NRzLcPzUnkA8GqXEPrLBLf4bRFzRYLZmC".into(),
498            },
499            Transaction {
500                tx_id: "c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8".into(),
501                amount: 1.2,
502                is_incoming: false,
503                confirmations: 0,
504                timestamp: "2024-01-15 12:00".into(),
505                address: "t1XYzLcPzUnkA8GqXEPrLBLf4bRFzRYLZmC".into(),
506            },
507        ],
508        is_syncing: false,
509        sync_progress: 100,
510        has_pending_tx: true,
511    };
512
513    // Example 2: New testnet wallet (no transactions)
514    let testnet_wallet = WalletState {
515        config: WalletConfig {
516            name: "Zcash".into(),
517            network: Network::Testnet,
518            currency_symbol: "TAZ".into(),
519            theme: Theme::Dark,
520        },
521        address: "tm9k2VqE9xPVdN8NNqXTABPeJr4GtSM8GGo".into(),
522        balance: WalletBalance {
523            total: 0.0,
524            available: 0.0,
525            pending: 0.0,
526            locked: 0.0,
527        },
528        transactions: vec![],
529        is_syncing: true,
530        sync_progress: 67,
531        has_pending_tx: false,
532    };
533
534    // Generate both pages
535    println!("=== MAINNET WALLET ===\n");
536    let mainnet_html = generate_wallet_page(&mainnet_wallet).render();
537    println!("{mainnet_html}");
538
539    println!("\n\n=== TESTNET WALLET (syncing, empty) ===\n");
540    let testnet_html = generate_wallet_page(&testnet_wallet).render();
541    println!("{testnet_html}");
542
543    // Show that the same function generates different HTML based on state
544    println!("\n\n=== DEMONSTRATION ===");
545    println!("The generate_wallet_page() function produces different HTML based on:");
546    println!("  - Network (mainnet/testnet) → different badge colors, warning messages");
547    println!("  - Theme (light/dark) → different body classes, data attributes");
548    println!("  - Sync status → spinner vs checkmark");
549    println!("  - Balance amounts → conditional styling for pending/locked");
550    println!("  - Transaction list → empty state vs table");
551    println!("  - Confirmations → different status badges");
552    println!("  - Can send/receive → enabled/disabled buttons");
553}