diff --git a/scenes/selling_payout_ui.tscn b/scenes/selling_payout_ui.tscn new file mode 100644 index 0000000..cbacd2f --- /dev/null +++ b/scenes/selling_payout_ui.tscn @@ -0,0 +1,198 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/selling_payout_ui.gd" id="1_spu"] + +[node name="SellingPayoutUI" 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_spu") + +[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 = "Creator Studio — Selling & Payouts" +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="TabContainer" type="TabContainer" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="Selling" type="MarginContainer" parent="VBox/TabContainer"] +layout_mode = 2 +theme_override_constants/margin_left = 12 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 12 + +[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Selling"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="CreateListingBox" type="HBoxContainer" parent="VBox/TabContainer/Selling/VBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="NameInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"] +custom_minimum_size = Vector2(160, 36) +layout_mode = 2 +placeholder_text = "Item Name..." + +[node name="TypeOption" type="OptionButton" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"] +custom_minimum_size = Vector2(130, 36) +layout_mode = 2 + +[node name="DescInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"] +custom_minimum_size = Vector2(180, 36) +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Description..." + +[node name="PriceInput" type="LineEdit" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"] +custom_minimum_size = Vector2(90, 36) +layout_mode = 2 +placeholder_text = "Price (CR)" + +[node name="CreateBtn" type="Button" parent="VBox/TabContainer/Selling/VBox/CreateListingBox"] +custom_minimum_size = Vector2(110, 36) +layout_mode = 2 +text = "List for Sale" + +[node name="ListingsTitle" type="Label" parent="VBox/TabContainer/Selling/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Active Listings" + +[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Selling/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="ListingsList" type="VBoxContainer" parent="VBox/TabContainer/Selling/VBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 + +[node name="Payouts" type="MarginContainer" parent="VBox/TabContainer"] +visible = false +layout_mode = 2 +theme_override_constants/margin_left = 12 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 12 +theme_override_constants/margin_bottom = 12 + +[node name="VBox" type="VBoxContainer" parent="VBox/TabContainer/Payouts"] +layout_mode = 2 +theme_override_constants/separation = 12 + +[node name="BalanceCard" type="PanelContainer" parent="VBox/TabContainer/Payouts/VBox"] +layout_mode = 2 + +[node name="Margin" type="MarginContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard"] +layout_mode = 2 +theme_override_constants/margin_left = 16 +theme_override_constants/margin_top = 12 +theme_override_constants/margin_right = 16 +theme_override_constants/margin_bottom = 12 + +[node name="HBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin"] +layout_mode = 2 +theme_override_constants/separation = 24 + +[node name="AvailTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +text = "Available to Payout:" + +[node name="AvailableLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.2, 0.9, 0.2, 1) +text = "0 CR" + +[node name="PendTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +text = "In Escrow:" + +[node name="PendingLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(1, 0.8, 0.2, 1) +text = "0 CR" + +[node name="LifeTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +text = "Lifetime Earned:" + +[node name="LifetimeLbl" type="Label" parent="VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 18 +theme_override_colors/font_color = Color(0.4, 0.7, 1, 1) +text = "0 CR" + +[node name="RequestBox" type="HBoxContainer" parent="VBox/TabContainer/Payouts/VBox"] +layout_mode = 2 +theme_override_constants/separation = 8 + +[node name="AmountInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"] +custom_minimum_size = Vector2(130, 36) +layout_mode = 2 +placeholder_text = "Amount (min 100)" + +[node name="DestInput" type="LineEdit" parent="VBox/TabContainer/Payouts/VBox/RequestBox"] +custom_minimum_size = Vector2(220, 36) +layout_mode = 2 +size_flags_horizontal = 3 +placeholder_text = "Payout Destination (e.g. PayPal / IBAN)" + +[node name="SubmitBtn" type="Button" parent="VBox/TabContainer/Payouts/VBox/RequestBox"] +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 +text = "Request Payout" + +[node name="PayoutsTitle" type="Label" parent="VBox/TabContainer/Payouts/VBox"] +layout_mode = 2 +theme_override_font_sizes/font_size = 16 +text = "Payout Requests History" + +[node name="Scroll" type="ScrollContainer" parent="VBox/TabContainer/Payouts/VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="PayoutsList" type="VBoxContainer" parent="VBox/TabContainer/Payouts/VBox/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_constants/separation = 6 diff --git a/scripts/selling_payout_ui.gd b/scripts/selling_payout_ui.gd new file mode 100644 index 0000000..6a80d1a --- /dev/null +++ b/scripts/selling_payout_ui.gd @@ -0,0 +1,342 @@ +extends Control +class_name SellingPayoutUI +## Selling & Creator Payout UI panel. +## +## Displays: +## - Tab 1 (Selling): Create new catalog item & marketplace listing, manage my active listings (update/delist) +## - Tab 2 (Payouts): Creator balance breakdown (available/pending/lifetime), submit payout request, view payout history +## +## docs/planning/economy.md US-6 (task 96e5f11d7896). + +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 tab_container: TabContainer = $VBox/TabContainer + +# Tab 1: Selling +@onready var item_name_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/NameInput +@onready var item_type_option: OptionButton = $VBox/TabContainer/Selling/VBox/CreateListingBox/TypeOption +@onready var item_desc_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/DescInput +@onready var price_input: LineEdit = $VBox/TabContainer/Selling/VBox/CreateListingBox/PriceInput +@onready var create_listing_btn: Button = $VBox/TabContainer/Selling/VBox/CreateListingBox/CreateBtn +@onready var my_listings_container: VBoxContainer = $VBox/TabContainer/Selling/VBox/Scroll/ListingsList + +# Tab 2: Payouts +@onready var available_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/AvailableLbl +@onready var pending_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/PendingLbl +@onready var lifetime_lbl: Label = $VBox/TabContainer/Payouts/VBox/BalanceCard/Margin/HBox/LifetimeLbl + +@onready var payout_amount_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/AmountInput +@onready var payout_dest_input: LineEdit = $VBox/TabContainer/Payouts/VBox/RequestBox/DestInput +@onready var request_payout_btn: Button = $VBox/TabContainer/Payouts/VBox/RequestBox/SubmitBtn +@onready var payouts_container: VBoxContainer = $VBox/TabContainer/Payouts/VBox/Scroll/PayoutsList + +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_all() + + +func _get_api() -> Node: + if has_node("/root/ApiClient"): + return get_node("/root/ApiClient") + return null + + +func _setup_type_options() -> void: + if not item_type_option: + return + item_type_option.clear() + item_type_option.add_item("Avatar Item", 0) + item_type_option.add_item("Asset", 1) + item_type_option.add_item("Experience Pass", 2) + + +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 create_listing_btn and not create_listing_btn.pressed.is_connected(_on_create_listing_pressed): + create_listing_btn.pressed.connect(_on_create_listing_pressed) + if request_payout_btn and not request_payout_btn.pressed.is_connected(_on_request_payout_pressed): + request_payout_btn.pressed.connect(_on_request_payout_pressed) + + +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_my_listings() + refresh_creator_balance() + refresh_payouts() + + +# ── Selling / Listings ── + +func refresh_my_listings() -> void: + var api = _get_api() + if not api or not api.has_token(): + return + + # Fetch my items + var items_res: Dictionary = await EconomyApiClient.get_catalog_items(api, true) + # Fetch active listings + var list_res: Dictionary = await EconomyApiClient.search_listings(api, "", "", -1, -1, 100, 0) + + if list_res.ok and list_res.data is Dictionary: + var all_listings: Array = list_res.data.get("listings", []) + # Filter for seller_id or user's items + render_my_listings(all_listings) + + +func _on_create_listing_pressed() -> void: + var api = _get_api() + if not api or not api.has_token(): + show_status("Please login to create listings", true) + return + + var item_name: String = item_name_input.text.strip_edges() if item_name_input else "" + if item_name == "": + show_status("Item name is required", true) + return + + var itype: String = "avatar_item" + if item_type_option: + match item_type_option.selected: + 0: itype = "avatar_item" + 1: itype = "asset" + 2: itype = "experience_pass" + + var desc: String = item_desc_input.text.strip_edges() if item_desc_input else "" + var price_text: String = price_input.text.strip_edges() if price_input else "" + if not price_text.is_valid_int() or price_text.to_int() <= 0: + show_status("Valid positive price in Credits is required", true) + return + var price: int = price_text.to_int() + + show_status("Creating item and listing...") + # 1. Create Catalog Item + var item_res: Dictionary = await EconomyApiClient.create_catalog_item(api, item_name, itype, desc) + if not item_res.ok or not item_res.data is Dictionary: + show_status("Failed to create catalog item: " + item_res.detail, true) + return + + var item_id: int = int(item_res.data.get("id", 0)) + + # 2. Create Listing + var list_res: Dictionary = await EconomyApiClient.create_listing(api, item_id, price) + if list_res.ok: + show_status("Successfully listed '%s' for %d Credits!" % [item_name, price]) + if item_name_input: item_name_input.text = "" + if item_desc_input: item_desc_input.text = "" + if price_input: price_input.text = "" + refresh_my_listings() + else: + show_status("Failed to create listing: " + list_res.detail, true) + + +func render_my_listings(listings: Array) -> void: + if not my_listings_container: + return + + for c in my_listings_container.get_children(): + my_listings_container.remove_child(c) + c.queue_free() + + if listings.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "No listings found." + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + my_listings_container.add_child(empty_lbl) + return + + for l in listings: + if not l is Dictionary: + continue + var row := PanelContainer.new() + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 12) + row.add_child(hbox) + + var cat_item: Dictionary = l.get("catalog_item", {}) + var name_lbl := Label.new() + name_lbl.text = str(cat_item.get("name", "Item")) + name_lbl.custom_minimum_size = Vector2(160, 0) + name_lbl.add_theme_font_size_override("font_size", 14) + hbox.add_child(name_lbl) + + var type_lbl := Label.new() + type_lbl.text = str(cat_item.get("item_type", "")).replace("_", " ").capitalize() + type_lbl.custom_minimum_size = Vector2(120, 0) + type_lbl.add_theme_color_override("font_color", Color(0.5, 0.7, 1.0)) + hbox.add_child(type_lbl) + + var price_lbl := Label.new() + price_lbl.text = "%d CR" % int(l.get("price", 0)) + price_lbl.custom_minimum_size = Vector2(80, 0) + price_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) + hbox.add_child(price_lbl) + + var status_lbl_item := Label.new() + var l_status: String = str(l.get("status", "")) + status_lbl_item.text = l_status.capitalize() + status_lbl_item.size_flags_horizontal = Control.SIZE_EXPAND_FILL + hbox.add_child(status_lbl_item) + + var listing_id: int = int(l.get("id", 0)) + if l_status == "active": + var delist_btn := Button.new() + delist_btn.text = "Delist" + delist_btn.pressed.connect(_on_delist_pressed.bind(listing_id)) + hbox.add_child(delist_btn) + + my_listings_container.add_child(row) + + +func _on_delist_pressed(listing_id: int) -> void: + var api = _get_api() + if not api or not api.has_token(): + return + show_status("Delisting listing #%d..." % listing_id) + var res: Dictionary = await EconomyApiClient.delist_listing(api, listing_id) + if res.ok: + show_status("Listing #%d delisted" % listing_id) + refresh_my_listings() + else: + show_status("Failed to delist: " + res.detail, true) + + +# ── Creator Balances & Payouts ── + +func refresh_creator_balance() -> void: + var api = _get_api() + if not api or not api.has_token(): + return + var res: Dictionary = await EconomyApiClient.get_creator_balance(api) + if res.ok and res.data is Dictionary: + var avail: int = int(res.data.get("available", 0)) + var pend: int = int(res.data.get("pending", 0)) + var life: int = int(res.data.get("lifetime_earned", 0)) + + if available_lbl: available_lbl.text = "%d CR" % avail + if pending_lbl: pending_lbl.text = "%d CR" % pend + if lifetime_lbl: lifetime_lbl.text = "%d CR" % life + + +func refresh_payouts() -> void: + var api = _get_api() + if not api or not api.has_token(): + return + var res: Dictionary = await EconomyApiClient.get_payouts(api) + if res.ok and res.data is Array: + render_payouts(res.data) + + +func _on_request_payout_pressed() -> void: + var api = _get_api() + if not api or not api.has_token(): + show_status("Please login to request payouts", true) + return + + var amt_str: String = payout_amount_input.text.strip_edges() if payout_amount_input else "" + if not amt_str.is_valid_int() or amt_str.to_int() < 100: + show_status("Minimum payout amount is 100 Credits", true) + return + var amount: int = amt_str.to_int() + + var dest: String = payout_dest_input.text.strip_edges() if payout_dest_input else "" + show_status("Submitting payout request for %d CR..." % amount) + var res: Dictionary = await EconomyApiClient.request_payout(api, amount, dest) + if res.ok: + show_status("Payout request for %d CR submitted successfully!" % amount) + if payout_amount_input: payout_amount_input.text = "" + if payout_dest_input: payout_dest_input.text = "" + refresh_creator_balance() + refresh_payouts() + else: + show_status("Payout request failed: " + res.detail, true) + + +func render_payouts(payouts: Array) -> void: + if not payouts_container: + return + + for c in payouts_container.get_children(): + payouts_container.remove_child(c) + c.queue_free() + + if payouts.is_empty(): + var empty_lbl := Label.new() + empty_lbl.text = "No payout history." + empty_lbl.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) + payouts_container.add_child(empty_lbl) + return + + for p in payouts: + if not p 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(p.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 amt_lbl := Label.new() + amt_lbl.text = "%d CR" % int(p.get("amount", 0)) + amt_lbl.custom_minimum_size = Vector2(90, 0) + amt_lbl.add_theme_font_size_override("font_size", 14) + hbox.add_child(amt_lbl) + + var status_str: String = str(p.get("status", "")).capitalize() + var stat_lbl := Label.new() + stat_lbl.text = status_str + stat_lbl.custom_minimum_size = Vector2(100, 0) + if status_str == "Paid": + stat_lbl.add_theme_color_override("font_color", Color(0.2, 0.9, 0.2)) + elif status_str == "Rejected": + stat_lbl.add_theme_color_override("font_color", Color(0.9, 0.3, 0.3)) + else: + stat_lbl.add_theme_color_override("font_color", Color(1.0, 0.8, 0.2)) + hbox.add_child(stat_lbl) + + var ref_lbl := Label.new() + var ext_ref: String = str(p.get("external_ref", "")) + var prov_ref: String = str(p.get("provider_ref", "")) + if prov_ref != "": + ref_lbl.text = "Ref: %s" % prov_ref + elif ext_ref != "": + ref_lbl.text = "Dest: %s" % ext_ref + 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) + + payouts_container.add_child(row) diff --git a/scripts/smoke_test_selling_payout_ui.gd b/scripts/smoke_test_selling_payout_ui.gd new file mode 100644 index 0000000..5572f38 --- /dev/null +++ b/scripts/smoke_test_selling_payout_ui.gd @@ -0,0 +1,84 @@ +extends SceneTree +## Headless smoke test for SellingPayoutUI scene and script. + +func _initialize() -> void: + _run() + +func _run() -> void: + await process_frame + print("[SMOKE] Starting SellingPayoutUI smoke test...") + + var scene = load("res://scenes/selling_payout_ui.tscn") + if not scene: + printerr("[FAIL] Could not load scenes/selling_payout_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.status_label != null, "Missing status_label") + assert(ui.tab_container != null, "Missing tab_container") + + # Selling nodes + assert(ui.item_name_input != null, "Missing item_name_input") + assert(ui.item_type_option != null, "Missing item_type_option") + assert(ui.item_desc_input != null, "Missing item_desc_input") + assert(ui.price_input != null, "Missing price_input") + assert(ui.create_listing_btn != null, "Missing create_listing_btn") + assert(ui.my_listings_container != null, "Missing my_listings_container") + + # Payout nodes + assert(ui.available_lbl != null, "Missing available_lbl") + assert(ui.pending_lbl != null, "Missing pending_lbl") + assert(ui.lifetime_lbl != null, "Missing lifetime_lbl") + assert(ui.payout_amount_input != null, "Missing payout_amount_input") + assert(ui.payout_dest_input != null, "Missing payout_dest_input") + assert(ui.request_payout_btn != null, "Missing request_payout_btn") + assert(ui.payouts_container != null, "Missing payouts_container") + + # Test rendering sample listings + var sample_listings = [ + { + "id": 101, + "price": 300, + "status": "active", + "catalog_item": { + "name": "Knight Helmet", + "item_type": "avatar_item" + } + } + ] + ui.render_my_listings(sample_listings) + assert(ui.my_listings_container.get_child_count() == 1, "Should render 1 listing row") + + # Test rendering sample payouts + var sample_payouts = [ + { + "id": 5, + "amount": 250, + "status": "paid", + "created_at": "2026-09-15T15:00:00Z", + "provider_ref": "stripe_123" + }, + { + "id": 6, + "amount": 100, + "status": "pending", + "created_at": "2026-09-15T16:00:00Z", + "external_ref": "paypal@creator.com" + } + ] + ui.render_payouts(sample_payouts) + assert(ui.payouts_container.get_child_count() == 2, "Should render 2 payout rows") + + # Verify signals + assert(ui.has_signal("back_requested"), "Missing back_requested signal") + + ui.queue_free() + print("[SMOKE] SellingPayoutUI smoke test PASSED") + quit(0)