Merge branch 'agent/9797a0cfd9e6-godot-marketplace-ui' into main
checkpoint / checkpoint (push) Successful in 5m55s
checkpoint / checkpoint (push) Successful in 5m55s
This commit is contained in:
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user