1use 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#[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
88fn 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
98fn 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
119fn 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 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 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
185fn 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
239fn 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 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
287fn 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
312fn 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
338fn 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
363fn 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 .child::<Main, _>(|main| {
405 main.child::<Div, _>(|_| {
406 grid::container(|c| {
407 c.child::<Div, _>(|_| {
408 grid::row_gutter(4, |r| {
409 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 .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 .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 .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 .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
462fn main() {
467 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 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 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 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}