From f0e8b64e0a5f2a34d6b5e6efb6a5c651b378dc7e Mon Sep 17 00:00:00 2001 From: Isaiah Date: Tue, 12 May 2026 00:40:51 -0400 Subject: [PATCH 01/10] =?UTF-8?q?Settings=20=E2=86=92=20Team:=20add=20"You?= =?UTF-8?q?r=20team=20is=20full"=20alert=20above=20the=20invite=20header?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a team has reached its workspace size cap (and is not delinquent), show a red alert above the "Invite team members" header explaining that the team is full and pointing the admin to the right next step: - `Upgrade` button (routes to /upgrade) when pricing data shows a higher-cap self-serve plan is available. - `Contact sales` button (mailto:sales@warp.dev) when the team is already on the Build Business plan, which is the top self-serve tier. - No CTA button for non-admins, delinquent teams, or admins on plans where no higher-cap plan exists — they still see the body copy pointing at a team admin / support@warp.dev. The per-seat cost disclaimer below the header continues to render unchanged when the team is on a paid Stripe plan. Co-Authored-By: Oz --- app/src/pricing/mod.rs | 9 + app/src/settings_view/admin_actions.rs | 5 + app/src/settings_view/teams_page.rs | 425 ++++++++++++++++++++----- 3 files changed, 353 insertions(+), 86 deletions(-) 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..50d2bbdf17 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 sales email link + pub fn contact_sales(ctx: &mut AppContext) { + ctx.open_url("mailto:sales@warp.dev"); + } } #[cfg(test)] diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index f2f44261bb..b0bef150a7 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -44,7 +44,7 @@ use crate::{ team::{DiscoverableTeam, Team}, update_manager::{TeamUpdateManager, TeamUpdateManagerEvent}, user_workspaces::{UserWorkspaces, UserWorkspacesEvent}, - workspace::{CustomerType, DelinquencyStatus, WorkspaceSizePolicy}, + workspace::{BillingMetadata, CustomerType, DelinquencyStatus, WorkspaceSizePolicy}, }, }; @@ -107,7 +107,8 @@ 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 SUBSECTION_HEADER_FONT_SIZE: f32 = 16.; +const SUBSUBSECTION_HEADER_FONT_SIZE: f32 = 14.; const INVITE_LINK_PREFIX: &str = "/team/"; const INVALID_DOMAINS_INSTRUCTIONS: &str = @@ -126,8 +127,7 @@ 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 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."; @@ -138,8 +138,7 @@ const DELINQUENT_ADMIN_SELF_SERVE_LINE_2_LINK_TEXT: &str = "update your payment 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_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."; @@ -199,6 +198,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 +243,7 @@ impl TeamsPageAction { | GenerateStripeBillingPortalLink { .. } | OpenAdminPanel { .. } | ContactSupport + | ContactSales | ToggleTeamDiscoverabilityBeforeCreation | ToggleTeamDiscoverability { .. } | JoinTeamWithTeamDiscovery { .. } @@ -266,6 +267,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" } @@ -322,6 +324,8 @@ struct TeamsWidgetMouseHandles { discoverable_team_toggle_state: SwitchStateHandle, checkbox_mouse_state: MouseStateHandle, admin_panel_button: MouseStateHandle, + contact_sales_button: MouseStateHandle, + seat_cap_upgrade_button: MouseStateHandle, } /// TeamsInviteOption is whether the user is looking at invite-by-link or invite-by-email. @@ -355,6 +359,19 @@ impl Tabs for TeamsInviteOption { } } +/// Actionable path out of a seat-cap warning, derived from real pricing data. +/// See `TeamsWidget::seat_cap_cta` for how each variant is chosen. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +enum SeatCapCta { + /// Self-serve upgrade is available; route to `/upgrade`. + Upgrade, + /// Team is on the highest self-serve plan; needs sales for more capacity. + ContactSales, + /// No actionable CTA: viewer is non-admin, team is delinquent, or no + /// higher-cap plan exists. + 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 +565,9 @@ impl TypedActionView for TeamsPageView { TeamsPageAction::ContactSupport => { AdminActions::contact_support(ctx); } + TeamsPageAction::ContactSales => { + AdminActions::contact_sales(ctx); + } TeamsPageAction::ToggleTeamDiscoverability { team_uid, current_state, @@ -1770,6 +1790,196 @@ impl TeamsWidget { Some((monthly_cost, yearly_cost)) } + /// Maps an admin's actionable path out of a seat-cap warning, derived from + /// real pricing data rather than a hardcoded tier list. + /// + /// - `Upgrade`: there's at least one self-serve plan with a strictly + /// higher seat cap than the team's current plan, and the team isn't + /// already on the highest self-serve tier (Business). Routes to + /// `/upgrade`. + /// - `ContactSales`: team is on the Build Business plan, which is the top + /// self-serve tier. Higher capacity needs an enterprise/sales touch. + /// - `None`: viewer is not an admin, or pricing data shows no higher-cap + /// plan exists, or the team is delinquent (PastDue/Unpaid) — the + /// manage-billing copy below the alert handles those. + fn seat_cap_cta( + has_admin_permissions: bool, + billing_metadata: &BillingMetadata, + workspace_size_policy: &WorkspaceSizePolicy, + pricing_info: &PricingInfoModel, + ) -> SeatCapCta { + if !has_admin_permissions { + return SeatCapCta::None; + } + if matches!( + billing_metadata.delinquency_status, + DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid, + ) { + return SeatCapCta::None; + } + // 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 SeatCapCta::ContactSales; + } + if Self::has_higher_seat_cap_plan_available(workspace_size_policy, pricing_info) { + SeatCapCta::Upgrade + } else { + SeatCapCta::None + } + } + + /// Returns true if the pricing data exposes any plan whose `max_team_size` + /// is strictly greater than the current team's workspace size cap (treating + /// `None` / unlimited plans as having a higher cap than any finite limit). + 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() + .any(|plan| match plan.max_team_size { + None => true, // unlimited + Some(max) => i64::from(max) > workspace_size_policy.limit, + }) + } + + /// Renders the red "Your team is full" alert. CTA only shown when + /// `seat_cap_cta` is `Upgrade` or `ContactSales` (admin + actionable path). + /// Non-admins / delinquent teams / teams at the top of the self-serve + /// ladder with no higher-cap plan just see the body copy. + fn render_seat_cap_alert( + &self, + team: &Team, + has_admin_permissions: bool, + workspace_size_policy: &WorkspaceSizePolicy, + 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_element = + self.render_subsection_header("Your team is full".to_owned(), appearance); + + let cta = Self::seat_cap_cta( + has_admin_permissions, + &team.billing_metadata, + workspace_size_policy, + pricing_info, + ); + let cta_sentence = if !has_admin_permissions { + "Contact a team admin to add more seats." + } else { + match cta { + SeatCapCta::Upgrade => "Upgrade to add more seats.", + SeatCapCta::ContactSales => "Contact sales to upgrade and add more seats.", + SeatCapCta::None => "Contact support@warp.dev to add more seats.", + } + }; + let body_text = format!("You've used all of your team's seats. {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()); + + // The CTA button only renders when there's an actionable path; non- + // admins, delinquent teams and teams already at the top of the + // self-serve ladder (Business with no higher-cap plan available) + // simply read the body copy. + if let Some((cta_label, cta_action, cta_mouse_state)) = match cta { + SeatCapCta::Upgrade => Some(( + "Upgrade", + TeamsPageAction::GenerateUpgradeLink { team_uid: team.uid }, + self.mouse_state_handles.seat_cap_upgrade_button.clone(), + )), + SeatCapCta::ContactSales => Some(( + "Contact sales", + TeamsPageAction::ContactSales, + self.mouse_state_handles.contact_sales_button.clone(), + )), + SeatCapCta::None => None, + } { + 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 +1996,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 +2008,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() } @@ -1918,15 +2113,8 @@ impl TeamsWidget { )); }; - // 4) Team members - main_content.add_child(self.render_team_members_section( - team_metadata, - ¤t_user_email, - view, - appearance, - )); - - // 5) Team discoverability toggle + // 4) Team discoverability toggle (sits with the invite flows, directly + // under the "By email" subsection) if team_metadata.billing_metadata.customer_type != CustomerType::Enterprise && has_admin_permissions && team_metadata.is_eligible_for_discovery @@ -1938,7 +2126,24 @@ impl TeamsWidget { )) } - // 6) Deleting/leaving teams + // 5) 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(), + ); + + // 6) Team members + main_content.add_child(self.render_team_members_section( + team_metadata, + ¤t_user_email, + view, + appearance, + )); + + // 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; @@ -2278,7 +2483,41 @@ impl TeamsWidget { ) -> Box { let mut invitation_section = Flex::column(); + // "Your team is full" alert sits above the "Invite team members" header + // when the team is at its seat cap. We skip the alert entirely for + // delinquent teams; the existing manage-billing copy in the + // invite-by-email section is the actionable path forward and would + // compete with the alert's CTA. + let team_size_i64 = i64::try_from(team_metadata.members.len()).unwrap_or(i64::MAX); + let is_full = + !workspace_size_policy.is_unlimited && team_size_i64 >= workspace_size_policy.limit; + let is_delinquent = matches!( + team_metadata.billing_metadata.delinquency_status, + DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid, + ); let pricing_info_model = view.pricing_info_model.as_ref(app); + if is_full && !is_delinquent { + let cap_alert = self.render_seat_cap_alert( + team_metadata, + has_admin_permissions, + &workspace_size_policy, + pricing_info_model, + appearance, + ); + invitation_section + .add_child(Container::new(cap_alert).with_padding_bottom(24.).finish()); + } + + // Top-level "Invite team members" header sits above the per-method + // (by link / by email) subsections. + 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, @@ -2333,9 +2572,9 @@ impl TeamsWidget { .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween); - // 1) "Invite by Link" subsection header + // 1) "By link" subsection header invite_by_link_header_row - .add_child(self.render_subsection_header("Invite by Link".to_owned(), appearance)); + .add_child(self.render_subsubsection_header("By link".to_owned(), appearance)); // 1.1) Toggle to the right of header only renders if user is admin if has_admin_permissions { @@ -2429,9 +2668,9 @@ impl TeamsWidget { ) -> 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(), @@ -2557,11 +2796,7 @@ impl TeamsWidget { ) }; - section.add_child( - Container::new(limit_hit_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish(), - ); + section.add_child(limit_hit_text); } } DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid => { @@ -2637,11 +2872,7 @@ impl TeamsWidget { ) }; - section.add_child( - Container::new(delinquent_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish(), - ); + section.add_child(delinquent_text); } DelinquencyStatus::TeamLimitExceeded => { // If team has hit their team size limit: @@ -2706,17 +2937,11 @@ impl TeamsWidget { ) }; - section.add_child( - Container::new(limit_exceeded_text) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish(), - ); + section.add_child(limit_exceeded_text); } }; - Container::new(section.finish()) - .with_padding_bottom(CONTENT_SEPARATION_PADDING) - .finish() + section.finish() } fn render_team_members_section( @@ -2728,11 +2953,12 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column().with_main_axis_size(MainAxisSize::Min); - // 1) "Team Members" header + // 1) "Team members" header. No top padding here — the call site adds a + // separator with its own spacing above this section. section.add_child( SavePosition::new( Container::new( - self.render_subsection_header("Team Members".to_owned(), appearance), + self.render_subsection_header("Team members".to_owned(), appearance), ) .with_padding_bottom(16.) .finish(), @@ -2937,18 +3163,15 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column(); - // Header + // Header row with title on the left and toggle on the right, matching + // the "By link" / "By email" subsection pattern. 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(), - ); + discoverable_header_row + .add_child(self.render_subsubsection_header("By discovery".to_owned(), appearance)); - // 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,26 +3189,24 @@ impl TeamsWidget { current_state, }) }); - discoverable_header_row.add_child( - Container::new(discoverable_team_toggle.finish()) + discoverable_header_row.add_child(discoverable_team_toggle.finish()); + + section.add_child( + Container::new(discoverable_header_row.finish()) .with_padding_top(CONTENT_SEPARATION_PADDING) + .with_padding_bottom(8.) .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(), - ); + section.add_child(self.render_sub_text( + team_discoverability_instructions, + appearance, + Some(Coords::uniform(0.).right(48.)), + )); section.finish() } @@ -3488,7 +3709,7 @@ impl TeamsWidget { appearance .theme() .active_ui_text_color() - .with_opacity(80) + .with_opacity(60) .into(), ), font_size: Some(SUBSECTION_HEADER_FONT_SIZE), @@ -3501,6 +3722,38 @@ impl TeamsWidget { .finish() } + /// Smaller in-page header used under a `render_subsection_header`, e.g. + /// "By link" / "By email" under "Invite team members". Matches the + /// subsection style (Medium weight, muted gray) but at a smaller font size. + 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() + .with_opacity(60) + .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( From 7c64e02923602f5f814dc298a5f13356599af74e Mon Sep 17 00:00:00 2001 From: Isaiah Date: Tue, 12 May 2026 10:05:33 -0400 Subject: [PATCH 02/10] Add 'outgrow' CTA to teams settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a footer CTA below the team-members list that nudges admins to grow their team. Two states: - Not on Business plan, has a finite seat cap, team fits in Business's cap → ' for up to N members' (clicks route through the existing self-serve upgrade flow). - Already on Business → 'Reach out to for more seats' (clicks open a mailto: to sales). Other cases (non-admin, unlimited plan, no Business in pricing data, already too big for Business) omit the CTA. Also fixes the 'additional members' pricing alert to use 20px vertical padding (was 12) so the disclaimer text has more breathing room from the container edge. Co-Authored-By: Oz --- app/src/settings_view/teams_page.rs | 497 ++++++++++++++++++---------- 1 file changed, 326 insertions(+), 171 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index b0bef150a7..b7d70ed5ab 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -19,6 +19,7 @@ use crate::auth::{AuthStateProvider, UserUid}; use crate::menu::{self, Menu, MenuItem, MenuItemFields}; use crate::modal::{Modal, ModalEvent, ModalViewState}; use crate::pricing::PricingInfoModel; +use warp_graphql::billing::StripeSubscriptionPlan; use crate::view_components::ToastFlavor; use crate::workspaces::team::{MembershipRole, TeamDeleteDisabledReason}; use crate::{ @@ -124,11 +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 = @@ -326,6 +322,9 @@ struct TeamsWidgetMouseHandles { admin_panel_button: MouseStateHandle, contact_sales_button: MouseStateHandle, seat_cap_upgrade_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. @@ -2143,6 +2142,24 @@ impl TeamsWidget { appearance, )); + // 6.5) Optional outgrow CTA — "Upgrade to {plan} for up to N members" + // when the team has natural room to grow into a higher-cap plan, or + // "Contact sales@warp.dev to grow your team further" when even the + // next plan isn't enough (or no higher self-serve plan exists). + 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(), + ); + } + // 7) Deleting/leaving teams let mut button_row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); let is_enterprise_team = @@ -2550,7 +2567,6 @@ impl TeamsWidget { view, appearance, chip_editor_style, - workspace_size_policy, has_admin_permissions, )); @@ -2567,16 +2583,35 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column(); + // 1) Header row: "By link" + admin-only instruction text on the left, + // toggle on the right. The text is in a column so the toggle is + // vertically centered against the combined header + subtext block, + // not just the header. + 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) "By link" subsection header - invite_by_link_header_row - .add_child(self.render_subsubsection_header("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; @@ -2597,19 +2632,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 { @@ -2663,7 +2685,6 @@ impl TeamsWidget { view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, - policy: WorkspaceSizePolicy, has_admin_permissions: bool, ) -> Box { let mut section = Flex::column(); @@ -2678,125 +2699,55 @@ impl TeamsWidget { 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(), - ); + // Always show the email invite form, even when the team is at + // its workspace size cap. The seat-cap alert at the top of the + // page already communicates the cap and offers an upgrade CTA, + // and the server enforces the cap at join time, so there's no + // value in hiding the form here — it just blocks admins from + // queueing invites for when seats free up. + 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(), + 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(), ) - .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.)), + .with_child( + self.render_send_email_invites_button(team.uid, view, appearance), ) - }; + .finish(), + ); - section.add_child(limit_hit_text); + 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(), + ) } } DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid => { @@ -2953,15 +2904,22 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column().with_main_axis_size(MainAxisSize::Min); - // 1) "Team members" header. No top padding here — the call site adds a - // separator with its own spacing above this section. + // 1) "Team members" header row: title on the left, "{N} team members" + // (with capacity tooltip when the plan has a finite cap) on the right. + // No top padding here — the call site adds a separator with its own + // spacing above this section. + 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(), @@ -2978,6 +2936,204 @@ impl TeamsWidget { section.finish() } + /// Renders the right-aligned "{N} team members" label that sits next to + /// the "Team members" subsection header. When the team's plan has a finite + /// seat cap, the count is followed by an info icon whose hover tooltip + /// exposes the plan name and capacity. The icon is omitted entirely when + /// the plan is unlimited. + 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() + } + + /// Footer CTA shown below the team-members list that nudges admins to + /// grow their team. Three states: + /// - On the Business plan — render the "Reach out to sales@warp.dev for + /// more seats" line. Business is the top of self-serve, so anything + /// bigger requires a sales conversation. + /// - Not on Business, current plan has a finite seat cap, and the team + /// would still fit inside Business's cap — render the + /// " for up to N members" line. + /// - Anything else (non-admin, unlimited plan, no Business plan in + /// pricing data, team already too big for Business) — omit the CTA. + fn render_outgrow_cta( + &self, + team: &Team, + has_admin_permissions: bool, + pricing_info: &PricingInfoModel, + appearance: &Appearance, + ) -> Option> { + if !has_admin_permissions { + return None; + } + + // Already on Business — sales is the only path to more capacity. + if team.billing_metadata.is_on_build_business_plan() { + return Some(self.render_outgrow_contact_sales_line(appearance)); + } + + // Not on Business: only suggest an upgrade when the current plan has + // a finite seat cap AND the team would actually fit in Business. + let policy = team.billing_metadata.tier.workspace_size_policy?; + if policy.is_unlimited { + return None; + } + + // Look up Business's seat cap from live pricing data. We accept + // either the legacy `Business` variant or the newer `BuildBusiness` + // variant — both surface as "Business" in the UI. + let business_cap = pricing_info + .plans() + .iter() + .find(|p| { + matches!( + p.plan, + StripeSubscriptionPlan::BuildBusiness | StripeSubscriptionPlan::Business, + ) + }) + .and_then(|p| p.max_team_size)?; + + let team_size = i64::try_from(team.members.len()).unwrap_or(i64::MAX); + if team_size >= i64::from(business_cap) { + // Team is already too big for Business; the contact-sales line + // would be the right answer here, but per the simplified spec we + // only show the sales line for teams that are actually on + // Business. Omit entirely. + return None; + } + + Some(self.render_outgrow_upgrade_line( + format!(" for up to {business_cap} members"), + team.uid, + appearance, + )) + } + + /// Builds the "Upgrade to Business for up to N members" line. + /// Clicking the link routes through the existing self-serve upgrade flow. + fn render_outgrow_upgrade_line( + &self, + cap_phrase: String, + team_uid: ServerId, + appearance: &Appearance, + ) -> Box { + let link = appearance + .ui_builder() + .link( + "Upgrade to Business".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(); + + let trailing = self.render_sub_text(cap_phrase, appearance, None); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min) + .with_child(link) + .with_child(trailing) + .finish() + } + + /// Builds the "Reach out to sales@warp.dev for more seats" + /// line. Clicking the email opens a `mailto:` to sales via the shared + /// `ContactSales` action. + fn render_outgrow_contact_sales_line(&self, appearance: &Appearance) -> Box { + let prefix = self.render_sub_text("Reach out to ".to_string(), appearance, None); + let link = appearance + .ui_builder() + .link( + "sales@warp.dev".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(); + let suffix = self.render_sub_text(" for more seats".to_string(), appearance, None); + + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_main_axis_size(MainAxisSize::Min) + .with_child(prefix) + .with_child(link) + .with_child(suffix) + .finish() + } + fn render_approved_domains_section( &self, team: &Team, @@ -3161,16 +3317,23 @@ impl TeamsWidget { current_user_email: &str, appearance: &Appearance, ) -> Box { - let mut section = Flex::column(); + // Header + subtext stacked on the left, toggle on the right. The text + // is in a column so the toggle is vertically centered against the + // combined header + subtext block, matching the "By link" pattern. + let header = self.render_subsubsection_header("By discovery".to_owned(), appearance); - // Header row with title on the left and toggle on the right, matching - // the "By link" / "By email" subsection pattern. - 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(self.render_subsubsection_header("By discovery".to_owned(), appearance)); + 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(); let team_uid = team.uid; let current_state = team.organization_settings.is_discoverable; @@ -3189,26 +3352,18 @@ impl TeamsWidget { current_state, }) }); - discoverable_header_row.add_child(discoverable_team_toggle.finish()); - section.add_child( - Container::new(discoverable_header_row.finish()) - .with_padding_top(CONTENT_SEPARATION_PADDING) - .with_padding_bottom(8.) - .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(self.render_sub_text( - team_discoverability_instructions, - appearance, - Some(Coords::uniform(0.).right(48.)), - )); + 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( From b2e2c5593196dd76ee4c2bb5085b353bee8104bc Mon Sep 17 00:00:00 2001 From: Isaiah Date: Tue, 12 May 2026 10:49:53 -0400 Subject: [PATCH 03/10] Refine teams settings: brighter subsection headers + seat-cap fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Bump render_subsection_header to 18pt at full active_ui_text_color opacity so "Invite team members" / "Team members" / "Plan usage limits" / "Your team is full" read as real section titles vs. the muted "By link" / "By email" / "By discovery" subsubsection labels. - Filter unlimited plans (max_team_size = None) out of has_higher_seat_cap_plan_available so legacy unlimited tiers (Pro, Team, Turbo, Lightspeed) aren't treated as upgrade targets. - Drop the delinquency gate on the "Team is full" alert — full is full regardless of payment state; the invite-by-email section continues to render its own manage-billing copy for delinquent teams. - Move "By discovery" into the invite section alongside "By link" / "By email" and trim a now-stale comment above the seat-cap alert. Co-Authored-By: Oz --- app/src/settings_view/teams_page.rs | 87 +++++++++++------------------ 1 file changed, 34 insertions(+), 53 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index b7d70ed5ab..c993c7f103 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -108,7 +108,7 @@ 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 = 16.; +const SUBSECTION_HEADER_FONT_SIZE: f32 = 18.; const SUBSUBSECTION_HEADER_FONT_SIZE: f32 = 14.; const INVITE_LINK_PREFIX: &str = "/team/"; @@ -1828,9 +1828,12 @@ impl TeamsWidget { } } - /// Returns true if the pricing data exposes any plan whose `max_team_size` - /// is strictly greater than the current team's workspace size cap (treating - /// `None` / unlimited plans as having a higher cap than any finite limit). + /// Returns true if pricing data exposes any plan with a strictly higher + /// finite seat cap than the current team's. Plans with `max_team_size = + /// None` (unlimited) are explicitly ignored — in practice those entries + /// are legacy plans (Pro, Team, Turbo, Lightspeed, …) that customers + /// can't upgrade into anymore, so treating them as a viable upgrade + /// target would surface a misleading CTA. fn has_higher_seat_cap_plan_available( workspace_size_policy: &WorkspaceSizePolicy, pricing_info: &PricingInfoModel, @@ -1841,10 +1844,8 @@ impl TeamsWidget { pricing_info .plans() .iter() - .any(|plan| match plan.max_team_size { - None => true, // unlimited - Some(max) => i64::from(max) > workspace_size_policy.limit, - }) + .filter_map(|plan| plan.max_team_size) + .any(|max| i64::from(max) > workspace_size_policy.limit) } /// Renders the red "Your team is full" alert. CTA only shown when @@ -2097,7 +2098,7 @@ impl TeamsWidget { .finish(), ); - // 3) Team invitation flows (invite link / email invites) + // 3) Team invitation flows (invite link / email invites / discovery) if let Some(workspace_size_policy) = team_metadata.billing_metadata.tier.workspace_size_policy { @@ -2112,20 +2113,7 @@ impl TeamsWidget { )); }; - // 4) Team discoverability toggle (sits with the invite flows, directly - // under the "By email" subsection) - 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, - )) - } - - // 5) Horizontal separator between the invite flows and the 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)) @@ -2134,7 +2122,7 @@ impl TeamsWidget { .finish(), ); - // 6) Team members + // 5) Team members main_content.add_child(self.render_team_members_section( team_metadata, ¤t_user_email, @@ -2142,10 +2130,7 @@ impl TeamsWidget { appearance, )); - // 6.5) Optional outgrow CTA — "Upgrade to {plan} for up to N members" - // when the team has natural room to grow into a higher-cap plan, or - // "Contact sales@warp.dev to grow your team further" when even the - // next plan isn't enough (or no higher self-serve plan exists). + // 6.5) Optional outgrow CTA let pricing_info_model = view.pricing_info_model.as_ref(app); if let Some(cta) = self.render_outgrow_cta( team_metadata, @@ -2160,7 +2145,7 @@ impl TeamsWidget { ); } - // 7) Deleting/leaving teams + // 6) 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; @@ -2500,20 +2485,12 @@ impl TeamsWidget { ) -> Box { let mut invitation_section = Flex::column(); - // "Your team is full" alert sits above the "Invite team members" header - // when the team is at its seat cap. We skip the alert entirely for - // delinquent teams; the existing manage-billing copy in the - // invite-by-email section is the actionable path forward and would - // compete with the alert's CTA. - let team_size_i64 = i64::try_from(team_metadata.members.len()).unwrap_or(i64::MAX); + // Optional "Team is full" warning box. + let team_size_i64 = i64::try_from(team_metadata.members.len()).unwrap_or(1); let is_full = !workspace_size_policy.is_unlimited && team_size_i64 >= workspace_size_policy.limit; - let is_delinquent = matches!( - team_metadata.billing_metadata.delinquency_status, - DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid, - ); let pricing_info_model = view.pricing_info_model.as_ref(app); - if is_full && !is_delinquent { + if is_full { let cap_alert = self.render_seat_cap_alert( team_metadata, has_admin_permissions, @@ -2525,8 +2502,6 @@ impl TeamsWidget { .add_child(Container::new(cap_alert).with_padding_bottom(24.).finish()); } - // Top-level "Invite team members" header sits above the per-method - // (by link / by email) subsections. invitation_section.add_child( Container::new( self.render_subsection_header("Invite team members".to_owned(), appearance), @@ -2570,6 +2545,21 @@ impl TeamsWidget { has_admin_permissions, )); + // 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() } @@ -2904,10 +2894,7 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column().with_main_axis_size(MainAxisSize::Min); - // 1) "Team members" header row: title on the left, "{N} team members" - // (with capacity tooltip when the plan has a finite cap) on the right. - // No top padding here — the call site adds a separator with its own - // spacing above this section. + // 1) "Team members" header row let header_row = Flex::row() .with_main_axis_size(MainAxisSize::Max) .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) @@ -3860,13 +3847,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(60) - .into(), - ), + font_color: Some(appearance.theme().active_ui_text_color().into()), font_size: Some(SUBSECTION_HEADER_FONT_SIZE), ..Default::default() }) From 15901449a3e84e337d3fbfd2b7d53d05d9ead526 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Tue, 12 May 2026 16:24:18 -0400 Subject: [PATCH 04/10] Trim verbose comments in teams settings PR Co-Authored-By: Oz --- app/src/settings_view/teams_page.rs | 363 ++++++++++------------------ app/src/word_block_editor.rs | 10 +- 2 files changed, 141 insertions(+), 232 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index c993c7f103..35c180a642 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -19,7 +19,6 @@ use crate::auth::{AuthStateProvider, UserUid}; use crate::menu::{self, Menu, MenuItem, MenuItemFields}; use crate::modal::{Modal, ModalEvent, ModalViewState}; use crate::pricing::PricingInfoModel; -use warp_graphql::billing::StripeSubscriptionPlan; use crate::view_components::ToastFlavor; use crate::workspaces::team::{MembershipRole, TeamDeleteDisabledReason}; use crate::{ @@ -30,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::{ @@ -107,7 +108,6 @@ 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.; @@ -117,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 = @@ -133,11 +133,6 @@ 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! { @@ -315,7 +310,6 @@ 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, @@ -358,8 +352,6 @@ impl Tabs for TeamsInviteOption { } } -/// Actionable path out of a seat-cap warning, derived from real pricing data. -/// See `TeamsWidget::seat_cap_cta` for how each variant is chosen. #[derive(Copy, Clone, Debug, PartialEq, Eq)] enum SeatCapCta { /// Self-serve upgrade is available; route to `/upgrade`. @@ -1238,6 +1230,30 @@ 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 when the team is at its + /// seat cap. Visual styling is unchanged; only interaction is blocked. + fn update_email_editor_interaction_state(&mut self, ctx: &mut ViewContext) { + let cap_reached = self + .user_workspaces + .as_ref(ctx) + .current_team() + .and_then(|team| { + let policy = team.billing_metadata.tier.workspace_size_policy?; + let team_size = i64::try_from(team.members.len()).unwrap_or(0); + Some(!policy.is_unlimited && team_size >= policy.limit) + }) + .unwrap_or(false); + let state = if cap_reached { + 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) { @@ -1789,18 +1805,7 @@ impl TeamsWidget { Some((monthly_cost, yearly_cost)) } - /// Maps an admin's actionable path out of a seat-cap warning, derived from - /// real pricing data rather than a hardcoded tier list. - /// - /// - `Upgrade`: there's at least one self-serve plan with a strictly - /// higher seat cap than the team's current plan, and the team isn't - /// already on the highest self-serve tier (Business). Routes to - /// `/upgrade`. - /// - `ContactSales`: team is on the Build Business plan, which is the top - /// self-serve tier. Higher capacity needs an enterprise/sales touch. - /// - `None`: viewer is not an admin, or pricing data shows no higher-cap - /// plan exists, or the team is delinquent (PastDue/Unpaid) — the - /// manage-billing copy below the alert handles those. + /// Maps an admin's actionable path out of a seat-cap warning fn seat_cap_cta( has_admin_permissions: bool, billing_metadata: &BillingMetadata, @@ -1828,12 +1833,6 @@ impl TeamsWidget { } } - /// Returns true if pricing data exposes any plan with a strictly higher - /// finite seat cap than the current team's. Plans with `max_team_size = - /// None` (unlimited) are explicitly ignored — in practice those entries - /// are legacy plans (Pro, Team, Turbo, Lightspeed, …) that customers - /// can't upgrade into anymore, so treating them as a viable upgrade - /// target would surface a misleading CTA. fn has_higher_seat_cap_plan_available( workspace_size_policy: &WorkspaceSizePolicy, pricing_info: &PricingInfoModel, @@ -1877,8 +1876,15 @@ impl TeamsWidget { .with_margin_right(horizontal_padding) .finish(); - let title_element = - self.render_subsection_header("Your team is full".to_owned(), appearance); + let team_size = i64::try_from(team.members.len()).unwrap_or(i64::MAX); + let is_over_cap = team_size > workspace_size_policy.limit; + + let title = if is_over_cap { + "You've exceeded your member limit" + } else { + "Your team is full" + }; + let title_element = self.render_subsection_header(title.to_owned(), appearance); let cta = Self::seat_cap_cta( has_admin_permissions, @@ -1887,15 +1893,21 @@ impl TeamsWidget { pricing_info, ); let cta_sentence = if !has_admin_permissions { - "Contact a team admin to add more seats." + "Contact a team admin to grow the team." } else { match cta { - SeatCapCta::Upgrade => "Upgrade to add more seats.", - SeatCapCta::ContactSales => "Contact sales to upgrade and add more seats.", - SeatCapCta::None => "Contact support@warp.dev to add more seats.", + SeatCapCta::Upgrade => "Upgrade to grow your team.", + SeatCapCta::ContactSales | SeatCapCta::None => { + "Contact sales@warp.dev to grow your team." + } } }; - let body_text = format!("You've used all of your team's seats. {cta_sentence}"); + let body_prefix = if is_over_cap { + "You've exceeded your plan's member limit. Existing members keep their access, but you won't be able to add new members." + } else { + "You've reached your plan's member limit." + }; + 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.) @@ -1916,10 +1928,7 @@ impl TeamsWidget { .with_main_axis_size(MainAxisSize::Max) .with_child(Shrinkable::new(1., left_content).finish()); - // The CTA button only renders when there's an actionable path; non- - // admins, delinquent teams and teams already at the top of the - // self-serve ladder (Business with no higher-cap plan available) - // simply read the body copy. + // CTA button only renders when there's an actionable path. if let Some((cta_label, cta_action, cta_mouse_state)) = match cta { SeatCapCta::Upgrade => Some(( "Upgrade", @@ -2543,6 +2552,7 @@ impl TeamsWidget { appearance, chip_editor_style, has_admin_permissions, + is_full, )); // By discovery — third invitation method, same hierarchical level as @@ -2573,10 +2583,8 @@ impl TeamsWidget { ) -> Box { let mut section = Flex::column(); - // 1) Header row: "By link" + admin-only instruction text on the left, - // toggle on the right. The text is in a column so the toggle is - // vertically centered against the combined header + subtext block, - // not just the header. + // 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() @@ -2669,6 +2677,7 @@ impl TeamsWidget { section.finish() } + #[allow(clippy::too_many_arguments)] fn render_invite_by_email_section( &self, team: &Team, @@ -2676,6 +2685,7 @@ impl TeamsWidget { appearance: &Appearance, chip_editor_style: UiComponentStyles, has_admin_permissions: bool, + cap_reached: bool, ) -> Box { let mut section = Flex::column(); @@ -2688,13 +2698,12 @@ impl TeamsWidget { ); match team.billing_metadata.delinquency_status { - DelinquencyStatus::Unknown | DelinquencyStatus::NoDelinquency => { - // Always show the email invite form, even when the team is at - // its workspace size cap. The seat-cap alert at the top of the - // page already communicates the cap and offers an upgrade CTA, - // and the server enforces the cap at join time, so there's no - // value in hiding the form here — it just blocks admins from - // queueing invites for when seats free up. + DelinquencyStatus::Unknown + | DelinquencyStatus::NoDelinquency + | DelinquencyStatus::TeamLimitExceeded => { + // Form stays visually unchanged at seat cap; the chip editor + // is disabled via `update_email_editor_interaction_state` and + // the send button is force-disabled below. section.add_child( Container::new(self.render_sub_text( INVITE_BY_EMAIL_EXPIRY_INSTRUCTIONS.into(), @@ -2720,21 +2729,28 @@ impl TeamsWidget { ) .finish(), ) - .with_child( - self.render_send_email_invites_button(team.uid, view, appearance), - ) + .with_child(self.render_send_email_invites_button( + team.uid, + view, + appearance, + cap_reached, + )) .finish(), ); - if !view.email_invites_block_editor_state.is_valid + // Skip the "invalid emails" hint when the form is disabled. + if !cap_reached + && !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, - )) + Container::new( + self.render_error_sub_text( + INVALID_EMAILS_INSTRUCTIONS.into(), + appearance, + ), + ) .with_padding_top(8.) .finish(), ) @@ -2815,71 +2831,6 @@ impl TeamsWidget { section.add_child(delinquent_text); } - 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.)), - ) - }; - - section.add_child(limit_exceeded_text); - } }; section.finish() @@ -2904,9 +2855,7 @@ impl TeamsWidget { .finish(); section.add_child( SavePosition::new( - Container::new(header_row) - .with_padding_bottom(16.) - .finish(), + Container::new(header_row).with_padding_bottom(16.).finish(), TEAM_MEMBERS_HEADER_POSITION_ID, ) .finish(), @@ -2923,16 +2872,9 @@ impl TeamsWidget { section.finish() } - /// Renders the right-aligned "{N} team members" label that sits next to - /// the "Team members" subsection header. When the team's plan has a finite - /// seat cap, the count is followed by an info icon whose hover tooltip - /// exposes the plan name and capacity. The icon is omitted entirely when - /// the plan is unlimited. - fn render_team_members_count( - &self, - team: &Team, - appearance: &Appearance, - ) -> Box { + /// 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() @@ -2997,16 +2939,8 @@ impl TeamsWidget { .finish() } - /// Footer CTA shown below the team-members list that nudges admins to - /// grow their team. Three states: - /// - On the Business plan — render the "Reach out to sales@warp.dev for - /// more seats" line. Business is the top of self-serve, so anything - /// bigger requires a sales conversation. - /// - Not on Business, current plan has a finite seat cap, and the team - /// would still fit inside Business's cap — render the - /// " for up to N members" line. - /// - Anything else (non-admin, unlimited plan, no Business plan in - /// pricing data, team already too big for Business) — omit the CTA. + /// Footer CTA below the team-members list. Reuses `seat_cap_cta` so the + /// page speaks with one voice; omitted when the CTA is `None`. fn render_outgrow_cta( &self, team: &Team, @@ -3014,64 +2948,40 @@ impl TeamsWidget { pricing_info: &PricingInfoModel, appearance: &Appearance, ) -> Option> { - if !has_admin_permissions { - return None; - } - - // Already on Business — sales is the only path to more capacity. - if team.billing_metadata.is_on_build_business_plan() { - return Some(self.render_outgrow_contact_sales_line(appearance)); - } - - // Not on Business: only suggest an upgrade when the current plan has - // a finite seat cap AND the team would actually fit in Business. - let policy = team.billing_metadata.tier.workspace_size_policy?; - if policy.is_unlimited { - return None; - } - - // Look up Business's seat cap from live pricing data. We accept - // either the legacy `Business` variant or the newer `BuildBusiness` - // variant — both surface as "Business" in the UI. - let business_cap = pricing_info - .plans() - .iter() - .find(|p| { - matches!( - p.plan, - StripeSubscriptionPlan::BuildBusiness | StripeSubscriptionPlan::Business, - ) - }) - .and_then(|p| p.max_team_size)?; - - let team_size = i64::try_from(team.members.len()).unwrap_or(i64::MAX); - if team_size >= i64::from(business_cap) { - // Team is already too big for Business; the contact-sales line - // would be the right answer here, but per the simplified spec we - // only show the sales line for teams that are actually on - // Business. Omit entirely. - return None; + // Fall back to an unlimited policy when tier data is missing; this + // yields no Upgrade path but still surfaces ContactSales on Business. + let policy = + team.billing_metadata + .tier + .workspace_size_policy + .unwrap_or(WorkspaceSizePolicy { + is_unlimited: true, + limit: 0, + }); + let cta = Self::seat_cap_cta( + has_admin_permissions, + &team.billing_metadata, + &policy, + pricing_info, + ); + match cta { + SeatCapCta::Upgrade => Some(self.render_outgrow_upgrade_line(team.uid, appearance)), + SeatCapCta::ContactSales => Some(self.render_outgrow_contact_sales_line(appearance)), + SeatCapCta::None => None, } - - Some(self.render_outgrow_upgrade_line( - format!(" for up to {business_cap} members"), - team.uid, - appearance, - )) } - /// Builds the "Upgrade to Business for up to N members" line. - /// Clicking the link routes through the existing self-serve upgrade flow. + /// "Want to grow your team? ." — routes through self-serve upgrade. fn render_outgrow_upgrade_line( &self, - cap_phrase: String, 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 Business".to_string(), + "Upgrade".to_string(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action(TeamsPageAction::GenerateUpgradeLink { team_uid }); @@ -3081,26 +2991,24 @@ impl TeamsWidget { .soft_wrap(false) .build() .finish(); - - let trailing = self.render_sub_text(cap_phrase, appearance, None); + let suffix = self.render_sub_text(".".to_string(), appearance, None); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) + .with_child(prefix) .with_child(link) - .with_child(trailing) + .with_child(suffix) .finish() } - /// Builds the "Reach out to sales@warp.dev for more seats" - /// line. Clicking the email opens a `mailto:` to sales via the shared - /// `ContactSales` action. + /// "Want to grow your team? ." — opens a mailto. fn render_outgrow_contact_sales_line(&self, appearance: &Appearance) -> Box { - let prefix = self.render_sub_text("Reach out to ".to_string(), appearance, None); + let prefix = self.render_sub_text("Want to grow your team? ".to_string(), appearance, None); let link = appearance .ui_builder() .link( - "sales@warp.dev".into(), + "Contact sales@warp.dev".into(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action(TeamsPageAction::ContactSales); @@ -3110,7 +3018,7 @@ impl TeamsWidget { .soft_wrap(false) .build() .finish(); - let suffix = self.render_sub_text(" for more seats".to_string(), appearance, None); + let suffix = self.render_sub_text(".".to_string(), appearance, None); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) @@ -3131,14 +3039,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( @@ -3146,7 +3047,7 @@ impl TeamsWidget { appearance, Some(Coords::uniform(0.).right(48.)), )) - .with_padding_top(8.) + .with_padding_top(16.) .finish(), ); @@ -3260,9 +3161,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, @@ -3304,9 +3208,8 @@ impl TeamsWidget { current_user_email: &str, appearance: &Appearance, ) -> Box { - // Header + subtext stacked on the left, toggle on the right. The text - // is in a column so the toggle is vertically centered against the - // combined header + subtext block, matching the "By link" pattern. + // 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); let domain = current_user_email.split('@').nth(1).unwrap_or(""); @@ -3858,9 +3761,8 @@ impl TeamsWidget { .finish() } - /// Smaller in-page header used under a `render_subsection_header`, e.g. - /// "By link" / "By email" under "Invite team members". Matches the - /// subsection style (Medium weight, muted gray) but at a smaller font size. + /// 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, @@ -3873,13 +3775,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(60) - .into(), - ), + font_color: Some(appearance.theme().active_ui_text_color().into()), font_size: Some(SUBSUBSECTION_HEADER_FONT_SIZE), ..Default::default() }) @@ -4290,12 +4186,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); From b36a2d6d3163c70d29b335ca88e2f323914d33f8 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Tue, 12 May 2026 16:34:18 -0400 Subject: [PATCH 05/10] cleanup/copy tweaks --- app/src/settings_view/teams_page.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index 35c180a642..088076fbcd 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -1903,7 +1903,7 @@ impl TeamsWidget { } }; let body_prefix = if is_over_cap { - "You've exceeded your plan's member limit. Existing members keep their access, but you won't be able to add new members." + "You've exceeded your plan's member limit. Existing team members keep their access, but you won't be able to add new members." } else { "You've reached your plan's member limit." }; @@ -2971,7 +2971,7 @@ impl TeamsWidget { } } - /// "Want to grow your team? ." — routes through self-serve upgrade. + /// "Want to grow your team? " — routes through self-serve upgrade. fn render_outgrow_upgrade_line( &self, team_uid: ServerId, @@ -2991,18 +2991,16 @@ impl TeamsWidget { .soft_wrap(false) .build() .finish(); - let suffix = self.render_sub_text(".".to_string(), appearance, None); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_child(prefix) .with_child(link) - .with_child(suffix) .finish() } - /// "Want to grow your team? ." — opens a mailto. + /// "Want to grow your team? " — opens a mailto. 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 @@ -3018,14 +3016,12 @@ impl TeamsWidget { .soft_wrap(false) .build() .finish(); - let suffix = self.render_sub_text(".".to_string(), appearance, None); Flex::row() .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_main_axis_size(MainAxisSize::Min) .with_child(prefix) .with_child(link) - .with_child(suffix) .finish() } From 50b109cefbb6c57ff8c5a3c6db4de54f1f05ed20 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Wed, 13 May 2026 10:33:05 -0400 Subject: [PATCH 06/10] Gate seat-cap Upgrade CTA on non-Enterprise tier Co-Authored-By: Oz --- app/src/settings_view/teams_page.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index 088076fbcd..f1386d51a4 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -1826,7 +1826,10 @@ impl TeamsWidget { if billing_metadata.is_on_build_business_plan() { return SeatCapCta::ContactSales; } - if Self::has_higher_seat_cap_plan_available(workspace_size_policy, pricing_info) { + let is_enterprise = billing_metadata.customer_type == CustomerType::Enterprise; + if !is_enterprise + && Self::has_higher_seat_cap_plan_available(workspace_size_policy, pricing_info) + { SeatCapCta::Upgrade } else { SeatCapCta::None @@ -2757,9 +2760,6 @@ impl TeamsWidget { } } 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. @@ -2778,6 +2778,7 @@ impl TeamsWidget { appearance, None, )); + let team_uid = team.uid; manage_billing_link_line.add_child( appearance .ui_builder() From e01cbe18c6fc50ed66a0180ee85447316f058645 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Thu, 14 May 2026 13:30:07 -0400 Subject: [PATCH 07/10] Point Contact sales CTAs at warp.dev/contact-sales Replaces the mailto:sales@warp.dev link in AdminActions::contact_sales with https://warp.dev/contact-sales, and updates the seat-cap alert body copy and outgrow footer link text from 'Contact sales@warp.dev' to 'Contact sales' so the visible label no longer suggests an email handoff. Co-Authored-By: Oz --- app/src/settings_view/admin_actions.rs | 4 ++-- app/src/settings_view/teams_page.rs | 8 +++----- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/app/src/settings_view/admin_actions.rs b/app/src/settings_view/admin_actions.rs index 50d2bbdf17..f9857e1dfe 100644 --- a/app/src/settings_view/admin_actions.rs +++ b/app/src/settings_view/admin_actions.rs @@ -21,9 +21,9 @@ impl AdminActions { ctx.open_url("mailto:support@warp.dev"); } - /// Open the sales email link + /// Open the contact sales page pub fn contact_sales(ctx: &mut AppContext) { - ctx.open_url("mailto:sales@warp.dev"); + ctx.open_url("https://warp.dev/contact-sales"); } } diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index f1386d51a4..c9c657fd47 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -1900,9 +1900,7 @@ impl TeamsWidget { } else { match cta { SeatCapCta::Upgrade => "Upgrade to grow your team.", - SeatCapCta::ContactSales | SeatCapCta::None => { - "Contact sales@warp.dev to grow your team." - } + SeatCapCta::ContactSales | SeatCapCta::None => "Contact sales to grow your team.", } }; let body_prefix = if is_over_cap { @@ -3001,13 +2999,13 @@ impl TeamsWidget { .finish() } - /// "Want to grow your team? " — opens a mailto. + /// "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@warp.dev".into(), + "Contact sales".into(), None, Some(Box::new(move |ctx| { ctx.dispatch_typed_action(TeamsPageAction::ContactSales); From c5a1db35422a15bdcae93c4feb7ed69a7faaf573 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 15 May 2026 16:20:27 -0400 Subject: [PATCH 08/10] Generalize seat-cap alert into a grow-team warning banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seat-cap alert at the top of Settings → Teams now covers every reason team growth is blocked: seat cap reached, seat cap exceeded, payment past due, and unpaid subscription. Title, body, CTA sentence, and CTA button each branch on the warning variant + viewer admin state + plan tier. The delinquency UI inside the by-email section is gone — the form just renders disabled when any warning is active, and the recovery action (Stripe portal for self-serve admins, Contact support for enterprise) lives in the banner CTA button. The outgrow footer is also suppressed while delinquent so we don't duplicate messaging. Also folds in earlier PR feedback: new BillingMetadata::is_enterprise_plan helper, dropped a stale clippy::too_many_arguments allow, and dropped an unused workspace_size_policy param threading. Co-Authored-By: Oz --- app/src/settings_view/teams_page.rs | 488 ++++++++++++++-------------- app/src/workspaces/workspace.rs | 4 + 2 files changed, 240 insertions(+), 252 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index c9c657fd47..292bb7ff1b 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -125,14 +125,6 @@ const INVALID_EMAILS_INSTRUCTIONS: &str = const OFFLINE_TEXT: &str = "You are offline."; -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 MAX_CHIP_WIDTH: f32 = 280.; lazy_static! { @@ -310,12 +302,10 @@ struct TeamsWidgetMouseHandles { stripe_billing_portal_link: MouseStateHandle, manage_plan_link: MouseStateHandle, enterprise_contact_us_link: MouseStateHandle, - invite_by_email_billing_portal_link: MouseStateHandle, discoverable_team_toggle_state: SwitchStateHandle, checkbox_mouse_state: MouseStateHandle, admin_panel_button: MouseStateHandle, - contact_sales_button: MouseStateHandle, - seat_cap_upgrade_button: MouseStateHandle, + grow_team_warning_cta_button: MouseStateHandle, team_members_count_tooltip: MouseStateHandle, outgrow_upgrade_link: MouseStateHandle, outgrow_contact_sales_link: MouseStateHandle, @@ -352,14 +342,35 @@ 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 SeatCapCta { +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, - /// No actionable CTA: viewer is non-admin, team is delinquent, or no - /// higher-cap plan exists. + /// 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, } @@ -1233,20 +1244,17 @@ impl TeamsPageView { self.update_email_editor_interaction_state(ctx); } - /// Disables the invite-by-email chip editor when the team is at its - /// seat cap. Visual styling is unchanged; only interaction is blocked. + /// 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 cap_reached = self + let blocked = self .user_workspaces .as_ref(ctx) .current_team() - .and_then(|team| { - let policy = team.billing_metadata.tier.workspace_size_policy?; - let team_size = i64::try_from(team.members.len()).unwrap_or(0); - Some(!policy.is_unlimited && team_size >= policy.limit) - }) + .map(|team| TeamsWidget::grow_team_warning(team).is_some()) .unwrap_or(false); - let state = if cap_reached { + let state = if blocked { InteractionState::Disabled } else { InteractionState::Editable @@ -1805,34 +1813,69 @@ impl TeamsWidget { Some((monthly_cost, yearly_cost)) } - /// Maps an admin's actionable path out of a seat-cap warning - fn seat_cap_cta( + 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, - workspace_size_policy: &WorkspaceSizePolicy, pricing_info: &PricingInfoModel, - ) -> SeatCapCta { + ) -> GrowTeamWarningCta { if !has_admin_permissions { - return SeatCapCta::None; - } - if matches!( - billing_metadata.delinquency_status, - DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid, - ) { - return SeatCapCta::None; + return GrowTeamWarningCta::None; } - // 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 SeatCapCta::ContactSales; - } - let is_enterprise = billing_metadata.customer_type == CustomerType::Enterprise; - if !is_enterprise - && Self::has_higher_seat_cap_plan_available(workspace_size_policy, pricing_info) - { - SeatCapCta::Upgrade - } else { - SeatCapCta::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; + } + // similar idea for enterprises... although we usually don't limit + // enterprises' seats in practice + if billing_metadata.is_enterprise_plan() { + return GrowTeamWarningCta::ContactSales; + } + 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 + } + } } } @@ -1850,15 +1893,12 @@ impl TeamsWidget { .any(|max| i64::from(max) > workspace_size_policy.limit) } - /// Renders the red "Your team is full" alert. CTA only shown when - /// `seat_cap_cta` is `Upgrade` or `ContactSales` (admin + actionable path). - /// Non-admins / delinquent teams / teams at the top of the self-serve - /// ladder with no higher-cap plan just see the body copy. - fn render_seat_cap_alert( + /// 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, - workspace_size_policy: &WorkspaceSizePolicy, pricing_info: &PricingInfoModel, appearance: &Appearance, ) -> Box { @@ -1879,35 +1919,61 @@ impl TeamsWidget { .with_margin_right(horizontal_padding) .finish(); - let team_size = i64::try_from(team.members.len()).unwrap_or(i64::MAX); - let is_over_cap = team_size > workspace_size_policy.limit; - - let title = if is_over_cap { - "You've exceeded your member limit" - } else { - "Your team is full" + 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::seat_cap_cta( + let cta = Self::grow_team_warning_cta( + warning, has_admin_permissions, &team.billing_metadata, - workspace_size_policy, 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 { - "Contact a team admin to grow the team." + if is_delinquency { + "Contact a team admin to restore access." + } else { + "Contact a team admin to grow the team." + } } else { match cta { - SeatCapCta::Upgrade => "Upgrade to grow your team.", - SeatCapCta::ContactSales | SeatCapCta::None => "Contact sales to grow your team.", + 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_prefix = if is_over_cap { - "You've exceeded your plan's member limit. Existing team members keep their access, but you won't be able to add new members." - } else { - "You've reached your plan's member limit." - }; 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) @@ -1929,20 +1995,29 @@ impl TeamsWidget { .with_main_axis_size(MainAxisSize::Max) .with_child(Shrinkable::new(1., left_content).finish()); - // CTA button only renders when there's an actionable path. - if let Some((cta_label, cta_action, cta_mouse_state)) = match cta { - SeatCapCta::Upgrade => Some(( + // 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 }, - self.mouse_state_handles.seat_cap_upgrade_button.clone(), )), - SeatCapCta::ContactSales => Some(( - "Contact sales", - TeamsPageAction::ContactSales, - self.mouse_state_handles.contact_sales_button.clone(), + GrowTeamWarningCta::ContactSales => { + Some(("Contact sales", TeamsPageAction::ContactSales)) + } + GrowTeamWarningCta::UpdateBilling => Some(( + "Update billing", + TeamsPageAction::GenerateStripeBillingPortalLink { team_uid: team.uid }, )), - SeatCapCta::None => None, + 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.), @@ -2109,19 +2184,14 @@ impl TeamsWidget { ); // 3) Team invitation flows (invite link / email invites / discovery) - 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, - )); - }; + main_content.add_child(self.render_team_invitation_section( + team_metadata, + has_admin_permissions, + view, + appearance, + chip_editor_style, + app, + )); // 4) Horizontal separator between the invite flows and the team members // list. 32px of breathing room above and below to match the design. @@ -2482,7 +2552,6 @@ impl TeamsWidget { section.finish() } - #[allow(clippy::too_many_arguments)] fn render_team_invitation_section( &self, team_metadata: &Team, @@ -2490,26 +2559,23 @@ impl TeamsWidget { view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, - workspace_size_policy: WorkspaceSizePolicy, app: &AppContext, ) -> Box { let mut invitation_section = Flex::column(); - // Optional "Team is full" warning box. - let team_size_i64 = i64::try_from(team_metadata.members.len()).unwrap_or(1); - let is_full = - !workspace_size_policy.is_unlimited && team_size_i64 >= workspace_size_policy.limit; + // "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 is_full { - let cap_alert = self.render_seat_cap_alert( + if let Some(warning) = warning { + let alert = self.render_grow_team_warning_alert( team_metadata, + warning, has_admin_permissions, - &workspace_size_policy, pricing_info_model, appearance, ); invitation_section - .add_child(Container::new(cap_alert).with_padding_bottom(24.).finish()); + .add_child(Container::new(alert).with_padding_bottom(24.).finish()); } invitation_section.add_child( @@ -2546,14 +2612,14 @@ 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, - has_admin_permissions, - is_full, + warning.is_some(), )); // By discovery — third invitation method, same hierarchical level as @@ -2678,15 +2744,13 @@ impl TeamsWidget { section.finish() } - #[allow(clippy::too_many_arguments)] fn render_invite_by_email_section( &self, team: &Team, view: &TeamsPageView, appearance: &Appearance, chip_editor_style: UiComponentStyles, - has_admin_permissions: bool, - cap_reached: bool, + force_disabled: bool, ) -> Box { let mut section = Flex::column(); @@ -2698,139 +2762,58 @@ impl TeamsWidget { .finish(), ); - match team.billing_metadata.delinquency_status { - DelinquencyStatus::Unknown - | DelinquencyStatus::NoDelinquency - | DelinquencyStatus::TeamLimitExceeded => { - // Form stays visually unchanged at seat cap; the chip editor - // is disabled via `update_email_editor_interaction_state` and - // the send button is force-disabled below. - 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( - 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, - cap_reached, - )) - .finish(), - ); + // 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(), + ); - // Skip the "invalid emails" hint when the form is disabled. - if !cap_reached - && !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, - ), + 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, ) - .with_padding_top(8.) + .build() .finish(), ) - } - } - DelinquencyStatus::PastDue | DelinquencyStatus::Unpaid => { - 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, - )); - let team_uid = team.uid; - 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.)), - ) - }; + .finish(), + ) + .with_child(self.render_send_email_invites_button( + team.uid, + view, + appearance, + force_disabled, + )) + .finish(), + ); - section.add_child(delinquent_text); - } - }; + // 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(), + ) + } section.finish() } @@ -2938,8 +2921,10 @@ impl TeamsWidget { .finish() } - /// Footer CTA below the team-members list. Reuses `seat_cap_cta` so the - /// page speaks with one voice; omitted when the CTA is `None`. + /// Footer CTA below the team-members list. Reuses `grow_team_warning_cta` + /// (with a synthetic `SeatCapReached` so we get the cap-related branches) + /// so the page speaks with one voice. Skipped while the team is delinquent + /// because the top warning banner already owns that messaging. fn render_outgrow_cta( &self, team: &Team, @@ -2947,26 +2932,25 @@ impl TeamsWidget { pricing_info: &PricingInfoModel, appearance: &Appearance, ) -> Option> { - // Fall back to an unlimited policy when tier data is missing; this - // yields no Upgrade path but still surfaces ContactSales on Business. - let policy = - team.billing_metadata - .tier - .workspace_size_policy - .unwrap_or(WorkspaceSizePolicy { - is_unlimited: true, - limit: 0, - }); - let cta = Self::seat_cap_cta( + 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, - &policy, pricing_info, ); match cta { - SeatCapCta::Upgrade => Some(self.render_outgrow_upgrade_line(team.uid, appearance)), - SeatCapCta::ContactSales => Some(self.render_outgrow_contact_sales_line(appearance)), - SeatCapCta::None => None, + 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, } } 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 From ce2689e2448f1d22f36a2210694de454c124d446 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 15 May 2026 16:47:19 -0400 Subject: [PATCH 09/10] comments --- app/src/settings_view/teams_page.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index 292bb7ff1b..e861467691 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -1862,10 +1862,8 @@ impl TeamsWidget { if billing_metadata.is_on_build_business_plan() { return GrowTeamWarningCta::ContactSales; } - // similar idea for enterprises... although we usually don't limit - // enterprises' seats in practice if billing_metadata.is_enterprise_plan() { - return GrowTeamWarningCta::ContactSales; + return GrowTeamWarningCta::None; } let Some(policy) = billing_metadata.tier.workspace_size_policy else { return GrowTeamWarningCta::None; @@ -2210,7 +2208,7 @@ impl TeamsWidget { appearance, )); - // 6.5) Optional outgrow CTA + // 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, @@ -2225,7 +2223,7 @@ impl TeamsWidget { ); } - // 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; @@ -2921,10 +2919,7 @@ impl TeamsWidget { .finish() } - /// Footer CTA below the team-members list. Reuses `grow_team_warning_cta` - /// (with a synthetic `SeatCapReached` so we get the cap-related branches) - /// so the page speaks with one voice. Skipped while the team is delinquent - /// because the top warning banner already owns that messaging. + // "Want to upgrade your team? " fn render_outgrow_cta( &self, team: &Team, From b15921ccf004c7875c2ef2589551dd40b5343697 Mon Sep 17 00:00:00 2001 From: Isaiah Date: Fri, 15 May 2026 16:51:41 -0400 Subject: [PATCH 10/10] fmt --- app/src/settings_view/teams_page.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/app/src/settings_view/teams_page.rs b/app/src/settings_view/teams_page.rs index e861467691..1d69168ef1 100644 --- a/app/src/settings_view/teams_page.rs +++ b/app/src/settings_view/teams_page.rs @@ -2572,8 +2572,7 @@ impl TeamsWidget { pricing_info_model, appearance, ); - invitation_section - .add_child(Container::new(alert).with_padding_bottom(24.).finish()); + invitation_section.add_child(Container::new(alert).with_padding_bottom(24.).finish()); } invitation_section.add_child( @@ -2780,12 +2779,9 @@ impl TeamsWidget { .with_child( Shrinkable::new( 1., - TextInput::new( - view.email_invites_block_editor.clone(), - chip_editor_style, - ) - .build() - .finish(), + TextInput::new(view.email_invites_block_editor.clone(), chip_editor_style) + .build() + .finish(), ) .finish(), )