use std::cell::RefCell; use std::collections::HashMap; use std::sync::Arc; use chrono::Local; use galaxy_core::features::FeatureFlag; use galaxy_core::ui::appearance::Appearance; use galaxy_core::ui::theme::Fill; use galaxy_graphql::billing::AddonCreditsOption; use galaxyui::elements::{ Align, Border, ChildAnchor, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Empty, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable, HyperlinkUrl, MainAxisAlignment, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Radius, Shrinkable, Text, Wrap, }; use galaxyui::fonts::{Properties, Weight}; use galaxyui::platform::Cursor; use galaxyui::prelude::ChildView; use galaxyui::ui_components::button::{ButtonVariant, TextAndIcon, TextAndIconAlignment}; use galaxyui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use galaxyui::ui_components::switch::SwitchStateHandle; use galaxyui::{ AppContext, Element, Entity, ModelHandle, SingletonEntity, TypedActionView, UpdateView, View, ViewContext, ViewHandle, }; use itertools::Itertools; use markdown_parser::{FormattedText, FormattedTextFragment, FormattedTextLine}; use pathfinder_color::ColorU; use pathfinder_geometry::vector::vec2f; use settings::Setting; use thousands::Separable; use super::admin_actions::AdminActions; use super::billing_and_usage::overage_limit_modal::{SpendingLimitModal, SpendingLimitModalEvent}; use super::billing_and_usage::usage_history_entry::UsageHistoryEntry; use super::billing_and_usage::usage_history_model::UsageHistoryModel; use super::settings_page::{ build_sub_header, render_body_item, render_customer_type_badge, render_info_icon, AdditionalInfo, HEADER_PADDING, }; use super::SettingsSection; use crate::ai::AIRequestUsageModel; use crate::auth::auth_manager::LoginGatedFeature; use crate::auth::auth_state::AuthState; use crate::auth::auth_view_modal::AuthViewVariant; use crate::auth::{AuthManager, AuthStateProvider, UserUid}; use crate::menu::{Event as MenuEvent, Menu, MenuItem, MenuItemFields}; use crate::modal::{Modal, ModalEvent, ModalViewState}; use crate::pricing::{PricingInfoModel, PricingInfoModelEvent}; use crate::server::ids::ServerId; use crate::server::telemetry::TelemetryEvent; use crate::settings::ai::AISettings; use crate::settings_view::settings_page::TOGGLE_BUTTON_RIGHT_PADDING; use crate::ui_components::blended_colors; use crate::ui_components::buttons::icon_button; use crate::ui_components::icons::Icon; use crate::ui_components::menu_button::{icon_button_with_context_menu, MenuDirection}; use crate::ui_components::tab_selector::{self, SettingsTab}; use crate::view_components::action_button::{ActionButton, PrimaryTheme, SecondaryTheme}; use crate::view_components::ToastFlavor; use crate::workspaces::team::Team; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_profiles::UserProfiles; use crate::workspaces::user_workspaces::{UserWorkspaces, UserWorkspacesEvent}; use crate::workspaces::workspace::{CustomerType, Workspace}; use crate::{send_telemetry_from_ctx, WorkspaceAction}; const HEADER_FONT_SIZE: f32 = 16.; const OVERAGE_USAGE_LINK_TEXT: &str = "View details on overage usage"; const OVERAGE_TOGGLE_ADMIN_HEADER: &str = "Enable premium model usage overages"; const OVERAGE_TOGGLE_USER_HEADER_ENABLED: &str = "Premium model usage overages are enabled"; const OVERAGE_TOGGLE_USER_HEADER_DISABLED: &str = "Premium model usage overages are not enabled"; const OVERAGE_TOGGLE_DESCRIPTION: &str = "Continue using premium models beyond your plan's limits. Usage is charged in $20 increments up to your spending limit, with any remaining balance charged on your scheduled billing date."; const OVERAGE_TOGGLE_USER_DESCRIPTION: &str = "Ask a team admin to enable overages for more AI usage."; const SORT_MENU_ITEM_DISPLAY_NAME_A_Z_LABEL: &str = "A to Z"; const SORT_MENU_ITEM_DISPLAY_NAME_Z_A_LABEL: &str = "Z to A"; const SORT_MENU_ITEM_REQUEST_USAGE_ASCENDING_LABEL: &str = "Usage ascending"; const SORT_MENU_ITEM_REQUEST_USAGE_DESCENDING_LABEL: &str = "Usage descending"; const AUTO_RELOAD_EXCEED_LIMIT_WARNING_STRING: &str = "Auto reload is disabled, as the next reload would exceed your monthly spend limit. Increase your limit to use auto reload."; const AUTO_RELOAD_DELINQUENT_WARNING_STRING: &str = "Restricted due to billing issue. Update your payment method to purchase add-on credits."; const RESTRICTED_BILLING_USAGE_WARNING_STRING: &str = "Auto reload is disabled due to recent failed reload. Please update your payment method and try again."; const OVERVIEW_TAB_TEXT: &str = "Overview"; const USAGE_HISTORY_TAB_TEXT: &str = "Usage History"; const ENTERPRISE_USAGE_CALLOUT_HEADER: &str = "Usage reporting is currently limited"; const ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_PREFIX: &str = "Enterprise credit usage isn't fully available in this view yet. For the most accurate spend tracking, "; const ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_LINK: &str = "visit the admin panel"; const ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_SUFFIX: &str = "."; const ENTERPRISE_USAGE_CALLOUT_BODY_NON_ADMIN: &str = "Enterprise credit usage isn't fully available in this view yet. Contact a team admin for detailed usage reporting."; const ADDON_CREDITS_DESCRIPTION: &str = "Add-on credits are purchased in prepaid packages that roll over each billing cycle and expire after one year. The more you purchase, the better the per-credit rate. Once your base plan credits are used, add-on credits will be consumed."; const ADDITIONAL_ADDON_CREDITS_DESCRIPTION_FOR_TEAM: &str = "Purchased add-on credits are shared across your team."; // Cloud agent trial widget constants. const AMBIENT_AGENT_TRIAL_TITLE: &str = "Cloud agent trial"; /// The threshold below which we only show the "Buy more" button (not "New agent"). use crate::ai::request_usage_model::AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD; pub fn create_discount_badge(discount: u32, appearance: &Appearance) -> Box { if discount == 0 { return Empty::new().finish(); } let theme = appearance.theme(); let bg_color: Fill = theme.terminal_colors().normal.green.into(); Container::new( Text::new_inline(format!("{discount}% off"), appearance.ui_font_family(), 10.) .with_color(theme.main_text_color(bg_color).into()) .finish(), ) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) .with_background(bg_color) .with_uniform_padding(4.) .finish() } #[derive(Debug, Clone, PartialEq, Eq)] pub enum BillingUsageTab { Overview, UsageHistory, } impl BillingUsageTab { pub fn get_tab_from_label(label: &str) -> Self { match label { OVERVIEW_TAB_TEXT => BillingUsageTab::Overview, USAGE_HISTORY_TAB_TEXT => BillingUsageTab::UsageHistory, _ => BillingUsageTab::Overview, } } pub fn label(&self) -> &str { match self { BillingUsageTab::Overview => OVERVIEW_TAB_TEXT, BillingUsageTab::UsageHistory => USAGE_HISTORY_TAB_TEXT, } } } /// Represents a user item for sorting in the billing and usage page. /// The display_name should already have email as fallback before creating this struct. pub(crate) struct UserSortingCriteria { pub display_name: String, pub requests_used: usize, pub data: T, } impl UserSortingCriteria { pub fn new(display_name: String, requests_used: usize, data: T) -> Self { Self { display_name, requests_used, data, } } } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SortKey { DisplayName, Requests, } #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum SortOrder { Asc, Desc, } pub(crate) struct ProratedRequestLimitsInfo { pub is_request_limit_prorated: bool, pub mouse_state: MouseStateHandle, pub is_current_user: bool, } pub struct BillingAndUsagePageView { auth_state: Arc, overage_limit_modal_state: ModalViewState>, addon_credit_modal_state: ModalViewState>, // Since UBP can take a second to enable due to needing to contact Stripe, // we allow the view to override the state of the toggle temporarily. usage_based_pricing_toggle_override: Option, usage_based_pricing_toggle_loading: bool, // Sorting menu state sorting_menu: ViewHandle>, sorting_menu_open: bool, // Current sort state for the members list current_sort_key: Option, current_sort_order: SortOrder, // Which view tab is currently selected selected_tab: BillingUsageTab, // Model for Usage History tab data usage_history_model: ModelHandle, // Track which usage history entries have been expanded expanded_usage_entries: HashMap, // Persistent mouse states for usage history entries, keyed by conversation_id usage_entries_mouse_states: RefCell>, // Persistent mouse states for tooltips in usage history entries, keyed by conversation_id usage_entries_tooltip_mouse_states: RefCell>, // Action button for loading more usage history entries load_more_button: ViewHandle, selected_addon_denomination: usize, addon_credits_options: Vec, addon_credit_denomination_buttons: Vec>, purchase_addon_credits_loading: bool, prorated_request_limits_info_mouse_states: Vec, // ── Plan-header mouse states ───────────────────────────────────────── upgrade_link: MouseStateHandle, anonymous_user_sign_up_button: MouseStateHandle, enterprise_contact_us_link: MouseStateHandle, stripe_billing_portal_link: MouseStateHandle, admin_panel_link: MouseStateHandle, // ── Page-body mouse / switch states ────────────────────────────────── requests_highlight_index: HighlightedHyperlink, ubp_switch_state: SwitchStateHandle, ubp_info_icon_mouse_state: MouseStateHandle, pencil_icon_mouse_state: MouseStateHandle, overage_usage_link_mouse_state: MouseStateHandle, // Mouse state for the inline "Increase your limit" link inside the warning row exceed_limit_link_mouse_state: MouseStateHandle, refresh_icon_mouse_state: MouseStateHandle, sort_icon_mouse_state: MouseStateHandle, overview_tab_mouse_state: MouseStateHandle, usage_history_tab_mouse_state: MouseStateHandle, addon_info_icon_mouse_state: MouseStateHandle, edit_monthly_limit: MouseStateHandle, auto_reload_switch: SwitchStateHandle, buy_button: MouseStateHandle, // Ambient agent trial widget buttons. ambient_trial_new_agent_button: MouseStateHandle, ambient_trial_buy_more_button: MouseStateHandle, ambient_trial_dismiss_button: MouseStateHandle, } impl BillingAndUsagePageView { pub fn new(ctx: &mut ViewContext) -> Self { ctx.subscribe_to_model(&UserWorkspaces::handle(ctx), |me, _handle, event, ctx| { me.handle_workspaces_event(event, ctx); ctx.notify(); }); ctx.subscribe_to_model(&AuthManager::handle(ctx), |me, _, _, ctx| { me.refresh_addon_credits_settings(ctx); ctx.notify(); }); let team_update_manager = TeamUpdateManager::handle(ctx); ctx.subscribe_to_model(&team_update_manager, |_, _handle, _, ctx| { ctx.notify(); }); ctx.subscribe_to_model(&AIRequestUsageModel::handle(ctx), |_, _, _, ctx| { ctx.notify() }); ctx.subscribe_to_model(&PricingInfoModel::handle(ctx), |me, _handle, event, ctx| { #[allow(irrefutable_let_patterns)] if let PricingInfoModelEvent::PricingInfoUpdated = event { me.update_addon_credits_options(ctx); me.refresh_addon_credits_settings(ctx); ctx.notify(); } }); let usage_history_model = ctx.add_model(UsageHistoryModel::new); ctx.subscribe_to_model(&usage_history_model, |_, _, _, ctx| { ctx.notify(); }); // On page init, fetch the usage history for the current user. usage_history_model.update(ctx, |m, ctx| m.refresh_usage_history_async(ctx)); let auth_state = AuthStateProvider::as_ref(ctx).get().clone(); let overage_limit_modal = ctx.add_typed_action_view(SpendingLimitModal::new); ctx.subscribe_to_view(&overage_limit_modal, |me, _, event, ctx| { me.handle_overage_limit_modal_event(event, ctx); }); let overage_limit_modal_view = ctx.add_typed_action_view(|ctx| { Modal::new( Some("Overage spending limit".to_string()), overage_limit_modal, ctx, ) .with_header_style(UiComponentStyles { padding: Some(Coords::uniform(24.).bottom(16.)), ..Default::default() }) .with_body_style(UiComponentStyles { padding: Some(Coords::uniform(24.).top(0.).bottom(12.)), ..Default::default() }) }); ctx.subscribe_to_view(&overage_limit_modal_view, |me, _, event, ctx| { me.handle_overage_modal_close_event(event, ctx); }); let addon_credit_modal = ctx.add_typed_action_view(SpendingLimitModal::new); ctx.subscribe_to_view(&addon_credit_modal, |me, _, event, ctx| { me.handle_addon_credit_modal_event(event, ctx); }); let addon_credit_modal_view = ctx.add_typed_action_view(|ctx| { Modal::new( Some("Monthly spending limit".to_string()), addon_credit_modal, ctx, ) .with_header_style(UiComponentStyles { padding: Some(Coords::uniform(24.).bottom(16.)), ..Default::default() }) .with_body_style(UiComponentStyles { padding: Some(Coords::uniform(24.).top(0.).bottom(12.)), ..Default::default() }) }); ctx.subscribe_to_view(&addon_credit_modal_view, |me, _, event, ctx| { me.handle_addon_credit_modal_close_event(event, ctx); }); // Sorting menu view and close handling let sorting_menu = ctx.add_typed_action_view(|_ctx| Menu::::new()); ctx.subscribe_to_view(&sorting_menu, |me, _, event, ctx| { if let MenuEvent::Close { .. } = event { me.sorting_menu_open = false; ctx.notify(); } }); let load_more_button = ctx.add_typed_action_view(|_ctx| { ActionButton::new("Load more", SecondaryTheme).on_click(|ctx| { ctx.dispatch_typed_action(BillingAndUsagePageAction::RenderMoreUsageEntries); }) }); let mut me = Self { auth_state, overage_limit_modal_state: ModalViewState::new(overage_limit_modal_view), addon_credit_modal_state: ModalViewState::new(addon_credit_modal_view), usage_based_pricing_toggle_override: None, usage_based_pricing_toggle_loading: false, sorting_menu, sorting_menu_open: false, usage_history_model, current_sort_key: Some(SortKey::DisplayName), current_sort_order: SortOrder::Asc, selected_tab: BillingUsageTab::Overview, expanded_usage_entries: HashMap::new(), usage_entries_mouse_states: RefCell::new(HashMap::new()), usage_entries_tooltip_mouse_states: RefCell::new(HashMap::new()), load_more_button, selected_addon_denomination: 0, addon_credits_options: Default::default(), addon_credit_denomination_buttons: Default::default(), purchase_addon_credits_loading: false, prorated_request_limits_info_mouse_states: Default::default(), upgrade_link: MouseStateHandle::default(), anonymous_user_sign_up_button: MouseStateHandle::default(), enterprise_contact_us_link: MouseStateHandle::default(), stripe_billing_portal_link: MouseStateHandle::default(), admin_panel_link: MouseStateHandle::default(), requests_highlight_index: HighlightedHyperlink::default(), ubp_switch_state: SwitchStateHandle::default(), ubp_info_icon_mouse_state: MouseStateHandle::default(), pencil_icon_mouse_state: MouseStateHandle::default(), overage_usage_link_mouse_state: MouseStateHandle::default(), exceed_limit_link_mouse_state: MouseStateHandle::default(), refresh_icon_mouse_state: MouseStateHandle::default(), sort_icon_mouse_state: MouseStateHandle::default(), overview_tab_mouse_state: MouseStateHandle::default(), usage_history_tab_mouse_state: MouseStateHandle::default(), addon_info_icon_mouse_state: MouseStateHandle::default(), edit_monthly_limit: MouseStateHandle::default(), auto_reload_switch: SwitchStateHandle::default(), buy_button: MouseStateHandle::default(), ambient_trial_new_agent_button: MouseStateHandle::default(), ambient_trial_buy_more_button: MouseStateHandle::default(), ambient_trial_dismiss_button: MouseStateHandle::default(), }; me.update_addon_credits_options(ctx); me.refresh_addon_credits_settings(ctx); me.update_prorated_mouse_states(ctx); me } fn refresh_addon_credits_settings(&mut self, ctx: &mut ViewContext) { let Some(workspace) = UserWorkspaces::as_ref(ctx).current_workspace() else { return; }; let addon_credits_settings = &workspace.settings.addon_credits_settings; if addon_credits_settings.auto_reload_enabled { self.selected_addon_denomination = addon_credits_settings .selected_auto_reload_credit_denomination .and_then(|amount| { self.addon_credits_options .iter() .find_position(|option| option.credits == amount) }) .map_or(0, |pair| pair.0); } self.update_denomination_buttons_focus(ctx); } fn update_denomination_buttons_focus(&mut self, ctx: &mut ViewContext) { for (i, button_handle) in self.addon_credit_denomination_buttons.iter().enumerate() { ctx.update_view(button_handle, |button, ctx| { if i == self.selected_addon_denomination { button.set_theme(PrimaryTheme, ctx); } else { button.set_theme(SecondaryTheme, ctx); } }); } } fn handle_workspaces_event( &mut self, event: &UserWorkspacesEvent, ctx: &mut ViewContext, ) { match event { UserWorkspacesEvent::TeamsChanged => { self.update_spending_limit_modals(ctx); self.update_prorated_mouse_states(ctx); } UserWorkspacesEvent::UpdateWorkspaceSettingsSuccess => { self.update_spending_limit_modals(ctx); self.refresh_addon_credits_settings(ctx); self.update_prorated_mouse_states(ctx); self.usage_based_pricing_toggle_override = None; self.usage_based_pricing_toggle_loading = false; ctx.notify(); } UserWorkspacesEvent::UpdateWorkspaceSettingsRejected(_err) => { self.show_toast( "Failed to update workspace settings", ToastFlavor::Error, ctx, ); self.usage_based_pricing_toggle_override = None; self.usage_based_pricing_toggle_loading = false; } UserWorkspacesEvent::AiOveragesUpdated => { ctx.notify(); } UserWorkspacesEvent::PurchaseAddonCreditsSuccess => { self.purchase_addon_credits_loading = false; self.show_toast( "Successfully purchased add-on credits", ToastFlavor::Success, ctx, ); AIRequestUsageModel::handle(ctx).update(ctx, |ai_request_usage_model, ctx| { ai_request_usage_model.refresh_request_usage_async(ctx) }); } UserWorkspacesEvent::PurchaseAddonCreditsRejected(err) => { self.purchase_addon_credits_loading = false; self.show_toast(&err.to_string(), ToastFlavor::Error, ctx); } _ => {} } } fn show_toast(&self, message: &str, flavor: ToastFlavor, ctx: &mut ViewContext) { ctx.emit(BillingAndUsagePageEvent::ShowToast { message: message.to_string(), flavor, }); } pub fn get_modal_content(&self) -> Option> { if self.overage_limit_modal_state.is_open() { Some(self.overage_limit_modal_state.render()) } else if self.addon_credit_modal_state.is_open() { Some(self.addon_credit_modal_state.render()) } else { None } } fn handle_overage_modal_close_event( &mut self, event: &ModalEvent, ctx: &mut ViewContext, ) { match event { ModalEvent::Close => { self.overage_limit_modal_state.close(); ctx.emit(BillingAndUsagePageEvent::HideModal); } } } fn handle_addon_credit_modal_close_event( &mut self, event: &ModalEvent, ctx: &mut ViewContext, ) { match event { ModalEvent::Close => { self.addon_credit_modal_state.close(); ctx.emit(BillingAndUsagePageEvent::HideModal); } } } fn handle_overage_limit_modal_event( &mut self, event: &SpendingLimitModalEvent, ctx: &mut ViewContext, ) { match event { SpendingLimitModalEvent::Close => { self.hide_overage_limit_modal(ctx); } SpendingLimitModalEvent::Update { amount_cents } => { let workspaces = UserWorkspaces::as_ref(ctx); let team_uid = workspaces.current_team_uid(); let usage_settings = workspaces.usage_based_pricing_settings(); if let Some(team_uid) = team_uid { self.update_usage_based_pricing_settings( team_uid, usage_settings.enabled, Some(*amount_cents), ctx, ); self.hide_overage_limit_modal(ctx); ctx.notify(); } } } } fn handle_addon_credit_modal_event( &mut self, event: &SpendingLimitModalEvent, ctx: &mut ViewContext, ) { match event { SpendingLimitModalEvent::Close => { self.hide_addon_credit_modal(ctx); } SpendingLimitModalEvent::Update { amount_cents } => { let workspaces = UserWorkspaces::as_ref(ctx); let team_uid = workspaces.current_team_uid(); if let Some(team_uid) = team_uid { UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { user_workspaces.update_addon_credits_settings( team_uid, None, Some(*amount_cents as i32), None, ctx, ); }); self.hide_addon_credit_modal(ctx); ctx.notify(); } } } } fn update_usage_based_pricing_settings( &mut self, team_uid: ServerId, enabled: bool, max_monthly_spend_cents: Option, ctx: &mut ViewContext, ) { self.usage_based_pricing_toggle_loading = true; UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { user_workspaces.update_usage_based_pricing_settings( team_uid, enabled, max_monthly_spend_cents, ctx, ); }); self.usage_based_pricing_toggle_override = Some(enabled); } fn show_overage_limit_modal(&mut self, ctx: &mut ViewContext) { self.overage_limit_modal_state.open(); self.overage_limit_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.focus_input(ctx); }); }); ctx.emit(BillingAndUsagePageEvent::ShowModal); } fn hide_overage_limit_modal(&mut self, ctx: &mut ViewContext) { self.overage_limit_modal_state.close(); ctx.emit(BillingAndUsagePageEvent::HideModal); } fn update_spending_limit_modals(&mut self, ctx: &mut ViewContext) { let workspaces = UserWorkspaces::as_ref(ctx); let usage_settings = workspaces.usage_based_pricing_settings(); let overage_limit = usage_settings.max_monthly_spend_cents.unwrap_or(5000); let addon_limit = workspaces .current_workspace() .and_then(|workspace| { workspace .settings .addon_credits_settings .max_monthly_spend_cents }) .unwrap_or(20000); self.overage_limit_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.update_amount_editor(overage_limit, ctx); }); }); self.addon_credit_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.update_amount_editor(addon_limit.max(0) as u32, ctx); }); }); ctx.notify(); } fn update_addon_credits_options(&mut self, ctx: &mut ViewContext) { self.addon_credits_options = PricingInfoModel::as_ref(ctx) .addon_credits_options() .map(|opts| opts.to_vec()) .unwrap_or_default(); self.addon_credit_denomination_buttons = self .addon_credits_options .iter() .enumerate() .map(|(i, option)| { ctx.add_typed_action_view(move |_ctx| { ActionButton::new(option.credits.separate_with_commas(), SecondaryTheme) .with_icon(Icon::Credits) .on_click(move |ctx| { ctx.dispatch_typed_action( BillingAndUsagePageAction::SelectTopupDenomination(i), ); }) }) }) .collect(); } fn update_prorated_mouse_states(&mut self, ctx: &mut ViewContext) { let workspace_members_count = UserWorkspaces::as_ref(ctx) .current_workspace() .map(|workspace| workspace.members.len()) .unwrap_or(0); self.prorated_request_limits_info_mouse_states = (0..workspace_members_count) .map(|_| MouseStateHandle::default()) .collect(); } fn show_addon_credit_modal(&mut self, ctx: &mut ViewContext) { self.addon_credit_modal_state.open(); self.addon_credit_modal_state .view .update(ctx, |modal, ctx| { modal.body().update(ctx, |body, ctx| { body.focus_input(ctx); }); }); ctx.emit(BillingAndUsagePageEvent::ShowModal); } fn hide_addon_credit_modal(&mut self, ctx: &mut ViewContext) { self.addon_credit_modal_state.close(); ctx.emit(BillingAndUsagePageEvent::HideModal); } } impl BillingAndUsagePageView { pub(super) fn on_page_selected( &mut self, _allow_steal_focus: bool, ctx: &mut ViewContext, ) { self.purchase_addon_credits_loading = false; std::mem::drop( TeamUpdateManager::handle(ctx) .update(ctx, |manager, ctx| manager.refresh_workspace_metadata(ctx)), ); AIRequestUsageModel::handle(ctx).update(ctx, |ai_request_usage_model, ctx| { ai_request_usage_model.refresh_request_usage_async(ctx) }); self.usage_history_model .update(ctx, |m, ctx| m.refresh_usage_history_async(ctx)); self.refresh_addon_credits_settings(ctx); } } #[derive(Debug, Clone)] pub enum BillingAndUsagePageEvent { SignupAnonymousUser, ShowToast { message: String, flavor: ToastFlavor, }, ShowModal, HideModal, } impl Entity for BillingAndUsagePageView { type Event = BillingAndUsagePageEvent; } impl View for BillingAndUsagePageView { fn ui_name() -> &'static str { "Billing and usage" } fn render(&self, app: &AppContext) -> Box { let appearance = Appearance::as_ref(app); Flex::column() .with_child(self.render_plan_header(appearance, app)) .with_child(self.render_page_body(appearance, app)) .finish() } } impl TypedActionView for BillingAndUsagePageView { type Action = BillingAndUsagePageAction; fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { if AuthStateProvider::as_ref(ctx) .get() .is_anonymous_or_logged_out() && action.blocked_for_anonymous_user() { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.attempt_login_gated_feature( action.into(), AuthViewVariant::RequireLoginCloseable, ctx, ) }); return; } match action { BillingAndUsagePageAction::Upgrade { team_uid, user_id } => match team_uid { Some(team_uid) => { ctx.open_url(&UserWorkspaces::upgrade_link_for_team(*team_uid)); } None => { ctx.open_url(&UserWorkspaces::upgrade_link(*user_id)); } }, BillingAndUsagePageAction::GenerateStripeBillingPortalLink { team_uid } => { UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { user_workspaces.generate_stripe_billing_portal_link(*team_uid, ctx); }); } BillingAndUsagePageAction::OpenAdminPanel { team_uid } => { AdminActions::open_admin_panel(*team_uid, ctx); } BillingAndUsagePageAction::ContactSupport => { AdminActions::contact_support(ctx); } BillingAndUsagePageAction::SignupAnonymousUser => { ctx.emit(BillingAndUsagePageEvent::SignupAnonymousUser); } BillingAndUsagePageAction::AttemptLoginGatedUpgrade => { AuthManager::handle(ctx).update(ctx, |auth_manager, ctx| { auth_manager.attempt_login_gated_feature( action.into(), AuthViewVariant::RequireLoginCloseable, ctx, ) }); } BillingAndUsagePageAction::OpenUrl(url) => { ctx.open_url(&url.url); } BillingAndUsagePageAction::UpdateUsageBasedPricingSettings { team_uid, enabled, max_monthly_spend_cents, } => { self.update_usage_based_pricing_settings( *team_uid, *enabled, *max_monthly_spend_cents, ctx, ); } BillingAndUsagePageAction::ShowOverageLimitModal => { self.show_overage_limit_modal(ctx); } BillingAndUsagePageAction::ToggleSortingMenu => { if self.sorting_menu_open { self.sorting_menu_open = false; ctx.focus_self(); ctx.notify(); return; } // Build four menu items with checkmark for selected state let sort_options = [ ( SORT_MENU_ITEM_DISPLAY_NAME_A_Z_LABEL, SortKey::DisplayName, SortOrder::Asc, ), ( SORT_MENU_ITEM_DISPLAY_NAME_Z_A_LABEL, SortKey::DisplayName, SortOrder::Desc, ), ( SORT_MENU_ITEM_REQUEST_USAGE_ASCENDING_LABEL, SortKey::Requests, SortOrder::Asc, ), ( SORT_MENU_ITEM_REQUEST_USAGE_DESCENDING_LABEL, SortKey::Requests, SortOrder::Desc, ), ]; let items: Vec> = sort_options .iter() .map(|(label, key, order)| { let is_selected = matches!( (self.current_sort_key, self.current_sort_order), (Some(k), o) if k == *key && o == *order ); let mut menu_item = MenuItemFields::new(*label).with_on_select_action( BillingAndUsagePageAction::ChangeUsageSort { key: *key, order: *order, }, ); menu_item = if is_selected { menu_item.with_icon(Icon::Check) } else { menu_item.with_indent() }; menu_item.into_item() }) .collect(); ctx.update_view(&self.sorting_menu, |menu, ctx| menu.set_items(items, ctx)); self.sorting_menu_open = true; ctx.focus(&self.sorting_menu); ctx.notify(); } BillingAndUsagePageAction::RefreshWorkspaceData => { std::mem::drop( TeamUpdateManager::handle(ctx) .update(ctx, |manager, ctx| manager.refresh_workspace_metadata(ctx)), ); AIRequestUsageModel::handle(ctx).update(ctx, |ai_request_usage_model, ctx| { ai_request_usage_model.refresh_request_usage_async(ctx) }); } BillingAndUsagePageAction::ChangeUsageSort { key, order } => { self.current_sort_key = Some(*key); self.current_sort_order = *order; self.sorting_menu_open = false; ctx.focus_self(); ctx.notify(); } BillingAndUsagePageAction::SelectTab(tab) => { if self.selected_tab != *tab { self.selected_tab = tab.clone(); ctx.notify(); } } BillingAndUsagePageAction::ToggleUsageEntryExpanded { conversation_id } => { let is_expanded = self .expanded_usage_entries .get(conversation_id) .copied() .unwrap_or(false); self.expanded_usage_entries .insert(conversation_id.clone(), !is_expanded); ctx.notify(); } BillingAndUsagePageAction::RenderMoreUsageEntries => { self.usage_history_model .update(ctx, |m, ctx| m.load_more_usage_history_async(ctx)); } BillingAndUsagePageAction::SelectTopupDenomination(i) => { self.selected_addon_denomination = *i; self.update_denomination_buttons_focus(ctx); UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { let team_uid = user_workspaces.current_team_uid(); if let Some((workspace, team_uid)) = user_workspaces.current_workspace().zip(team_uid) { if workspace .settings .addon_credits_settings .auto_reload_enabled { if let Some(option) = self .addon_credits_options .get(self.selected_addon_denomination) { user_workspaces.update_addon_credits_settings( team_uid, None, None, Some(option.credits), ctx, ); } } } }); ctx.notify(); } BillingAndUsagePageAction::PurchaseAddonCredits { team_uid } => { if let Some(option) = self .addon_credits_options .get(self.selected_addon_denomination) { let credits = option.credits; let team_uid = *team_uid; self.purchase_addon_credits_loading = true; UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { user_workspaces.purchase_addon_credits(team_uid, credits, ctx); }); ctx.notify(); } } BillingAndUsagePageAction::ShowAddOnCreditModal => { self.show_addon_credit_modal(ctx); } BillingAndUsagePageAction::UpdateAutoReloadEnabled { team_uid, enabled } => { send_telemetry_from_ctx!( TelemetryEvent::AutoReloadToggledFromBillingSettings { enabled: *enabled, banner_toggle_flag_enabled: FeatureFlag::BuildPlanAutoReloadBannerToggle .is_enabled(), post_purchase_modal_flag_enabled: FeatureFlag::BuildPlanAutoReloadPostPurchaseModal.is_enabled(), }, ctx ); let selected_auto_reload_value = if *enabled { self.addon_credits_options .get(self.selected_addon_denomination) .map(|option| option.credits) } else { None }; UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { user_workspaces.update_addon_credits_settings( *team_uid, Some(*enabled), None, selected_auto_reload_value, ctx, ); }); } BillingAndUsagePageAction::DismissAmbientAgentTrialWidget => { AISettings::handle(ctx).update(ctx, |settings, ctx| { let _ = settings .ambient_agent_trial_widget_dismissed .set_value(true, ctx); }); ctx.notify(); } BillingAndUsagePageAction::NavigateToByokSettings => { ctx.dispatch_typed_action_deferred(WorkspaceAction::ShowSettingsPageWithSearch { search_query: "api".to_string(), section: Some(SettingsSection::WarpAgent), }); } } } } #[derive(Debug, Clone)] pub enum BillingAndUsagePageAction { OpenUrl(HyperlinkUrl), Upgrade { team_uid: Option, user_id: UserUid, }, GenerateStripeBillingPortalLink { team_uid: ServerId, }, OpenAdminPanel { team_uid: ServerId, }, ContactSupport, SignupAnonymousUser, AttemptLoginGatedUpgrade, UpdateUsageBasedPricingSettings { team_uid: ServerId, enabled: bool, max_monthly_spend_cents: Option, }, ShowOverageLimitModal, RefreshWorkspaceData, ToggleSortingMenu, ChangeUsageSort { key: SortKey, order: SortOrder, }, SelectTab(BillingUsageTab), ToggleUsageEntryExpanded { conversation_id: String, }, RenderMoreUsageEntries, SelectTopupDenomination(usize), PurchaseAddonCredits { team_uid: ServerId, }, ShowAddOnCreditModal, UpdateAutoReloadEnabled { team_uid: ServerId, enabled: bool, }, DismissAmbientAgentTrialWidget, NavigateToByokSettings, } impl BillingAndUsagePageAction { fn blocked_for_anonymous_user(&self) -> bool { use BillingAndUsagePageAction::*; matches!( self, Upgrade { .. } | GenerateStripeBillingPortalLink { .. }, ) } } impl From<&BillingAndUsagePageAction> for LoginGatedFeature { fn from(val: &BillingAndUsagePageAction) -> LoginGatedFeature { match val { BillingAndUsagePageAction::Upgrade { .. } => "Upgrade Plan", BillingAndUsagePageAction::GenerateStripeBillingPortalLink { .. } => { "Generate Stripe Billing Portal Link" } _ => "Unknown reason", } } } #[derive(Copy, Clone, Debug)] enum Divisor { Unlimited, Limit(usize), } impl BillingAndUsagePageView { /// Renders the ambient agent trial widget showing remaining credits and action buttons. /// Returns None if the user has no ambient-only credits (None value from server), /// or if the widget has been dismissed (only dismissible when below threshold). fn render_ambient_agent_trial_widget( &self, ai_request_usage_model: &AIRequestUsageModel, appearance: &Appearance, app: &AppContext, ) -> Option> { let credits_remaining = ai_request_usage_model.ambient_only_credits_remaining()?; // Check if the widget has been dismissed. let is_dismissed = *AISettings::as_ref(app).ambient_agent_trial_widget_dismissed; if is_dismissed { return None; } let theme = appearance.theme(); let ui_builder = appearance.ui_builder(); let fg = theme.foreground().into_solid(); let bg = theme.background().into_solid(); let title = Text::new_inline(AMBIENT_AGENT_TRIAL_TITLE, appearance.ui_font_family(), 14.) .with_color(theme.active_ui_text_color().into()) .with_style(Properties::default().weight(Weight::Semibold)) .finish(); let credits_text = if credits_remaining == 1 { "1 credit remaining".to_string() } else { format!( "{} credits remaining", credits_remaining.separate_with_commas() ) }; let credits_label = Text::new_inline(credits_text, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_sub(theme, theme.surface_1())) .finish(); let left_side = Flex::row() .with_child(title) .with_child(Container::new(credits_label).with_margin_left(8.).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(); let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); // Only show "New agent" button if credits >= threshold. if credits_remaining >= AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD { let new_agent_button = ui_builder .button( ButtonVariant::Secondary, self.ambient_trial_new_agent_button.clone(), ) .with_text_label("New agent".to_string()) .with_style(UiComponentStyles { font_color: Some(bg), background: Some(fg.into()), font_size: Some(14.), font_weight: Some(Weight::Semibold), padding: Some(Coords { top: 7., bottom: 7., left: 12., right: 12., }), ..Default::default() }) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(WorkspaceAction::AddAmbientAgentTab); }) .finish(); right_side.add_child( Container::new(new_agent_button) .with_margin_right(8.) .finish(), ); } // Only show "Buy more" button for users not on a paid plan. let is_on_paid_plan = UserWorkspaces::as_ref(app) .current_team() .is_some_and(|team| team.billing_metadata.is_user_on_paid_plan()); if !is_on_paid_plan { let user_id = AuthStateProvider::as_ref(app).get().user_id(); let buy_more_button = ui_builder .button( ButtonVariant::Secondary, self.ambient_trial_buy_more_button.clone(), ) .with_text_label("Buy more".to_string()) .with_style(UiComponentStyles { background: Some(bg.into()), font_size: Some(14.), font_weight: Some(Weight::Semibold), padding: Some(Coords { top: 7., bottom: 7., left: 12., right: 12., }), ..Default::default() }) .build() .on_click(move |ctx, _, _| { if let Some(user_id) = user_id { ctx.dispatch_typed_action(BillingAndUsagePageAction::Upgrade { team_uid: None, user_id, }); } }) .finish(); right_side.add_child(buy_more_button); } // Show dismiss button only when credits are below threshold. if credits_remaining < AMBIENT_AGENT_TRIAL_CREDIT_THRESHOLD { let dismiss_button = icon_button( appearance, Icon::X, false, self.ambient_trial_dismiss_button.clone(), ) .with_style(UiComponentStyles { width: Some(32.), height: Some(32.), padding: Some(Coords::uniform(8.)), ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( BillingAndUsagePageAction::DismissAmbientAgentTrialWidget, ); }) .finish(); right_side.add_child(Container::new(dismiss_button).with_margin_left(4.).finish()); } let row_content = Flex::row() .with_child(Shrinkable::new(1., left_side).finish()) .with_child(right_side.finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish(); let bright_blue: ColorU = theme.terminal_colors().bright.blue.into(); let gradient_start = ColorU::transparent_black(); let gradient_end = ColorU::new(bright_blue.r, bright_blue.g, bright_blue.b, 40); let card = Container::new(row_content) .with_horizontal_background_gradient(gradient_start, gradient_end) .with_border(Border::all(1.).with_border_color(theme.accent_overlay().into())) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_margin_bottom(16.) .with_uniform_padding(12.) .finish(); Some(card) } fn render_usage_based_pricing_section( &self, enabled: bool, team: &Team, appearance: &Appearance, app: &AppContext, has_admin_permissions: bool, ubp_toggle_loading: bool, ) -> Box { let is_delinquent = team.billing_metadata.is_delinquent_due_to_payment_issue(); let enabled_and_not_delinquent = enabled && !is_delinquent; let (header_text, description_text) = if has_admin_permissions { (OVERAGE_TOGGLE_ADMIN_HEADER, OVERAGE_TOGGLE_DESCRIPTION) } else if enabled { ( OVERAGE_TOGGLE_USER_HEADER_ENABLED, OVERAGE_TOGGLE_DESCRIPTION, ) } else { ( OVERAGE_TOGGLE_USER_HEADER_DISABLED, OVERAGE_TOGGLE_USER_DESCRIPTION, ) }; let header = Text::new_inline(header_text, appearance.ui_font_family(), 14.) .with_color(appearance.theme().active_ui_text_color().into()) .finish(); let description = appearance .ui_builder() .paragraph(description_text) .with_style(UiComponentStyles { font_color: Some(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )), font_size: Some(12.), margin: Some(Coords { top: 4., bottom: 0., left: 0., right: 0., }), ..Default::default() }) .build() .finish(); let mut column = Flex::column(); if has_admin_permissions { let team_uid = team.uid; let toggle = appearance .ui_builder() .switch(self.ubp_switch_state.clone()) .check(enabled_and_not_delinquent) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( BillingAndUsagePageAction::UpdateUsageBasedPricingSettings { team_uid, enabled: !enabled, max_monthly_spend_cents: None, }, ); }); let toggle = if ubp_toggle_loading || is_delinquent { toggle.disable().finish() } else { toggle.finish() }; column.add_child( Flex::row() .with_child(header) .with_child(Container::new(toggle).with_margin_left(16.).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish(), ); } else { column.add_child(header); } column.add_child(Container::new(description).with_margin_right(100.).finish()); if enabled_and_not_delinquent || team.billing_metadata.has_overages_used() { column.add_child(self.render_monthly_overage_spending_limit( appearance, app, has_admin_permissions, )); column.add_child(self.render_total_overages_row(appearance, app)); if let Some(manage_link) = self.render_manage_overages_link(appearance, team.uid, has_admin_permissions) { column.add_child(manage_link); } } column.finish() } fn render_monthly_overage_spending_limit( &self, appearance: &Appearance, app: &AppContext, has_admin_permissions: bool, ) -> Box { let workspaces = UserWorkspaces::as_ref(app); let usage_settings = workspaces.usage_based_pricing_settings(); let spend_limit_text = if let Some(cents) = usage_settings.max_monthly_spend_cents { format!("${:.2}", cents as f64 / 100.0) } else { "Not set".to_string() }; let info_icon = render_info_icon( appearance, AdditionalInfo:: { mouse_state: self.ubp_info_icon_mouse_state.clone(), on_click_action: None, secondary_text: None, tooltip_override_text: Some( "Sets the monthly overage spending limit beyond the plan amount".to_string(), ), }, ); let label = Text::new_inline( "Monthly overage spending limit", appearance.ui_font_family(), 12., ) .with_color(appearance.theme().active_ui_text_color().into()) .finish(); let value = Text::new_inline(spend_limit_text, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish(); let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); if has_admin_permissions { let pencil_icon = icon_button( appearance, Icon::Pencil, false, self.pencil_icon_mouse_state.clone(), ) .with_style(UiComponentStyles { width: Some(20.), height: Some(20.), ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::ShowOverageLimitModal); }) .finish(); right_side.add_child(Container::new(pencil_icon).with_margin_right(8.).finish()); } right_side.add_child(value); Container::new( Flex::row() .with_child( Flex::row() .with_child(label) .with_child(Container::new(info_icon).with_margin_left(4.).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) .with_child(right_side.finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish(), ) .with_margin_top(16.) .finish() } fn render_manage_overages_link( &self, appearance: &Appearance, team_uid: ServerId, has_admin_permissions: bool, ) -> Option> { if has_admin_permissions { Some( appearance .ui_builder() .link( OVERAGE_USAGE_LINK_TEXT.to_string(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action( BillingAndUsagePageAction::GenerateStripeBillingPortalLink { team_uid, }, ); })), self.overage_usage_link_mouse_state.clone(), ) .build() .with_margin_top(16.) .finish(), ) } else { None } } fn render_warning_icon(appearance: &Appearance) -> Box { let theme = appearance.theme(); ConstrainedBox::new( Icon::AlertTriangle .to_galaxyui_icon(theme.ui_error_color().into()) .finish(), ) .with_height(16.) .with_width(16.) .finish() } fn render_warning_row_with_content( appearance: &Appearance, content: Box, ) -> Box { Container::new( Flex::row() .with_child( Container::new(Self::render_warning_icon(appearance)) .with_margin_right(8.) .finish(), ) .with_child(Shrinkable::new(1.0, content).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ) .with_margin_top(8.) // 8px from spacing + 8px here = 16px total .finish() } fn render_warning_row( &self, appearance: &Appearance, warning_string: String, ) -> Box { let theme = appearance.theme(); let warning_text = Text::new(warning_string, appearance.ui_font_family(), 12.) .with_color(theme.ui_error_color()) .finish(); Self::render_warning_row_with_content(appearance, warning_text) } fn render_warning_row_with_link( &self, appearance: &Appearance, text_fragments: Vec, ) -> Box { let theme = appearance.theme(); // Build: [plain text] [always-underlined link] [plain text] let mut children: Vec> = Vec::new(); let ui_builder = appearance.ui_builder(); for fragment in text_fragments { match fragment.styles.hyperlink { Some(markdown_parser::Hyperlink::Url(url)) => { let link = ui_builder .link( fragment.text, Some(url), None, self.exceed_limit_link_mouse_state.clone(), ) .with_style(UiComponentStyles { // Make it look like a link in the error row font_size: Some(12.), font_color: Some(theme.ui_error_color()), border_color: Some(theme.ui_error_color().into()), // always underline border_width: Some(1.), ..Default::default() }) .build() .finish(); children.push(link); } Some(markdown_parser::Hyperlink::Action(action)) => { // Downcast to our action type and dispatch on click let maybe_action = action .as_any() .downcast_ref::() .cloned(); let link = ui_builder .link( fragment.text, None, maybe_action.map(|act| { Box::new(move |ctx: &mut galaxyui::EventContext| { ctx.dispatch_typed_action(act.clone()); }) as Box }), self.exceed_limit_link_mouse_state.clone(), ) .with_style(UiComponentStyles { font_size: Some(12.), font_color: Some(theme.ui_error_color()), border_color: Some(theme.ui_error_color().into()), border_width: Some(1.), ..Default::default() }) .build() .finish(); children.push(link); } None => { // Plain text in error color let text = Text::new_inline(fragment.text, appearance.ui_font_family(), 12.) .with_color(theme.ui_error_color()) .finish(); children.push(text); } } } let content = Flex::row().with_children(children).finish(); Self::render_warning_row_with_content(appearance, content) } #[allow(clippy::too_many_arguments)] fn render_addon_credits_panel( &self, selected_topup_denomination: usize, workspace: &Workspace, team_uid: ServerId, has_admin_permissions: bool, bonus_credit_balance: i32, addon_credits_options: &[AddonCreditsOption], addon_credit_denomination_buttons: &[ViewHandle], purchase_addon_credits_loading: bool, delinquent_due_to_payment_issue: bool, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); let fg = appearance.theme().foreground(); let bg = appearance.theme().background(); let ui_builder = appearance.ui_builder(); let theme = appearance.theme(); let header = Text::new_inline("Add-on credits", appearance.ui_font_family(), 16.) .with_color(fg.into()) .with_style(Properties::default().weight(Weight::Bold)) .finish(); let credits_value = Text::new_inline( bonus_credit_balance.separate_with_commas(), appearance.ui_font_family(), 16., ) .with_color(fg.into()) .finish(); let icon = Container::new( ConstrainedBox::new(Icon::Credits.to_galaxyui_icon(fg).finish()) .with_height(16.) .with_width(16.) .finish(), ) .with_margin_right(12.) .finish(); let card_header = Flex::row() .with_child(Shrinkable::new(1., Align::new(header).left().finish()).finish()) .with_child(icon) .with_child(credits_value) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(); let team_can_purchase_addon_credits = UserWorkspaces::as_ref(app) .current_team() .and_then(|team| team.billing_metadata.tier.purchase_add_on_credits_policy) .is_some_and(|policy| policy.enabled); let can_upgrade_to_build = UserWorkspaces::as_ref(app) .current_workspace() .map(|workspace| workspace.billing_metadata.can_upgrade_to_build_plan()) .unwrap_or(false); let no_credits_access_explanation = match ( team_can_purchase_addon_credits, can_upgrade_to_build, has_admin_permissions, ) { // If the team can purchase addon credits (which implies they're already on a Build-like plan) // and the current user is a team admin, don't show any explanation, so that we show the // fuller experience with the rest of the settings below this. (true, _, true) => None, // If the team cannot purchase addon credits, but they can upgrade to a Build-like plan, // and the current user is an admin, then we show them a nudge to switch to Build. (false, true, true) => { let upgrade_url = UserWorkspaces::upgrade_link_for_team(team_uid); let is_legacy_paid = UserWorkspaces::handle(app) .as_ref(app) .current_team() .is_some_and(|team| team.billing_metadata.is_on_legacy_paid_plan()); let (link_text, suffix) = if is_legacy_paid { ("Switch to the Build plan", " to purchase add-on credits.") } else { ("Upgrade to the Build plan", " to purchase add-on credits.") }; let text_fragments = vec![ FormattedTextFragment::hyperlink(link_text, upgrade_url), FormattedTextFragment::plain_text(suffix), ]; Some( FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(text_fragments)]), appearance.ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), theme.sub_text_color(bg).into(), HighlightedHyperlink::default(), ) .with_hyperlink_font_color(theme.accent().into_solid()) .register_default_click_handlers_with_action_support( |hyperlink_lens, event, ctx| match hyperlink_lens { galaxyui::elements::HyperlinkLens::Url(url) => { ctx.open_url(url); } galaxyui::elements::HyperlinkLens::Action(action_ref) => { if let Some(action) = action_ref .as_any() .downcast_ref::() { event.dispatch_typed_action(action.clone()); } } }, ) .finish(), ) } // If the team cannot purchase addon credits, and they can't upgrade to Build, that means // they're on an Enterprise-like plan. For admins, we show them a message to contact their // Account Executive. (false, false, true) => { let paragraph_text = "Contact your Account Executive for more add-on credits."; Some( ui_builder .paragraph(paragraph_text) .with_style(UiComponentStyles { font_color: Some(theme.sub_text_color(bg).into()), ..Default::default() }) .build() .finish(), ) } // Every other case relates to not being a team admin. If you aren't an admin, we show // a generic message telling you to talk to them. (_, _, false) => { let paragraph_text = "Contact a team admin to purchase add-on credits."; Some( ui_builder .paragraph(paragraph_text) .with_style(UiComponentStyles { font_color: Some(theme.sub_text_color(bg).into()), ..Default::default() }) .build() .finish(), ) } }; // If we have an explanation, render it + return early, since the rest of the content // here (monthly spend limits, ad-hoc purchasing of credits) isn't relevant. if let Some(no_credits_access_explanation) = no_credits_access_explanation { let card_content = Flex::column() .with_children([ Container::new(card_header).with_margin_bottom(8.).finish(), no_credits_access_explanation, ]) .finish(); return Container::new(card_content) .with_background_color(theme.surface_1().into_solid()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_margin_bottom(16.) .with_uniform_padding(16.) .finish(); } let team_member_count = UserWorkspaces::as_ref(app) .current_team() .map(|team| team.members.len()) .unwrap_or(1); let paragraph_text = if team_member_count > 1 { format!("{ADDON_CREDITS_DESCRIPTION} {ADDITIONAL_ADDON_CREDITS_DESCRIPTION_FOR_TEAM}") } else { ADDON_CREDITS_DESCRIPTION.to_string() }; let paragraph = ui_builder .paragraph(paragraph_text) .with_style(UiComponentStyles { font_color: Some(theme.sub_text_color(bg).into()), ..Default::default() }) .build() .finish(); let info_icon = render_info_icon( appearance, AdditionalInfo:: { mouse_state: self.addon_info_icon_mouse_state.clone(), on_click_action: None, secondary_text: None, tooltip_override_text: Some( "Sets the monthly limit spent on add-on credits".to_string(), ), }, ); let spend_limit_text = workspace .settings .addon_credits_settings .max_monthly_spend_cents .map(|cents| format!("${:.2}", cents as f64 / 100.0)) .unwrap_or_else(|| "$200.00".to_string()); let monthly_spend_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ ui_builder.span("Monthly spend limit").build().finish(), Shrinkable::new(1., Align::new(info_icon).left().finish()).finish(), icon_button( appearance, Icon::Pencil, false, self.edit_monthly_limit.clone(), ) .build() .on_click(|ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::ShowAddOnCreditModal); }) .finish(), ui_builder.span(spend_limit_text).build().finish(), ]) .finish(); let bonus_grants_purchased = UserWorkspaces::as_ref(app) .current_workspace() .map(|workspace| workspace.bonus_grants_purchased_this_month.clone()); let purchased_this_month_row = if let Some(bonus_grants) = bonus_grants_purchased { if bonus_grants.total_credits_purchased == 0 { None } else { let credits_purchased = bonus_grants.total_credits_purchased; let cost_cents = bonus_grants.cents_spent; let cost_dollars = cost_cents as f64 / 100.0; let label = Text::new_inline("Purchased this month", appearance.ui_font_family(), 12.) .with_color(appearance.theme().active_ui_text_color().into()) .finish(); let credits_text = if credits_purchased == 1 { "1 credit".to_string() } else { format!("{} credits", credits_purchased.separate_with_commas()) }; let credits_component = Container::new( Text::new_inline(credits_text, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ) .with_margin_right(8.) .finish(); let cost_component = Text::new_inline( format!("${cost_dollars:.2}"), appearance.ui_font_family(), 12., ) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish(); let right_side = Flex::row() .with_child(credits_component) .with_child(cost_component) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(); Some( Container::new( Flex::row() .with_child(label) .with_child(right_side) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish(), ) .with_margin_bottom(4.) .finish(), ) } } else { None }; let selected_option = addon_credits_options.get(selected_topup_denomination); let auto_reload_enabled = workspace .settings .addon_credits_settings .auto_reload_enabled; let auto_reload_amount = selected_option .map(|option| option.credits.to_string()) .filter(|_| auto_reload_enabled) .unwrap_or("your selected".to_string()); let auto_reload_switch = ui_builder .switch(self.auto_reload_switch.clone()) .check(auto_reload_enabled); let auto_reload_switch = if delinquent_due_to_payment_issue { auto_reload_switch.disable().build().finish() } else { auto_reload_switch .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::UpdateAutoReloadEnabled { team_uid, enabled: !auto_reload_enabled, }); }) .finish() }; let auto_reload_switch = Container::new(render_body_item::( "Auto reload".into(), None, Default::default(), Default::default(), appearance, auto_reload_switch, Some(format!( "When enabled, auto reload will automatically purchase {auto_reload_amount} \ credits when your add-on credit balance reaches 100 credits remaining." )), )) .with_padding_right(-TOGGLE_BUTTON_RIGHT_PADDING) .finish(); let denomination_buttons = addon_credit_denomination_buttons .iter() .map(|button_handle| ChildView::new(button_handle).finish()) .collect::>>(); let denominations = Wrap::row() .with_children(denomination_buttons) .with_spacing(8.) .finish(); let mut card_content_upper = Flex::column() .with_children([card_header, paragraph, monthly_spend_row]) .with_spacing(8.); if let Some(purchased_row) = purchased_this_month_row { card_content_upper.add_child(purchased_row); } card_content_upper.add_child(auto_reload_switch); let base_rate = addon_credits_options .first() .map_or(0., |option| option.rate()); let (rendered_price, discount_badge) = match selected_option { Some(option) => { let price_dollars = option.price_usd_cents as f64 / 100.0; let rendered_price = Container::new( Text::new_inline( format!("${price_dollars:.2}"), appearance.ui_font_family(), 16., ) .with_color(fg.into()) .finish(), ) .with_margin_right(16.) .finish(); let discount_percent = if base_rate > 0.0 { let actual_rate = option.rate(); ((base_rate - actual_rate) / base_rate * 100.0).round() as u32 } else { 0 }; let discount_badge = Container::new(create_discount_badge(discount_percent, appearance)) .with_margin_right(8.) .finish(); (rendered_price, discount_badge) } None => (Empty::new().finish(), Empty::new().finish()), }; let button_text = if purchase_addon_credits_loading { "Buying…".to_string() } else { "Buy".to_string() }; let would_exceed_limit = selected_option.is_some_and(|option| { let purchase_cost_cents = option.price_usd_cents; let monthly_limit_cents = workspace .settings .addon_credits_settings .max_monthly_spend_cents .unwrap_or(20000); // Default $200 limit let already_spent_cents = workspace.bonus_grants_purchased_this_month.cents_spent; (already_spent_cents + purchase_cost_cents) > monthly_limit_cents }); let is_buy_button_disabled = purchase_addon_credits_loading || would_exceed_limit || delinquent_due_to_payment_issue; let button_font_color = is_buy_button_disabled.then_some( appearance .theme() .disabled_text_color(appearance.theme().surface_3()) .into(), ); let button_bg_color = is_buy_button_disabled.then_some(appearance.theme().surface_3().into()); let button_border = is_buy_button_disabled.then_some(ColorU::transparent_black().into()); let mut buy_button = ui_builder .button(ButtonVariant::Accent, self.buy_button.clone()) .with_text_label(button_text) .with_style(UiComponentStyles { font_size: Some(14.), font_weight: Some(Weight::Semibold), font_color: button_font_color, background: button_bg_color, border_color: button_border, ..Default::default() }) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::PurchaseAddonCredits { team_uid, }); }); if is_buy_button_disabled { buy_button = buy_button.disable(); } let buy_button = buy_button.finish(); let mut buy_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_children([ Shrinkable::new(1., denominations).finish(), Flex::row() .with_children([discount_badge, rendered_price]) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(), ]); if auto_reload_enabled { card_content_upper.add_child(buy_row.finish()); if delinquent_due_to_payment_issue { card_content_upper.add_child(self.render_warning_row( appearance, AUTO_RELOAD_DELINQUENT_WARNING_STRING.to_string(), )); } else if would_exceed_limit { card_content_upper.add_child(self.render_warning_row( appearance, AUTO_RELOAD_EXCEED_LIMIT_WARNING_STRING.to_string(), )); } let card_upper = Container::new(card_content_upper.finish()) .with_uniform_padding(16.) .finish(); Container::new(card_upper) .with_background_color(theme.surface_1().into_solid()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_margin_bottom(16.) .finish() } else { buy_row.add_child(buy_button); let card_upper = Container::new(card_content_upper.finish()) .with_horizontal_padding(16.) .with_padding_top(16.) .finish(); let mut card_content_lower_children = vec![ ui_builder.span("One-time purchase").build().finish(), buy_row.finish(), ]; if delinquent_due_to_payment_issue { card_content_lower_children.push(self.render_warning_row( appearance, AUTO_RELOAD_DELINQUENT_WARNING_STRING.to_string(), )); } else if workspace .billing_metadata .has_failed_addon_credit_auto_reload_status() { card_content_lower_children.push(self.render_warning_row( appearance, RESTRICTED_BILLING_USAGE_WARNING_STRING.to_string(), )); } else if would_exceed_limit { let warning_fragments = vec![ FormattedTextFragment::plain_text( "Reloading would exceed your monthly limit. ", ), FormattedTextFragment::hyperlink_action( "Increase your limit", BillingAndUsagePageAction::ShowAddOnCreditModal, ), FormattedTextFragment::plain_text(" to continue."), ]; card_content_lower_children .push(self.render_warning_row_with_link(appearance, warning_fragments)); } let card_content_lower = Flex::column() .with_children(card_content_lower_children) .with_spacing(8.) .finish(); let card_lower = Container::new(card_content_lower) .with_uniform_padding(16.) .with_border(Border::top(1.).with_border_color(theme.outline().into())) .finish(); let card_content = Flex::column() .with_children([card_upper, card_lower]) .finish(); Container::new(card_content) .with_background_color(theme.surface_1().into_solid()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_margin_bottom(16.) .finish() } } fn render_total_overages_row( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let billing_metadata = UserWorkspaces::as_ref(app) .current_team() .map(|team| team.billing_metadata.clone()) .unwrap_or_default(); let ai_overages = billing_metadata.ai_overages.as_ref(); let is_period_over_now = ai_overages .map(|overages| overages.current_period_end < chrono::Utc::now()) .unwrap_or(false); let (total_overages_count, total_overages_cost, total_overages_period_end) = if is_period_over_now { (Some(0), Some(0), None) } else { ( ai_overages.map(|o| o.current_monthly_requests_used), ai_overages.map(|o| o.current_monthly_request_cost_cents), ai_overages.map(|overages| overages.current_period_end), ) }; let (request_count_label, cost_label) = if let (Some(count), Some(cost)) = (total_overages_count, total_overages_cost) { if count == 1 { ( "1 credit".to_string(), format!("${:.2}", cost as f64 / 100.0), ) } else { ( format!("{} credits", count.separate_with_commas()), format!("${:.2}", cost as f64 / 100.0), ) } } else { ("0 credits".to_string(), "$0.00".to_string()) }; let mut left_side_component = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let label = Text::new_inline("Total overages", appearance.ui_font_family(), 12.) .with_color(appearance.theme().active_ui_text_color().into()) .finish(); left_side_component.add_child(Container::new(label).with_margin_right(8.).finish()); let request_count_component = Container::new( Text::new_inline(request_count_label, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ) .with_margin_right(8.) .finish(); let cost_component = Text::new_inline(cost_label, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish(); if let Some(period_end) = total_overages_period_end { let local_period_end = period_end.with_timezone(&Local); let formatted_date = local_period_end.format("%b %d at %-I:%M %p").to_string(); let billing_date_text = format!("Usage resets on {formatted_date}"); left_side_component.add_child( Container::new( Text::new_inline(billing_date_text, appearance.ui_font_family(), 12.) .with_color(blended_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ) .with_margin_right(8.) .finish(), ); }; let right_side_components = Flex::row() .with_child(request_count_component) .with_child(cost_component) .with_cross_axis_alignment(CrossAxisAlignment::Center); Container::new( Flex::row() .with_child(left_side_component.finish()) .with_child(right_side_components.finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish(), ) .with_margin_top(16.) .finish() } fn render_request_usage_count( &self, used: usize, divisor: Option, workspace_is_delinquent_due_to_payment_issue: bool, appearance: &Appearance, prorated_request_limits_info: Option, ) -> Box { let mut row = Flex::row(); let show_alert = workspace_is_delinquent_due_to_payment_issue || matches!(divisor, Some(Divisor::Limit(limit)) if used >= limit); if let Some(info) = prorated_request_limits_info { if info.is_request_limit_prorated { row.add_child(render_info_icon( appearance, AdditionalInfo:: { mouse_state: info.mouse_state, on_click_action: None, secondary_text: None, tooltip_override_text: match info.is_current_user { true => Some("Your credit limit is prorated because you joined midway through the billing cycle.".to_string()), false => Some("This credit limit is prorated because this user joined midway through the billing cycle.".to_string()), }, }, )) } } if show_alert { row.add_child( ConstrainedBox::new( Icon::AlertTriangle .to_galaxyui_icon(appearance.theme().ui_error_color().into()) .finish(), ) .with_height(16.) .with_width(16.) .finish(), ) } let request_count_label = if workspace_is_delinquent_due_to_payment_issue { "Restricted due to billing issue".to_string() } else { match divisor { Some(Divisor::Unlimited) => { format!("{}/Unlimited", used.separate_with_commas()) } Some(Divisor::Limit(limit)) => format!( "{}/{}", used.separate_with_commas(), limit.separate_with_commas() ), None => used.separate_with_commas(), } }; row.add_child( appearance .ui_builder() .paragraph(request_count_label) .with_style(UiComponentStyles { font_color: { if show_alert { Some(appearance.theme().ui_error_color()) } else { Some(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) } }, font_size: Some(14.), margin: Some(Coords { top: 0., bottom: 0., left: 8., right: 0., }), ..Default::default() }) .build() .finish(), ); row.finish() } /// Renders a row of what is being limited, along with the current used/limit. #[allow(clippy::too_many_arguments)] fn render_ai_usage_limit_row( &self, name: String, used: usize, divisor: Option, refresh_duration: String, workspace_is_delinquent_due_to_payment_issue: bool, appearance: &Appearance, prorated_request_limits_info: Option, ) -> Box { let request_usage_details = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::End) .with_child(self.render_request_usage_count( used, divisor, workspace_is_delinquent_due_to_payment_issue, appearance, prorated_request_limits_info, )); let left_side = if !name.is_empty() { Shrinkable::new( 2., Container::new( Text::new_inline(name, appearance.ui_font_family(), 14.) .with_style(Properties::default()) .with_color( appearance .theme() .main_text_color(appearance.theme().surface_2()) .into(), ) .finish(), ) .with_margin_bottom(20.) .finish(), ) .finish() } else { let header = "Credits"; let description = format!("This is the {refresh_duration} limit of AI credits for your account."); let request_usage_description = FormattedTextElement::from_str( description, appearance.ui_font_family(), appearance.ui_font_size(), ) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )); Shrinkable::new( 2., Container::new( Flex::column() .with_child( appearance .ui_builder() .paragraph(header) .with_style(UiComponentStyles { font_color: Some(blended_colors::text_main( appearance.theme(), appearance.theme().surface_1(), )), margin: Some(Coords { top: 0., bottom: 4., left: 0., right: 0., }), ..Default::default() }) .build() .finish(), ) .with_child(request_usage_description.finish()) .finish(), ) .with_margin_bottom(16.) .finish(), ) .finish() }; Flex::row() .with_child(left_side) .with_child( Shrinkable::new( 1., Container::new(request_usage_details.finish()) .with_margin_bottom(16.) .finish(), ) .finish(), ) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_main_axis_size(MainAxisSize::Max) .finish() } } impl BillingAndUsagePageView { fn render_page_body(&self, appearance: &Appearance, app: &AppContext) -> Box { let ai_request_usage_model = AIRequestUsageModel::as_ref(app); let formatted_next_refresh_time = ai_request_usage_model .next_refresh_time_local() .format("%b %d at %-I:%M %p") .to_string(); let workspace_is_delinquent_due_to_payment_issue = UserWorkspaces::as_ref(app) .current_team() .map(|team| team.billing_metadata.is_delinquent_due_to_payment_issue()) .unwrap_or_default(); let mut usage = Flex::column(); let tabs = vec![ SettingsTab::new( BillingUsageTab::Overview.label(), self.overview_tab_mouse_state.clone(), ), SettingsTab::new( BillingUsageTab::UsageHistory.label(), self.usage_history_tab_mouse_state.clone(), ), ]; let tab_selector = tab_selector::render_tab_selector( tabs, self.selected_tab.label(), // On click, set clicked tab as selected |label, ctx| { ctx.dispatch_typed_action(BillingAndUsagePageAction::SelectTab( BillingUsageTab::get_tab_from_label(label), )); }, appearance, ); usage.add_child(tab_selector); // Render correct page based on selected tab if self.selected_tab == BillingUsageTab::Overview { let prorated_mouse_states = self.prorated_request_limits_info_mouse_states.clone(); let usage_content = self.render_usage_content( appearance, app, ai_request_usage_model, &formatted_next_refresh_time, workspace_is_delinquent_due_to_payment_issue, &prorated_mouse_states, ); usage.add_child(usage_content); } else { usage.add_child(self.render_usage_history_content(appearance, app)); } usage.finish() } } impl BillingAndUsagePageView { fn render_usage_history_content( &self, appearance: &Appearance, app: &AppContext, ) -> Box { let usage_history = self.usage_history_model.as_ref(app); if usage_history.entries().is_empty() { return self.render_empty_usage_history_content( usage_history.is_loading(), appearance, app, ); } let mut content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_main_axis_alignment(MainAxisAlignment::Center) .with_child( Container::new( Text::new_inline("Last 30 days".to_string(), appearance.ui_font_family(), 14.) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ) .with_vertical_margin(12.) .finish(), ); let mut usage_history_list = Flex::column().with_spacing(8.); let entries = usage_history.entries(); for entry in entries.iter() { let is_expanded = self .expanded_usage_entries .get(&entry.conversation_id) .copied() .unwrap_or(false); let mouse_state = self .usage_entries_mouse_states .borrow_mut() .entry(entry.conversation_id.clone()) .or_default() .clone(); let tooltip_mouse_state = self .usage_entries_tooltip_mouse_states .borrow_mut() .entry(entry.conversation_id.clone()) .or_default() .clone(); usage_history_list.add_child( Container::new( UsageHistoryEntry::new( Some(entry.clone()), is_expanded, Some(mouse_state), tooltip_mouse_state, ) .render(appearance, app), ) .finish(), ); } content.add_child(usage_history_list.finish()); if usage_history.has_more_entries() { let load_more = self.load_more_button.as_ref(app).render(app); content.add_child( Container::new( Flex::row() .with_child(load_more) .with_main_axis_alignment(MainAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .finish(), ) .with_margin_top(24.) .finish(), ); } Container::new(content.finish()).finish() } /// Renders default views to show when there is no usage history, /// either because the history is loading or because there is no history. fn render_empty_usage_history_content( &self, is_loading: bool, appearance: &Appearance, app: &AppContext, ) -> Box { let mut content = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_main_axis_alignment(MainAxisAlignment::Center); if is_loading { let mut loading_usage_entry_list = Flex::column().with_spacing(8.); for _ in 0..3 { loading_usage_entry_list.add_child( UsageHistoryEntry::new(None, false, None, MouseStateHandle::default()) .render(appearance, app), ); } content.add_child(loading_usage_entry_list.finish()); } else { let res = Flex::column() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( Container::new( ConstrainedBox::new( Icon::Conversation .to_galaxyui_icon( blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), ) .into(), ) .finish(), ) .with_width(24.) .with_height(24.) .finish(), ) .with_margin_bottom(12.) .finish(), ) .with_child( Container::new( Text::new("No usage history", appearance.ui_font_family(), 14.) .with_color(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ) .with_margin_bottom(4.) .finish(), ) .with_child( Text::new( "Kick off an agent task to view usage history here.", appearance.ui_font_family(), 14., ) .with_color(blended_colors::text_disabled( appearance.theme(), appearance.theme().surface_1(), )) .finish(), ); content.add_child( Container::new(res.finish()) .with_vertical_margin(160.) .finish(), ); } content.finish() } /// Renders usage reporting callout card for Enterprise users indicating that usage reporting /// is currently limited. Admins see a link to the admin panel; non-admins see a message to /// contact their admin. pub fn render_enterprise_usage_card( &self, team_uid: ServerId, has_admin_permissions: bool, appearance: &Appearance, ) -> Box { let theme = appearance.theme(); let bg = theme.background(); let icon = Container::new( ConstrainedBox::new( Icon::AlertCircle .to_galaxyui_icon(blended_colors::text_sub(theme, theme.surface_1()).into()) .finish(), ) .with_width(16.) .with_height(16.) .finish(), ) .with_margin_right(8.) .finish(); let header = Text::new_inline( ENTERPRISE_USAGE_CALLOUT_HEADER, appearance.ui_font_family(), 16., ) .with_color(theme.foreground().into()) .with_style(Properties::default().weight(Weight::Bold)) .finish(); let card_header = Flex::row() .with_child(icon) .with_child(Shrinkable::new(1., Align::new(header).left().finish()).finish()) .with_cross_axis_alignment(CrossAxisAlignment::Center) .finish(); // Body text differs for admin vs non-admin let body = if has_admin_permissions { let admin_panel_url = AdminActions::admin_panel_link_for_team(team_uid); let text_fragments = vec![ FormattedTextFragment::plain_text(ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_PREFIX), FormattedTextFragment::hyperlink( ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_LINK, admin_panel_url, ), FormattedTextFragment::plain_text(ENTERPRISE_USAGE_CALLOUT_BODY_ADMIN_SUFFIX), ]; FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(text_fragments)]), 12., appearance.ui_font_family(), appearance.ui_font_family(), theme.sub_text_color(bg).into(), HighlightedHyperlink::default(), ) .with_hyperlink_font_color(theme.accent().into_solid()) .register_default_click_handlers(|hyperlink, _event_ctx, app_ctx| { app_ctx.open_url(&hyperlink.url); }) .finish() } else { appearance .ui_builder() .paragraph(ENTERPRISE_USAGE_CALLOUT_BODY_NON_ADMIN) .with_style(UiComponentStyles { font_color: Some(theme.sub_text_color(bg).into()), font_size: Some(12.), ..Default::default() }) .build() .finish() }; let card_content = Flex::column() .with_children([ Container::new(card_header).with_margin_bottom(8.).finish(), body, ]) .finish(); let card_container = Container::new(card_content) .with_background_color(theme.surface_1().into_solid()) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.))) .with_uniform_padding(16.) .with_margin_bottom(16.) .finish(); Flex::column().with_child(card_container).finish() } #[allow(clippy::too_many_arguments)] fn render_usage_content( &self, appearance: &Appearance, app: &AppContext, ai_request_usage_model: &AIRequestUsageModel, formatted_next_refresh_time: &str, workspace_is_delinquent_due_to_payment_issue: bool, prorated_request_limits_info_mouse_states: &[MouseStateHandle], ) -> Box { let mut usage = Flex::column(); let workspace = UserWorkspaces::as_ref(app).current_workspace(); // Check if we should show the sort button (admin with team size > 1) let workspace_team_members = workspace .map(|workspace| workspace.members.clone()) .unwrap_or_default(); let current_user_email = AuthStateProvider::as_ref(app) .get() .user_email() .unwrap_or_default(); let team = UserWorkspaces::as_ref(app).current_team(); let has_admin_permissions = team.is_some_and(|team| team.has_admin_permissions(¤t_user_email)); let mut usage_header_right_side = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( appearance .ui_builder() .paragraph(format!("Resets {formatted_next_refresh_time}")) .with_style(UiComponentStyles { font_color: Some(blended_colors::text_sub( appearance.theme(), appearance.theme().surface_1(), )), ..Default::default() }) .build() .finish(), ) .with_child( Container::new( icon_button( appearance, Icon::Refresh, false, self.refresh_icon_mouse_state.clone(), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::RefreshWorkspaceData); }) .finish(), ) .with_margin_left(8.) .finish(), ); // only show sort button if user is admin and has more than 1 member if has_admin_permissions && workspace_team_members.len() > 1 { usage_header_right_side.add_child( Container::new({ let mut button = icon_button_with_context_menu( Icon::Sort, move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::ToggleSortingMenu) }, self.sort_icon_mouse_state.clone(), &self.sorting_menu, self.sorting_menu_open, MenuDirection::Right, Some(Cursor::PointingHand), None, appearance, ); let hoverable = Hoverable::new(self.sort_icon_mouse_state.clone(), |mouse_state| { if mouse_state.is_hovered() { let tooltip = appearance.ui_builder().tool_tip("Sort by".to_string()); button.add_positioned_overlay_child( tooltip.build().finish(), OffsetPositioning::offset_from_parent( vec2f(0., 4.), ParentOffsetBounds::Unbounded, ParentAnchor::BottomMiddle, ChildAnchor::TopMiddle, ), ); } button.finish() }); hoverable.finish() }) .with_margin_left(8.) .finish(), ); } // Render the ambient agent trial widget if the user has ambient-only credits. if let Some(ambient_trial_widget) = self.render_ambient_agent_trial_widget(ai_request_usage_model, appearance, app) { usage.add_child(ambient_trial_widget); } if let (Some(workspace), Some(team)) = (workspace, team) { let bonus_credit_balance = ai_request_usage_model.total_workspace_bonus_credits_remaining(workspace.uid); // Hide addon credits panel for Enterprise PAYG users when they have 0 credits. let is_enterprise_payg_with_zero_credits = workspace .billing_metadata .is_enterprise_pay_as_you_go_enabled() && bonus_credit_balance == 0; if !is_enterprise_payg_with_zero_credits { usage.add_child(self.render_addon_credits_panel( self.selected_addon_denomination, workspace, team.uid, has_admin_permissions, bonus_credit_balance, &self.addon_credits_options, &self.addon_credit_denomination_buttons, self.purchase_addon_credits_loading, workspace_is_delinquent_due_to_payment_issue, app, )); } } let usage_header = Container::new( Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child( build_sub_header( appearance, "Usage", Some( appearance .theme() .main_text_color(appearance.theme().surface_2()), ), ) .finish(), ) .with_child(usage_header_right_side.finish()) .finish(), ) .with_padding_bottom(HEADER_PADDING) .finish(); usage.add_child(usage_header); // For enterprise plan users with base limit = 0, show a limited usage reporting callout // as this is not applicable to them if let Some(t) = team { if t.billing_metadata.customer_type == CustomerType::Enterprise && t.billing_metadata .tier .warp_ai_policy .is_some_and(|p| p.limit == 0) { usage.add_child(self.render_enterprise_usage_card( t.uid, has_admin_permissions, appearance, )); return usage.finish(); } } // Show a summed "Team total" row first. let num_team_members = workspace_team_members.len(); let should_show_team_total = num_team_members > 1 && has_admin_permissions; if should_show_team_total { let team_total_used: usize = workspace_team_members .iter() .map(|m| m.usage_info.requests_used_since_last_refresh as usize) .sum(); let is_unlimited = ai_request_usage_model.is_unlimited(); let team_divisor = if is_unlimited { Some(Divisor::Unlimited) } else { None }; usage.add_child(self.render_ai_usage_limit_row( "Team total".to_string(), team_total_used, team_divisor, ai_request_usage_model.refresh_duration_to_string(), workspace_is_delinquent_due_to_payment_issue, appearance, None, )); let divider = Container::new( ConstrainedBox::new(Empty::new().finish()) .with_height(1.) .finish(), ) .with_background_color(appearance.theme().outline().into_solid()) .with_margin_bottom(16.) .finish(); usage.add_child(divider); } // Build UserSortingCriteria with (display_name, requests_used, rendered_row) // Note: display_name already has email as fallback let mut user_information = if !has_admin_permissions { vec![] } else { workspace_team_members .iter() .enumerate() .map(|(i, member)| { // Compute effective display name (fallback to email if missing or empty) let display_name = UserProfiles::as_ref(app) .profile_for_uid(member.uid) .and_then(|profile| profile.display_name.clone()) .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| member.email.clone()); let requests_used = member.usage_info.requests_used_since_last_refresh as usize; let row = self.render_ai_usage_limit_row( display_name.clone(), requests_used, if member.usage_info.is_unlimited { Some(Divisor::Unlimited) } else { Some(Divisor::Limit(member.usage_info.request_limit as usize)) }, ai_request_usage_model.refresh_duration_to_string(), workspace_is_delinquent_due_to_payment_issue, appearance, Some(ProratedRequestLimitsInfo { is_request_limit_prorated: member.usage_info.is_request_limit_prorated, mouse_state: prorated_request_limits_info_mouse_states[i].clone(), is_current_user: member.email == current_user_email, }), ); UserSortingCriteria::new(display_name, requests_used, row) }) .collect_vec() }; if user_information.is_empty() { let display_name = AuthStateProvider::as_ref(app) .get() .display_name() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| current_user_email.clone()); let user_workspace_member = workspace_team_members .iter() .find(|m| m.email == current_user_email); let row = self.render_ai_usage_limit_row( display_name.clone(), ai_request_usage_model.requests_used(), if ai_request_usage_model.is_unlimited() { Some(Divisor::Unlimited) } else { Some(Divisor::Limit(ai_request_usage_model.request_limit())) }, ai_request_usage_model.refresh_duration_to_string(), workspace_is_delinquent_due_to_payment_issue, appearance, user_workspace_member.map(|member| ProratedRequestLimitsInfo { is_request_limit_prorated: member.usage_info.is_request_limit_prorated, mouse_state: prorated_request_limits_info_mouse_states[0].clone(), // We know the workspace has at least one member, so just take the first mouse state handle since we don't use the others. is_current_user: true, }), ); user_information.push(UserSortingCriteria::new( display_name, ai_request_usage_model.requests_used(), row, )); } // Apply sort according to current_sort_key/current_sort_order via shared helper // Get current user's display name (with email fallback) for pinning let current_user_display_name = AuthStateProvider::as_ref(app) .get() .display_name() .filter(|s| !s.trim().is_empty()) .unwrap_or_else(|| current_user_email.clone()); // TODO: move sorting once per initial load or sort option change https://github.com/warpdotdev/warp-internal/pull/18288/files#r2392139761 sort_user_items_in_place( &mut user_information, ¤t_user_display_name, self.current_sort_key, self.current_sort_order, ); let user_information = user_information .into_iter() .map(|item| item.data) .collect_vec(); usage.extend(user_information); let auth_state = AuthStateProvider::as_ref(app).get(); let upgrade_cta_text_fragments = if let Some(team) = UserWorkspaces::as_ref(app).current_team() { if workspace_is_delinquent_due_to_payment_issue { if has_admin_permissions { vec![ FormattedTextFragment::hyperlink_action( "Manage billing", BillingAndUsagePageAction::GenerateStripeBillingPortalLink { team_uid: team.uid, }, ), FormattedTextFragment::plain_text(" to regain access to AI features."), ] } else { // Non-admin team member - show message to contact admin vec![FormattedTextFragment::plain_text( "Contact your team admin to resolve billing issues.", )] } } else if team.billing_metadata.can_upgrade_to_higher_tier_plan() { let upgrade_url = UserWorkspaces::upgrade_link_for_team(team.uid); if has_admin_permissions { if team.billing_metadata.can_upgrade_to_build_plan() { if team.billing_metadata.is_on_legacy_paid_plan() { vec![ FormattedTextFragment::hyperlink( "Switch to the Build plan", upgrade_url, ), FormattedTextFragment::plain_text( " for a more flexible pricing model.", ), ] } else { let mut fragments = vec![FormattedTextFragment::hyperlink( "Upgrade to the Build plan", upgrade_url, )]; if team.billing_metadata.is_byo_api_key_enabled() { fragments.push(FormattedTextFragment::plain_text(" or ")); fragments.push(FormattedTextFragment::hyperlink_action( "bring your own key", BillingAndUsagePageAction::NavigateToByokSettings, )); } fragments.push(FormattedTextFragment::plain_text( " for increased access to AI features.", )); fragments } } else { let upgrade_text = match team.billing_metadata.customer_type { CustomerType::Prosumer => "Upgrade to Turbo plan", CustomerType::Turbo => "Upgrade to Lightspeed plan", _ => "Upgrade", }; vec![ FormattedTextFragment::hyperlink(upgrade_text, upgrade_url), FormattedTextFragment::plain_text(" to get more AI usage."), ] } } else { vec![] } } else if team.billing_metadata.is_on_build_plan() { vec![ FormattedTextFragment::hyperlink( "Upgrade to Max", UserWorkspaces::upgrade_link_for_team(team.uid), ), FormattedTextFragment::plain_text(" for more AI credits."), ] } else if team.billing_metadata.is_on_build_max_plan() { vec![ FormattedTextFragment::hyperlink( "Switch to Business", UserWorkspaces::upgrade_link_for_team(team.uid), ), FormattedTextFragment::plain_text( " for security features like SSO and automatically applied zero data retention.", ), ] } else if team.billing_metadata.is_on_build_business_plan() || team.billing_metadata.is_on_legacy_business_plan() { vec![ FormattedTextFragment::hyperlink( "Upgrade to Enterprise", "mailto:sales@warp.dev", ), FormattedTextFragment::plain_text(" for custom limits and dedicated support."), ] } else if !team.billing_metadata.is_usage_based_pricing_toggleable() { vec![ FormattedTextFragment::hyperlink("Contact support", "mailto:support@warp.dev"), FormattedTextFragment::plain_text(" for more AI usage."), ] } else { vec![] } } else { let user_id = auth_state.user_id().unwrap_or_default(); let upgrade_url = UserWorkspaces::upgrade_link(user_id); let mut fragments = vec![FormattedTextFragment::hyperlink( "Upgrade to the Build plan", upgrade_url, )]; if UserWorkspaces::as_ref(app).is_byo_api_key_enabled(app) { fragments.push(FormattedTextFragment::plain_text(" or ")); fragments.push(FormattedTextFragment::hyperlink_action( "bring your own key", BillingAndUsagePageAction::NavigateToByokSettings, )); } fragments.push(FormattedTextFragment::plain_text( " for more credits and access to more models.", )); fragments }; let mut upgrade_cta = FormattedTextElement::new( FormattedText::new([FormattedTextLine::Line(upgrade_cta_text_fragments)]), appearance.ui_font_size(), appearance.ui_font_family(), appearance.ui_font_family(), blended_colors::text_sub(appearance.theme(), appearance.theme().surface_1()), self.requests_highlight_index.clone(), ) .with_hyperlink_font_color(appearance.theme().accent().into_solid()); if AuthStateProvider::as_ref(app) .get() .is_anonymous_or_logged_out() { upgrade_cta = upgrade_cta.register_default_click_handlers(|_, ctx, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::AttemptLoginGatedUpgrade); }); } else { upgrade_cta = upgrade_cta.register_default_click_handlers_with_action_support( |hyperlink_lens, event, ctx| match hyperlink_lens { galaxyui::elements::HyperlinkLens::Url(url) => { ctx.open_url(url); } galaxyui::elements::HyperlinkLens::Action(action_ref) => { if let Some(action) = action_ref .as_any() .downcast_ref::() { event.dispatch_typed_action(action.clone()); } } }, ); }; usage.add_child( Container::new(upgrade_cta.finish()) .with_margin_bottom(16.) .finish(), ); let workspaces = UserWorkspaces::as_ref(app); if let Some(team) = workspaces.current_team() { if team.billing_metadata.is_usage_based_pricing_toggleable() { let usage_based_pricing_settings = workspaces.usage_based_pricing_settings(); let enabled = self .usage_based_pricing_toggle_override .unwrap_or(usage_based_pricing_settings.enabled); usage.add_child( Container::new(self.render_usage_based_pricing_section( enabled, team, appearance, app, has_admin_permissions, self.usage_based_pricing_toggle_loading, )) .with_margin_bottom(16.) .finish(), ); } } usage.finish() } } /// Sorts items in-place. The current user is always pinned first. /// For display name sort, comparison is case-insensitive on display name. /// For requests sort, ties are broken by display name ascending. pub(crate) fn sort_user_items_in_place( items: &mut [UserSortingCriteria], display_name: &str, key: Option, order: SortOrder, ) { let pin_current_user = |a: &UserSortingCriteria, b: &UserSortingCriteria| -> Option { if a.display_name == display_name { Some(std::cmp::Ordering::Less) } else if b.display_name == display_name { Some(std::cmp::Ordering::Greater) } else { None } }; let compare_by_name = |a: &UserSortingCriteria, b: &UserSortingCriteria, ascending: bool| -> std::cmp::Ordering { let a_key = a.display_name.to_lowercase(); let b_key = b.display_name.to_lowercase(); if ascending { a_key.cmp(&b_key) } else { b_key.cmp(&a_key) } }; items.sort_by(|a, b| { if let Some(ordering) = pin_current_user(a, b) { return ordering; } match (key, order) { (Some(SortKey::DisplayName), SortOrder::Asc) => compare_by_name(a, b, true), (Some(SortKey::DisplayName), SortOrder::Desc) => compare_by_name(a, b, false), (Some(SortKey::Requests), SortOrder::Asc) => { let primary = a.requests_used.cmp(&b.requests_used); if primary == std::cmp::Ordering::Equal { compare_by_name(a, b, true) } else { primary } } (Some(SortKey::Requests), SortOrder::Desc) => { let primary = b.requests_used.cmp(&a.requests_used); if primary == std::cmp::Ordering::Equal { compare_by_name(a, b, true) } else { primary } } _ => a.display_name.cmp(&b.display_name), } }); } impl BillingAndUsagePageView { fn render_anonymous_account_info( &self, auth_state: &AuthState, appearance: &Appearance, ) -> Box { let button_styles = UiComponentStyles { font_size: Some(14.), font_weight: Some(Weight::Semibold), border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.))), padding: Some(Coords { top: 12., bottom: 12., left: 40., right: 40., }), ..Default::default() }; let user_info = appearance .ui_builder() .button( ButtonVariant::Accent, self.anonymous_user_sign_up_button.clone(), ) .with_style(button_styles) .with_text_label("Sign up".to_owned()) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::SignupAnonymousUser); }) .finish(); let mut plan_info = Flex::column() .with_main_axis_alignment(MainAxisAlignment::SpaceEvenly) .with_cross_axis_alignment(CrossAxisAlignment::End); let current_user_id = auth_state.user_id().unwrap_or_default(); plan_info.add_child(render_customer_type_badge(appearance, "Free".into())); plan_info.add_child( Container::new( appearance .ui_builder() .button(ButtonVariant::Link, self.upgrade_link.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Compare plans", Icon::CoinsStacked.to_galaxyui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::Upgrade { team_uid: None, user_id: current_user_id, }); }) .finish(), ) .with_margin_top(8.) .finish(), ); Flex::row() .with_child( Shrinkable::new( 1.0, Flex::row() .with_child(user_info) .with_main_axis_alignment(MainAxisAlignment::Start) .with_main_axis_size(MainAxisSize::Max) .finish(), ) .finish(), ) .with_child(Align::new(plan_info.finish()).right().finish()) .with_cross_axis_alignment(CrossAxisAlignment::Start) .finish() } fn render_plan_header_text(&self, appearance: &Appearance) -> Box { Text::new_inline("Plan", appearance.ui_font_family(), HEADER_FONT_SIZE) .with_style(Properties::default().weight(Weight::Bold)) .with_color(appearance.theme().active_ui_text_color().into()) .finish() } fn render_team_admin_actions( &self, team: &Team, _current_user_id: UserUid, appearance: &Appearance, ) -> Option> { if team.billing_metadata.customer_type == CustomerType::Enterprise || !team.has_billing_history { return None; } let team_uid = team.uid; let content = Container::new( appearance .ui_builder() .button(ButtonVariant::Link, self.enterprise_contact_us_link.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Manage billing", Icon::CoinsStacked.to_galaxyui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action( BillingAndUsagePageAction::GenerateStripeBillingPortalLink { team_uid }, ); }) .finish(), ) .with_margin_left(12.) .finish(); Some( Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(content) .finish(), ) } fn render_plan_badge_for_team( &self, team: &Team, appearance: &Appearance, ) -> Option> { if team.billing_metadata.customer_type != CustomerType::Unknown { Some( Container::new(render_customer_type_badge( appearance, team.billing_metadata.customer_type.to_display_string(), )) .with_margin_right(12.) .finish(), ) } else { None } } fn render_admin_panel_button( &self, team_uid: ServerId, appearance: &Appearance, ) -> Box { Container::new( appearance .ui_builder() .button(ButtonVariant::Link, self.stripe_billing_portal_link.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Open admin panel", Icon::Users.to_galaxyui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::OpenAdminPanel { team_uid, }); }) .finish(), ) .with_margin_left(12.) .finish() } fn render_non_team_user_actions( &self, auth_state: &AuthState, appearance: &Appearance, ) -> (Box, Box) { let current_user_id = auth_state.user_id().unwrap_or_default(); let plan_badge = render_customer_type_badge(appearance, "Free".into()); let badge_element = Container::new(plan_badge).with_margin_right(16.).finish(); let compare_plans_button = Container::new( appearance .ui_builder() .button(ButtonVariant::Link, self.admin_panel_link.clone()) .with_text_and_icon_label( TextAndIcon::new( TextAndIconAlignment::IconFirst, "Compare plans", Icon::CoinsStacked.to_galaxyui_icon(appearance.theme().accent()), MainAxisSize::Min, MainAxisAlignment::Center, vec2f(14., 14.), ) .with_inner_padding(4.), ) .build() .on_click(move |ctx, _, _| { ctx.dispatch_typed_action(BillingAndUsagePageAction::Upgrade { team_uid: None, user_id: current_user_id, }); }) .finish(), ) .with_margin_left(12.) .finish(); (badge_element, compare_plans_button) } fn render_account_info( &self, auth_state: &AuthState, app: &AppContext, appearance: &Appearance, ) -> Box { let mut plan_header = Flex::row() .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max); plan_header.add_child(self.render_plan_header_text(appearance)); let mut right_side = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_alignment(MainAxisAlignment::End); let current_user_id = auth_state.user_id().unwrap_or_default(); let workspaces = UserWorkspaces::as_ref(app); if let Some(team) = workspaces.current_team() { if let Some(plan_badge) = self.render_plan_badge_for_team(team, appearance) { right_side.add_child(plan_badge); } let current_user_email = auth_state.user_email().unwrap_or_default(); let has_admin_permissions = team.has_admin_permissions(¤t_user_email); if has_admin_permissions { if let Some(admin_actions) = self.render_team_admin_actions(team, current_user_id, appearance) { right_side.add_child(admin_actions); } if team.billing_metadata.is_enterprise_plan() { let admin_panel_button = self.render_admin_panel_button(team.uid, appearance); right_side.add_child(admin_panel_button); } } } else { let (plan_badge, compare_plans_button) = self.render_non_team_user_actions(auth_state, appearance); right_side.add_child(plan_badge); right_side.add_child(compare_plans_button); } plan_header.add_child(right_side.finish()); plan_header.finish() } } impl BillingAndUsagePageView { fn render_plan_header(&self, appearance: &Appearance, app: &AppContext) -> Box { let account_info = if self.auth_state.is_anonymous_or_logged_out() { self.render_anonymous_account_info(self.auth_state.as_ref(), appearance) } else { self.render_account_info(self.auth_state.as_ref(), app, appearance) }; let mut col = Flex::column(); col.add_child( Container::new(account_info) .with_margin_bottom(HEADER_PADDING) .finish(), ); col.finish() } } #[cfg(test)] #[path = "billing_and_usage_page_tests.rs"] mod tests;