Add freemium access economy and token store
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
extends MarginContainer
|
||||
|
||||
const Brand := preload("res://scripts/ui/medmind_style.gd")
|
||||
|
||||
var main: Control
|
||||
var summary: Label
|
||||
var quota: Label
|
||||
var status_label: Label
|
||||
var token_labels: Dictionary = {}
|
||||
var action_buttons: Array[Button] = []
|
||||
var reset_dialog: ConfirmationDialog
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
|
||||
_build_ui()
|
||||
AccessService.status_changed.connect(_render)
|
||||
AccessService.operation_succeeded.connect(_on_success)
|
||||
AccessService.operation_failed.connect(_on_failure)
|
||||
AccessService.checkout_started.connect(_on_checkout_started)
|
||||
UIThemeService.theme_changed.connect(refresh_theme_controls)
|
||||
AccessService.refresh()
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
AccessService.refresh()
|
||||
_render(AccessService.status)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
var scroll := ScrollContainer.new()
|
||||
scroll.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED
|
||||
add_child(scroll)
|
||||
var page := VBoxContainer.new()
|
||||
page.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
page.add_theme_constant_override("separation", 20)
|
||||
scroll.add_child(page)
|
||||
|
||||
var title := Label.new()
|
||||
title.text = "Credits & Access"
|
||||
title.add_theme_font_override("font", Brand.font)
|
||||
title.add_theme_font_size_override("font_size", 36)
|
||||
page.add_child(title)
|
||||
var intro := Label.new()
|
||||
intro.text = "Study 20 QBank questions in a rolling 24-hour window. Eligible first attempts earn credits; 100 credits unlocks unlimited QBank access for 24 hours."
|
||||
intro.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
intro.add_theme_font_override("font", Brand.font)
|
||||
intro.add_theme_font_size_override("font_size", 17)
|
||||
page.add_child(intro)
|
||||
|
||||
var overview := _panel()
|
||||
page.add_child(overview)
|
||||
var overview_content := VBoxContainer.new()
|
||||
overview_content.add_theme_constant_override("separation", 10)
|
||||
overview.add_child(overview_content)
|
||||
summary = _label("", 24)
|
||||
quota = _label("", 17)
|
||||
overview_content.add_child(summary)
|
||||
overview_content.add_child(quota)
|
||||
var redeem := _button("Redeem 100 credits for 24 hours", AccessService.redeem_credits)
|
||||
overview_content.add_child(redeem)
|
||||
|
||||
var token_grid := GridContainer.new()
|
||||
token_grid.columns = 3
|
||||
token_grid.add_theme_constant_override("h_separation", 16)
|
||||
token_grid.add_theme_constant_override("v_separation", 16)
|
||||
page.add_child(token_grid)
|
||||
_add_access_card(token_grid, "day", "1 day", "$1.00", "24 hours")
|
||||
_add_access_card(token_grid, "week", "1 week", "$5.00", "168 hours")
|
||||
_add_access_card(token_grid, "month", "1 month", "$20.00", "30 days")
|
||||
|
||||
var extras := _panel()
|
||||
page.add_child(extras)
|
||||
var extras_content := VBoxContainer.new()
|
||||
extras_content.add_theme_constant_override("separation", 12)
|
||||
extras.add_child(extras_content)
|
||||
extras_content.add_child(_label("Study tools", 24))
|
||||
token_labels["exam"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["exam"])
|
||||
extras_content.add_child(_button("Buy exam simulation token — $1.99",
|
||||
func(): AccessService.begin_checkout("exam_token_1")))
|
||||
token_labels["reset"] = _label("", 16)
|
||||
extras_content.add_child(token_labels["reset"])
|
||||
extras_content.add_child(_button("Buy QBank reset token — $4.99",
|
||||
func(): AccessService.begin_checkout("qbank_reset_1")))
|
||||
extras_content.add_child(_button("Use QBank reset token", _confirm_reset))
|
||||
|
||||
status_label = _label("", 15)
|
||||
status_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
page.add_child(status_label)
|
||||
|
||||
reset_dialog = ConfirmationDialog.new()
|
||||
reset_dialog.title = "Reset QBank progress?"
|
||||
reset_dialog.dialog_text = "This starts a new QBank cycle. Questions become unused and credit-eligible again. Historical aggregate samples, bookmarks, notes, and review content remain."
|
||||
reset_dialog.ok_button_text = "Reset QBank"
|
||||
reset_dialog.confirmed.connect(AccessService.reset_qbank)
|
||||
add_child(reset_dialog)
|
||||
refresh_theme_controls()
|
||||
|
||||
|
||||
func _add_access_card(parent: GridContainer, token: String, heading: String, price: String, duration: String) -> void:
|
||||
var panel := _panel()
|
||||
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
parent.add_child(panel)
|
||||
var content := VBoxContainer.new()
|
||||
content.add_theme_constant_override("separation", 10)
|
||||
panel.add_child(content)
|
||||
content.add_child(_label(heading, 24))
|
||||
content.add_child(_label("%s of unlimited QBank access" % duration, 15))
|
||||
token_labels[token] = _label("", 15)
|
||||
content.add_child(token_labels[token])
|
||||
content.add_child(_button("Buy token — %s" % price,
|
||||
func(): AccessService.begin_checkout("access_%s_1" % token)))
|
||||
content.add_child(_button("Activate token",
|
||||
func(): AccessService.activate_token(token)))
|
||||
|
||||
|
||||
func _render(value: Dictionary) -> void:
|
||||
if summary == null:
|
||||
return
|
||||
var credits := int(value.get("credits", 0))
|
||||
var unlimited := bool(value.get("unlimited", false))
|
||||
var expiry := str(value.get("unlimitedUntilUtc", ""))
|
||||
summary.text = "%d credits • %s" % [
|
||||
credits,
|
||||
("Unlimited QBank active until %s" % expiry) if unlimited else "Free QBank access"
|
||||
]
|
||||
quota.text = "%d of %d free questions remaining in the rolling window" % [
|
||||
int(value.get("questionsRemaining", 20)),
|
||||
int(value.get("freeQuestionLimit", 20))
|
||||
]
|
||||
token_labels.get("day", _label()).text = "%d available" % int(value.get("dayTokens", 0))
|
||||
token_labels.get("week", _label()).text = "%d available" % int(value.get("weekTokens", 0))
|
||||
token_labels.get("month", _label()).text = "%d available" % int(value.get("monthTokens", 0))
|
||||
token_labels.get("exam", _label()).text = "Exam tokens: %d" % int(value.get("examTokens", 0))
|
||||
token_labels.get("reset", _label()).text = "QBank reset tokens: %d" % int(value.get("resetTokens", 0))
|
||||
|
||||
|
||||
func _confirm_reset() -> void:
|
||||
reset_dialog.popup_centered(Vector2i(620, 260))
|
||||
|
||||
|
||||
func _on_success(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Brand.SUCCESS)
|
||||
|
||||
|
||||
func _on_failure(message: String) -> void:
|
||||
status_label.text = message
|
||||
status_label.add_theme_color_override("font_color", Color("ef4444"))
|
||||
|
||||
|
||||
func _on_checkout_started(_purchase_id: String, _url: String) -> void:
|
||||
status_label.text = "Complete checkout in the secure payment window."
|
||||
|
||||
|
||||
func refresh_theme_controls() -> void:
|
||||
for button in action_buttons:
|
||||
var dark := UIThemeService.is_dark_mode()
|
||||
button.add_theme_font_override("font", Brand.font)
|
||||
button.add_theme_color_override("font_color", Brand.text_color(dark))
|
||||
button.add_theme_stylebox_override("normal", Brand.secondary_button_box(dark))
|
||||
button.add_theme_stylebox_override("hover", Brand.secondary_button_box(dark, true))
|
||||
button.add_theme_stylebox_override("pressed", Brand.secondary_button_box(dark, true))
|
||||
|
||||
|
||||
func _panel() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.add_theme_stylebox_override("panel", Brand.panel_box(UIThemeService.is_dark_mode()))
|
||||
return panel
|
||||
|
||||
|
||||
func _label(text := "", size := 16) -> Label:
|
||||
var label := Label.new()
|
||||
label.text = text
|
||||
label.add_theme_font_override("font", Brand.font)
|
||||
label.add_theme_font_size_override("font_size", size)
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
return label
|
||||
|
||||
|
||||
func _button(text: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.text = text
|
||||
button.custom_minimum_size = Vector2(0, 48)
|
||||
button.pressed.connect(callback)
|
||||
action_buttons.append(button)
|
||||
return button
|
||||
@@ -0,0 +1 @@
|
||||
uid://bgkgwx6ds6cjn
|
||||
+18
-1
@@ -350,6 +350,11 @@ func _build_ui() -> void:
|
||||
donations.main = self
|
||||
screens_container.add_child(donations)
|
||||
screens["donations"] = donations
|
||||
var access_store := preload("res://scripts/ui/access_store_screen.gd").new()
|
||||
access_store.name = "AccessStoreScreen"
|
||||
access_store.main = self
|
||||
screens_container.add_child(access_store)
|
||||
screens["access_store"] = access_store
|
||||
|
||||
feedback_dialog = preload("res://scripts/ui/app_feedback_dialog.gd").new()
|
||||
feedback_dialog.name = "AppFeedbackDialog"
|
||||
@@ -369,10 +374,16 @@ func _build_ui() -> void:
|
||||
|
||||
AuthState.auth_changed.connect(_on_auth_changed)
|
||||
AuthState.session_expired.connect(_on_session_expired)
|
||||
QuestionBankService.access_required.connect(_on_qbank_access_required)
|
||||
_refresh_header_controls()
|
||||
_build_app_menu()
|
||||
|
||||
|
||||
func _on_qbank_access_required(_message: String) -> void:
|
||||
if AuthState.is_logged_in:
|
||||
show_screen("access_store")
|
||||
|
||||
|
||||
func _build_sidebar() -> PanelContainer:
|
||||
var panel := PanelContainer.new()
|
||||
panel.name = "Sidebar"
|
||||
@@ -426,6 +437,7 @@ func _populate_navigation(container: VBoxContainer, include_brand: bool) -> void
|
||||
spacer.size_flags_vertical = Control.SIZE_EXPAND_FILL
|
||||
container.add_child(spacer)
|
||||
container.add_child(HSeparator.new())
|
||||
_add_nav_button(container, "access_store", "Credits & Access", "clock-3", true)
|
||||
_add_nav_button(container, "donations", "Support RanaZe", "heart-pulse", true)
|
||||
_add_nav_button(container, "performance", "Profile", "user-round", true)
|
||||
_add_nav_button(container, "settings", "Settings", "settings", true)
|
||||
@@ -471,6 +483,7 @@ func _on_navigation_pressed(key: String) -> void:
|
||||
"notes": show_screen("notes")
|
||||
"bookmarks": show_screen("bookmarks")
|
||||
"donations": show_screen("donations")
|
||||
"access_store": show_screen("access_store")
|
||||
"logout": _logout()
|
||||
|
||||
|
||||
@@ -489,7 +502,8 @@ func _update_header_title(screen_name: String) -> void:
|
||||
"settings": "Settings",
|
||||
"notes": "Notes",
|
||||
"bookmarks": "Bookmarks",
|
||||
"donations": "Support RanaZe"
|
||||
"donations": "Support RanaZe",
|
||||
"access_store": "Credits & Access"
|
||||
}
|
||||
header_title.text = str(titles.get(screen_name, ""))
|
||||
|
||||
@@ -814,6 +828,9 @@ func _on_theme_changed(is_dark_mode: bool) -> void:
|
||||
var donations = screens.get("donations")
|
||||
if donations != null and donations.has_method("refresh_theme_controls"):
|
||||
donations.refresh_theme_controls()
|
||||
var access_store = screens.get("access_store")
|
||||
if access_store != null and access_store.has_method("refresh_theme_controls"):
|
||||
access_store.refresh_theme_controls()
|
||||
var player = screens.get("player")
|
||||
if player != null and player.has_method("refresh_theme_controls"):
|
||||
player.refresh_theme_controls()
|
||||
|
||||
@@ -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 == "access_store":
|
||||
key = "access_store"
|
||||
return key
|
||||
|
||||
@@ -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 AccessService.status_changed.is_connected(_on_access_changed):
|
||||
AccessService.status_changed.connect(_on_access_changed)
|
||||
|
||||
|
||||
func on_show(_payload: Variant = null) -> void:
|
||||
@@ -147,6 +149,7 @@ func on_show(_payload: Variant = null) -> void:
|
||||
load_more_button.disabled = true
|
||||
ExamSimulationService.load_readiness()
|
||||
ExamSimulationService.load_active()
|
||||
AccessService.refresh()
|
||||
_load_history_page()
|
||||
|
||||
|
||||
@@ -223,7 +226,8 @@ func _render_active_section() -> void:
|
||||
card_layout.add_child(actions)
|
||||
var primary := Button.new()
|
||||
primary.name = "PrimarySimulationAction"
|
||||
primary.text = "Resume Simulation" if active else "Start Simulation"
|
||||
var has_exam_token := int(AccessService.status.get("examTokens", 0)) > 0
|
||||
primary.text = "Resume Simulation" if active else ("Start Simulation" if has_exam_token else "Get Exam Token")
|
||||
primary.custom_minimum_size = Vector2(0, 48)
|
||||
primary.disabled = pending_launch or (not active and not bool(ExamSimulationService.readiness.get("ready", false)))
|
||||
_style_gradient_button(primary)
|
||||
@@ -452,6 +456,7 @@ func _on_simulation_loaded() -> void:
|
||||
return
|
||||
if pending_launch:
|
||||
pending_launch = false
|
||||
AccessService.refresh()
|
||||
_route_loaded_simulation()
|
||||
return
|
||||
if not pending_simulation_id.is_empty():
|
||||
@@ -483,6 +488,9 @@ func _route_loaded_simulation() -> void:
|
||||
|
||||
|
||||
func _launch_or_resume() -> void:
|
||||
if not _has_active_simulation() and int(AccessService.status.get("examTokens", 0)) <= 0:
|
||||
main.show_screen("access_store")
|
||||
return
|
||||
pending_launch = true
|
||||
pending_simulation_id = ""
|
||||
_render_active_section()
|
||||
@@ -538,6 +546,11 @@ func _on_request_failed(message: String) -> void:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _on_access_changed(_status: Dictionary) -> void:
|
||||
if visible:
|
||||
_render_active_section()
|
||||
|
||||
|
||||
func _update_responsive_layout() -> void:
|
||||
var use_compact := size.x < 940
|
||||
if compact_layout == use_compact:
|
||||
@@ -564,7 +577,8 @@ func _active_history_summary() -> Dictionary:
|
||||
|
||||
func _readiness_message() -> String:
|
||||
if bool(ExamSimulationService.readiness.get("ready", false)):
|
||||
return "The question bank is ready. Start when you have enough uninterrupted time for a full simulation."
|
||||
var tokens := int(AccessService.status.get("examTokens", 0))
|
||||
return "Exam tokens available: %d. One token is consumed when a new simulation is created." % tokens
|
||||
var available := int(ExamSimulationService.readiness.get("availableQuestions", 0))
|
||||
var required := int(ExamSimulationService.readiness.get("requiredQuestions", 232))
|
||||
return "Simulation unlocks when %d published questions are available. Currently %d/%d." % [required, available, required]
|
||||
|
||||
Reference in New Issue
Block a user