diff --git a/app/src/pricing/mod.rs b/app/src/pricing/mod.rs index 0b36b157c5..3ab5e5258c 100644 --- a/app/src/pricing/mod.rs +++ b/app/src/pricing/mod.rs @@ -37,6 +37,15 @@ impl PricingInfoModel { .find(|p| &p.plan == plan) } + /// Returns the pricing data for all known plans, or an empty slice if + /// pricing information has not yet been fetched from the server. + pub fn plans(&self) -> &[PlanPricing] { + self.pricing_info + .as_ref() + .map(|info| info.plans.as_slice()) + .unwrap_or(&[]) + } + /// Returns the overage cost in dollars (converted from cents). #[allow(dead_code)] pub fn overage_cost_dollars(&self) -> Option { diff --git a/app/src/settings_view/admin_actions.rs b/app/src/settings_view/admin_actions.rs index 063600296c..f9857e1dfe 100644 --- a/app/src/settings_view/admin_actions.rs +++ b/app/src/settings_view/admin_actions.rs @@ -20,6 +20,11 @@ impl AdminActions { pub fn contact_support(ctx: &mut AppContext) { ctx.open_url("mailto:support@warp.dev"); } + + /// Open the contact sales page + pub fn contact_sales(ctx: &mut AppContext) { + ctx.open_url("https://warp.dev/contact-sales"); + } } #[cfg(test)] diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index f2f44261bb..1d69168ef1 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -29,7 +29,9 @@ use crate::{ CloudActionConfirmationDialog, CloudActionConfirmationDialogEvent, CloudActionConfirmationDialogVariant, }, - editor::{EditorView, Event as EditorEvent, SingleLineEditorOptions, TextOptions}, + editor::{ + EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions, + }, network::NetworkStatus, send_telemetry_from_ctx, server::{ @@ -44,7 +46,7 @@ use crate::{ team::{DiscoverableTeam, Team}, update_manager::{TeamUpdateManager, TeamUpdateManagerEvent}, user_workspaces::{UserWorkspaces, UserWorkspacesEvent}, - workspace::{CustomerType, DelinquencyStatus, WorkspaceSizePolicy}, + workspace::{BillingMetadata, CustomerType, DelinquencyStatus, WorkspaceSizePolicy}, }, }; @@ -106,8 +108,8 @@ const CLOSE_BUTTON_ICON_SIZE: f32 = 20.; const CONTENT_SEPARATION_PADDING: f32 = 24.; const TEXT_FIELD_TOP_PADDING: f32 = 12.; const HORIZONTAL_BAR_TO_SUB_HEADER_PADDING: f32 = 9.; -const COMPARE_PLANS_BUTTON_WIDTH: f32 = 120.; const SUBSECTION_HEADER_FONT_SIZE: f32 = 18.; +const SUBSUBSECTION_HEADER_FONT_SIZE: f32 = 14.; const INVITE_LINK_PREFIX: &str = "/team/"; const INVALID_DOMAINS_INSTRUCTIONS: &str = @@ -115,7 +117,7 @@ const INVALID_DOMAINS_INSTRUCTIONS: &str = const INVITE_LINK_TOGGLE_INSTRUCTIONS: &str = "As an admin, you can choose whether to enable or disable the ability for team members to invite others by invitation link."; const INVITE_LINK_DOMAIN_RESTRICTIONS_INSTRUCTIONS: &str = - "Only allow users with emails at specific domains to join your team through the invite link."; + "Restrict by domain — only allow users with emails at specific domains to join your team through the invite link."; const INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS: &str = "Email invitations are valid for 7 days."; const INVALID_EMAILS_INSTRUCTIONS: &str = @@ -123,26 +125,6 @@ const INVALID_EMAILS_INSTRUCTIONS: &str = const OFFLINE_TEXT: &str = "You are offline."; -const LIMIT_HIT_ADMIN_TEXT: &str = - "You've reached the team member limit for your plan. Upgrade to add more teammates."; -const LIMIT_HIT_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT: &str = "You've reached the team member limit for your plan. Contact support@warp.dev to add more teammates."; -const LIMIT_HIT_NON_ADMIN_TEXT: &str = - "You've reached the team member limit for your plan. Contact a team admin to add more teammates."; - -const DELINQUENT_ADMIN_NON_SELF_SERVE_TEXT: &str = "Team invites have been restricted due to a payment issue. Please contact support@warp.dev to restore access."; -const DELINQUENT_NON_ADMIN_TEXT: &str = "Team invites have been restricted due to a payment issue. Please contact a team admin to restore access."; -const DELINQUENT_ADMIN_SELF_SERVE_LINE_1_TEXT: &str = - "Team invites have been restricted due to a subscription payment issue."; -const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_PREFIX_TEXT: &str = "Please "; -const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_LINK_TEXT: &str = "update your payment information"; -const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_SUFFIX_TEXT: &str = " to restore access."; - -const TEAM_LIMIT_EXCEEDED_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT: &str = "You've exceeded the team member limit for your plan. Please contact support@warp.dev to upgrade your team."; -const TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT: &str = - "You've exceeded the team member limit for your plan. Contact a team admin to upgrade your team."; -const TEAM_LIMIT_EXCEEDED_ADMIN_UPGRADEABLE: &str = - "You've exceeded the team member limit for your plan. Upgrade to add more teammates."; - const MAX_CHIP_WIDTH: f32 = 280.; lazy_static! { @@ -199,6 +181,7 @@ pub enum TeamsPageAction { team_uid: ServerId, }, ContactSupport, + ContactSales, /// This action is for toggling the discoverability checkbox before a team is created. ToggleTeamDiscoverabilityBeforeCreation, /// This action is for toggling the discoverability toggle after a team has been created. @@ -243,6 +226,7 @@ impl TeamsPageAction { | GenerateStripeBillingPortalLink { .. } | OpenAdminPanel { .. } | ContactSupport + | ContactSales | ToggleTeamDiscoverabilityBeforeCreation | ToggleTeamDiscoverability { .. } | JoinTeamWithTeamDiscovery { .. } @@ -266,6 +250,7 @@ impl From<&TeamsPageAction> for LoginGatedFeature { GenerateStripeBillingPortalLink { .. } => "Generate Stripe Billing Portal Link", OpenAdminPanel { .. } => "Open Admin Panel", ContactSupport => "Contact Support", + ContactSales => "Contact Sales", ToggleTeamDiscoverability { .. } | ToggleTeamDiscoverabilityBeforeCreation => { "Toggle Team Discoverability" } @@ -317,11 +302,13 @@ struct TeamsWidgetMouseHandles { stripe_billing_portal_link: MouseStateHandle, manage_plan_link: MouseStateHandle, enterprise_contact_us_link: MouseStateHandle, - invite_by_email_upgrade_button: MouseStateHandle, - invite_by_email_billing_portal_link: MouseStateHandle, discoverable_team_toggle_state: SwitchStateHandle, checkbox_mouse_state: MouseStateHandle, admin_panel_button: MouseStateHandle, + grow_team_warning_cta_button: MouseStateHandle, + team_members_count_tooltip: MouseStateHandle, + outgrow_upgrade_link: MouseStateHandle, + outgrow_contact_sales_link: MouseStateHandle, } /// TeamsInviteOption is whether the user is looking at invite-by-link or invite-by-email. @@ -355,6 +342,38 @@ impl Tabs for TeamsInviteOption { } } +/// What's blocking the team from growing right now. Resolved by +/// `grow_team_warning`; consumed by `render_grow_team_warning_alert` and +/// `grow_team_warning_cta`. Priority order is delinquency > over-cap > at-cap. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum GrowTeamWarning { + /// Team size equals the workspace size policy limit. + SeatCapReached, + /// Team size exceeds the workspace size policy limit. + SeatCapExceeded, + /// Subscription has a past-due payment. + PaymentPastDue, + /// Subscription is unpaid. + PaymentUnpaid, +} + +/// The action an admin can take to resolve a `GrowTeamWarning`. `None` +/// indicates no actionable path (non-admin viewer, enterprise with no +/// self-serve option, or no higher-cap plan available). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum GrowTeamWarningCta { + /// Self-serve upgrade is available; route to `/upgrade`. + Upgrade, + /// Team is on the highest self-serve plan; needs sales for more capacity. + ContactSales, + /// Self-serve admin can resolve billing via the Stripe portal. + UpdateBilling, + /// Non-self-serve admin (e.g. enterprise) should reach out to support. + ContactSupport, + /// No actionable CTA from this viewer in this state. + None, +} + /// The order of the ItemState enum values determines the ordering of the members and /// invites list in the team management page (see `impl Ord for Item`` below). #[derive(Clone, PartialOrd, PartialEq, Eq, Ord)] @@ -548,6 +567,9 @@ impl TypedActionView for TeamsPageView { TeamsPageAction::ContactSupport => { AdminActions::contact_support(ctx); } + TeamsPageAction::ContactSales => { + AdminActions::contact_sales(ctx); + } TeamsPageAction::ToggleTeamDiscoverability { team_uid, current_state, @@ -1219,6 +1241,27 @@ impl TeamsPageView { self.update_team_member_mouse_state_handles(ctx); self.update_email_validator(ctx); self.update_team_name(ctx); + self.update_email_editor_interaction_state(ctx); + } + + /// Disables the invite-by-email chip editor whenever the grow-team + /// warning banner is showing (seat cap reached, delinquent billing, + /// etc.). Visual styling is unchanged; only interaction is blocked. + fn update_email_editor_interaction_state(&mut self, ctx: &mut ViewContext) { + let blocked = self + .user_workspaces + .as_ref(ctx) + .current_team() + .map(|team| TeamsWidget::grow_team_warning(team).is_some()) + .unwrap_or(false); + let state = if blocked { + InteractionState::Disabled + } else { + InteractionState::Editable + }; + self.email_invites_block_editor.update(ctx, |editor, ctx| { + editor.set_interaction_state(state, ctx); + }); } fn update_team_member_mouse_state_handles(&mut self, ctx: &mut ViewContext) { @@ -1770,6 +1813,256 @@ impl TeamsWidget { Some((monthly_cost, yearly_cost)) } + fn grow_team_warning(team: &Team) -> Option { + match team.billing_metadata.delinquency_status { + DelinquencyStatus::PastDue => return Some(GrowTeamWarning::PaymentPastDue), + DelinquencyStatus::Unpaid => return Some(GrowTeamWarning::PaymentUnpaid), + DelinquencyStatus::NoDelinquency + // team limit is split into 2 cases below + | DelinquencyStatus::TeamLimitExceeded + | DelinquencyStatus::Unknown => {} + } + let policy = team.billing_metadata.tier.workspace_size_policy?; + if policy.is_unlimited { + return None; + } + let team_size = i64::try_from(team.members.len()).unwrap_or(i64::MAX); + if team_size > policy.limit { + return Some(GrowTeamWarning::SeatCapExceeded); + } + if team_size >= policy.limit { + return Some(GrowTeamWarning::SeatCapReached); + } + None + } + + /// Maps an admin's actionable path out of a `GrowTeamWarning`. + fn grow_team_warning_cta( + warning: GrowTeamWarning, + has_admin_permissions: bool, + billing_metadata: &BillingMetadata, + pricing_info: &PricingInfoModel, + ) -> GrowTeamWarningCta { + if !has_admin_permissions { + return GrowTeamWarningCta::None; + } + match warning { + GrowTeamWarning::PaymentPastDue | GrowTeamWarning::PaymentUnpaid => { + // Self-serve admins should be able to fix billing themselves; + // everyone else (enterprise / legacy) needs to reach support. + if billing_metadata.is_on_stripe_paid_plan() { + GrowTeamWarningCta::UpdateBilling + } else { + GrowTeamWarningCta::ContactSupport + } + } + GrowTeamWarning::SeatCapReached | GrowTeamWarning::SeatCapExceeded => { + // Build Business is the top of the self-serve ladder; the only + // path to more seats is an enterprise / sales conversation. + if billing_metadata.is_on_build_business_plan() { + return GrowTeamWarningCta::ContactSales; + } + if billing_metadata.is_enterprise_plan() { + return GrowTeamWarningCta::None; + } + let Some(policy) = billing_metadata.tier.workspace_size_policy else { + return GrowTeamWarningCta::None; + }; + if Self::has_higher_seat_cap_plan_available(&policy, pricing_info) { + GrowTeamWarningCta::Upgrade + } else { + GrowTeamWarningCta::None + } + } + } + } + + fn has_higher_seat_cap_plan_available( + workspace_size_policy: &WorkspaceSizePolicy, + pricing_info: &PricingInfoModel, + ) -> bool { + if workspace_size_policy.is_unlimited { + return false; + } + pricing_info + .plans() + .iter() + .filter_map(|plan| plan.max_team_size) + .any(|max| i64::from(max) > workspace_size_policy.limit) + } + + /// Renders the red warning alert at the top of the invite section. + fn render_grow_team_warning_alert( + &self, + team: &Team, + warning: GrowTeamWarning, + has_admin_permissions: bool, + pricing_info: &PricingInfoModel, + appearance: &Appearance, + ) -> Box { + let horizontal_padding = 16.; + let theme = appearance.theme(); + let active_text = theme.active_ui_text_color(); + + let alert_icon = Container::new( + ConstrainedBox::new( + Icon::AlertCircle + .to_warpui_icon(active_text.with_opacity(90)) + .finish(), + ) + .with_max_height(20.) + .with_max_width(20.) + .finish(), + ) + .with_margin_right(horizontal_padding) + .finish(); + + let title = match warning { + GrowTeamWarning::SeatCapReached => "Your team is full", + GrowTeamWarning::SeatCapExceeded => "You've exceeded your member limit", + GrowTeamWarning::PaymentPastDue => "Payment past due", + GrowTeamWarning::PaymentUnpaid => "Subscription unpaid", + }; + let title_element = self.render_subsection_header(title.to_owned(), appearance); + + let cta = Self::grow_team_warning_cta( + warning, + has_admin_permissions, + &team.billing_metadata, + pricing_info, + ); + + let body_prefix = match warning { + GrowTeamWarning::SeatCapReached => "You've reached your plan's member limit.", + GrowTeamWarning::SeatCapExceeded => { + "You've exceeded your plan's member limit. Existing team members keep their access, but you won't be able to add new members." + } + GrowTeamWarning::PaymentPastDue => { + "Team invites have been restricted due to a past-due payment." + } + GrowTeamWarning::PaymentUnpaid => { + "Team invites have been restricted due to an unpaid subscription." + } + }; + + let is_delinquency = matches!( + warning, + GrowTeamWarning::PaymentPastDue | GrowTeamWarning::PaymentUnpaid + ); + let cta_sentence = if !has_admin_permissions { + if is_delinquency { + "Contact a team admin to restore access." + } else { + "Contact a team admin to grow the team." + } + } else { + match cta { + GrowTeamWarningCta::Upgrade => "Upgrade to grow your team.", + GrowTeamWarningCta::ContactSales => "Contact sales to grow your team.", + GrowTeamWarningCta::UpdateBilling => { + "Update your payment information to restore access." + } + GrowTeamWarningCta::ContactSupport => "Contact support to restore access.", + GrowTeamWarningCta::None => { + if is_delinquency { + "Contact support to restore access." + } else { + "Contact sales to grow your team." + } + } + } + }; + let body_text = format!("{body_prefix} {cta_sentence}"); + let body = self.render_sub_text(body_text, appearance, None); + let title_container = Container::new(title_element) + .with_margin_bottom(4.) + .finish(); + let text_column = Flex::column() + .with_child(title_container) + .with_child(body) + .finish(); + let left_content = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(alert_icon) + .with_child(Shrinkable::new(1., text_column).finish()) + .finish(); + + let mut content_row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_main_axis_size(MainAxisSize::Max) + .with_child(Shrinkable::new(1., left_content).finish()); + + // CTA button only renders when there's an actionable path. A single + // mouse state handle is fine because at most one CTA shows at a time. + if let Some((cta_label, cta_action)) = match cta { + GrowTeamWarningCta::Upgrade => Some(( + "Upgrade", + TeamsPageAction::GenerateUpgradeLink { team_uid: team.uid }, + )), + GrowTeamWarningCta::ContactSales => { + Some(("Contact sales", TeamsPageAction::ContactSales)) + } + GrowTeamWarningCta::UpdateBilling => Some(( + "Update billing", + TeamsPageAction::GenerateStripeBillingPortalLink { team_uid: team.uid }, + )), + GrowTeamWarningCta::ContactSupport => { + Some(("Contact support", TeamsPageAction::ContactSupport)) + } + GrowTeamWarningCta::None => None, + } { + let cta_mouse_state = self + .mouse_state_handles + .grow_team_warning_cta_button + .clone(); + let cta_styles = UiComponentStyles { + font_weight: Some(Weight::Medium), + font_size: Some(13.), + height: Some(32.), + padding: Some(Coords { + top: 6., + bottom: 6., + left: 14., + right: 14., + }), + ..Default::default() + }; + let error_color = theme.ui_error_color(); + let cta_button = appearance + .ui_builder() + .button(ButtonVariant::Secondary, cta_mouse_state) + .with_style(cta_styles) + .with_centered_text_label(cta_label.to_owned()) + .with_hovered_styles(UiComponentStyles { + background: Some( + themes::theme::Fill::from(error_color) + .with_opacity(20) + .into(), + ), + border_color: Some(themes::theme::Fill::from(error_color).into()), + ..Default::default() + }) + .build() + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| ctx.dispatch_typed_action(cta_action.clone())) + .finish(); + content_row = + content_row.with_child(Container::new(cta_button).with_margin_left(16.).finish()); + } + + let error_color = theme.ui_error_color(); + let background_fill = themes::theme::Fill::from(error_color).with_opacity(10); + let border_fill = themes::theme::Fill::from(error_color); + Container::new(content_row.finish()) + .with_vertical_padding(12.) + .with_horizontal_padding(horizontal_padding) + .with_background(background_fill) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) + .with_border(Border::all(1.).with_border_fill(border_fill)) + .finish() + } + fn render_team_member_cost_info( &self, team_metadata: &Team, @@ -1786,7 +2079,9 @@ impl TeamsWidget { let additional_members_cost_money_msg = if let Some((monthly_cost, yearly_cost)) = self.get_per_seat_costs(team_metadata, pricing_info_model) { - format!("Additional members are billed at your plan's per-user rate: ${monthly_cost:.0}/month or ${yearly_cost:.0}/year, depending on your billing interval. {prorated_message}") + format!( + "Additional members are billed at your plan's per-user rate: ${monthly_cost:.0}/month or ${yearly_cost:.0}/year, depending on your billing interval. {prorated_message}" + ) } else { format!( "Additional members are billed at your plan's per-user rate. {prorated_message}" @@ -1796,46 +2091,29 @@ impl TeamsWidget { let horizontal_padding = 16.; let theme = appearance.theme(); let currency_icon = Container::new( - ConstrainedBox::new( - Icon::CoinsStacked - .to_warpui_icon(appearance.theme().active_ui_text_color().with_opacity(90)) - .finish(), - ) - .with_max_height(20.) - .with_max_width(20.) - .finish(), + ConstrainedBox::new(Icon::CoinsStacked.to_warpui_icon(theme.accent()).finish()) + .with_max_height(20.) + .with_max_width(20.) + .finish(), ) .with_margin_right(horizontal_padding) .finish(); - let member_pricing_header = - Container::new(self.render_subsection_header("Team members".to_owned(), appearance)) - .with_margin_bottom(8.) - .finish(); - let member_pricing_info = self.render_sub_text(additional_members_cost_money_msg, appearance, None); - let text_column = Flex::column() - .with_child(member_pricing_header) - .with_child(member_pricing_info); - let content_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) .with_child(currency_icon) - .with_child(Shrinkable::new(1., text_column.finish()).finish()); + .with_child(Shrinkable::new(1., member_pricing_info).finish()); - // Wrap in a container with styling similar to Alert + // Wrap in a container with an accent-tinted alert background. Container::new(content_row.finish()) - .with_vertical_padding(12.) + .with_vertical_padding(20.) .with_horizontal_padding(horizontal_padding) - .with_background(themes::theme::Fill::from(internal_colors::neutral_4(theme))) + .with_background(internal_colors::accent_overlay_1(theme)) .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.))) - .with_border( - Border::all(1.) - .with_border_fill(themes::theme::Fill::from(internal_colors::neutral_3(theme))), - ) .finish() } @@ -1903,22 +2181,26 @@ impl TeamsWidget { .finish(), ); - // 3) Team invitation flows (invite link / email invites) - if let Some(workspace_size_policy) = - team_metadata.billing_metadata.tier.workspace_size_policy - { - main_content.add_child(self.render_team_invitation_section( - team_metadata, - has_admin_permissions, - view, - appearance, - chip_editor_style, - workspace_size_policy, - app, - )); - }; + // 3) Team invitation flows (invite link / email invites / discovery) + main_content.add_child(self.render_team_invitation_section( + team_metadata, + has_admin_permissions, + view, + appearance, + chip_editor_style, + app, + )); - // 4) Team members + // 4) Horizontal separator between the invite flows and the team members + // list. 32px of breathing room above and below to match the design. + main_content.add_child( + Container::new(render_separator(appearance)) + .with_padding_top(32.) + .with_padding_bottom(32.) + .finish(), + ); + + // 5) Team members main_content.add_child(self.render_team_members_section( team_metadata, ¤t_user_email, @@ -1926,19 +2208,22 @@ impl TeamsWidget { appearance, )); - // 5) Team discoverability toggle - if team_metadata.billing_metadata.customer_type != CustomerType::Enterprise - && has_admin_permissions - && team_metadata.is_eligible_for_discovery - { - main_content.add_child(self.render_discoverability_toggle_section( - team_metadata, - ¤t_user_email, - appearance, - )) + // 6) Optional outgrow CTA + let pricing_info_model = view.pricing_info_model.as_ref(app); + if let Some(cta) = self.render_outgrow_cta( + team_metadata, + has_admin_permissions, + pricing_info_model, + appearance, + ) { + main_content.add_child( + Container::new(cta) + .with_padding_top(CONTENT_SEPARATION_PADDING) + .finish(), + ); } - // 6) Deleting/leaving teams + // 7) Deleting/leaving teams let mut button_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let is_enterprise_team = team_metadata.billing_metadata.customer_type == CustomerType::Enterprise; @@ -2265,7 +2550,6 @@ impl TeamsWidget { section.finish() } - #[allow(clippy::too_many_arguments)] fn render_team_invitation_section( &self, team_metadata: &Team, @@ -2273,12 +2557,32 @@ impl TeamsWidget { view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, - workspace_size_policy: WorkspaceSizePolicy, app: &AppContext, ) -> Box { let mut invitation_section = Flex::column(); + // "team is full" or "billing issue" or some other alert thats restricting you from adding team members + let warning = Self::grow_team_warning(team_metadata); let pricing_info_model = view.pricing_info_model.as_ref(app); + if let Some(warning) = warning { + let alert = self.render_grow_team_warning_alert( + team_metadata, + warning, + has_admin_permissions, + pricing_info_model, + appearance, + ); + invitation_section.add_child(Container::new(alert).with_padding_bottom(24.).finish()); + } + + invitation_section.add_child( + Container::new( + self.render_subsection_header("Invite team members".to_owned(), appearance), + ) + .with_padding_bottom(16.) + .finish(), + ); + if team_metadata.billing_metadata.is_on_stripe_paid_plan() { let pricing_alert = self.render_team_member_cost_info( team_metadata, @@ -2305,16 +2609,31 @@ impl TeamsWidget { )); } - // Invite by email + // Invite by email. Disabled whenever the warning banner is showing — + // the banner owns the explanation + recovery CTA. invitation_section.add_child(self.render_invite_by_email_section( team_metadata, view, appearance, chip_editor_style, - workspace_size_policy, - has_admin_permissions, + warning.is_some(), )); + // By discovery — third invitation method, same hierarchical level as + // By link / By email. Gated on non-Enterprise, admin viewer, and the + // team being eligible for discovery. + let current_user_email = view.auth_state.user_email().unwrap_or_default(); + if team_metadata.billing_metadata.customer_type != CustomerType::Enterprise + && has_admin_permissions + && team_metadata.is_eligible_for_discovery + { + invitation_section.add_child(self.render_discoverability_toggle_section( + team_metadata, + ¤t_user_email, + appearance, + )); + } + invitation_section.finish() } @@ -2328,16 +2647,33 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column(); + // Header + admin-only subtext on the left, toggle on the right. The + // text is stacked so the toggle centers against the whole block. + let header = self.render_subsubsection_header("By link".to_owned(), appearance); + let text_column = if has_admin_permissions { + Flex::column() + .with_child(header) + .with_child( + Container::new(self.render_sub_text( + INVITE_LINK_TOGGLE_INSTRUCTIONS.into(), + appearance, + Some(Coords::uniform(0.).right(48.)), + )) + .with_padding_top(8.) + .finish(), + ) + .finish() + } else { + Flex::column().with_child(header).finish() + }; + let mut invite_by_link_header_row = Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); - - // 1) "Invite by Link" subsection header - invite_by_link_header_row - .add_child(self.render_subsection_header("Invite by Link".to_owned(), appearance)); + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_child(Shrinkable::new(1., text_column).finish()); - // 1.1) Toggle to the right of header only renders if user is admin + // Toggle on the right only renders if user is admin if has_admin_permissions { let team_uid = team.uid; let current_state = team.organization_settings.is_invite_link_enabled; @@ -2358,19 +2694,6 @@ impl TeamsWidget { section.add_child(invite_by_link_header_row.finish()); - // 2) Instruction text for invite by link toggle - if has_admin_permissions { - section.add_child( - Container::new(self.render_sub_text( - INVITE_LINK_TOGGLE_INSTRUCTIONS.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), - )) - .with_padding_top(8.) - .finish(), - ); - } - // 3) Invite link + domain restrictions // Only renders if invite by link is enabled if team.organization_settings.is_invite_link_enabled { @@ -2424,299 +2747,69 @@ impl TeamsWidget { view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, - policy: WorkspaceSizePolicy, - has_admin_permissions: bool, + force_disabled: bool, ) -> Box { let mut section = Flex::column(); - // "Invite by Email" subsection header + // "By email" subsection header section.add_child( - Container::new(self.render_subsection_header("Invite by Email".to_owned(), appearance)) + Container::new(self.render_subsubsection_header("By email".to_owned(), appearance)) .with_padding_top(CONTENT_SEPARATION_PADDING) .with_padding_bottom(8.) .finish(), ); - match team.billing_metadata.delinquency_status { - DelinquencyStatus::Unknown | DelinquencyStatus::NoDelinquency => { - if policy.is_unlimited - || policy.limit - > team - .members - .len() - .try_into() - .expect("team size should be within max i64 range") - { - // Instruction text for invite by email expiry - section.add_child( - Container::new(self.render_sub_text( - INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), - )) - .with_padding_bottom(TEXT_FIELD_TOP_PADDING) - .finish(), - ); - - // Email invite editor + button - section.add_child( - Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_child( - Shrinkable::new( - 1., - TextInput::new( - view.email_invites_block_editor.clone(), - chip_editor_style, - ) - .build() - .finish(), - ) - .finish(), - ) - .with_child( - self.render_send_email_invites_button(team.uid, view, appearance), - ) - .finish(), - ); - - if !view.email_invites_block_editor_state.is_valid - && !view.email_invites_block_editor_state.is_empty - && view.email_invites_block_editor_state.num_chips > 0 - { - section.add_child( - Container::new(self.render_error_sub_text( - INVALID_EMAILS_INSTRUCTIONS.into(), - appearance, - )) - .with_padding_top(8.) - .finish(), - ) - } - } else { - // Team is not delinquent, but has hit their team size limit. - - let team_uid = team.uid; - - let limit_hit_text = if team.billing_metadata.can_upgrade_to_higher_tier_plan() - { - let mut limit_hit_text_and_upgrade_button = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); - - let text = if has_admin_permissions { - LIMIT_HIT_ADMIN_TEXT - } else { - LIMIT_HIT_NON_ADMIN_TEXT - }; - - limit_hit_text_and_upgrade_button.add_child( - Shrinkable::new( - 1., - self.render_sub_text( - text.into(), - appearance, - Some(Coords::uniform(0.).right(12.)), - ), - ) - .finish(), - ); - - limit_hit_text_and_upgrade_button.add_child( - self.render_compare_plans_button( - "Compare plans", - self.mouse_state_handles - .invite_by_email_upgrade_button - .clone(), - team_uid, - appearance, - Some( - self.button_properties() - .set_width(COMPARE_PLANS_BUTTON_WIDTH), - ), - ), - ); - - limit_hit_text_and_upgrade_button.finish() - } else { - // Otherwise, they've hit the team size limit, but are not able - // to upgrade to team plan (e.g. they're on a tier that has - // a limit on # of seats but it's not one of free/free preview/legacy/prosumer). - // In that case show message to contact their admin/support with no - // button to `/upgrade`. - let text = if has_admin_permissions { - LIMIT_HIT_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT - } else { - LIMIT_HIT_NON_ADMIN_TEXT - }; - self.render_sub_text( - text.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), - ) - }; + // Form stays visually unchanged when blocked; the chip editor is + // disabled via `update_email_editor_interaction_state` and the send + // button is force-disabled below. The warning banner at the top of + // the invitation section owns the explanation + recovery CTA. + section.add_child( + Container::new(self.render_sub_text( + INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS.into(), + appearance, + Some(Coords::uniform(0.).right(48.)), + )) + .with_padding_bottom(TEXT_FIELD_TOP_PADDING) + .finish(), + ); - section.add_child( - Container::new(limit_hit_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) + section.add_child( + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Shrinkable::new( + 1., + TextInput::new(view.email_invites_block_editor.clone(), chip_editor_style) + .build() .finish(), - ); - } - } - DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid => { - // If team has hit their team size limit: - let team_uid = team.uid; - - let delinquent_text = if has_admin_permissions { - // If the user is an admin, and team is on paid stripe plan, - // then provide a clickable link to manage their billing. - if team.billing_metadata.is_on_stripe_paid_plan() { - let mut limit_exceeded_with_upgrade_text = Flex::column(); - - limit_exceeded_with_upgrade_text.add_child(self.render_sub_text( - DELINQUENT_ADMIN_SELF_SERVE_LINE_1_TEXT.into(), - appearance, - None, - )); - - let mut manage_billing_link_line = Flex::row(); - manage_billing_link_line.add_child(self.render_sub_text( - DELINQUENT_ADMIN_SELF_SERVE_LINE_2_PREFIX_TEXT.into(), - appearance, - None, - )); - manage_billing_link_line.add_child( - appearance - .ui_builder() - .link( - DELINQUENT_ADMIN_SELF_SERVE_LINE_2_LINK_TEXT.into(), - None, - Some(Box::new(move |ctx| { - ctx.dispatch_typed_action( - TeamsPageAction::GenerateStripeBillingPortalLink { - team_uid, - }, - ); - })), - self.mouse_state_handles - .invite_by_email_billing_portal_link - .clone(), - ) - .soft_wrap(false) - .build() - .finish(), - ); - manage_billing_link_line.add_child(self.render_sub_text( - DELINQUENT_ADMIN_SELF_SERVE_LINE_2_SUFFIX_TEXT.into(), - appearance, - None, - )); - - limit_exceeded_with_upgrade_text - .add_child(manage_billing_link_line.finish()); - limit_exceeded_with_upgrade_text.finish() - } else { - // Otherwise, they're in delinquent state, but are not able to - // update their billing information like self-serve tier (e.g. - // delinquent enterprise customer). In that case show message to - // contact support instead. - self.render_sub_text( - DELINQUENT_ADMIN_NON_SELF_SERVE_TEXT.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), - ) - } - } else { - // If user is not admin, show them a message that asks them to contact - // their admin to fix their billing instead. - self.render_sub_text( - DELINQUENT_NON_ADMIN_TEXT.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), - ) - }; - - section.add_child( - Container::new(delinquent_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish(), - ); - } - DelinquencyStatus::TeamLimitExceeded => { - // If team has hit their team size limit: - let team_uid = team.uid; - - let limit_exceeded_text = if team.billing_metadata.can_upgrade_to_higher_tier_plan() - { - let mut limit_exceeded_text_and_upgrade_button = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); - - let text = if has_admin_permissions { - TEAM_LIMIT_EXCEEDED_ADMIN_UPGRADEABLE - } else { - TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT - }; - - limit_exceeded_text_and_upgrade_button.add_child( - Shrinkable::new( - 1., - self.render_sub_text( - text.into(), - appearance, - Some(Coords::uniform(0.).right(12.)), - ), - ) - .finish(), - ); - - limit_exceeded_text_and_upgrade_button.add_child( - self.render_compare_plans_button( - "Compare plans", - self.mouse_state_handles - .invite_by_email_upgrade_button - .clone(), - team_uid, - appearance, - Some( - self.button_properties() - .set_width(COMPARE_PLANS_BUTTON_WIDTH), - ), - ), - ); - - limit_exceeded_text_and_upgrade_button.finish() - } else { - // Otherwise, they've hit the team size limit, but are not able - // to upgrade to team plan (e.g. they're on a tier that has - // a limit on # of seats but it's not one of free/free preview/legacy/prosumer). - // In that case show message to contact their admin/support with no - // button to `/upgrade`. - let text = if has_admin_permissions { - TEAM_LIMIT_EXCEEDED_ADMIN_NOT_AUTO_UPGRADEABLE_TEXT - } else { - TEAM_LIMIT_EXCEEDED_NON_ADMIN_TEXT - }; - self.render_sub_text( - text.into(), - appearance, - Some(Coords::uniform(0.).right(48.)), ) - }; + .finish(), + ) + .with_child(self.render_send_email_invites_button( + team.uid, + view, + appearance, + force_disabled, + )) + .finish(), + ); - section.add_child( - Container::new(limit_exceeded_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish(), - ); - } - }; + // Skip the "invalid emails" hint when the form is disabled. + if !force_disabled + && !view.email_invites_block_editor_state.is_valid + && !view.email_invites_block_editor_state.is_empty + && view.email_invites_block_editor_state.num_chips > 0 + { + section.add_child( + Container::new( + self.render_error_sub_text(INVALID_EMAILS_INSTRUCTIONS.into(), appearance), + ) + .with_padding_top(8.) + .finish(), + ) + } - Container::new(section.finish()) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish() + section.finish() } fn render_team_members_section( @@ -2728,14 +2821,17 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column().with_main_axis_size(MainAxisSize::Min); - // 1) "Team Members" header + // 1) "Team members" header row + let header_row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(self.render_subsection_header("Team members".to_owned(), appearance)) + .with_child(self.render_team_members_count(team, appearance)) + .finish(); section.add_child( SavePosition::new( - Container::new( - self.render_subsection_header("Team Members".to_owned(), appearance), - ) - .with_padding_bottom(16.) - .finish(), + Container::new(header_row).with_padding_bottom(16.).finish(), TEAM_MEMBERS_HEADER_POSITION_ID, ) .finish(), @@ -2752,6 +2848,157 @@ impl TeamsWidget { section.finish() } + /// Right-aligned "{N} team members" label next to the section header. + /// On finite-cap plans, appends an info icon with a capacity tooltip. + fn render_team_members_count(&self, team: &Team, appearance: &Appearance) -> Box { + let count = team.members.len(); + let count_label = if count == 1 { + "1 team member".to_string() + } else { + format!("{count} team members") + }; + let theme = appearance.theme(); + let count_color = theme.active_ui_text_color(); + // Info icon uses the muted gray that matches other secondary UI hints. + let muted_color = theme.active_ui_text_color().with_opacity(60); + + let count_text = appearance + .ui_builder() + .span(count_label) + .with_style(UiComponentStyles { + font_family_id: Some(appearance.ui_font_family()), + font_color: Some(count_color.into()), + font_size: Some(12.), + ..Default::default() + }) + .build() + .finish(); + + // No capacity tooltip when the plan is unlimited (or workspace size + // policy is missing). Just render the count text on its own. + let policy = team.billing_metadata.tier.workspace_size_policy; + let finite_cap = match policy { + Some(p) if !p.is_unlimited => Some(p.limit), + _ => None, + }; + let Some(cap) = finite_cap else { + return count_text; + }; + + let plan_display = team.billing_metadata.customer_type.to_display_string(); + let tooltip_text = + format!("Your plan ({plan_display}) has a maximum capacity of {cap} members."); + + let info_icon = Container::new( + ConstrainedBox::new(Icon::Info.to_warpui_icon(muted_color).finish()) + .with_max_height(14.) + .with_max_width(14.) + .finish(), + ) + .with_margin_left(6.) + .finish(); + + let info_icon_with_tooltip = appearance.ui_builder().overlay_tool_tip_on_element( + tooltip_text, + self.mouse_state_handles.team_members_count_tooltip.clone(), + info_icon, + ParentAnchor::TopRight, + ChildAnchor::BottomRight, + vec2f(0., -5.), + ); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min) + .with_child(count_text) + .with_child(info_icon_with_tooltip) + .finish() + } + + // "Want to upgrade your team? " + fn render_outgrow_cta( + &self, + team: &Team, + has_admin_permissions: bool, + pricing_info: &PricingInfoModel, + appearance: &Appearance, + ) -> Option> { + if team.billing_metadata.is_delinquent_due_to_payment_issue() { + return None; + } + let cta = Self::grow_team_warning_cta( + GrowTeamWarning::SeatCapReached, + has_admin_permissions, + &team.billing_metadata, + pricing_info, + ); + match cta { + GrowTeamWarningCta::Upgrade => { + Some(self.render_outgrow_upgrade_line(team.uid, appearance)) + } + GrowTeamWarningCta::ContactSales => { + Some(self.render_outgrow_contact_sales_line(appearance)) + } + GrowTeamWarningCta::UpdateBilling + | GrowTeamWarningCta::ContactSupport + | GrowTeamWarningCta::None => None, + } + } + + /// "Want to grow your team? " — routes through self-serve upgrade. + fn render_outgrow_upgrade_line( + &self, + team_uid: ServerId, + appearance: &Appearance, + ) -> Box { + let prefix = self.render_sub_text("Want to grow your team? ".to_string(), appearance, None); + let link = appearance + .ui_builder() + .link( + "Upgrade".to_string(), + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(TeamsPageAction::GenerateUpgradeLink { team_uid }); + })), + self.mouse_state_handles.outgrow_upgrade_link.clone(), + ) + .soft_wrap(false) + .build() + .finish(); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min) + .with_child(prefix) + .with_child(link) + .finish() + } + + /// "Want to grow your team? " — opens the contact sales page. + fn render_outgrow_contact_sales_line(&self, appearance: &Appearance) -> Box { + let prefix = self.render_sub_text("Want to grow your team? ".to_string(), appearance, None); + let link = appearance + .ui_builder() + .link( + "Contact sales".into(), + None, + Some(Box::new(move |ctx| { + ctx.dispatch_typed_action(TeamsPageAction::ContactSales); + })), + self.mouse_state_handles.outgrow_contact_sales_link.clone(), + ) + .soft_wrap(false) + .build() + .finish(); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min) + .with_child(prefix) + .with_child(link) + .finish() + } + fn render_approved_domains_section( &self, team: &Team, @@ -2762,14 +3009,7 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column(); - // 1) "Restrict by domain" header - section.add_child( - Container::new(self.render_sub_header("Restrict by domain".to_owned(), appearance)) - .with_padding_top(16.) - .finish(), - ); - - // 2) Instruction text for domain restrictions + Domain approval mechanism (input box + button) + // 1) Instruction text for domain restrictions + Domain approval mechanism (input box + button) if has_admin_permissions { section.add_child( Container::new(self.render_sub_text( @@ -2777,7 +3017,7 @@ impl TeamsWidget { appearance, Some(Coords::uniform(0.).right(48.)), )) - .with_padding_top(8.) + .with_padding_top(16.) .finish(), ); @@ -2891,9 +3131,12 @@ impl TeamsWidget { team_uid: ServerId, view: &TeamsPageView, appearance: &Appearance, + force_disabled: bool, ) -> Box { - // Only render enabled button with action if email list is valid. - let (action, variant) = if view.email_invites_block_editor_state.is_valid { + // Only render enabled button with action if email list is valid AND + // the caller hasn't forced the disabled state (e.g. team at seat cap). + let (action, variant) = if !force_disabled && view.email_invites_block_editor_state.is_valid + { ( Some(TeamsPageAction::SendEmailInvites { team_uid }), ButtonVariant::Accent, @@ -2935,20 +3178,23 @@ impl TeamsWidget { current_user_email: &str, appearance: &Appearance, ) -> Box { - let mut section = Flex::column(); + // Same layout as the "By link" header row: text column on the left, + // toggle on the right. + let header = self.render_subsubsection_header("By discovery".to_owned(), appearance); - // Header - let mut discoverable_header_row = Flex::row() - .with_cross_axis_alignment(CrossAxisAlignment::Center) - .with_main_axis_size(MainAxisSize::Max) - .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); - discoverable_header_row.add_child( - Container::new(self.render_sub_header("Make team discoverable".to_owned(), appearance)) - .with_padding_top(CONTENT_SEPARATION_PADDING) - .finish(), + let domain = current_user_email.split('@').nth(1).unwrap_or(""); + let team_discoverability_instructions = + format!("Allow Warp users with an @{domain} email to find and join the team."); + let subtext = self.render_sub_text( + team_discoverability_instructions, + appearance, + Some(Coords::uniform(0.).right(48.)), ); + let text_column = Flex::column() + .with_child(header) + .with_child(Container::new(subtext).with_padding_top(8.).finish()) + .finish(); - // Toggle to the right of header let team_uid = team.uid; let current_state = team.organization_settings.is_discoverable; let discoverable_team_toggle = appearance @@ -2966,28 +3212,18 @@ impl TeamsWidget { current_state, }) }); - discoverable_header_row.add_child( - Container::new(discoverable_team_toggle.finish()) - .with_padding_top(CONTENT_SEPARATION_PADDING) - .finish(), - ); - section.add_child(discoverable_header_row.finish()); - // Instruction text for toggle - let domain = current_user_email.split('@').nth(1).unwrap_or(""); - let team_discoverability_instructions = - format!("Allow Warp users with an @{domain} email to find and join the team."); - section.add_child( - Container::new(self.render_sub_text( - team_discoverability_instructions, - appearance, - Some(Coords::uniform(0.).right(48.)), - )) - .with_padding_top(8.) - .finish(), - ); + let row = Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Max) + .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) + .with_child(Shrinkable::new(1., text_column).finish()) + .with_child(discoverable_team_toggle.finish()) + .finish(); - section.finish() + Container::new(row) + .with_padding_top(CONTENT_SEPARATION_PADDING) + .finish() } fn render_leave_or_delete_team_button( @@ -3484,13 +3720,7 @@ impl TeamsWidget { .with_style(UiComponentStyles { font_family_id: Some(appearance.ui_font_family()), font_weight: Some(Weight::Medium), - font_color: Some( - appearance - .theme() - .active_ui_text_color() - .with_opacity(80) - .into(), - ), + font_color: Some(appearance.theme().active_ui_text_color().into()), font_size: Some(SUBSECTION_HEADER_FONT_SIZE), ..Default::default() }) @@ -3501,6 +3731,31 @@ impl TeamsWidget { .finish() } + /// Smaller in-page header used under a `render_subsection_header` + /// (e.g. "By link" / "By email" / "By discovery"). + fn render_subsubsection_header( + &self, + text: String, + appearance: &Appearance, + ) -> Box { + Align::new( + appearance + .ui_builder() + .span(text) + .with_style(UiComponentStyles { + font_family_id: Some(appearance.ui_font_family()), + font_weight: Some(Weight::Medium), + font_color: Some(appearance.theme().active_ui_text_color().into()), + font_size: Some(SUBSUBSECTION_HEADER_FONT_SIZE), + ..Default::default() + }) + .build() + .finish(), + ) + .left() + .finish() + } + fn render_description(&self, text: String, appearance: &Appearance) -> Box { Text::new(text, appearance.ui_font_family(), 12.) .with_color( @@ -3901,12 +4156,17 @@ impl TeamsWidget { styles: UiComponentStyles, appearance: &Appearance, ) -> Box { - let button = appearance + let mut builder = appearance .ui_builder() .button(variant, mouse_state_handle) .with_style(styles) - .with_centered_text_label(label.to_owned()) - .build(); + .with_centered_text_label(label.to_owned()); + + // No action → render as truly disabled, otherwise hover styling still applies. + if action.is_none() { + builder = builder.disabled(); + } + let button = builder.build(); if let Some(action) = action { button diff --git a/app/src/word_block_editor.rs b/app/src/word_block_editor.rs index 62b26bbb9c..f52c018c28 100644 --- a/app/src/word_block_editor.rs +++ b/app/src/word_block_editor.rs @@ -13,7 +13,7 @@ use warpui::{ use crate::{ appearance::Appearance, - editor::{EditorView, Event, SingleLineEditorOptions, TextOptions}, + editor::{EditorView, Event, InteractionState, SingleLineEditorOptions, TextOptions}, }; use crate::{editor::PropagateAndNoOpNavigationKeys, themes::theme::Fill}; @@ -207,6 +207,14 @@ impl WordBlockEditorView { ctx.notify(); } + /// Forwards the interaction state to the inner editor view. + pub fn set_interaction_state(&mut self, state: InteractionState, ctx: &mut ViewContext) { + self.editor_view.update(ctx, |editor, ctx| { + editor.set_interaction_state(state, ctx); + ctx.notify(); + }); + } + fn delete_word(&mut self, index: usize, ctx: &mut ViewContext) { self.list_of_words.remove(index); ctx.emit(WordBlockEditorViewEvent::WordListValidityChanged); diff --git a/app/src/workspaces/workspace.rs b/app/src/workspaces/workspace.rs index 93ef75c827..10925d025f 100644 --- a/app/src/workspaces/workspace.rs +++ b/app/src/workspaces/workspace.rs @@ -531,6 +531,10 @@ impl BillingMetadata { self.customer_type == CustomerType::Business } + pub fn is_enterprise_plan(&self) -> bool { + self.customer_type == CustomerType::Enterprise + } + pub fn is_on_legacy_paid_plan(&self) -> bool { match self.customer_type { CustomerType::Prosumer