Add Rz Coin utility store
This commit is contained in:
@@ -2,16 +2,23 @@ extends Node
|
||||
|
||||
signal credits_changed
|
||||
signal credits_failed(message: String)
|
||||
signal store_operation_completed(message: String)
|
||||
|
||||
const EndpointConfig := preload("res://scripts/autoload/api_endpoint_config.gd")
|
||||
const DEFAULT_URL := "https://us3q.ranaze.com/api/credits"
|
||||
|
||||
var credits: int = 0
|
||||
var exam_tokens: int = 0
|
||||
var qbank_reset_tokens: int = 0
|
||||
var bank_cycle: int = 0
|
||||
var products: Array = []
|
||||
var loaded := false
|
||||
var loading := false
|
||||
var request: HTTPRequest
|
||||
var action_request: HTTPRequest
|
||||
var request_token := ""
|
||||
var api_url := DEFAULT_URL
|
||||
var pending_store_action := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
@@ -22,6 +29,10 @@ func _ready() -> void:
|
||||
request.timeout = 20.0
|
||||
request.request_completed.connect(_on_completed)
|
||||
add_child(request)
|
||||
action_request = HTTPRequest.new()
|
||||
action_request.timeout = 20.0
|
||||
action_request.request_completed.connect(_on_action_completed)
|
||||
add_child(action_request)
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
|
||||
|
||||
@@ -39,6 +50,10 @@ func refresh() -> void:
|
||||
|
||||
func apply_status(parsed: Dictionary) -> void:
|
||||
credits = int(parsed.get("credits", credits))
|
||||
exam_tokens = int(parsed.get("examTokens", exam_tokens))
|
||||
qbank_reset_tokens = int(parsed.get("qBankResetTokens", qbank_reset_tokens))
|
||||
bank_cycle = int(parsed.get("bankCycle", bank_cycle))
|
||||
products = parsed.get("products", products)
|
||||
loaded = true
|
||||
credits_changed.emit()
|
||||
|
||||
@@ -58,9 +73,61 @@ func _on_completed(result: int, response_code: int, _headers: PackedStringArray,
|
||||
apply_status(parsed)
|
||||
|
||||
|
||||
func purchase(sku: String) -> void:
|
||||
pending_store_action = "purchase"
|
||||
_post("/purchases", {"sku": sku, "operationId": _operation_id("purchase")})
|
||||
|
||||
|
||||
func reset_qbank() -> void:
|
||||
pending_store_action = "reset"
|
||||
_post("/qbank-reset", {"confirmation": "RESET_QBANK", "operationId": _operation_id("reset")})
|
||||
|
||||
|
||||
func _post(path: String, payload: Dictionary) -> void:
|
||||
if not AuthState.can_send_authenticated_request():
|
||||
return
|
||||
var headers := PackedStringArray([
|
||||
"Authorization: Bearer %s" % AuthState.access_token,
|
||||
"Content-Type: application/json"
|
||||
])
|
||||
var error := action_request.request(api_url + path, headers, HTTPClient.METHOD_POST, JSON.stringify(payload))
|
||||
if error != OK:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
|
||||
|
||||
func _on_action_completed(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
var parsed: Variant = JSON.parse_string(body.get_string_from_utf8())
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
credits_failed.emit("The Rz Coin Store could not complete that action.")
|
||||
return
|
||||
if response_code >= 200 and response_code < 300 and typeof(parsed) == TYPE_DICTIONARY:
|
||||
apply_status(parsed)
|
||||
if pending_store_action == "reset":
|
||||
ProfileService.reset_qbank_eligibility()
|
||||
pending_store_action = ""
|
||||
store_operation_completed.emit("Your Rz Coin Store balance was updated.")
|
||||
return
|
||||
if typeof(parsed) == TYPE_DICTIONARY:
|
||||
var status: Variant = parsed.get("credits", {})
|
||||
if typeof(status) == TYPE_DICTIONARY:
|
||||
apply_status(status)
|
||||
credits_failed.emit(str(parsed.get("message", "The store action could not be completed.")))
|
||||
else:
|
||||
credits_failed.emit("The store action could not be completed.")
|
||||
pending_store_action = ""
|
||||
|
||||
|
||||
func _operation_id(prefix: String) -> String:
|
||||
return "%s:%s:%d" % [prefix, str(AuthState.user_id), Time.get_ticks_usec()]
|
||||
|
||||
|
||||
func _on_auth_changed() -> void:
|
||||
if not AuthState.is_logged_in:
|
||||
credits = 0
|
||||
exam_tokens = 0
|
||||
qbank_reset_tokens = 0
|
||||
bank_cycle = 0
|
||||
products = []
|
||||
loaded = false
|
||||
loading = false
|
||||
if request.get_http_client_status() != HTTPClient.STATUS_DISCONNECTED:
|
||||
|
||||
@@ -8,6 +8,7 @@ signal state_changed
|
||||
signal request_failed(message: String)
|
||||
signal outcome_changed
|
||||
signal history_changed
|
||||
signal exam_token_required
|
||||
|
||||
var api_url := DEFAULT_API_URL
|
||||
var outcome_api_url := "https://us3q.ranaze.com/api/step3-outcomes"
|
||||
@@ -152,6 +153,14 @@ func _on_request_completed(result: int, response_code: int, _headers: PackedStri
|
||||
AuthState.handle_unauthorized(request_access_token)
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code < 200 or response_code >= 300:
|
||||
var server_message := body.get_string_from_utf8().strip_edges()
|
||||
var error_parsed: Variant = JSON.parse_string(server_message)
|
||||
if typeof(error_parsed) == TYPE_DICTIONARY:
|
||||
var credit_status: Variant = error_parsed.get("credits", {})
|
||||
if typeof(credit_status) == TYPE_DICTIONARY:
|
||||
CreditsService.apply_status(credit_status)
|
||||
if str(error_parsed.get("code", "")) == "exam_token_required":
|
||||
exam_token_required.emit()
|
||||
server_message = str(error_parsed.get("message", server_message))
|
||||
if server_message.length() > 240:
|
||||
server_message = server_message.left(240)
|
||||
var message := "Simulation request failed with status %d." % response_code
|
||||
|
||||
@@ -130,6 +130,14 @@ func reset_qbank_memory() -> void:
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func reset_qbank_eligibility() -> void:
|
||||
_ensure_tracking_fields()
|
||||
profile["completed_question_ids"] = []
|
||||
profile["correct_question_ids"] = []
|
||||
save_profile()
|
||||
profile_changed.emit()
|
||||
|
||||
|
||||
func apply_question_results(results: Dictionary) -> void:
|
||||
_ensure_daily_current()
|
||||
profile["current_streak"] = int(results.get("ending_streak", profile["current_streak"]))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
extends PanelContainer
|
||||
|
||||
signal store_requested
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
const SHIELD_ICON := preload("res://assets/icons/lucide/shield.svg")
|
||||
const STAR_ICON := preload("res://assets/icons/lucide/star.svg")
|
||||
@@ -21,6 +22,9 @@ var _initialized := false
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_vertical = Control.SIZE_SHRINK_CENTER
|
||||
mouse_default_cursor_shape = Control.CURSOR_POINTING_HAND
|
||||
tooltip_text = "Open Rz Coin Store"
|
||||
gui_input.connect(_on_gui_input)
|
||||
_build_ui()
|
||||
refresh_theme_controls()
|
||||
_on_credits_changed()
|
||||
@@ -30,6 +34,11 @@ func _ready() -> void:
|
||||
resized.connect(func(): pivot_offset = size * 0.5)
|
||||
|
||||
|
||||
func _on_gui_input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
store_requested.emit()
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var margin := MarginContainer.new()
|
||||
margin.add_theme_constant_override("margin_left", 7)
|
||||
|
||||
@@ -207,6 +207,7 @@ func _build_ui() -> void:
|
||||
header.add_child(account_chevron_button)
|
||||
|
||||
credits_badge = CreditsBadge.new()
|
||||
credits_badge.store_requested.connect(func(): show_screen("rz_store"))
|
||||
header.add_child(credits_badge)
|
||||
|
||||
var screens_container := MarginContainer.new()
|
||||
@@ -355,6 +356,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var rz_store := preload("res://scripts/ui/rz_store_screen.gd").new()
|
||||
rz_store.name = "RzCoinStore"
|
||||
rz_store.main = self
|
||||
screens_container.add_child(rz_store)
|
||||
screens["rz_store"] = rz_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -426,6 +432,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
_add_nav_button(container, "flashcards", "Flashcards", "clipboard-plus", true, Brand.is_uat_environment())
|
||||
_add_nav_button(container, "bookmarks", "Bookmarks", "bookmark", true)
|
||||
_add_nav_button(container, "notes", "Notes", "notebook-pen", true)
|
||||
_add_nav_button(container, "rz_store", "Rz Coin Store", "star", true)
|
||||
|
||||
var spacer := Control.new()
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
@@ -476,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"rz_store": show_screen("rz_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
|
||||
@@ -29,4 +29,6 @@ static func key_for_screen(screen_name: String, payload: Variant = null) -> Stri
|
||||
key = "bookmarks"
|
||||
elif screen_name == "donations":
|
||||
key = "donations"
|
||||
elif screen_name == "rz_store":
|
||||
key = "rz_store"
|
||||
return key
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
extends VBoxContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var main: Control
|
||||
var balance_label: Label
|
||||
var inventory_label: Label
|
||||
var products_box: VBoxContainer
|
||||
var status_label: Label
|
||||
var confirm_dialog: ConfirmationDialog
|
||||
var pending_sku := ""
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 18)
|
||||
var title := Label.new()
|
||||
title.text = "Rz Coin Store"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 30)
|
||||
add_child(title)
|
||||
var intro := Label.new()
|
||||
intro.text = "Spend coins earned from correct, on-time first answers. Purchases use Rz Coins only."
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(intro)
|
||||
balance_label = Label.new()
|
||||
balance_label.add_theme_font_size_override("font_size", 20)
|
||||
add_child(balance_label)
|
||||
inventory_label = Label.new()
|
||||
add_child(inventory_label)
|
||||
products_box = VBoxContainer.new()
|
||||
products_box.add_theme_constant_override("separation", 12)
|
||||
add_child(products_box)
|
||||
status_label = Label.new()
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(status_label)
|
||||
confirm_dialog = ConfirmationDialog.new()
|
||||
confirm_dialog.confirmed.connect(_confirm_purchase)
|
||||
add_child(confirm_dialog)
|
||||
CreditsService.credits_changed.connect(_refresh)
|
||||
CreditsService.credits_failed.connect(_show_error)
|
||||
CreditsService.store_operation_completed.connect(_show_message)
|
||||
_refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
CreditsService.refresh()
|
||||
_refresh()
|
||||
|
||||
|
||||
func _refresh() -> void:
|
||||
balance_label.text = "%d Rz Coins" % CreditsService.credits
|
||||
inventory_label.text = "Inventory: %d exam token(s) · %d QBank reset token(s)" % [
|
||||
CreditsService.exam_tokens, CreditsService.qbank_reset_tokens]
|
||||
for child in products_box.get_children():
|
||||
child.queue_free()
|
||||
for product_variant in CreditsService.products:
|
||||
if typeof(product_variant) != TYPE_DICTIONARY:
|
||||
continue
|
||||
var product: Dictionary = product_variant
|
||||
var card := PanelContainer.new()
|
||||
card.add_theme_stylebox_override("panel", Brand.content_card_box(UIThemeService.is_dark_mode(), true, Brand.ROYAL_BLUE))
|
||||
var row := HBoxContainer.new()
|
||||
row.add_theme_constant_override("separation", 16)
|
||||
card.add_child(row)
|
||||
var copy := VBoxContainer.new()
|
||||
copy.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
var name_label := Label.new()
|
||||
name_label.text = str(product.get("name", "Store item"))
|
||||
name_label.add_theme_font_size_override("font_size", 19)
|
||||
copy.add_child(name_label)
|
||||
var description := Label.new()
|
||||
description.text = str(product.get("description", ""))
|
||||
description.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
copy.add_child(description)
|
||||
row.add_child(copy)
|
||||
var buy := Button.new()
|
||||
var price := int(product.get("coinPrice", 0))
|
||||
buy.text = "Buy · %d coins" % price
|
||||
buy.disabled = CreditsService.credits < price
|
||||
buy.pressed.connect(_ask_purchase.bind(str(product.get("sku", "")), str(product.get("name", "item")), price))
|
||||
row.add_child(buy)
|
||||
products_box.add_child(card)
|
||||
if CreditsService.qbank_reset_tokens > 0:
|
||||
var reset := Button.new()
|
||||
reset.text = "Use QBank Reset Token"
|
||||
reset.tooltip_text = "Question eligibility resets; ELO, history, bookmarks, notes, reviews, and earning limits are preserved."
|
||||
reset.pressed.connect(_ask_reset)
|
||||
products_box.add_child(reset)
|
||||
|
||||
|
||||
func _ask_purchase(sku: String, product_name: String, price: int) -> void:
|
||||
pending_sku = sku
|
||||
confirm_dialog.title = "Confirm purchase"
|
||||
confirm_dialog.dialog_text = "Buy %s for %d Rz Coins?" % [product_name, price]
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _confirm_purchase() -> void:
|
||||
if pending_sku == "__reset__":
|
||||
CreditsService.reset_qbank()
|
||||
elif not pending_sku.is_empty():
|
||||
CreditsService.purchase(pending_sku)
|
||||
pending_sku = ""
|
||||
|
||||
|
||||
func _ask_reset() -> void:
|
||||
pending_sku = "__reset__"
|
||||
confirm_dialog.title = "Reset QBank eligibility"
|
||||
confirm_dialog.dialog_text = "Make all QBank questions eligible again? ELO, answer history, bookmarks, notes, reviews, and the rolling coin cap will be preserved."
|
||||
confirm_dialog.popup_centered()
|
||||
|
||||
|
||||
func _show_error(message: String) -> void:
|
||||
status_label.text = message
|
||||
|
||||
|
||||
func _show_message(message: String) -> void:
|
||||
status_label.text = message
|
||||
_refresh()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cldpnvs2k6r3t
|
||||
@@ -137,6 +137,8 @@ func _connect_services() -> void:
|
||||
ExamSimulationService.state_changed.connect(_on_simulation_loaded)
|
||||
if not ExamSimulationService.request_failed.is_connected(_on_request_failed):
|
||||
ExamSimulationService.request_failed.connect(_on_request_failed)
|
||||
if not ExamSimulationService.exam_token_required.is_connected(_on_exam_token_required):
|
||||
ExamSimulationService.exam_token_required.connect(_on_exam_token_required)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -538,6 +540,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_exam_token_required() -> void:
|
||||
pending_launch = false
|
||||
main.show_screen("rz_store")
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
|
||||
Reference in New Issue
Block a user