diff --git a/app/src/ai/onboarding.rs b/app/src/ai/onboarding.rs index d425e773793..a19f2cd6fe3 100644 --- a/app/src/ai/onboarding.rs +++ b/app/src/ai/onboarding.rs @@ -1,13 +1,16 @@ -//! Onboarding-specific AI types and conversions. +//! Onboarding-specific AI types, conversions and credit helpers. use ai::LLMId; -use onboarding::OnboardingAuthState; use onboarding::slides::OnboardingModelInfo; +use onboarding::{CreditPackOption, OnboardingAuthState}; use warp_core::ui::icons::Icon; -use warpui::{AppContext, SingletonEntity}; +use warpui::{AppContext, SingletonEntity, WindowId}; +use super::AIRequestUsageModel; use super::llms::{LLMInfo, LLMPreferences}; use crate::auth::AuthStateProvider; +use crate::pricing::{PricingInfoModel, onboarding_credit_pack_options}; +use crate::server::ids::ServerId; use crate::workspaces::user_workspaces::UserWorkspaces; impl From<&LLMInfo> for OnboardingModelInfo { @@ -52,3 +55,38 @@ pub fn current_onboarding_auth_state(ctx: &AppContext) -> OnboardingAuthState { OnboardingAuthState::FreeUser } } + +/// The ad-hoc credit packs to offer during onboarding, priced for the current +/// viewer. Empty when the server hasn't sent pricing yet or the viewer's plan +/// can't buy packs at all, which hides the option. +pub fn onboarding_credit_packs(ctx: &AppContext) -> Vec { + let workspaces = UserWorkspaces::as_ref(ctx); + let Some(policy) = workspaces.purchase_policy() else { + return Vec::new(); + }; + if !policy.allows_purchases() { + return Vec::new(); + } + let Some(options) = PricingInfoModel::as_ref(ctx).addon_credits_options() else { + return Vec::new(); + }; + onboarding_credit_pack_options(options, policy.effective_premium_bps()) +} + +/// The team to bill an onboarding credit-pack purchase to: whichever team the +/// window is currently scoped to. Usually `None` during onboarding, which lets +/// the server resolve or create the buyer's personal team — but team discovery +/// and domain capture can land a user on a team during signup, and in that case +/// the server should be told which one rather than being handed `None`. +pub fn onboarding_purchase_team_uid(window_id: WindowId, ctx: &AppContext) -> Option { + UserWorkspaces::as_ref(ctx).team_uid_for_window(window_id) +} + +/// The server-authoritative answer to "can this user start an AI request right +/// now". `None` until the first answer arrives, which onboarding treats as "not +/// yet known" rather than as availability. +pub fn has_ai_credit_availability(ctx: &AppContext) -> bool { + AIRequestUsageModel::as_ref(ctx) + .server_availability() + .is_some_and(|availability| availability.available) +} diff --git a/app/src/pricing/mod.rs b/app/src/pricing/mod.rs index 3ab5e5258cc..0b218dfe82e 100644 --- a/app/src/pricing/mod.rs +++ b/app/src/pricing/mod.rs @@ -1,8 +1,42 @@ +use onboarding::CreditPackOption; use warp_graphql::billing::{ AddonCreditsOption, OveragesPricing, PlanPricing, PricingInfo, StripeSubscriptionPlan, }; use warpui::{Entity, ModelContext, SingletonEntity}; +/// Converts the server's add-on credit packs into the display options shown on +/// the onboarding offer slide. +/// +/// `premium_bps` is the viewer's `PurchaseAddOnCreditsPolicy` surcharge (see +/// [`crate::workspaces::workspace::PurchaseAddOnCreditsPolicy::effective_premium_bps`]), +/// applied with the same integer math the server charges with, so the price we +/// show is the price billed. Savings are computed against the smallest pack's +/// per-credit list rate — the premium scales every pack equally, so it doesn't +/// change the relative volume discount. +pub fn onboarding_credit_pack_options( + options: &[AddonCreditsOption], + premium_bps: i32, +) -> Vec { + let base_rate = options.first().map_or(0., |option| option.rate()); + options + .iter() + .map(|option| { + let savings_percent = if base_rate > 0. { + (((base_rate - option.rate()) / base_rate) * 100.) + .round() + .max(0.) as u32 + } else { + 0 + }; + CreditPackOption { + credits: option.credits, + price_usd_cents: option.price_usd_cents_with_premium(premium_bps), + savings_percent, + } + }) + .collect() +} + /// A global model for maintaining pricing information from the server. #[derive(Debug)] pub struct PricingInfoModel { @@ -83,3 +117,7 @@ impl Entity for PricingInfoModel { } impl SingletonEntity for PricingInfoModel {} + +#[cfg(test)] +#[path = "pricing_tests.rs"] +mod tests; diff --git a/app/src/pricing/pricing_tests.rs b/app/src/pricing/pricing_tests.rs new file mode 100644 index 00000000000..c48272e1b17 --- /dev/null +++ b/app/src/pricing/pricing_tests.rs @@ -0,0 +1,89 @@ +use warp_graphql::billing::AddonCreditsOption; + +use super::onboarding_credit_pack_options; + +/// The production add-on credit packs (`GetAddonCreditsOptions` on the server). +fn production_packs() -> Vec { + [ + (400, 1_000), + (1_000, 2_000), + (3_000, 5_000), + (6_500, 10_000), + ] + .into_iter() + .map(|(credits, price_usd_cents)| AddonCreditsOption { + credits, + price_usd_cents, + }) + .collect() +} + +#[test] +fn subscriber_packs_are_offered_at_list_price() { + let packs = onboarding_credit_pack_options(&production_packs(), 0); + + let prices: Vec<_> = packs.iter().map(|pack| pack.price_label()).collect(); + assert_eq!(prices, ["$10", "$20", "$50", "$100"]); +} + +/// Free-plan buyers pay the `price_premium_bps` surcharge (2000 bps = +20%) on +/// top of the list price. Regression test for REV-1886: the onboarding offer +/// must show the premium-adjusted price the server actually charges, never the +/// list price. +#[test] +fn free_plan_packs_apply_the_twenty_percent_premium() { + let packs = onboarding_credit_pack_options(&production_packs(), 2_000); + + let labels: Vec<_> = packs + .iter() + .map(|pack| (pack.credits_label(), pack.price_label())) + .collect(); + assert_eq!( + labels, + [ + ("400".to_string(), "$12".to_string()), + ("1,000".to_string(), "$24".to_string()), + ("3,000".to_string(), "$60".to_string()), + ("6,500".to_string(), "$120".to_string()), + ] + ); +} + +/// Volume savings are relative to the smallest pack's per-credit rate, and are +/// unaffected by the premium (which scales every pack equally). +#[test] +fn volume_savings_are_relative_to_the_smallest_pack() { + for premium_bps in [0, 2_000] { + let packs = onboarding_credit_pack_options(&production_packs(), premium_bps); + + let savings: Vec<_> = packs.iter().map(|pack| pack.savings_percent).collect(); + assert_eq!(savings, [0, 20, 33, 38], "premium_bps = {premium_bps}"); + } +} + +#[test] +fn no_packs_produces_no_options() { + assert!(onboarding_credit_pack_options(&[], 2_000).is_empty()); +} + +/// A pack that is a worse per-credit deal than the smallest one must not +/// render a negative or wrapped-around "savings" badge. +#[test] +fn packs_worse_than_the_base_rate_show_no_savings() { + let packs = vec![ + AddonCreditsOption { + credits: 400, + price_usd_cents: 1_000, + }, + AddonCreditsOption { + credits: 400, + price_usd_cents: 1_500, + }, + ]; + + let savings: Vec<_> = onboarding_credit_pack_options(&packs, 0) + .iter() + .map(|pack| pack.savings_percent) + .collect(); + assert_eq!(savings, [0, 0]); +} diff --git a/app/src/root_view.rs b/app/src/root_view.rs index a7e110f7c3b..078b109608f 100644 --- a/app/src/root_view.rs +++ b/app/src/root_view.rs @@ -41,7 +41,11 @@ use crate::ai::AIRequestUsageModel; use crate::ai::agent::api::ServerConversationToken; use crate::ai::blocklist::SerializedBlockListItem; use crate::ai::llms::{LLMPreferences, LLMPreferencesEvent}; -use crate::ai::onboarding::{build_onboarding_models, current_onboarding_auth_state}; +use crate::ai::onboarding::{ + build_onboarding_models, current_onboarding_auth_state, has_ai_credit_availability, + onboarding_credit_packs, onboarding_purchase_team_uid, +}; +use crate::ai::request_usage_model::AIRequestUsageModelEvent; use crate::app_state::{AppState, PaneUuid, WindowSnapshot}; use crate::appearance::Appearance; use crate::auth::auth_manager::{AuthManager, AuthManagerEvent}; @@ -71,6 +75,7 @@ use crate::linear::LinearIssueWork; use crate::notebooks::manager::NotebookSource; use crate::pane_group::{NewTerminalOptions, PanesLayout}; use crate::persistence::ModelEvent; +use crate::pricing::{PricingInfoModel, PricingInfoModelEvent}; use crate::server::cloud_objects::update_manager::UpdateManager; use crate::server::ids::{ServerId, SyncId}; use crate::server::server_api::auth::UserAuthenticationError; @@ -140,6 +145,47 @@ fn offer_variant_for_account_class(account_class: FtueAccountClass) -> Option, + event: &UserWorkspacesEvent, + ctx: &mut ViewContext, +) { + if !onboarding_view + .as_ref(ctx) + .is_awaiting_purchased_credits(ctx) + { + return; + } + match event { + UserWorkspacesEvent::PurchaseAddonCreditsSuccess => { + onboarding_view.update(ctx, |onboarding_view, ctx| { + onboarding_view.on_credit_purchase_completed(ctx); + }); + } + UserWorkspacesEvent::PurchaseAddonCreditsCheckoutRequired { checkout_url } => { + let checkout_url = checkout_url.clone(); + onboarding_view.update(ctx, |onboarding_view, ctx| { + onboarding_view.on_credit_purchase_checkout_opened(ctx); + }); + ctx.open_url(&checkout_url); + } + UserWorkspacesEvent::PurchaseAddonCreditsRejected(err) => { + safe_error!( + safe: ("Onboarding add-on credits purchase failed"), + full: ("Onboarding add-on credits purchase failed: {err}") + ); + onboarding_view.update(ctx, |onboarding_view, ctx| { + onboarding_view.on_credit_purchase_failed(ctx); + }); + } + _ => {} + } +} + #[derive(Debug, Clone)] enum WindowState { /// Quake mode window is open and visible on the screen. @@ -1690,6 +1736,9 @@ enum AccountFirstCompletion { PaidTeam, FreeIcpSetupLater, FreeStandardSetupLater, + /// The user bought a one-time credit pack on the offer slide instead of + /// subscribing. They stay on the free plan, so they remain free-standard. + FreeStandardCreditsPurchased, UpgradeCompleted, } @@ -1700,6 +1749,9 @@ impl AccountFirstCompletion { AccountFirstCompletion::PaidTeam => "paid_team", AccountFirstCompletion::FreeIcpSetupLater => "free_icp_setup_later", AccountFirstCompletion::FreeStandardSetupLater => "free_standard_setup_later", + AccountFirstCompletion::FreeStandardCreditsPurchased => { + "free_standard_credits_purchased" + } AccountFirstCompletion::UpgradeCompleted => "upgrade_completed", } } @@ -1711,7 +1763,10 @@ impl AccountFirstCompletion { Some(FtueAccountClass::Paid) } AccountFirstCompletion::FreeIcpSetupLater => Some(FtueAccountClass::FreeIcp), - AccountFirstCompletion::FreeStandardSetupLater => Some(FtueAccountClass::FreeStandard), + AccountFirstCompletion::FreeStandardSetupLater + | AccountFirstCompletion::FreeStandardCreditsPurchased => { + Some(FtueAccountClass::FreeStandard) + } } } @@ -1721,6 +1776,7 @@ impl AccountFirstCompletion { AccountFirstCompletion::PaidTeam | AccountFirstCompletion::FreeIcpSetupLater | AccountFirstCompletion::FreeStandardSetupLater + | AccountFirstCompletion::FreeStandardCreditsPurchased | AccountFirstCompletion::UpgradeCompleted ) } @@ -2124,7 +2180,7 @@ impl RootView { let auth_state = current_onboarding_auth_state(ctx); - AgentOnboardingView::new( + let mut view = AgentOnboardingView::new( themes.clone(), false, // Always use unskippable onboarding. models, @@ -2133,9 +2189,24 @@ impl RootView { FeatureFlag::AgentView.is_enabled(), auth_state, ctx, - ) + ); + view.set_credit_pack_options(onboarding_credit_packs(ctx), ctx); + view }); + // Keep the offer slide's credit packs in sync with server pricing. + let onboarding_view_for_pricing = onboarding_view.clone(); + ctx.subscribe_to_model( + &PricingInfoModel::handle(ctx), + move |_, _pricing, event, ctx| { + let PricingInfoModelEvent::PricingInfoUpdated = event; + let options = onboarding_credit_packs(ctx); + onboarding_view_for_pricing.update(ctx, |onboarding_view, ctx| { + onboarding_view.set_credit_pack_options(options, ctx); + }); + }, + ); + let onboarding_view_clone = onboarding_view.clone(); ctx.subscribe_to_model( &LLMPreferences::handle(ctx), @@ -2169,9 +2240,38 @@ impl RootView { .set_workspace_enforces_autonomy(workspace_enforces_autonomy, ctx); }); } + handle_onboarding_credit_purchase_event( + &onboarding_view_for_workspaces, + event, + ctx, + ); let auth_state = current_onboarding_auth_state(ctx); + let credit_pack_options = onboarding_credit_packs(ctx); onboarding_view_for_workspaces.update(ctx, |onboarding_view, ctx| { onboarding_view.set_auth_state(auth_state, ctx); + // The purchase policy (and so the premium) comes from the + // user's workspace, so a metadata refresh can move the + // displayed prices. + onboarding_view.set_credit_pack_options(credit_pack_options, ctx); + }); + }, + ); + + // Browser checkout doesn't report back to the app, so the purchase is + // only complete once the server reports the user can make AI requests. + let onboarding_view_for_usage = onboarding_view.clone(); + ctx.subscribe_to_model( + &AIRequestUsageModel::handle(ctx), + move |_, _usage, event, ctx| { + if !matches!(event, AIRequestUsageModelEvent::CreditAvailabilityUpdated) { + return; + } + // The view completes the purchase only when the server says AI + // is available, so a user who cancels checkout without gaining + // access stays on the slide. + let available = has_ai_credit_availability(ctx); + onboarding_view_for_usage.update(ctx, |onboarding_view, ctx| { + onboarding_view.on_ai_credit_availability_observed(available, ctx); }); }, ); @@ -2849,12 +2949,35 @@ impl RootView { self.complete_account_first(AccountFirstCompletion::FreeStandardSetupLater, ctx) } }, + AgentOnboardingEvent::PurchaseCreditsRequested { credits } => { + // Bill whichever team this window is scoped to. That is usually + // `None` during onboarding, which lets the server resolve or + // create the buyer's personal team — but team discovery and + // domain capture can land a user on a team during signup, and + // the server should be told which one rather than handed `None`. + let credits = *credits; + let team_uid = onboarding_purchase_team_uid(ctx.window_id(), ctx); + UserWorkspaces::handle(ctx).update(ctx, |user_workspaces, ctx| { + user_workspaces.purchase_addon_credits(team_uid, credits, ctx); + }); + } + AgentOnboardingEvent::OfferCreditsPurchased { variant } => match variant { + // Only the free-standard offer surfaces credit packs. + OfferVariant::ChooseHowToStart => self.complete_account_first( + AccountFirstCompletion::FreeStandardCreditsPurchased, + ctx, + ), + OfferVariant::HeadStart => {} + }, AgentOnboardingEvent::AppBecameActive => { // fetch the models / workspace metadata when the user tabs/intents back // into the app during onboarding after potentially upgrading LLMPreferences::handle(ctx).update(ctx, |prefs, ctx| { prefs.refresh_available_models(ctx); }); + // The workspace-metadata refresh above also carries the + // server's AI credit availability decision, which is what lets + // a checkout-pending onboarding purchase complete. TeamUpdateManager::handle(ctx).update(ctx, |manager, ctx| { drop(manager.refresh_workspace_metadata(ctx)); }); diff --git a/app/src/root_view_tests.rs b/app/src/root_view_tests.rs index 8a9d8b263d8..0d7054169d9 100644 --- a/app/src/root_view_tests.rs +++ b/app/src/root_view_tests.rs @@ -136,6 +136,14 @@ fn account_first_completion_metadata_matches_terminal_outcomes() { Some(FtueAccountClass::FreeStandard), true, ), + ( + AccountFirstCompletion::FreeStandardCreditsPurchased, + "free_standard_credits_purchased", + // Buying an ad-hoc credit pack does not put the user on a plan, so + // they stay free-standard. + Some(FtueAccountClass::FreeStandard), + true, + ), ( AccountFirstCompletion::UpgradeCompleted, "upgrade_completed", diff --git a/crates/onboarding/src/agent_onboarding_view.rs b/crates/onboarding/src/agent_onboarding_view.rs index a5daf8b7e70..7cc9f2f8cb3 100644 --- a/crates/onboarding/src/agent_onboarding_view.rs +++ b/crates/onboarding/src/agent_onboarding_view.rs @@ -11,8 +11,8 @@ use warpui_core::windowing::state::{ApplicationStage, StateEvent}; use crate::components::feature_optout_dialog::{FeatureOptOutDialog, render_feature_optout_dialog}; use crate::model::{ - OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel, OnboardingStep, - SelectedSettings, + CreditPackOption, OnboardingAuthState, OnboardingStateEvent, OnboardingStateModel, + OnboardingStep, SelectedSettings, }; use crate::slides::{ AgentSlide, AiAccessSlide, AiAccessSlideEvent, AiSetupSlide, CustomizeUISlide, IntentionSlide, @@ -69,6 +69,18 @@ pub enum AgentOnboardingEvent { OfferSetUpLaterSelected { variant: OfferVariant, }, + /// The user chose to buy a one-time credit pack on the offer slide. The app + /// owns the purchase mutation, so it performs the purchase and reports the + /// outcome back through [`AgentOnboardingView::on_credit_purchase_completed`] + /// and its siblings. + PurchaseCreditsRequested { + credits: i32, + }, + /// The purchased credits landed on the account, so onboarding is done for + /// this user. + OfferCreditsPurchased { + variant: OfferVariant, + }, /// Emitted when the app regains focus (e.g. user returns from the browser). /// The parent should refresh any stale data: available models, workspace/billing metadata, etc. AppBecameActive, @@ -175,6 +187,12 @@ impl AgentOnboardingView { OnboardingStateEvent::AuthStateChanged => { me.handle_auth_state_changed(ctx); } + OnboardingStateEvent::CreditPurchaseRequested { credits } => { + ctx.emit(AgentOnboardingEvent::PurchaseCreditsRequested { credits: *credits }); + } + OnboardingStateEvent::CreditPurchaseCompleted => { + me.handle_credit_purchase_completed(ctx); + } OnboardingStateEvent::ModelsUpdated | OnboardingStateEvent::SelectedSlideChanged | OnboardingStateEvent::IntentionChanged @@ -352,6 +370,71 @@ impl AgentOnboardingView { ctx.notify(); } + /// Supplies the ad-hoc credit packs offered on the "Choose how to start" + /// slide. Built by the app from server pricing plus the viewer's add-on + /// credits policy, so the premium-adjusted prices always match what the + /// server charges. An empty list hides the buy-credits option. + pub fn set_credit_pack_options( + &mut self, + options: Vec, + ctx: &mut ViewContext, + ) { + self.onboarding_state.update(ctx, |state, ctx| { + state.set_credit_pack_options(options, ctx); + }); + ctx.notify(); + } + + /// The credit purchase needs browser checkout. Onboarding stays on the + /// offer slide until credits are available. + pub fn on_credit_purchase_checkout_opened(&mut self, ctx: &mut ViewContext) { + self.onboarding_state.update(ctx, |state, ctx| { + state.on_credit_checkout_opened(ctx); + }); + ctx.notify(); + } + + /// Reports the server's AI credit availability decision, seen on a refresh. + /// Safe to call on every refresh: it only completes a checkout-pending + /// purchase, and only when the server says AI is available. + pub fn on_ai_credit_availability_observed( + &mut self, + available: bool, + ctx: &mut ViewContext, + ) { + self.onboarding_state.update(ctx, |state, ctx| { + state.on_credit_availability_observed(available, ctx); + }); + ctx.notify(); + } + + /// The purchased credits are on the account. Safe to call speculatively + /// (e.g. from a workspace refresh): it is a no-op unless a purchase started + /// from the offer slide is still awaiting its credits. + pub fn on_credit_purchase_completed(&mut self, ctx: &mut ViewContext) { + self.onboarding_state.update(ctx, |state, ctx| { + state.on_credit_purchase_completed(ctx); + }); + ctx.notify(); + } + + /// The purchase could not be started or was rejected. + pub fn on_credit_purchase_failed(&mut self, ctx: &mut ViewContext) { + self.onboarding_state.update(ctx, |state, ctx| { + state.on_credit_purchase_failed(ctx); + }); + ctx.notify(); + } + + /// Whether a credit purchase started on the offer slide is still waiting on + /// its credits, so the app knows to watch for them on the next refresh. + pub fn is_awaiting_purchased_credits(&self, ctx: &AppContext) -> bool { + self.onboarding_state + .as_ref(ctx) + .credit_purchase_state() + .is_in_flight() + } + pub fn show_post_auth_offer(&mut self, variant: OfferVariant, ctx: &mut ViewContext) { self.onboarding_state.update(ctx, |state, ctx| { state.show_post_auth_offer(variant, ctx); @@ -508,6 +591,13 @@ impl AgentOnboardingView { ctx.emit(AgentOnboardingEvent::OnboardingCompleted(settings)); } + fn handle_credit_purchase_completed(&mut self, ctx: &mut ViewContext) { + let Some(variant) = self.onboarding_state.as_ref(ctx).offer_variant() else { + return; + }; + ctx.emit(AgentOnboardingEvent::OfferCreditsPurchased { variant }); + } + /// Reacts to a billing/auth transition. When the user becomes a paying user /// we show a success toast; if they're still on the AI-access slide we also /// advance them, since selecting a plan was the remaining action there. diff --git a/crates/onboarding/src/bin/main.rs b/crates/onboarding/src/bin/main.rs index fb3e63b8f99..3b091c3a954 100644 --- a/crates/onboarding/src/bin/main.rs +++ b/crates/onboarding/src/bin/main.rs @@ -6,7 +6,8 @@ use ai::LLMId; use anyhow::Result; use onboarding::slides::OnboardingModelInfo; use onboarding::{ - AgentOnboardingEvent, AgentOnboardingView, MockTelemetryContextProvider, SelectedSettings, + AgentOnboardingEvent, AgentOnboardingView, CreditPackOption, MockTelemetryContextProvider, + OfferVariant, SelectedSettings, }; use pathfinder_color::ColorU; use rust_embed::RustEmbed; @@ -41,6 +42,43 @@ impl AssetProvider for Assets { } } +/// Env var for jumping straight to a post-auth offer slide, which is otherwise +/// only reachable from the app after authentication. Accepts +/// `choose_how_to_start` or `head_start`. +const DEMO_OFFER_ENV: &str = "ONBOARDING_DEMO_OFFER"; + +fn demo_offer_variant() -> Option { + match std::env::var(DEMO_OFFER_ENV).ok()?.as_str() { + "choose_how_to_start" => Some(OfferVariant::ChooseHowToStart), + "head_start" => Some(OfferVariant::HeadStart), + other => { + log::warn!("unknown {DEMO_OFFER_ENV} value: {other}"); + None + } + } +} + +/// Stand-in for the server's add-on credit packs, priced with the free plan's +/// +20% premium. The real client sources these from `pricingInfo`; the demo +/// binary has no server, so it ships a representative sample. +fn demo_credit_packs() -> Vec { + [ + (400, 1_200, 0), + (1_000, 2_400, 20), + (3_000, 6_000, 33), + (6_500, 12_000, 38), + ] + .into_iter() + .map( + |(credits, price_usd_cents, savings_percent)| CreditPackOption { + credits, + price_usd_cents, + savings_percent, + }, + ) + .collect() +} + fn main() -> Result<()> { // Initialize logging for the onboarding binary. warp_logging::init(warp_logging::LogConfig { @@ -48,6 +86,17 @@ fn main() -> Result<()> { ..Default::default() })?; + // Feature flags must be marked initialized before anything reads one: the + // onboarding slides check flags while rendering, and in a debug build that + // check panics if initialization never happened. The real app does this in + // `init_feature_flags`, which also turns on the flags for its release + // channel; this demo has no channel, so it previews the flag defaults. + if demo_offer_variant().is_some() { + // Except for this one, which the offer slides live behind. + warp_core::features::FeatureFlag::AccountFirstOnboarding.set_enabled(true); + } + warp_core::features::mark_initialized(); + let app_builder = warpui::platform::AppBuilder::new( platform::AppCallbacks::default(), Box::new(ASSETS), @@ -119,6 +168,10 @@ impl OnboardingMainView { }); onboarding_view.update(ctx, |view, ctx| { view.start_onboarding(ctx); + if let Some(variant) = demo_offer_variant() { + view.set_credit_pack_options(demo_credit_packs(), ctx); + view.show_post_auth_offer(variant, ctx); + } }); ctx.subscribe_to_view(&onboarding_view, |me, _view, event, ctx| { me.handle_onboarding_event(event, ctx); @@ -161,6 +214,24 @@ impl OnboardingMainView { self.state = OnboardingMainState::Finished(finished_view); ctx.notify(); } + // Without a server the demo can't actually charge anything, so it + // simulates the checkout hand-off: the slide stays put until the + // "credits" arrive, exactly as it does in the app. + AgentOnboardingEvent::PurchaseCreditsRequested { credits } => { + log::info!("demo: purchase of {credits} credits requested"); + if let OnboardingMainState::Onboarding(view) = &self.state { + view.update(ctx, |view, ctx| { + view.on_credit_purchase_checkout_opened(ctx); + }); + } + } + AgentOnboardingEvent::OfferCreditsPurchased { .. } + | AgentOnboardingEvent::OfferSetUpLaterSelected { .. } => { + let finished_view = + ctx.add_typed_action_view(|_| FinishedOnboardingView::new(None)); + self.state = OnboardingMainState::Finished(finished_view); + ctx.notify(); + } AgentOnboardingEvent::SyncWithOsToggled { .. } | AgentOnboardingEvent::UpgradeRequested | AgentOnboardingEvent::UpgradeCopyUrlRequested diff --git a/crates/onboarding/src/lib.rs b/crates/onboarding/src/lib.rs index 52c9739d518..55b361a4918 100644 --- a/crates/onboarding/src/lib.rs +++ b/crates/onboarding/src/lib.rs @@ -71,7 +71,10 @@ impl std::fmt::Display for SessionDefault { } pub use agent_onboarding_view::{AgentOnboardingAction, AgentOnboardingEvent, AgentOnboardingView}; -pub use model::{OnboardingAuthState, SelectedSettings, UICustomizationSettings}; +pub use model::{ + CreditPackOption, CreditPurchaseState, OnboardingAuthState, SelectedSettings, + UICustomizationSettings, +}; pub use slides::{OfferVariant, ProjectOnboardingSettings}; pub use telemetry::OnboardingEvent; diff --git a/crates/onboarding/src/model.rs b/crates/onboarding/src/model.rs index d560b426251..6fc83c6dc72 100644 --- a/crates/onboarding/src/model.rs +++ b/crates/onboarding/src/model.rs @@ -163,6 +163,81 @@ impl std::fmt::Display for AiAccessChoice { } } +/// A one-time add-on credit pack offered on the "Choose how to start" slide. +/// +/// Display-only data: the app crate builds these from the server's pricing +/// info and the viewer's add-on credits purchase policy (which carries the +/// free-plan premium), so the onboarding crate never hardcodes prices. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CreditPackOption { + /// The number of AI credits the pack grants. + pub credits: i32, + /// The final purchase price in USD cents, with any plan premium already + /// applied — i.e. exactly what the user is charged. + pub price_usd_cents: i32, + /// Whole-percent savings on the per-credit rate versus the smallest pack. + /// Zero for the smallest pack (and whenever savings can't be computed). + pub savings_percent: u32, +} + +impl CreditPackOption { + /// `"$12"` for a whole-dollar price, `"$12.50"` otherwise. + pub fn price_label(&self) -> String { + if self.price_usd_cents % 100 == 0 { + format!("${}", self.price_usd_cents / 100) + } else { + format!("${:.2}", self.price_usd_cents as f64 / 100.) + } + } + + /// The credit count, thousands-separated so large packs stay readable + /// (`"6,500"`). The unit comes from the surrounding card, matching how the + /// Billing & Usage denominations are labelled. + pub fn credits_label(&self) -> String { + let digits = self.credits.abs().to_string(); + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + grouped.push(','); + } + grouped.push(digit); + } + if self.credits < 0 { + grouped.insert(0, '-'); + } + grouped + } +} + +/// Progress of a one-time credit-pack purchase started from the offer slide. +/// +/// A purchase without a saved payment method (the common case for a brand-new +/// account) hands off to browser checkout; onboarding then waits for the +/// credits to actually land rather than trusting the browser round-trip, so +/// abandoning checkout leaves the user on this slide. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum CreditPurchaseState { + #[default] + Idle, + /// The purchase mutation is in flight. + Purchasing, + /// Checkout was opened in the browser; waiting for credits to be available. + AwaitingCheckout, + /// The purchase failed. The user stays on the slide and can retry. + Failed, +} + +impl CreditPurchaseState { + /// Whether a purchase is underway, so the primary action should not start + /// another one. + pub fn is_in_flight(self) -> bool { + matches!( + self, + CreditPurchaseState::Purchasing | CreditPurchaseState::AwaitingCheckout + ) + } +} + /// Which opt-out entry point opened the "Are you sure you don't want AI?" modal. /// Determines where "Give me AI features" routes the user on cancel. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -180,6 +255,14 @@ pub(crate) enum OnboardingStateEvent { UpgradeRequested, AuthStateChanged, NoAiConfirmationChanged, + /// The user asked to buy the selected credit pack. The app crate owns the + /// purchase mutation, so it listens for this and calls the server. + CreditPurchaseRequested { + credits: i32, + }, + /// The purchased credits landed on the account, so onboarding may advance + /// past the offer slide. + CreditPurchaseCompleted, } #[derive(Clone, Debug)] @@ -205,6 +288,14 @@ pub(crate) struct OnboardingStateModel { /// When set, the "Are you sure you don't want AI?" confirmation modal is /// shown; the value records which entry point triggered it. no_ai_confirmation: Option, + /// The ad-hoc credit packs offered on the "Choose how to start" slide, + /// supplied by the app crate from server pricing. Empty until pricing has + /// been fetched, which hides the buy-credits option entirely. + credit_pack_options: Vec, + /// Index into `credit_pack_options` of the pack the user has selected. + selected_credit_pack_index: usize, + /// Progress of a credit purchase started from the offer slide. + credit_purchase_state: CreditPurchaseState, } impl OnboardingStateModel { @@ -230,6 +321,9 @@ impl OnboardingStateModel { auth_state, offer_variant: None, no_ai_confirmation: None, + credit_pack_options: Vec::new(), + selected_credit_pack_index: 0, + credit_purchase_state: CreditPurchaseState::default(), } } @@ -382,6 +476,135 @@ impl OnboardingStateModel { ctx.notify(); } + /// The ad-hoc credit packs to offer, in the order the server listed them + /// (smallest first). Empty until the app supplies server pricing. + pub(crate) fn credit_pack_options(&self) -> &[CreditPackOption] { + &self.credit_pack_options + } + + /// Replaces the offered credit packs. Keeps the user's selection when it + /// still points at a pack, otherwise falls back to the first one. + pub(crate) fn set_credit_pack_options( + &mut self, + options: Vec, + ctx: &mut ModelContext, + ) { + if self.credit_pack_options == options { + return; + } + self.credit_pack_options = options; + if self.selected_credit_pack_index >= self.credit_pack_options.len() { + self.selected_credit_pack_index = 0; + } + ctx.notify(); + } + + pub(crate) fn selected_credit_pack_index(&self) -> usize { + self.selected_credit_pack_index + } + + pub(crate) fn selected_credit_pack(&self) -> Option { + self.credit_pack_options + .get(self.selected_credit_pack_index) + .copied() + } + + /// Selects the credit pack at `index`. Ignored while a purchase is in + /// flight so the pack being paid for can't change underneath it. + pub(crate) fn select_credit_pack(&mut self, index: usize, ctx: &mut ModelContext) { + if self.credit_purchase_state.is_in_flight() + || index >= self.credit_pack_options.len() + || self.selected_credit_pack_index == index + { + return; + } + let credits = self.credit_pack_options[index].credits; + send_telemetry_from_ctx!( + OnboardingEvent::SettingChanged { + setting: "credit_pack".to_string(), + value: credits.to_string(), + }, + ctx + ); + self.selected_credit_pack_index = index; + ctx.notify(); + } + + pub(crate) fn credit_purchase_state(&self) -> CreditPurchaseState { + self.credit_purchase_state + } + + /// Starts buying the selected credit pack. The app crate owns the purchase + /// mutation, so this only moves to `Purchasing` and asks for the purchase; + /// the outcome comes back via [`Self::on_credit_checkout_opened`], + /// [`Self::on_credit_purchase_completed`], or + /// [`Self::on_credit_purchase_failed`]. + pub(crate) fn request_credit_purchase(&mut self, ctx: &mut ModelContext) { + if self.credit_purchase_state.is_in_flight() { + return; + } + let Some(pack) = self.selected_credit_pack() else { + return; + }; + self.credit_purchase_state = CreditPurchaseState::Purchasing; + ctx.emit(OnboardingStateEvent::CreditPurchaseRequested { + credits: pack.credits, + }); + ctx.notify(); + } + + /// The purchase needs browser checkout (no saved payment method). + /// Onboarding stays on this slide until credits are available. + pub(crate) fn on_credit_checkout_opened(&mut self, ctx: &mut ModelContext) { + if self.credit_purchase_state != CreditPurchaseState::Purchasing { + return; + } + self.credit_purchase_state = CreditPurchaseState::AwaitingCheckout; + ctx.notify(); + } + + /// Reports the server's answer to "can this user start an AI request right + /// now", observed on a refresh while checkout is pending. The purchase + /// completes as soon as the answer is yes. + /// + /// This is deliberately the generic availability decision rather than + /// "did these particular add-on credits land". Onboarding doesn't care + /// *how* the user ended up with access — only that it never lets someone + /// through who still can't use AI. This offer is shown to users with no + /// base credits, so an available answer means access genuinely arrived, + /// and cancelling checkout leaves them here. + pub(crate) fn on_credit_availability_observed( + &mut self, + available: bool, + ctx: &mut ModelContext, + ) { + if self.credit_purchase_state != CreditPurchaseState::AwaitingCheckout || !available { + return; + } + self.on_credit_purchase_completed(ctx); + } + + /// The credits landed — either charged synchronously or granted after the + /// user finished browser checkout. Advances past the offer slide. + pub(crate) fn on_credit_purchase_completed(&mut self, ctx: &mut ModelContext) { + if !self.credit_purchase_state.is_in_flight() { + return; + } + self.credit_purchase_state = CreditPurchaseState::Idle; + ctx.emit(OnboardingStateEvent::CreditPurchaseCompleted); + ctx.notify(); + } + + /// The purchase could not be started or was rejected. The user keeps their + /// place on the slide and can retry or choose another option. + pub(crate) fn on_credit_purchase_failed(&mut self, ctx: &mut ModelContext) { + if !self.credit_purchase_state.is_in_flight() { + return; + } + self.credit_purchase_state = CreditPurchaseState::Failed; + ctx.notify(); + } + pub(crate) fn no_ai_confirmation(&self) -> Option { self.no_ai_confirmation } diff --git a/crates/onboarding/src/model_tests.rs b/crates/onboarding/src/model_tests.rs index 81066c57880..028889b14d8 100644 --- a/crates/onboarding/src/model_tests.rs +++ b/crates/onboarding/src/model_tests.rs @@ -5,8 +5,8 @@ use warpui_core::{App, ModelHandle}; use crate::OnboardingIntention; use crate::model::{ - AiSetupChoice, NoAiConfirmationSource, OnboardingAuthState, OnboardingStateModel, - OnboardingStep, SelectedSettings, + AiSetupChoice, CreditPackOption, CreditPurchaseState, NoAiConfirmationSource, + OnboardingAuthState, OnboardingStateModel, OnboardingStep, SelectedSettings, }; use crate::slides::OfferVariant; @@ -101,6 +101,266 @@ fn post_auth_offer_supports_back_to_theme_and_no_direct_next() { }); } +fn credit_packs() -> Vec { + vec![ + CreditPackOption { + credits: 400, + price_usd_cents: 1_200, + savings_percent: 0, + }, + CreditPackOption { + credits: 1_000, + price_usd_cents: 2_400, + savings_percent: 20, + }, + ] +} + +fn purchase_state(app: &App, model: &ModelHandle) -> CreditPurchaseState { + model.read(app, |model, _| model.credit_purchase_state()) +} + +#[test] +fn credit_packs_default_to_the_first_option_and_are_selectable() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.read(&app, |model, _| { + assert!(model.credit_pack_options().is_empty()); + assert_eq!(model.selected_credit_pack(), None); + }); + + model.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(), ctx) + }); + model.read(&app, |model, _| { + assert_eq!(model.selected_credit_pack_index(), 0); + assert_eq!(model.selected_credit_pack().map(|p| p.credits), Some(400)); + }); + + model.update(&mut app, |model, ctx| model.select_credit_pack(1, ctx)); + model.read(&app, |model, _| { + assert_eq!(model.selected_credit_pack().map(|p| p.credits), Some(1_000)); + }); + + // Out-of-range selections are ignored rather than panicking. + model.update(&mut app, |model, ctx| model.select_credit_pack(9, ctx)); + model.read(&app, |model, _| { + assert_eq!(model.selected_credit_pack_index(), 1); + }); + }); +} + +/// Regression test for REV-1886: browser checkout must not advance onboarding +/// on its own. The purchase stays in flight until the credits actually land, +/// so abandoning checkout leaves the user on the offer slide. +#[test] +fn abandoned_checkout_leaves_the_purchase_in_flight() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + }); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::Purchasing + ); + + model.update(&mut app, |model, ctx| model.on_credit_checkout_opened(ctx)); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::AwaitingCheckout + ); + assert_eq!(step(&app, &model), OnboardingStep::PostAuthOffer); + + // Only the server reporting AI as available clears the in-flight + // purchase. + model.update(&mut app, |model, ctx| { + model.on_credit_availability_observed(true, ctx) + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + }); +} + +/// Regression test for REV-1886: cancelling browser checkout must leave the +/// user on the offer slide. The common case is a brand-new account that still +/// can't make an AI request, so every refresh while checkout is open reports +/// unavailable and the slide must hold. +#[test] +fn canceled_checkout_does_not_advance_a_user_without_ai_access() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + model.on_credit_checkout_opened(ctx); + }); + + // Every refresh while checkout is open still reports no AI access. + for _ in 0..3 { + model.update(&mut app, |model, ctx| { + model.on_credit_availability_observed(false, ctx) + }); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::AwaitingCheckout, + "an unavailable answer must not complete the purchase" + ); + assert_eq!(step(&app, &model), OnboardingStep::PostAuthOffer); + } + + // Access arriving completes it. + model.update(&mut app, |model, ctx| { + model.on_credit_availability_observed(true, ctx) + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + }); +} + +/// Onboarding doesn't care *how* the user ended up able to use AI — a team +/// plan landing mid-checkout counts just as much as the add-on credits they +/// were buying. The bar is "can make an AI request", not "this purchase +/// settled". +#[test] +fn access_arriving_from_any_source_completes_the_purchase() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + model.on_credit_checkout_opened(ctx); + // Not the add-on credits: some other grant made AI usable. + model.on_credit_availability_observed(true, ctx); + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + }); +} + +/// The availability report rides along on a generic usage refresh, so it must +/// be inert outside a pending checkout. +#[test] +fn observing_availability_outside_checkout_does_nothing() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(), ctx); + model.on_credit_availability_observed(true, ctx); + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + + // Still inert while the purchase mutation is in flight: that path + // completes on the server's explicit success, not on an availability + // read. + model.update(&mut app, |model, ctx| { + model.request_credit_purchase(ctx); + model.on_credit_availability_observed(true, ctx); + }); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::Purchasing + ); + }); +} + +#[test] +fn a_synchronous_purchase_completes_without_checkout() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + model.on_credit_purchase_completed(ctx); + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + }); +} + +#[test] +fn a_rejected_purchase_is_retryable() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + model.on_credit_purchase_failed(ctx); + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Failed); + + model.update(&mut app, |model, ctx| model.request_credit_purchase(ctx)); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::Purchasing + ); + }); +} + +#[test] +fn a_purchase_cannot_start_without_packs_or_while_one_is_in_flight() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + + // No packs offered yet: nothing to buy. + model.update(&mut app, |model, ctx| model.request_credit_purchase(ctx)); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + + model.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(), ctx); + model.request_credit_purchase(ctx); + model.on_credit_checkout_opened(ctx); + // A second request must not restart checkout... + model.request_credit_purchase(ctx); + // ...and the pack being paid for must not change underneath it. + model.select_credit_pack(1, ctx); + }); + assert_eq!( + purchase_state(&app, &model), + CreditPurchaseState::AwaitingCheckout + ); + model.read(&app, |model, _| { + assert_eq!(model.selected_credit_pack_index(), 0); + }); + }); +} + +/// Completion callbacks are safe to fire speculatively (they are driven by a +/// generic usage refresh), so they must be inert when nothing was purchased. +#[test] +fn purchase_callbacks_are_inert_when_no_purchase_is_in_flight() { + App::test((), |mut app| async move { + let model = add_test_model(&mut app); + model.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(), ctx); + model.on_credit_purchase_completed(ctx); + model.on_credit_checkout_opened(ctx); + model.on_credit_purchase_failed(ctx); + }); + assert_eq!(purchase_state(&app, &model), CreditPurchaseState::Idle); + }); +} + +#[test] +fn credit_pack_labels_are_formatted_for_display() { + let pack = CreditPackOption { + credits: 6_500, + price_usd_cents: 12_000, + savings_percent: 38, + }; + assert_eq!(pack.credits_label(), "6,500"); + assert_eq!(pack.price_label(), "$120"); + + let fractional = CreditPackOption { + credits: 400, + price_usd_cents: 1_250, + savings_percent: 0, + }; + assert_eq!(fractional.credits_label(), "400"); + assert_eq!(fractional.price_label(), "$12.50"); +} + #[test] fn account_first_path_uses_three_step_progress() { let _account_first = FeatureFlag::AccountFirstOnboarding.override_enabled(true); diff --git a/crates/onboarding/src/slides/agent_slide.rs b/crates/onboarding/src/slides/agent_slide.rs index cc05ddc4ec4..76601593295 100644 --- a/crates/onboarding/src/slides/agent_slide.rs +++ b/crates/onboarding/src/slides/agent_slide.rs @@ -157,7 +157,9 @@ impl AgentSlide { | OnboardingStateEvent::IntentionChanged | OnboardingStateEvent::Completed | OnboardingStateEvent::UpgradeRequested - | OnboardingStateEvent::NoAiConfirmationChanged => {} + | OnboardingStateEvent::NoAiConfirmationChanged + | OnboardingStateEvent::CreditPurchaseRequested { .. } + | OnboardingStateEvent::CreditPurchaseCompleted => {} } }); diff --git a/crates/onboarding/src/slides/offer_slide.rs b/crates/onboarding/src/slides/offer_slide.rs index 8a667c282e6..84b0cc81d97 100644 --- a/crates/onboarding/src/slides/offer_slide.rs +++ b/crates/onboarding/src/slides/offer_slide.rs @@ -1,3 +1,4 @@ +use pathfinder_color::ColorU; use ui_components::{Component as _, Options as _, button}; use warp_core::send_telemetry_from_ctx; use warp_core::ui::appearance::Appearance; @@ -6,7 +7,7 @@ use warp_core::ui::theme::Fill; use warp_core::ui::theme::color::internal_colors; use warpui_core::elements::{ Border, ClippedScrollStateHandle, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, - Empty, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, + Empty, Expanded, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Stack, }; use warpui_core::fonts::Weight; @@ -22,10 +23,19 @@ use warpui_core::{ use super::OnboardingSlide; use super::upgrade_auth_prompt::render_upgrade_auth_prompt_bar; -use crate::model::OnboardingStateModel; +use crate::model::{CreditPackOption, CreditPurchaseState, OnboardingStateModel}; use crate::slides::{layout, slide_content}; use crate::telemetry::OnboardingEvent; +/// Upper bound on rendered credit packs. The server offers four today; the cap +/// keeps a fixed pool of mouse states (hover tracking needs stable handles) +/// without capping what the server may add later in any meaningful way. +const MAX_CREDIT_PACKS: usize = 8; + +/// Gap between credit pack tiles, matching the Billing & Usage page's add-on +/// credit denominations row. +const CREDIT_PACK_TILE_SPACING: f32 = 8.; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OfferVariant { HeadStart, @@ -52,15 +62,24 @@ impl OfferVariant { pub(crate) fn primary_label(self) -> &'static str { match self { OfferVariant::HeadStart => "Unlock the full AI experience", - OfferVariant::ChooseHowToStart => "Use Warp with AI", + // Two of the three options are ways to use Warp with AI, so this + // card is named for what actually distinguishes it: the plan. + OfferVariant::ChooseHowToStart => "Subscribe to a Warp plan", } } - pub(crate) fn primary_description(self) -> &'static str { + /// `shows_credit_packs` is the same condition that decides whether the + /// buy-credits card renders (see [`OfferSlide::shows_credit_packs`]) — the + /// add-on savings line only makes sense next to the packs it refers to, so + /// without them the card keeps its original copy. + pub(crate) fn primary_description(self, shows_credit_packs: bool) -> &'static str { match self { OfferVariant::HeadStart => { "Get more monthly usage, expanded cloud agent access, and collaboration features." } + OfferVariant::ChooseHowToStart if shows_credit_packs => { + "Warp Agent works locally or in the cloud with frontier and OSS models. Get monthly credits at the best value, and save 20% on add-on credits with any Build plan." + } OfferVariant::ChooseHowToStart => { "Warp Agent works locally or in the cloud with frontier and OSS models. Proactively fix terminal errors, implement changes, and ship verified code." } @@ -85,6 +104,25 @@ impl OfferVariant { } } + /// Whether this offer includes the one-time credit-pack option. Only the + /// free-standard offer does: the head-start offer already ships with + /// included AI usage, so a pack purchase isn't the decision being made. + pub(crate) fn supports_credit_packs(self) -> bool { + matches!(self, OfferVariant::ChooseHowToStart) + } + + pub(crate) fn credits_label(self) -> &'static str { + "Buy AI credits" + } + + pub(crate) fn credits_description(self) -> &'static str { + "Best for trying Warp without a subscription. Buy a one-time credit pack and start using the Warp Agent right away." + } + + fn credits_action(self) -> &'static str { + "buy_ai_credits" + } + pub(crate) fn included_features(self) -> &'static [&'static str] { match self { OfferVariant::HeadStart => &[ @@ -113,6 +151,9 @@ impl OfferVariant { fn primary_action(self) -> &'static str { match self { OfferVariant::HeadStart => "get_more_ai", + // Telemetry identifier, not user-facing copy: kept stable across + // the card's rename to "Subscribe to a Warp plan" so existing + // dashboards don't lose continuity. OfferVariant::ChooseHowToStart => "use_warp_with_ai", } } @@ -121,7 +162,9 @@ impl OfferVariant { #[derive(Clone, Debug)] pub enum OfferSlideAction { SelectPrimary, + SelectBuyCredits, SelectSetUpLater, + SelectCreditPack(usize), Back, GetWarping, CopyUpgradeUrl, @@ -132,6 +175,8 @@ pub enum OfferSlideAction { enum OfferChoice { #[default] Primary, + /// Buy a one-time credit pack instead of subscribing. + BuyCredits, SetUpLater, } @@ -145,7 +190,11 @@ pub enum OfferSlideEvent { pub struct OfferSlide { onboarding_state: ModelHandle, primary_mouse_state: MouseStateHandle, + buy_credits_mouse_state: MouseStateHandle, secondary_mouse_state: MouseStateHandle, + /// One hover handle per rendered credit pack row. Allocated up front so + /// each row keeps a stable handle across renders. + credit_pack_mouse_states: [MouseStateHandle; MAX_CREDIT_PACKS], back_button: button::Button, get_warping_button: button::Button, selected_choice: OfferChoice, @@ -163,7 +212,9 @@ impl OfferSlide { Self { onboarding_state, primary_mouse_state: MouseStateHandle::default(), + buy_credits_mouse_state: MouseStateHandle::default(), secondary_mouse_state: MouseStateHandle::default(), + credit_pack_mouse_states: std::array::from_fn(|_| MouseStateHandle::default()), back_button: button::Button::default(), get_warping_button: button::Button::default(), selected_choice: OfferChoice::default(), @@ -178,15 +229,65 @@ impl OfferSlide { self.onboarding_state.as_ref(app).offer_variant() } - fn render_content(&self, appearance: &Appearance, variant: OfferVariant) -> Box { + /// The credit packs to render, capped at [`MAX_CREDIT_PACKS`]. Empty when + /// the offer doesn't include the option or pricing hasn't arrived yet, in + /// which case the buy-credits card is not shown at all. + fn credit_packs<'a>( + &self, + variant: OfferVariant, + app: &'a AppContext, + ) -> &'a [CreditPackOption] { + if !variant.supports_credit_packs() { + return &[]; + } + let packs = self.onboarding_state.as_ref(app).credit_pack_options(); + &packs[..packs.len().min(MAX_CREDIT_PACKS)] + } + + fn shows_credit_packs(&self, variant: OfferVariant, app: &AppContext) -> bool { + !self.credit_packs(variant, app).is_empty() + } + + /// The selectable options, top to bottom. Also the order the arrow keys + /// move through. + fn choices(&self, variant: OfferVariant, app: &AppContext) -> Vec { + let mut choices = vec![OfferChoice::Primary]; + if self.shows_credit_packs(variant, app) { + choices.push(OfferChoice::BuyCredits); + } + choices.push(OfferChoice::SetUpLater); + choices + } + + /// The selected option, falling back to the subscribe option if the + /// buy-credits option was selected and has since disappeared (e.g. pricing + /// went away on a refresh). + fn effective_choice(&self, variant: OfferVariant, app: &AppContext) -> OfferChoice { + if self.selected_choice == OfferChoice::BuyCredits && !self.shows_credit_packs(variant, app) + { + return OfferChoice::Primary; + } + self.selected_choice + } + + fn credit_purchase_state(&self, app: &AppContext) -> CreditPurchaseState { + self.onboarding_state.as_ref(app).credit_purchase_state() + } + + fn render_content( + &self, + appearance: &Appearance, + variant: OfferVariant, + app: &AppContext, + ) -> Box { slide_content::onboarding_slide_content( vec![ Align::new(Self::render_header(appearance, variant)) .left() .finish(), - self.render_options(appearance, variant), + self.render_options(appearance, variant, app), ], - self.render_bottom_nav(appearance), + self.render_bottom_nav(appearance, variant, app), self.scroll_state.clone(), appearance, ) @@ -261,38 +362,258 @@ impl OfferSlide { header.finish() } - fn render_options(&self, appearance: &Appearance, variant: OfferVariant) -> Box { + fn render_options( + &self, + appearance: &Appearance, + variant: OfferVariant, + app: &AppContext, + ) -> Box { + let selected_choice = self.effective_choice(variant, app); + let shows_credit_packs = self.shows_credit_packs(variant, app); let primary = Self::render_option_card( appearance, variant.primary_label(), - variant.primary_description(), - self.selected_choice == OfferChoice::Primary, - true, + variant.primary_description(shows_credit_packs), + selected_choice == OfferChoice::Primary, + Some("Recommended"), self.primary_mouse_state.clone(), OfferSlideAction::SelectPrimary, + None, ); let secondary = Self::render_option_card( appearance, variant.secondary_label(), variant.secondary_description(), - self.selected_choice == OfferChoice::SetUpLater, - false, + selected_choice == OfferChoice::SetUpLater, + None, self.secondary_mouse_state.clone(), OfferSlideAction::SelectSetUpLater, + None, ); - Container::new( - Flex::column() - .with_main_axis_size(MainAxisSize::Min) - .with_cross_axis_alignment(CrossAxisAlignment::Stretch) - .with_child(Container::new(primary).with_margin_bottom(12.).finish()) - .with_child(secondary) + + let mut options = Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_child(Container::new(primary).with_margin_bottom(12.).finish()); + + if shows_credit_packs { + let buy_credits = Self::render_option_card( + appearance, + variant.credits_label(), + variant.credits_description(), + selected_choice == OfferChoice::BuyCredits, + None, + self.buy_credits_mouse_state.clone(), + OfferSlideAction::SelectBuyCredits, + Some(self.render_credit_packs(appearance, variant, app)), + ); + options = + options.with_child(Container::new(buy_credits).with_margin_bottom(12.).finish()); + } + + options = options.with_child(secondary); + + if let Some(status) = self.render_purchase_status(appearance, app) { + options = options.with_child(Container::new(status).with_margin_top(12.).finish()); + } + + Container::new(options.finish()) + .with_margin_top(38.) + .finish() + } + + /// The selectable credit packs, laid out as a single horizontal row of + /// equal-width tiles so the whole slide fits without the onboarding + /// container scrolling. Mirrors the Billing & Usage page's add-on credit + /// denominations row (`Wrap::row` of compact credit chips, 8px apart); + /// tiles are `Expanded` here so the packs always stay on one line rather + /// than wrapping onto a second. + fn render_credit_packs( + &self, + appearance: &Appearance, + variant: OfferVariant, + app: &AppContext, + ) -> Box { + let packs = self.credit_packs(variant, app); + let selected_index = self + .onboarding_state + .as_ref(app) + .selected_credit_pack_index(); + let mut row = Flex::row() + .with_main_axis_size(MainAxisSize::Max) + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(CREDIT_PACK_TILE_SPACING); + for (index, pack) in packs.iter().enumerate() { + row.add_child( + Expanded::new( + 1., + Self::render_credit_pack_tile( + appearance, + *pack, + index == selected_index, + self.credit_pack_mouse_states[index].clone(), + index, + ), + ) + .finish(), + ); + } + row.finish() + } + + /// One pack tile: the credit count (with the same credits icon the Billing + /// & Usage denominations use), the premium-adjusted price, and the volume + /// savings badge. Stacked vertically so four tiles fit across the card. + fn render_credit_pack_tile( + appearance: &Appearance, + pack: CreditPackOption, + selected: bool, + mouse_state: MouseStateHandle, + index: usize, + ) -> Box { + let theme = appearance.theme(); + let bg_solid = theme.background().into_solid(); + let border = if selected { + theme.accent() + } else { + Fill::Solid(internal_colors::neutral_4(theme)) + }; + let text_main = internal_colors::text_main(theme, bg_solid); + let text_sub = internal_colors::text_sub(theme, bg_solid); + + let credits_icon = ConstrainedBox::new(Box::new( + Icon::Credits.to_warpui_icon(Fill::Solid(text_main)), + )) + .with_width(13.) + .with_height(13.) + .finish(); + let credits = appearance + .ui_builder() + .paragraph(pack.credits_label()) + .with_style(UiComponentStyles { + font_size: Some(15.), + font_weight: Some(Weight::Semibold), + font_color: Some(text_main), + ..Default::default() + }) + .build() + .finish(); + let credits_row = Flex::row() + .with_main_axis_size(MainAxisSize::Min) + .with_main_axis_alignment(MainAxisAlignment::Center) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(credits_icon) + .with_child(Container::new(credits).with_margin_left(5.).finish()) + .finish(); + + let price = appearance + .ui_builder() + .paragraph(pack.price_label()) + .with_style(UiComponentStyles { + font_size: Some(13.), + font_color: Some(text_sub), + ..Default::default() + }) + .build() + .finish(); + + // Every tile renders a badge slot so all four are the same height. The + // smallest pack has no volume discount, so its slot lays out the same + // text fully transparent: that reserves exactly the right line box + // without a fixed-height constant and without drawing a "Save 0%" that + // would read as a real discount. + let green = theme.ansi_fg_green(); + let has_savings = pack.savings_percent > 0; + let badge_percent = if has_savings { pack.savings_percent } else { 0 }; + let mut badge = Container::new( + appearance + .ui_builder() + .paragraph(format!("Save {badge_percent}%")) + .with_style(UiComponentStyles { + font_size: Some(11.), + font_color: Some(if has_savings { + green + } else { + ColorU::transparent_black() + }), + ..Default::default() + }) + .build() .finish(), ) - .with_margin_top(38.) + .with_horizontal_padding(6.) + .with_vertical_padding(1.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(9.))); + if has_savings { + badge = badge.with_background(Fill::Solid(green).with_opacity(10)); + } + + let tile = Flex::column() + .with_main_axis_size(MainAxisSize::Min) + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child(credits_row) + .with_child(Container::new(price).with_margin_top(4.).finish()) + .with_child(Container::new(badge.finish()).with_margin_top(6.).finish()) + .finish(); + let background = selected.then(|| internal_colors::accent_overlay_1(theme)); + + Hoverable::new(mouse_state, move |_| { + let mut container = Container::new(tile) + .with_horizontal_padding(8.) + .with_vertical_padding(10.) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(6.))) + .with_border(Border::all(1.).with_border_fill(border)); + if let Some(background) = background { + container = container.with_background(background); + } + container.finish() + }) + .with_cursor(Cursor::PointingHand) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(OfferSlideAction::SelectCreditPack(index)); + }) .finish() } - fn render_bottom_nav(&self, appearance: &Appearance) -> Box { + /// Inline status for a *failed* credit purchase only. The in-flight states + /// deliberately render nothing: the "Waiting for checkout\u{2026}" button label + /// already says everything the user needs, so a second running commentary + /// under the cards would be noise. A rejection is not transient — without + /// this line the purchase would fail silently. + fn render_purchase_status( + &self, + appearance: &Appearance, + app: &AppContext, + ) -> Option> { + let theme = appearance.theme(); + match self.credit_purchase_state(app) { + CreditPurchaseState::Idle + | CreditPurchaseState::Purchasing + | CreditPurchaseState::AwaitingCheckout => return None, + CreditPurchaseState::Failed => {} + } + Some( + appearance + .ui_builder() + .paragraph( + "We couldn't start that purchase. Try again, or choose \"Set up AI later\" to continue.", + ) + .with_style(UiComponentStyles { + font_size: Some(13.), + font_color: Some(theme.ansi_fg_red()), + ..Default::default() + }) + .build() + .finish(), + ) + } + + fn render_bottom_nav( + &self, + appearance: &Appearance, + variant: OfferVariant, + app: &AppContext, + ) -> Box { let back = self.back_button.render( appearance, button::Params { @@ -307,12 +628,21 @@ impl OfferSlide { }, ); let enter = Keystroke::parse("enter").unwrap_or_default(); + // A purchase in flight owns the primary action until it resolves; the + // user can still pick "Set up AI later" to leave without buying. + let purchase_in_flight = self.effective_choice(variant, app) == OfferChoice::BuyCredits + && self.credit_purchase_state(app).is_in_flight(); let get_warping = self.get_warping_button.render( appearance, button::Params { - content: button::Content::Label("Get Warping".into()), + content: button::Content::Label(if purchase_in_flight { + "Waiting for checkout\u{2026}".into() + } else { + "Get Warping".into() + }), theme: &button::themes::Primary, options: button::Options { + disabled: purchase_in_flight, keystroke: Some(enter), on_click: Some(Box::new(|ctx, _app, _pos| { ctx.dispatch_typed_action(OfferSlideAction::GetWarping); @@ -337,9 +667,10 @@ impl OfferSlide { label: &'static str, description: &'static str, selected: bool, - recommended: bool, + badge_label: Option<&'static str>, mouse_state: MouseStateHandle, action: OfferSlideAction, + extra_content: Option>, ) -> Box { let theme = appearance.theme(); let background = selected.then(|| internal_colors::accent_overlay_1(theme)); @@ -363,12 +694,12 @@ impl OfferSlide { .with_main_axis_alignment(MainAxisAlignment::SpaceBetween) .with_cross_axis_alignment(CrossAxisAlignment::Center) .with_child(label); - if recommended { + if let Some(badge_label) = badge_label { let green = theme.ansi_fg_green(); let badge = Container::new( appearance .ui_builder() - .paragraph("Recommended") + .paragraph(badge_label) .with_style(UiComponentStyles { font_size: Some(12.), font_color: Some(green), @@ -397,12 +728,15 @@ impl OfferSlide { }) .build() .finish(); - let content = Flex::column() + let mut column = Flex::column() .with_main_axis_size(MainAxisSize::Min) .with_cross_axis_alignment(CrossAxisAlignment::Stretch) .with_child(header.finish()) - .with_child(Container::new(description).with_margin_top(8.).finish()) - .finish(); + .with_child(Container::new(description).with_margin_top(8.).finish()); + if let Some(extra_content) = extra_content { + column = column.with_child(Container::new(extra_content).with_margin_top(16.).finish()); + } + let content = column.finish(); Hoverable::new(mouse_state, move |_| { let mut card = Container::new(content) @@ -459,6 +793,23 @@ impl OfferSlide { ctx.emit(OfferSlideEvent::SetUpLaterSelected { variant }); } + /// Starts a one-time credit-pack purchase. The app crate performs the + /// purchase and reports back; onboarding stays on this slide until the + /// credits actually land, so abandoning checkout doesn't advance anyone. + fn buy_credits(&mut self, ctx: &mut ViewContext) { + let Some(variant) = self.variant(ctx) else { + return; + }; + if self.credit_purchase_state(ctx).is_in_flight() { + return; + } + self.send_action(variant, variant.credits_action(), ctx); + self.onboarding_state.update(ctx, |model, ctx| { + model.request_credit_purchase(ctx); + }); + ctx.notify(); + } + fn select_choice(&mut self, choice: OfferChoice, ctx: &mut ViewContext) { if self.selected_choice == choice { return; @@ -467,9 +818,37 @@ impl OfferSlide { ctx.notify(); } + /// Moves the selection `delta` positions through the options that are + /// actually on screen, clamped at both ends. + fn move_selection(&mut self, delta: isize, ctx: &mut ViewContext) { + let Some(variant) = self.variant(ctx) else { + return; + }; + let choices = self.choices(variant, ctx); + let current = self.effective_choice(variant, ctx); + let current_index = choices + .iter() + .position(|choice| *choice == current) + .unwrap_or(0) as isize; + let next_index = (current_index + delta).clamp(0, choices.len() as isize - 1) as usize; + self.select_choice(choices[next_index], ctx); + } + + fn select_credit_pack(&mut self, index: usize, ctx: &mut ViewContext) { + self.select_choice(OfferChoice::BuyCredits, ctx); + self.onboarding_state.update(ctx, |model, ctx| { + model.select_credit_pack(index, ctx); + }); + ctx.notify(); + } + fn get_warping(&mut self, ctx: &mut ViewContext) { - match self.selected_choice { + let Some(variant) = self.variant(ctx) else { + return; + }; + match self.effective_choice(variant, ctx) { OfferChoice::Primary => self.request_upgrade(ctx), + OfferChoice::BuyCredits => self.buy_credits(ctx), OfferChoice::SetUpLater => self.set_up_later(ctx), } } @@ -496,7 +875,7 @@ impl View for OfferSlide { }; let appearance = Appearance::as_ref(app); let slide = layout::static_left( - || self.render_content(appearance, variant), + || self.render_content(appearance, variant, app), || self.render_visual(), ); if !self.show_auth_prompt_bar { @@ -524,11 +903,11 @@ impl View for OfferSlide { impl OnboardingSlide for OfferSlide { fn on_up(&mut self, ctx: &mut ViewContext) { - self.select_choice(OfferChoice::Primary, ctx); + self.move_selection(-1, ctx); } fn on_down(&mut self, ctx: &mut ViewContext) { - self.select_choice(OfferChoice::SetUpLater, ctx); + self.move_selection(1, ctx); } fn on_enter(&mut self, ctx: &mut ViewContext) { self.get_warping(ctx); @@ -541,9 +920,13 @@ impl TypedActionView for OfferSlide { fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { OfferSlideAction::SelectPrimary => self.select_choice(OfferChoice::Primary, ctx), + OfferSlideAction::SelectBuyCredits => { + self.select_choice(OfferChoice::BuyCredits, ctx); + } OfferSlideAction::SelectSetUpLater => { self.select_choice(OfferChoice::SetUpLater, ctx); } + OfferSlideAction::SelectCreditPack(index) => self.select_credit_pack(*index, ctx), OfferSlideAction::Back => self.back(ctx), OfferSlideAction::GetWarping => self.get_warping(ctx), OfferSlideAction::CopyUpgradeUrl => { diff --git a/crates/onboarding/src/slides/offer_slide_tests.rs b/crates/onboarding/src/slides/offer_slide_tests.rs index 7db262492c2..bebad518f1c 100644 --- a/crates/onboarding/src/slides/offer_slide_tests.rs +++ b/crates/onboarding/src/slides/offer_slide_tests.rs @@ -1,21 +1,69 @@ +use std::cell::RefCell; +use std::rc::Rc; + use ai::LLMId; -use warpui_core::{App, View as _}; +use warp_core::telemetry::testing::MockTelemetryContextProvider; +use warp_core::ui::appearance::Appearance; +use warpui_core::elements::Empty; +use warpui_core::platform::WindowStyle; +use warpui_core::{App, AppContext, Element, Entity, ModelHandle, TypedActionView, View as _}; + +use super::{ + MAX_CREDIT_PACKS, OfferChoice, OfferSlide, OfferSlideAction, OfferVariant, OnboardingSlide as _, +}; +use crate::model::{ + CreditPackOption, CreditPurchaseState, OnboardingAuthState, OnboardingStateModel, +}; + +/// A do-nothing view used only to observe the events an [`OfferSlide`] emits. +struct EventObserver { + events: Rc>>, +} + +impl Entity for EventObserver { + type Event = (); +} + +impl warpui_core::View for EventObserver { + fn ui_name() -> &'static str { + "EventObserver" + } + + fn render(&self, _: &AppContext) -> Box { + Empty::new().finish() + } +} + +impl TypedActionView for EventObserver { + type Action = (); +} + +fn add_onboarding_state(app: &mut App) -> ModelHandle { + app.add_model(|_| { + OnboardingStateModel::new( + Vec::new(), + LLMId::from("auto"), + false, + true, + OnboardingAuthState::FreeUser, + ) + }) +} -use super::{OfferSlide, OfferVariant}; -use crate::model::{OnboardingAuthState, OnboardingStateModel}; +fn credit_packs(count: usize) -> Vec { + (0..count) + .map(|index| CreditPackOption { + credits: 400 * (index as i32 + 1), + price_usd_cents: 1_200 * (index as i32 + 1), + savings_percent: index as u32 * 10, + }) + .collect() +} #[test] fn offer_slide_can_render_before_classification() { App::test((), |mut app| async move { - let onboarding_state = app.add_model(|_| { - OnboardingStateModel::new( - Vec::new(), - LLMId::from("auto"), - false, - true, - OnboardingAuthState::FreeUser, - ) - }); + let onboarding_state = add_onboarding_state(&mut app); let slide = OfferSlide::new(onboarding_state); app.read(|ctx| { @@ -35,7 +83,7 @@ fn head_start_copy_and_telemetry_names_match_spec() { ); assert_eq!(variant.primary_label(), "Unlock the full AI experience"); assert_eq!( - variant.primary_description(), + variant.primary_description(false), "Get more monthly usage, expanded cloud agent access, and collaboration features." ); assert_eq!(variant.secondary_label(), "Start with included AI"); @@ -62,18 +110,287 @@ fn choose_how_to_start_copy_and_telemetry_names_match_spec() { assert_eq!(variant.title(), "Choose how to start"); assert_eq!(variant.subtitle(), None); - assert_eq!(variant.primary_label(), "Use Warp with AI"); + assert_eq!(variant.primary_label(), "Subscribe to a Warp plan"); assert_eq!( - variant.primary_description(), - "Warp Agent works locally or in the cloud with frontier and OSS models. Proactively fix terminal errors, implement changes, and ship verified code." + variant.primary_description(true), + "Warp Agent works locally or in the cloud with frontier and OSS models. Get monthly credits at the best value, and save 20% on add-on credits with any Build plan." ); assert_eq!(variant.secondary_label(), "Set up AI later"); assert_eq!( variant.secondary_description(), "Explore the terminal, bring your own inference, or use another CLI agent. Add AI usage and features anytime." ); + assert_eq!(variant.credits_label(), "Buy AI credits"); + assert_eq!( + variant.credits_description(), + "Best for trying Warp without a subscription. Buy a one-time credit pack and start using the Warp Agent right away." + ); assert!(variant.included_features().is_empty()); assert_eq!(variant.slide_name(), "choose_how_to_start"); assert_eq!(variant.account_class(), "free_standard"); assert_eq!(variant.primary_action(), "use_warp_with_ai"); + assert_eq!(variant.credits_action(), "buy_ai_credits"); +} + +/// The subscribe card must frame the add-on discount as a saving (matching the +/// web copy), never as a surcharge on the free plan. +#[test] +fn subscribe_copy_frames_add_on_credits_as_a_saving() { + let description = OfferVariant::ChooseHowToStart.primary_description(true); + + assert!(description.contains("save 20% on add-on credits")); + assert!(!description.to_lowercase().contains("surcharge")); + assert!(!description.to_lowercase().contains("premium")); +} + +/// The add-on savings line only makes sense beside the packs it refers to, so +/// without them the card falls back to its original copy. +#[test] +fn subscribe_copy_drops_the_add_on_line_when_no_packs_are_shown() { + let without_packs = OfferVariant::ChooseHowToStart.primary_description(false); + + assert_eq!( + without_packs, + "Warp Agent works locally or in the cloud with frontier and OSS models. Proactively fix terminal errors, implement changes, and ship verified code." + ); + assert!(!without_packs.contains("add-on credits")); + + // The head-start offer never shows packs and is unaffected either way. + assert_eq!( + OfferVariant::HeadStart.primary_description(true), + OfferVariant::HeadStart.primary_description(false) + ); +} + +/// The head-start offer already includes AI usage, so it keeps two options. +#[test] +fn only_the_free_standard_offer_supports_credit_packs() { + assert!(OfferVariant::ChooseHowToStart.supports_credit_packs()); + assert!(!OfferVariant::HeadStart.supports_credit_packs()); +} + +#[test] +fn buy_credits_is_hidden_until_packs_are_available_and_on_the_head_start_offer() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.update(MockTelemetryContextProvider::register); + let onboarding_state = add_onboarding_state(&mut app); + let (_, slide) = app.add_window(WindowStyle::NotStealFocus, { + let onboarding_state = onboarding_state.clone(); + move |_| OfferSlide::new(onboarding_state) + }); + + // Pricing hasn't arrived yet, so there is nothing to buy. + onboarding_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + }); + app.read(|ctx| { + assert_eq!( + slide + .as_ref(ctx) + .choices(OfferVariant::ChooseHowToStart, ctx), + vec![OfferChoice::Primary, OfferChoice::SetUpLater] + ); + drop(slide.as_ref(ctx).render(ctx)); + }); + + onboarding_state.update(&mut app, |model, ctx| { + model.set_credit_pack_options(credit_packs(4), ctx); + }); + app.read(|ctx| { + assert_eq!( + slide + .as_ref(ctx) + .choices(OfferVariant::ChooseHowToStart, ctx), + vec![ + OfferChoice::Primary, + OfferChoice::BuyCredits, + OfferChoice::SetUpLater + ] + ); + drop(slide.as_ref(ctx).render(ctx)); + }); + + // The head-start offer never shows packs, even when pricing is known. + let head_start_state = add_onboarding_state(&mut app); + let (_, head_start_slide) = app.add_window(WindowStyle::NotStealFocus, { + let head_start_state = head_start_state.clone(); + move |_| OfferSlide::new(head_start_state) + }); + head_start_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::HeadStart, ctx); + model.set_credit_pack_options(credit_packs(4), ctx); + }); + app.read(|ctx| { + assert_eq!( + head_start_slide + .as_ref(ctx) + .choices(OfferVariant::HeadStart, ctx), + vec![OfferChoice::Primary, OfferChoice::SetUpLater] + ); + }); + }); +} + +#[test] +fn arrow_keys_move_through_all_three_options() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.update(MockTelemetryContextProvider::register); + let onboarding_state = add_onboarding_state(&mut app); + let (_, slide) = app.add_window(WindowStyle::NotStealFocus, { + let onboarding_state = onboarding_state.clone(); + move |_| OfferSlide::new(onboarding_state) + }); + onboarding_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(4), ctx); + }); + + let selected = |app: &App| slide.read(app, |slide, _| slide.selected_choice); + assert_eq!(selected(&app), OfferChoice::Primary); + + slide.update(&mut app, |slide, ctx| slide.on_down(ctx)); + assert_eq!(selected(&app), OfferChoice::BuyCredits); + slide.update(&mut app, |slide, ctx| slide.on_down(ctx)); + assert_eq!(selected(&app), OfferChoice::SetUpLater); + // Clamped at the end rather than wrapping. + slide.update(&mut app, |slide, ctx| slide.on_down(ctx)); + assert_eq!(selected(&app), OfferChoice::SetUpLater); + + slide.update(&mut app, |slide, ctx| slide.on_up(ctx)); + assert_eq!(selected(&app), OfferChoice::BuyCredits); + slide.update(&mut app, |slide, ctx| slide.on_up(ctx)); + assert_eq!(selected(&app), OfferChoice::Primary); + slide.update(&mut app, |slide, ctx| slide.on_up(ctx)); + assert_eq!(selected(&app), OfferChoice::Primary); + }); +} + +/// Regression test for REV-1886: "Get Warping" on the buy-credits option must +/// start a purchase rather than opening the upgrade page, and must not advance +/// onboarding while that purchase is still in flight. +#[test] +fn get_warping_buys_credits_when_the_credit_option_is_selected() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.update(MockTelemetryContextProvider::register); + let onboarding_state = add_onboarding_state(&mut app); + let (_, slide) = app.add_window(WindowStyle::NotStealFocus, { + let onboarding_state = onboarding_state.clone(); + move |_| OfferSlide::new(onboarding_state) + }); + onboarding_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(4), ctx); + }); + + // Selecting a pack also selects the buy-credits option. + slide.update(&mut app, |slide, ctx| { + slide.handle_action(&OfferSlideAction::SelectCreditPack(2), ctx) + }); + assert_eq!( + slide.read(&app, |slide, _| slide.selected_choice), + OfferChoice::BuyCredits + ); + onboarding_state.read(&app, |model, _| { + assert_eq!( + model.selected_credit_pack().map(|pack| pack.credits), + Some(1_200) + ); + }); + + slide.update(&mut app, |slide, ctx| { + slide.handle_action(&OfferSlideAction::GetWarping, ctx) + }); + onboarding_state.read(&app, |model, _| { + assert_eq!( + model.credit_purchase_state(), + CreditPurchaseState::Purchasing + ); + }); + + // A second Get Warping while the purchase is in flight is a no-op. + onboarding_state.update(&mut app, |model, ctx| model.on_credit_checkout_opened(ctx)); + slide.update(&mut app, |slide, ctx| { + slide.handle_action(&OfferSlideAction::GetWarping, ctx) + }); + onboarding_state.read(&app, |model, _| { + assert_eq!( + model.credit_purchase_state(), + CreditPurchaseState::AwaitingCheckout + ); + }); + }); +} + +/// "Set up AI later" remains the escape hatch even while a credit purchase is +/// awaiting checkout, so an abandoned checkout never traps the user. +#[test] +fn set_up_later_still_works_while_checkout_is_pending() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.update(MockTelemetryContextProvider::register); + let onboarding_state = add_onboarding_state(&mut app); + let (_, slide) = app.add_window(WindowStyle::NotStealFocus, { + let onboarding_state = onboarding_state.clone(); + move |_| OfferSlide::new(onboarding_state) + }); + onboarding_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(4), ctx); + model.request_credit_purchase(ctx); + model.on_credit_checkout_opened(ctx); + }); + + let events = Rc::new(RefCell::new(Vec::new())); + let (_, observer) = app.add_window(WindowStyle::NotStealFocus, { + let events = events.clone(); + move |_| EventObserver { events } + }); + observer.update(&mut app, |_, ctx| { + ctx.subscribe_to_view(&slide, |observer, _, event, _| { + observer.events.borrow_mut().push(format!("{event:?}")); + }); + }); + + slide.update(&mut app, |slide, ctx| { + slide.handle_action(&OfferSlideAction::SelectSetUpLater, ctx); + slide.handle_action(&OfferSlideAction::GetWarping, ctx); + }); + + let recorded = events.borrow().clone(); + assert_eq!(recorded.len(), 1, "expected one event, got {recorded:?}"); + assert!(recorded[0].contains("SetUpLaterSelected")); + }); +} + +/// The pack rows draw from a fixed pool of hover handles, so an unexpectedly +/// long server list must be truncated rather than panic. +#[test] +fn more_packs_than_the_render_cap_are_truncated() { + App::test((), |mut app| async move { + app.add_singleton_model(|_| Appearance::mock()); + app.update(MockTelemetryContextProvider::register); + let onboarding_state = add_onboarding_state(&mut app); + let (_, slide) = app.add_window(WindowStyle::NotStealFocus, { + let onboarding_state = onboarding_state.clone(); + move |_| OfferSlide::new(onboarding_state) + }); + onboarding_state.update(&mut app, |model, ctx| { + model.show_post_auth_offer(OfferVariant::ChooseHowToStart, ctx); + model.set_credit_pack_options(credit_packs(MAX_CREDIT_PACKS + 3), ctx); + }); + + app.read(|ctx| { + let slide = slide.as_ref(ctx); + assert_eq!( + slide + .credit_packs(OfferVariant::ChooseHowToStart, ctx) + .len(), + MAX_CREDIT_PACKS + ); + drop(slide.render(ctx)); + }); + }); }