diff --git a/scenes/marketplace_ui.tscn b/scenes/marketplace_ui.tscn new file mode 100644 index 0000000..ca5bb6a --- /dev/null +++ b/scenes/marketplace_ui.tscn @@ -0,0 +1,96 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/marketplace_ui.gd" id="1_mpu"] + +[node name="MarketplaceUI" 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_mpu") + +[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 = 12 + +[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 = "Marketplace" +horizontal_alignment = 1 + +[node name="BalanceLabel" type="Label" parent="VBox/HeaderHBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(1, 0.84, 0, 1) +text = "Wallet: 0 CR" + +[node name="RefreshButton" type="Button" parent="VBox/HeaderHBox"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="FilterBar" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="SearchEdit" type="LineEdit" parent="VBox/FilterBar"] +custom_minimum_size = Vector2(200, 36) +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Search items by name or description..." + +[node name="TypeOption" type="OptionButton" parent="VBox/FilterBar"] +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 + +[node name="MinPriceEdit" type="LineEdit" parent="VBox/FilterBar"] +custom_minimum_size = Vector2(90, 36) +layout_mode = 2 +placeholder_text = "Min CR" + +[node name="MaxPriceEdit" type="LineEdit" parent="VBox/FilterBar"] +custom_minimum_size = Vector2(90, 36) +layout_mode = 2 +placeholder_text = "Max CR" + +[node name="SearchBtn" type="Button" parent="VBox/FilterBar"] +custom_minimum_size = Vector2(90, 36) +layout_mode = 2 +text = "Search" + +[node name="StatusLabel" type="Label" parent="VBox"] +layout_mode = 2 +horizontal_alignment = 1 + +[node name="Scroll" type="ScrollContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ListingsGrid" type="GridContainer" parent="VBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/h_separation = 16 +theme_override_constants/v_separation = 16 +columns = 4 diff --git a/scripts/marketplace_ui.gd b/scripts/marketplace_ui.gd new file mode 100644 index 0000000..a3d8f66 --- /dev/null +++ b/scripts/marketplace_ui.gd @@ -0,0 +1,231 @@ +extends Control +class_name MarketplaceUI +## Marketplace Browse & Buy UI panel. +## +## Displays: +## - Search bar with keyword, item type, and price filters +## - Marketplace active listings cards +## - Item details and direct atomic purchase flow +## - Wallet balance and real-time status feedback +## +## docs/planning/economy.md US-6 (task 9797a0cfd9e6). + +signal back_requested +signal item_purchased(listing_id: int) + +@onready var back_button: Button = $VBox/HeaderHBox/BackButton +@onready var refresh_button: Button = $VBox/HeaderHBox/RefreshButton +@onready var balance_label: Label = $VBox/HeaderHBox/BalanceLabel +@onready var status_label: Label = $VBox/StatusLabel + +# Filter inputs +@onready var search_edit: LineEdit = $VBox/FilterBar/SearchEdit +@onready var type_option: OptionButton = $VBox/FilterBar/TypeOption +@onready var min_price_edit: LineEdit = $VBox/FilterBar/MinPriceEdit +@onready var max_price_edit: LineEdit = $VBox/FilterBar/MaxPriceEdit +@onready var search_btn: Button = $VBox/FilterBar/SearchBtn + +# Listings container +@onready var listings_container: GridContainer = $VBox/Scroll/ListingsGrid + +var _api_client: Node = null + + +func _ready() -> void: + _api_client = _get_api() + _setup_type_options() + _wire_signals() + if _api_client != null and _api_client.has_token(): + refresh_balance() + search_listings() + + +func _get_api() -> Node: + if has_node("/root/ApiClient"): + return get_node("/root/ApiClient") + return null + + +func _setup_type_options() -> void: + if not type_option: + return + type_option.clear() + type_option.add_item("All Types", 0) + type_option.add_item("Avatar Item", 1) + type_option.add_item("Asset", 2) + type_option.add_item("Experience Pass", 3) + + +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 search_btn and not search_btn.pressed.is_connected(search_listings): + search_btn.pressed.connect(search_listings) + if search_edit and not search_edit.text_submitted.is_connected(func(_txt): search_listings()): + search_edit.text_submitted.connect(func(_txt): search_listings()) + + +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_balance() + search_listings() + + +func refresh_balance() -> 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 = "Wallet: %d CR" % balance + + +func search_listings() -> void: + var api = _get_api() + if not api: + return + + var q: String = search_edit.text.strip_edges() if search_edit else "" + var item_type: String = "" + if type_option and type_option.selected > 0: + match type_option.selected: + 1: item_type = "avatar_item" + 2: item_type = "asset" + 3: item_type = "experience_pass" + + var min_price: int = -1 + if min_price_edit and min_price_edit.text.is_valid_int(): + min_price = min_price_edit.text.to_int() + + var max_price: int = -1 + if max_price_edit and max_price_edit.text.is_valid_int(): + max_price = max_price_edit.text.to_int() + + show_status("Loading listings...") + var res: Dictionary = await EconomyApiClient.search_listings(api, q, item_type, min_price, max_price) + if res.ok and res.data is Dictionary: + var items: Array = res.data.get("listings", []) + render_listings(items) + show_status("Found %d active listings" % items.size()) + else: + show_status("Search failed: " + res.detail, true) + + +func render_listings(listings: Array) -> void: + if not listings_container: + return + + for c in listings_container.get_children(): + listings_container.remove_child(c) + c.queue_free() + + if listings.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "No listings matching your search." + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + listings_container.add_child(empty_lbl) + return + + for l in listings: + if not l is Dictionary: + continue + var card := _create_listing_card(l) + listings_container.add_child(card) + + +func _create_listing_card(listing: Dictionary) -> Control: + var card := PanelContainer.new() + card.custom_minimum_size = Vector2(240, 200) + + var margin := MarginContainer.new() + margin.add_theme_constant_override("margin_left", 12) + margin.add_theme_constant_override("margin_top", 12) + margin.add_theme_constant_override("margin_right", 12) + margin.add_theme_constant_override("margin_bottom", 12) + card.add_child(margin) + + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 8) + margin.add_child(vbox) + + var cat_item: Dictionary = listing.get("catalog_item", {}) + var title_lbl := Label.new() + title_lbl.text = str(cat_item.get("name", "Unnamed Item")) + title_lbl.add_theme_font_size_override("font_size", 16) + vbox.add_child(title_lbl) + + var type_lbl := Label.new() + var itype: String = str(cat_item.get("item_type", "")).replace("_", " ").capitalize() + type_lbl.text = itype + type_lbl.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0)) + type_lbl.add_theme_font_size_override("font_size", 12) + vbox.add_child(type_lbl) + + var seller_lbl := Label.new() + var seller_name: String = str(listing.get("seller_username", "Unknown")) + seller_lbl.text = "By " + seller_name + seller_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + seller_lbl.add_theme_font_size_override("font_size", 12) + vbox.add_child(seller_lbl) + + var desc_lbl := Label.new() + var desc: String = str(cat_item.get("description", "")) + if desc.length() > 60: + desc = desc.substr(0, 57) + "..." + desc_lbl.text = desc + desc_lbl.size_flags_vertical = Control.SIZE_EXPAND_FILL + desc_lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(desc_lbl) + + var bottom_hbox := HBoxContainer.new() + vbox.add_child(bottom_hbox) + + var price: int = int(listing.get("price", 0)) + var price_lbl := Label.new() + price_lbl.text = "%d CR" % price + price_lbl.add_theme_font_size_override("font_size", 16) + price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) + price_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL + bottom_hbox.add_child(price_lbl) + + var buy_btn := Button.new() + buy_btn.text = "Buy" + buy_btn.custom_minimum_size = Vector2(80, 32) + var listing_id: int = int(listing.get("id", 0)) + buy_btn.pressed.connect(_on_buy_clicked.bind(listing_id, str(cat_item.get("name", "Item")), price)) + bottom_hbox.add_child(buy_btn) + + return card + + +func _on_buy_clicked(listing_id: int, item_name: String, price: int) -> void: + var api = _get_api() + if not api or not api.has_token(): + show_status("Please login to purchase items", true) + return + + show_status("Purchasing '%s' for %d CR..." % [item_name, price]) + var res: Dictionary = await EconomyApiClient.purchase_listing(api, listing_id) + if res.ok: + show_status("Successfully purchased '%s'! Item added to inventory." % item_name) + item_purchased.emit(listing_id) + refresh_all() + else: + show_status("Purchase failed: " + res.detail, true) diff --git a/scripts/smoke_test_marketplace_ui.gd b/scripts/smoke_test_marketplace_ui.gd new file mode 100644 index 0000000..c1564cd --- /dev/null +++ b/scripts/smoke_test_marketplace_ui.gd @@ -0,0 +1,72 @@ +extends SceneTree +## Headless smoke test for MarketplaceUI scene and script. + +func _initialize() -> void: + _run() + +func _run() -> void: + await process_frame + print("[SMOKE] Starting MarketplaceUI smoke test...") + + var scene = load("res://scenes/marketplace_ui.tscn") + if not scene: + printerr("[FAIL] Could not load scenes/marketplace_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.status_label != null, "Missing status_label") + assert(ui.search_edit != null, "Missing search_edit") + assert(ui.type_option != null, "Missing type_option") + assert(ui.min_price_edit != null, "Missing min_price_edit") + assert(ui.max_price_edit != null, "Missing max_price_edit") + assert(ui.search_btn != null, "Missing search_btn") + assert(ui.listings_container != null, "Missing listings_container") + + # Verify type options populated + assert(ui.type_option.item_count >= 4, "Type options not populated") + + # Test rendering empty listings + ui.render_listings([]) + assert(ui.listings_container.get_child_count() == 1, "Should show empty notice") + + # Test rendering sample listings + var sample_listings = [ + { + "id": 10, + "price": 200, + "seller_username": "merchant_bob", + "catalog_item": { + "name": "Wizard Robe", + "item_type": "avatar_item", + "description": "Flowing velvet robe" + } + }, + { + "id": 11, + "price": 500, + "seller_username": "dungeon_master", + "catalog_item": { + "name": "Castle Map", + "item_type": "asset", + "description": "Detailed 3D fortress map" + } + } + ] + ui.render_listings(sample_listings) + assert(ui.listings_container.get_child_count() == 2, "Should render 2 listing cards") + + # Verify signals + assert(ui.has_signal("back_requested"), "Missing back_requested signal") + assert(ui.has_signal("item_purchased"), "Missing item_purchased signal") + + ui.queue_free() + print("[SMOKE] MarketplaceUI smoke test PASSED") + quit(0)