From 5860046fdb3b550fa71921db9ed5ae1aecc2cbd2 Mon Sep 17 00:00:00 2001 From: Tyrone Tudehope Date: Wed, 27 Mar 2024 22:37:43 +0200 Subject: [PATCH] feat: Apply existing coupon to upgrade message; School upgrade path --- .../entry/user/src/settings/dom/plan_info.rs | 133 +++++++++++++----- .../src/core/inputs/composed/select/select.ts | 2 +- shared/rust/src/domain/billing.rs | 69 +++++++++ 3 files changed, 169 insertions(+), 35 deletions(-) diff --git a/frontend/apps/crates/entry/user/src/settings/dom/plan_info.rs b/frontend/apps/crates/entry/user/src/settings/dom/plan_info.rs index 28a24e4096..be1ca78655 100644 --- a/frontend/apps/crates/entry/user/src/settings/dom/plan_info.rs +++ b/frontend/apps/crates/entry/user/src/settings/dom/plan_info.rs @@ -1,11 +1,12 @@ use components::confirm; use dominator::{clone, html, Dom}; -use futures_signals::signal::SignalExt; +use futures_signals::signal::{Mutable, SignalExt}; use shared::domain::billing::{ AmountInCents, AppliedCoupon, BillingInterval, PaymentMethodType, PaymentNetwork, PlanType, SubscriptionTier, }; use std::rc::Rc; +use strum::IntoEnumIterator; use utils::{events, js_object, prelude::plan_type_signal, unwrap::UnwrapJiExt}; use wasm_bindgen::JsValue; use wasm_bindgen_futures::spawn_local; @@ -105,7 +106,7 @@ impl SettingsPage { state.render_payment_method(&plan_info.payment_method_type), html!("div", { .prop("slot", "change-to-annual") - .child_signal(plan_type_signal().map(clone!(state => move |plan_type| { + .child_signal(plan_type_signal().map(clone!(state, plan_info => move |plan_type| { let frequency = plan_type?.billing_interval(); match frequency { BillingInterval::Monthly => { @@ -116,11 +117,11 @@ impl SettingsPage { Some(PlanType::SchoolLevel1Monthly) => "Get 1 month FREE by switching to annual billing", _ => "Get 2 months FREE by switching to annual billing" }) - .event(clone!(state => move|_ :events::Click| { - spawn_local(clone!(state => async move { + .event(clone!(state, plan_info => move|_ :events::Click| { + spawn_local(clone!(state, plan_info => async move { let plan_type: PlanType = plan_type.unwrap_ji(); let new_plan_type = plan_type.monthly_to_annual(); - let new_price = number_as_price(new_plan_type.plan_price()); + let new_price = discounted_price(new_plan_type, &plan_info); let message = format!("You will be charged {new_price} per year. A renewal reminder will be sent 30 days before the end of your subscription."); let confirmed = confirm::Confirm { @@ -139,40 +140,92 @@ impl SettingsPage { BillingInterval::Annually => None, } }))) - .child_signal(plan_type_signal().map(clone!(state => move |plan_type| { + .child_signal(plan_type_signal().map(clone!(state, plan_info => move |plan_type| { let plan_type: PlanType = plan_type?; let subscription_tier = plan_type.subscription_tier(); - match subscription_tier { - SubscriptionTier::Basic => { - let new_plan_type = plan_type.basic_to_pro(); - let billing_interval = plan_type.billing_interval(); - Some(html!("button-rect", { - .prop("type", "filled") - .prop("color", "blue") - .text(&format!("Switch to Pro {billing_interval}")) - .event(clone!(state => move|_ :events::Click| { - spawn_local(clone!(state => async move { - let charge_interval = match billing_interval { - BillingInterval::Monthly => "month", - BillingInterval::Annually => "year", - }; - let new_price = number_as_price(new_plan_type.plan_price()); - let message = format!("You will be charged {new_price} per {charge_interval}. A renewal reminder will be sent 30 days before the end of your subscription."); + if plan_type.is_individual_plan() { + match subscription_tier { + SubscriptionTier::Basic => { + let new_plan_type = plan_type.basic_to_pro(); + let billing_interval = plan_type.billing_interval(); + Some(html!("button-rect", { + .prop("type", "filled") + .prop("color", "blue") + .text(&format!("Switch to Pro {billing_interval}")) + .event(clone!(state, plan_info => move|_ :events::Click| { + spawn_local(clone!(state, plan_info => async move { + let charge_interval = match billing_interval { + BillingInterval::Monthly => "month", + BillingInterval::Annually => "year", + }; + let new_price = discounted_price(new_plan_type, &plan_info); + let message = format!("You will be charged {new_price} per {charge_interval}. A renewal reminder will be sent 30 days before the end of your subscription."); - let confirmed = confirm::Confirm { - title: "Switch to the Pro plan".to_string(), - message, - confirm_text: "Confirm".to_string(), - cancel_text: "Cancel".to_string() - }.confirm().await; - if confirmed { - state.change_to(new_plan_type); + let confirmed = confirm::Confirm { + title: "Switch to the Pro plan".to_string(), + message, + confirm_text: "Confirm".to_string(), + cancel_text: "Cancel".to_string() + }.confirm().await; + if confirmed { + state.change_to(new_plan_type); + } + })); + })) + })) + }, + SubscriptionTier::Pro => None, + } + } else { + match plan_type { + PlanType::SchoolUnlimitedMonthly | PlanType::SchoolUnlimitedAnnually => None, + _ => { + let plan_type_signal = Mutable::new(None::); + let plan_types = PlanType::iter().filter(|p| { + p.can_upgrade_from_same_interval(&plan_type) + }).map(|p| html!("input-select-option", { + .text(p.user_display_name()) + .prop("value", p.as_str()) + .prop_signal("selected", plan_type_signal.signal_cloned().map(move |current| match current { + None => false, + Some(current) => current == p, + })) + .event(clone!(state, plan_info, plan_type_signal => move |e: events::CustomSelectedChange| { + if e.selected() { + plan_type_signal.set(Some(p)); + spawn_local(clone!(state, plan_info, plan_type_signal => async move { + let billing_interval = plan_type.billing_interval(); + let charge_interval = match billing_interval { + BillingInterval::Monthly => "month", + BillingInterval::Annually => "year", + }; + let new_price = discounted_price(p, &plan_info); + let message = format!("You will be charged {new_price} per {charge_interval}. A renewal reminder will be sent 30 days before the end of your subscription."); + + let confirmed = confirm::Confirm { + title: format!("Upgrade to {}", p.user_display_name()), + message, + confirm_text: "Confirm".to_string(), + cancel_text: "Cancel".to_string() + }.confirm().await; + if confirmed { + state.change_to(p); + } else { + plan_type_signal.set(None); + } + })); } - })); + })) + })).collect::>(); + Some(html!("input-select", { + .style("min-width", "200rem") + .prop("label", "Upgrade to") + .prop("multiple", false) + .prop_signal("value", plan_type_signal.signal_cloned().map(|p| p.map_or("", |p| p.user_display_name()))) + .children(plan_types) })) - })) - }, - SubscriptionTier::Pro => None, + } + } } }))) }), @@ -269,6 +322,18 @@ fn price_string( format!("${discounted}{frequency}{coupon}") } +fn discounted_price(plan_type: PlanType, plan_info: &Rc) -> String { + let discount_percent = match plan_info.coupon { + Some(AppliedCoupon { coupon_percent, .. }) => match coupon_percent { + Some(percent) => 1.0 - f64::from(percent), + None => 1.0, + }, + None => 1.0, + }; + let new_plan_price = (plan_type.plan_price() as f64 * discount_percent) as u32; // This should still be cents + number_as_price(new_plan_price) +} + /// Move to utils? fn number_as_price(cents: u32) -> String { let locales = js_sys::Array::of1(&JsValue::from("en-US")); diff --git a/frontend/elements/src/core/inputs/composed/select/select.ts b/frontend/elements/src/core/inputs/composed/select/select.ts index 6fce3d43c4..9c3e8847ee 100644 --- a/frontend/elements/src/core/inputs/composed/select/select.ts +++ b/frontend/elements/src/core/inputs/composed/select/select.ts @@ -115,7 +115,7 @@ export class _ extends LitElement { ${this.value ? html`${this.value}` : html`${this.placeholder}${this.placeholder ? this.placeholder : html` `}`} &'static str { + match self { + Self::IndividualBasicMonthly | Self::IndividualBasicAnnually => "Basic", + Self::IndividualProMonthly | Self::IndividualProAnnually => "Pro", + Self::SchoolLevel1Monthly | Self::SchoolLevel1Annually => { + formatcp!("Up to {} teachers", PLAN_SCHOOL_LEVEL_1_TEACHER_COUNT) + } + Self::SchoolLevel2Monthly | Self::SchoolLevel2Annually => { + formatcp!("Up to {} teachers", PLAN_SCHOOL_LEVEL_2_TEACHER_COUNT) + } + Self::SchoolLevel3Monthly | Self::SchoolLevel3Annually => { + formatcp!("Up to {} teachers", PLAN_SCHOOL_LEVEL_3_TEACHER_COUNT) + } + Self::SchoolLevel4Monthly | Self::SchoolLevel4Annually => { + formatcp!("Up to {} teachers", PLAN_SCHOOL_LEVEL_4_TEACHER_COUNT) + } + Self::SchoolUnlimitedMonthly | Self::SchoolUnlimitedAnnually => { + formatcp!("More than {} teachers", PLAN_SCHOOL_LEVEL_4_TEACHER_COUNT) + } + } + } + /// `SubscriptionTier` of the current plan #[must_use] pub const fn subscription_tier(&self) -> SubscriptionTier { @@ -980,6 +1004,51 @@ impl PlanType { } } + /// Whether it is possible to upgrade from another plan type to self in the same billing interval + #[must_use] + pub const fn can_upgrade_from_same_interval(&self, from_type: &Self) -> bool { + match self { + Self::IndividualProAnnually => matches!(from_type, Self::IndividualBasicAnnually,), + Self::SchoolLevel1Monthly => false, + Self::SchoolLevel2Monthly => matches!(from_type, Self::SchoolLevel1Monthly), + Self::SchoolLevel3Monthly => matches!( + from_type, + Self::SchoolLevel1Monthly | Self::SchoolLevel2Monthly, + ), + Self::SchoolLevel4Monthly => matches!( + from_type, + Self::SchoolLevel1Monthly | Self::SchoolLevel2Monthly | Self::SchoolLevel3Monthly, + ), + Self::SchoolUnlimitedMonthly => matches!( + from_type, + Self::SchoolLevel1Monthly + | Self::SchoolLevel2Monthly + | Self::SchoolLevel3Monthly + | Self::SchoolLevel4Monthly, + ), + Self::SchoolLevel1Annually => false, + Self::SchoolLevel2Annually => matches!(from_type, Self::SchoolLevel1Annually), + Self::SchoolLevel3Annually => matches!( + from_type, + Self::SchoolLevel1Annually | Self::SchoolLevel2Annually + ), + Self::SchoolLevel4Annually => matches!( + from_type, + Self::SchoolLevel1Annually + | Self::SchoolLevel2Annually + | Self::SchoolLevel3Annually + ), + Self::SchoolUnlimitedAnnually => matches!( + from_type, + Self::SchoolLevel1Annually + | Self::SchoolLevel2Annually + | Self::SchoolLevel3Annually + | Self::SchoolLevel4Annually + ), + _ => false, + } + } + /// check if is individual plan #[must_use] pub const fn is_individual_plan(&self) -> bool {