From 30ff63f3fdfda9257224cfe02c3aee77a8349899 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 20:49:43 +0000 Subject: [PATCH] feat(godot): wallet balance, credit pack purchase and ledger history UI --- scenes/wallet_ui.tscn | 136 +++++++++++++++++++ scripts/smoke_test_wallet_ui.gd | 72 +++++++++++ scripts/wallet_ui.gd | 222 ++++++++++++++++++++++++++++++++ 3 files changed, 430 insertions(+) create mode 100644 scenes/wallet_ui.tscn create mode 100644 scripts/smoke_test_wallet_ui.gd create mode 100644 scripts/wallet_ui.gd diff --git a/scenes/wallet_ui.tscn b/scenes/wallet_ui.tscn new file mode 100644 index 0000000..6fcf0a4 --- /dev/null +++ b/scenes/wallet_ui.tscn @@ -0,0 +1,136 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/wallet_ui.gd" id="1_wui"] + +[node name="WalletUI" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_wui") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = 32.0 +offset_right = -32.0 +offset_bottom = -32.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 16 + +[node name="HeaderHBox" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="VBox/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "< Back" + +[node name="TitleLabel" type="Label" parent="VBox/HeaderHBox"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 24 +text = "Wallet & Credits" +horizontal_alignment = 1 + +[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="StatusLabel" type="Label" parent="VBox"] +layout_mode = 2 +horizontal_alignment = 1 + +[node name="BalanceCard" type="PanelContainer" parent="VBox"] +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="VBox/BalanceCard"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 16 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 16 + +[node name="HBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin"] +layout_mode = 2 +theme_override_constants/separation = 20 + +[node name="BalTitle" type="Label" parent="VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Available Balance:" + +[node name="BalanceLabel" type="Label" parent="VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 22 +text = "0 CR" + +[node name="Spacer" type="Control" parent="VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +size_flags_horizontal = 3 + +[node name="PacksHBox" type="HBoxContainer" parent="VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="Buy100Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] +layout_mode = 2 +text = "+100 CR ($0.99)" + +[node name="Buy500Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] +layout_mode = 2 +text = "+500 CR ($4.99)" + +[node name="Buy1000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] +layout_mode = 2 +text = "+1,000 CR ($9.99)" + +[node name="Buy5000Btn" type="Button" parent="VBox/BalanceCard/Margin/HBox/PacksHBox"] +layout_mode = 2 +text = "+5,000 CR ($44.99)" + +[node name="LedgerSection" type="VBoxContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 +theme_override_constants/separation = 8 + +[node name="LedgerTitle" type="Label" parent="VBox/LedgerSection"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +text = "Ledger History" + +[node name="Scroll" type="ScrollContainer" parent="VBox/LedgerSection"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="TxList" type="VBoxContainer" parent="VBox/LedgerSection/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 + +[node name="PagingHBox" type="HBoxContainer" parent="VBox/LedgerSection"] +layout_mode = 2 +alignment = 1 +theme_override_constants/separation = 16 + +[node name="PrevPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"] +custom_minimum_size = Vector2(80, 32) +layout_mode = 2 +text = "Previous" + +[node name="PageLabel" type="Label" parent="VBox/LedgerSection/PagingHBox"] +layout_mode = 2 +text = "Page 1 of 1" + +[node name="NextPageBtn" type="Button" parent="VBox/LedgerSection/PagingHBox"] +custom_minimum_size = Vector2(80, 32) +layout_mode = 2 +text = "Next" diff --git a/scripts/smoke_test_wallet_ui.gd b/scripts/smoke_test_wallet_ui.gd new file mode 100644 index 0000000..f79a452 --- /dev/null +++ b/scripts/smoke_test_wallet_ui.gd @@ -0,0 +1,72 @@ +extends SceneTree +## Headless smoke test for WalletUI scene and script. + +func _initialize() -> void: + _run() + +func _run() -> void: + await process_frame + print("[SMOKE] Starting WalletUI smoke test...") + + var scene = load("res://scenes/wallet_ui.tscn") + if not scene: + printerr("[FAIL] Could not load scenes/wallet_ui.tscn") + quit(1) + return + + var ui: Control = scene.instantiate() + root.add_child(ui) + await process_frame + + # Verify onready nodes + assert(ui.back_button != null, "Missing back_button") + assert(ui.refresh_button != null, "Missing refresh_button") + assert(ui.balance_label != null, "Missing balance_label") + assert(ui.buy_100_btn != null, "Missing buy_100_btn") + assert(ui.buy_500_btn != null, "Missing buy_500_btn") + assert(ui.buy_1000_btn != null, "Missing buy_1000_btn") + assert(ui.buy_5000_btn != null, "Missing buy_5000_btn") + assert(ui.tx_container != null, "Missing tx_container") + assert(ui.prev_page_btn != null, "Missing prev_page_btn") + assert(ui.next_page_btn != null, "Missing next_page_btn") + assert(ui.page_label != null, "Missing page_label") + + # Test rendering empty transactions + ui.render_transactions([]) + assert(ui.tx_container.get_child_count() == 1, "Should have 1 empty notice child") + + # Test rendering sample transactions + var sample_txs = [ + { + "id": 1, + "created_at": "2026-09-15T12:00:00Z", + "type": "credit_purchase", + "amount": 500, + "balance_after": 500, + "reference_type": "payment", + "reference_id": "sess_123" + }, + { + "id": 2, + "created_at": "2026-09-15T12:15:00Z", + "type": "marketplace_purchase", + "amount": -200, + "balance_after": 300, + "reference_type": "marketplace_listing", + "reference_id": "45" + } + ] + ui.render_transactions(sample_txs) + assert(ui.tx_container.get_child_count() == 2, "Should have 2 transaction rows") + + # Test paging logic + ui.total_tx_count = 45 + ui.current_page = 0 + ui._update_paging_controls() + assert(ui.prev_page_btn.disabled == true, "Prev should be disabled on page 0") + assert(ui.next_page_btn.disabled == false, "Next should be enabled on page 0") + assert("Page 1 of 3" in ui.page_label.text, "Page label text mismatch") + + ui.queue_free() + print("[SMOKE] WalletUI smoke test PASSED") + quit(0) diff --git a/scripts/wallet_ui.gd b/scripts/wallet_ui.gd new file mode 100644 index 0000000..9d72292 --- /dev/null +++ b/scripts/wallet_ui.gd @@ -0,0 +1,222 @@ +extends Control +class_name WalletUI +## Wallet & Transaction History UI panel. +## +## Displays: +## - Current Credit balance +## - Credit pack quick purchases (100, 500, 1000, 5000 CR) +## - Paged ledger transaction history with status/amounts/references +## +## docs/planning/economy.md US-6 (task 34c725431645). + +signal back_requested + +@onready var back_button: Button = $VBox/HeaderHBox/BackButton +@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton +@onready var status_label: Label = $VBox/StatusLabel + +@onready var balance_label: Label = $VBox/BalanceCard/Margin/HBox/BalanceLabel +@onready var buy_100_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy100Btn +@onready var buy_500_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy500Btn +@onready var buy_1000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy1000Btn +@onready var buy_5000_btn: Button = $VBox/BalanceCard/Margin/HBox/PacksHBox/Buy5000Btn + +@onready var tx_container: VBoxContainer = $VBox/LedgerSection/Scroll/TxList +@onready var prev_page_btn: Button = $VBox/LedgerSection/PagingHBox/PrevPageBtn +@onready var next_page_btn: Button = $VBox/LedgerSection/PagingHBox/NextPageBtn +@onready var page_label: Label = $VBox/LedgerSection/PagingHBox/PageLabel + +var _api_client: Node = null +var current_page: int = 0 +const PAGE_SIZE: int = 20 +var total_tx_count: int = 0 + + +func _ready() -> void: + _api_client = _get_api() + _wire_signals() + if _api_client != null and _api_client.has_token(): + refresh_all() + + +func _get_api() -> Node: + if has_node("/root/ApiClient"): + return get_node("/root/ApiClient") + return null + + +func _wire_signals() -> void: + if back_button and not back_button.pressed.is_connected(_on_back_pressed): + back_button.pressed.connect(_on_back_pressed) + if refresh_button and not refresh_button.pressed.is_connected(refresh_all): + refresh_button.pressed.connect(refresh_all) + + if buy_100_btn and not buy_100_btn.pressed.is_connected(_buy_pack.bind(100)): + buy_100_btn.pressed.connect(_buy_pack.bind(100)) + if buy_500_btn and not buy_500_btn.pressed.is_connected(_buy_pack.bind(500)): + buy_500_btn.pressed.connect(_buy_pack.bind(500)) + if buy_1000_btn and not buy_1000_btn.pressed.is_connected(_buy_pack.bind(1000)): + buy_1000_btn.pressed.connect(_buy_pack.bind(1000)) + if buy_5000_btn and not buy_5000_btn.pressed.is_connected(_buy_pack.bind(5000)): + buy_5000_btn.pressed.connect(_buy_pack.bind(5000)) + + if prev_page_btn and not prev_page_btn.pressed.is_connected(_on_prev_page): + prev_page_btn.pressed.connect(_on_prev_page) + if next_page_btn and not next_page_btn.pressed.is_connected(_on_next_page): + next_page_btn.pressed.connect(_on_next_page) + + +func _on_back_pressed() -> void: + back_requested.emit() + + +func show_status(text: String, is_error: bool = false) -> void: + if not status_label: + return + status_label.text = text + if is_error: + status_label.add_theme_color_override("font_color", Color(1.0, 0.3, 0.3)) + else: + status_label.add_theme_color_override("font_color", Color(0.3, 0.9, 0.3)) + + +func refresh_all() -> void: + refresh_wallet() + refresh_transactions() + + +func refresh_wallet() -> void: + var api = _get_api() + if not api or not api.has_token(): + return + var res: Dictionary = await EconomyApiClient.get_wallet(api) + if res.ok and res.data is Dictionary: + var balance: int = int(res.data.get("balance", 0)) + if balance_label: + balance_label.text = "%d CR" % balance + else: + show_status("Failed to fetch wallet: " + res.detail, true) + + +func refresh_transactions() -> void: + var api = _get_api() + if not api or not api.has_token(): + return + var offset: int = current_page * PAGE_SIZE + var res: Dictionary = await EconomyApiClient.get_wallet_transactions(api, PAGE_SIZE, offset) + if res.ok and res.data is Dictionary: + var items: Array = res.data.get("items", []) + total_tx_count = int(res.data.get("total", 0)) + render_transactions(items) + _update_paging_controls() + else: + show_status("Failed to fetch transactions: " + res.detail, true) + + +func _buy_pack(credits: int) -> void: + var api = _get_api() + if not api or not api.has_token(): + show_status("Please login to purchase Credits", true) + return + show_status("Processing purchase of %d Credits..." % credits) + var res: Dictionary = await EconomyApiClient.purchase_credits(api, credits) + if res.ok and res.data is Dictionary: + var new_bal: int = int(res.data.get("new_balance", 0)) + if balance_label: + balance_label.text = "%d CR" % new_bal + show_status("Successfully purchased %d Credits! Balance: %d CR" % [credits, new_bal]) + refresh_transactions() + else: + show_status("Purchase failed: " + res.detail, true) + + +func render_transactions(tx_list: Array) -> void: + if not tx_container: + return + for c in tx_container.get_children(): + tx_container.remove_child(c) + c.queue_free() + + if tx_list.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "No transactions found." + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + tx_container.add_child(empty_lbl) + return + + for tx in tx_list: + if not tx is Dictionary: + continue + var row := PanelContainer.new() + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 12) + row.add_child(hbox) + + var date_str: String = str(tx.get("created_at", "")) + if date_str.length() > 19: + date_str = date_str.substr(0, 19).replace("T", " ") + var date_lbl := Label.new() + date_lbl.text = date_str + date_lbl.custom_minimum_size = Vector2(140, 0) + date_lbl.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7)) + hbox.add_child(date_lbl) + + var type_lbl := Label.new() + type_lbl.text = str(tx.get("type", "")).replace("_", " ").capitalize() + type_lbl.custom_minimum_size = Vector2(160, 0) + hbox.add_child(type_lbl) + + var amount: int = int(tx.get("amount", 0)) + var amt_lbl := Label.new() + if amount > 0: + amt_lbl.text = "+%d CR" % amount + amt_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2)) + else: + amt_lbl.text = "%d CR" % amount + amt_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3)) + amt_lbl.custom_minimum_size = Vector2(100, 0) + hbox.add_child(amt_lbl) + + var bal_lbl := Label.new() + bal_lbl.text = "Bal: %s CR" % str(tx.get("balance_after", 0)) + bal_lbl.custom_minimum_size = Vector2(100, 0) + bal_lbl.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) + hbox.add_child(bal_lbl) + + var ref_lbl := Label.new() + var ref_type: String = str(tx.get("reference_type", "")) + var ref_id: String = str(tx.get("reference_id", "")) + if ref_type != "": + ref_lbl.text = "%s #%s" % [ref_type, ref_id] + else: + ref_lbl.text = "-" + ref_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + ref_lbl.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5)) + hbox.add_child(ref_lbl) + + tx_container.add_child(row) + + +func _update_paging_controls() -> void: + var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE))) + if total_pages < 1: + total_pages = 1 + if page_label: + page_label.text = "Page %d of %d" % [current_page + 1, total_pages] + if prev_page_btn: + prev_page_btn.disabled = current_page <= 0 + if next_page_btn: + next_page_btn.disabled = current_page >= total_pages - 1 + + +func _on_prev_page() -> void: + if current_page > 0: + current_page -= 1 + refresh_transactions() + + +func _on_next_page() -> void: + var total_pages: int = int(ceil(float(total_tx_count) / float(PAGE_SIZE))) + if current_page < total_pages - 1: + current_page += 1 + refresh_transactions()