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() if get_tree() and get_tree().current_scene == self: get_tree().change_scene_to_file("res://scenes/main_menu.tscn") 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)