diff --git a/examples/gallery/pages/account/billing.rs b/examples/gallery/pages/account/billing.rs new file mode 100644 index 0000000..a215b77 --- /dev/null +++ b/examples/gallery/pages/account/billing.rs @@ -0,0 +1,236 @@ +use ely_gpui_component::account::{ + BillingHistory, Invitation, InvitationList, Invoice, InvoiceState, Member, PaymentMethodForm, + Quota, Role, Standing, Subscription, SubscriptionCard, TeamMemberTable, UpgradePrompt, + UsageQuota, +}; +use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px}; + +use crate::ui::{change, keep, section}; + +/// The plan demo: whether it was canceled, and whether a card was saved. +#[derive(Default)] +struct Plan { + canceled: bool, + card: Option, +} + +pub fn plan(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-plan", Plan::default, window, cx); + let now = state.read(cx); + let (canceled, card) = (now.canceled, now.card.clone()); + let [cancel, resume, saved] = [(); 3].map(|_| state.clone()); + let standing = match canceled { + true => Standing::Canceled { + ends: "1 October 2026".into(), + }, + false => Standing::Active { + renews: "1 October 2026".into(), + }, + }; + let quota = |key: &str, name: &str, used: f64, limit: f64, unit: &str| Quota { + key: key.to_string().into(), + name: name.to_string().into(), + used, + limit, + unit: unit.to_string().into(), + }; + section( + "SubscriptionCard / PlanBadge · UsageQuota · PaymentMethodForm", + "The plan with where it stands and what comes next; Cancel asks twice and Keep plan takes it back. Each limit's bar turns amber near it and red at it. A card saves once its number checks out, its month is ahead and its code is whole.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child( + div().w(px(340.)).flex().flex_col().gap_6().child( + SubscriptionCard::new( + "account-plan", + Subscription { plan: "Pro".into(), price: 20.0, currency: "USD".into(), yearly: false, standing }, + ) + .on_change(|_, _| log::info!("gallery: change plan")) + .on_cancel(move |_, cx| change(&cancel, cx, |plan| plan.canceled = true)) + .on_resume(move |_, cx| change(&resume, cx, |plan| plan.canceled = false)), + ) + .child( + UsageQuota::new( + "account-quota", + [ + quota("seats", "Seats", 9.0, 12.0, "seats"), + quota("storage", "Storage", 96.5, 100.0, "GB"), + quota("runs", "Automation runs", 1_240.0, 2_000.0, "runs"), + ], + ) + .resets("1 October"), + ), + ) + .child(div().w(px(360.)).flex().flex_col().gap_3().child( + PaymentMethodForm::new("account-card-form").on_submit(move |card, _, cx| { + let saved_as: SharedString = format!("{} ending {}", card.brand.name(), &card.number[card.number.len() - 4..]).into(); + change(&saved, cx, move |plan| plan.card = Some(saved_as)) + }), + ) + .children(card.map(|card| div().text_sm().child(format!("Saved: {card}"))))), + ) +} + +pub fn history(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let invoice = |key: &str, date: &str, description: &str, amount: f64, state| Invoice { + key: key.to_string().into(), + date: date.to_string().into(), + description: description.to_string().into(), + amount, + currency: "USD".into(), + state, + }; + section( + "BillingHistory · UpgradePrompt / Paywall", + "Past bills with what each was for, its amount, date and state, and a download. A prompt past a limit stands as one line, or as a paywall with what the next plan brings.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child(div().w(px(400.)).child( + BillingHistory::new( + "account-bills", + [ + invoice("sep", "1 Sep 2026", "Pro, monthly", 20.0, InvoiceState::Due), + invoice("aug", "1 Aug 2026", "Pro, monthly", 20.0, InvoiceState::Paid), + invoice("jul", "1 Jul 2026", "Pro, monthly", 20.0, InvoiceState::Failed), + invoice("jun", "12 Jun 2026", "Extra seats", -15.0, InvoiceState::Refunded), + ], + ) + .on_download(|key, _, _| log::info!("gallery: download {key}")), + )) + .child( + div() + .w(px(360.)) + .flex() + .flex_col() + .gap_4() + .child( + UpgradePrompt::new("account-nudge", "9 of 10 projects used") + .body("Pro has no limit on projects.") + .on_upgrade(|_, _| log::info!("gallery: upgrade")) + .on_dismiss(|_, _| log::info!("gallery: not now")), + ) + .child( + UpgradePrompt::new("account-wall", "Version history is on Pro") + .body("See and restore every version of a document.") + .benefit("Every version, kept a year") + .benefit("Restore with one press") + .benefit("Compare any two") + .on_upgrade(|_, _| log::info!("gallery: upgrade")) + .on_dismiss(|_, _| log::info!("gallery: not now")), + ), + ), + ) +} + +fn roles() -> Vec { + let role = |key: &str, name: &str, description: &str| Role { + key: key.to_string().into(), + name: name.to_string().into(), + description: description.to_string().into(), + }; + vec![ + role("member", "Member", "Projects and documents"), + role("admin", "Admin", "Everything, billing too"), + role("guest", "Guest", "Only what is shared"), + ] +} + +/// The team demo: members and invitations out. +struct Team { + members: Vec, + invitations: Vec, + sent: usize, +} + +fn team() -> Team { + let member = |key: &str, name: &str, role: &str, seen: &str, owner| Member { + key: key.to_string().into(), + name: name.to_string().into(), + email: format!("{key}@atlas.dev").into(), + role: role.to_string().into(), + seen: seen.to_string().into(), + image: None, + owner, + }; + Team { + members: vec![ + member("ada", "Ada Lovelace", "admin", "Active now", true), + member("grace", "Grace Hopper", "admin", "Active today", false), + member("alan", "Alan Turing", "member", "3 days ago", false), + ], + invitations: vec![ + Invitation { + key: "kat".into(), + email: "katherine@nasa.gov".into(), + role: "member".into(), + sent: "Sent 2 days ago".into(), + expired: false, + }, + Invitation { + key: "edsger".into(), + email: "edsger@tue.nl".into(), + role: "guest".into(), + sent: "Sent 9 days ago".into(), + expired: true, + }, + ], + sent: 0, + } +} + +pub fn members(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("account-team", team, window, cx); + let now = state.read(cx); + let (members, invitations) = (now.members.clone(), now.invitations.clone()); + let [role, removed, invited, revoked] = [(); 4].map(|_| state.clone()); + section( + "TeamMemberTable · RoleSelector · InvitationList", + "Members with a role to change, each role saying what it may do, and a Remove that asks twice; the owner stays. Invitations out resend or revoke, and an address with a role invites someone new.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_6() + .w(px(640.)) + .child( + TeamMemberTable::new("account-members", members, roles()) + .on_role(move |key, next, _, cx| { + let (key, next) = (key.clone(), next.clone()); + change(&role, cx, move |team| team.members.iter_mut().filter(|member| member.key == key).for_each(|member| member.role = next.clone())) + }) + .on_remove(move |key, _, cx| { + let key = key.clone(); + change(&removed, cx, move |team| team.members.retain(|member| member.key != key)) + }), + ) + .child( + InvitationList::new("account-invites", invitations, roles()) + .on_invite(move |email, role, _, cx| { + let (email, role) = (email.clone(), role.clone()); + change(&invited, cx, move |team| { + team.sent += 1; + team.invitations.insert(0, Invitation { key: format!("new-{}", team.sent).into(), email, role, sent: "Sent just now".into(), expired: false }); + }) + }) + .on_resend(|key, _, _| log::info!("gallery: resend {key}")) + .on_revoke(move |key, _, cx| { + let key = key.clone(); + change(&revoked, cx, move |team| team.invitations.retain(|each| each.key != key)) + }), + ), + ) +} diff --git a/examples/gallery/pages/account/mod.rs b/examples/gallery/pages/account/mod.rs index b3c237e..3be74ef 100644 --- a/examples/gallery/pages/account/mod.rs +++ b/examples/gallery/pages/account/mod.rs @@ -1,6 +1,7 @@ use gpui::{AnyElement, App, Window, div, prelude::*}; mod access; +mod billing; mod sign_in; use super::Page; @@ -51,5 +52,8 @@ fn render(window: &mut Window, cx: &mut App) -> AnyElement { .child(access::switchers(window, cx)) .child(access::profile(window, cx)) .child(access::security_page(window, cx)) + .child(billing::plan(window, cx)) + .child(billing::history(window, cx)) + .child(billing::members(window, cx)) .into_any_element() } diff --git a/src/account/billing.rs b/src/account/billing.rs new file mode 100644 index 0000000..f08554d --- /dev/null +++ b/src/account/billing.rs @@ -0,0 +1,425 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, prelude::*, +}; + +use super::login::Run; +use crate::{ + buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode, IconButton}, + data_display::{Badge, Meter, Tone}, + layout::Card, + primitives::IconName, + theme::{ActiveTheme, ControlSize, TextSize}, + typography::{ + Ellipsis, + format::{Separators, currency, number}, + tabular, + }, +}; + +type OnKey = Rc; + +/// Where a subscription stands, each with the date that matters, as words. +#[derive(Clone, Debug, PartialEq)] +pub enum Standing { + Active { renews: SharedString }, + Trial { ends: SharedString }, + Canceled { ends: SharedString }, + PastDue, +} + +impl Standing { + fn badge(&self) -> (&'static str, Tone) { + match self { + Standing::Active { .. } => ("Active", Tone::Success), + Standing::Trial { .. } => ("Trial", Tone::Info), + Standing::Canceled { .. } => ("Canceled", Tone::Neutral), + Standing::PastDue => ("Past due", Tone::Danger), + } + } + + fn line(&self) -> String { + match self { + Standing::Active { renews } => format!("Renews on {renews}."), + Standing::Trial { ends } => format!("The trial ends on {ends}."), + Standing::Canceled { ends } => format!("Ends on {ends}. Nothing more will be charged."), + Standing::PastDue => { + "The last payment failed. Update the card to keep the plan.".into() + } + } + } +} + +/// A plan paid for: its name, its price per period in a currency, and where it stands. +#[derive(Clone, Debug, PartialEq)] +pub struct Subscription { + pub plan: SharedString, + pub price: f64, + pub currency: SharedString, + pub yearly: bool, + pub standing: Standing, +} + +/// A plan at a glance: its name and where it stands, its price, what happens next, and a way to change it, cancel it after a second press, or take it back. +#[derive(IntoElement)] +pub struct SubscriptionCard { + id: ElementId, + subscription: Subscription, + on_change: Option, + on_cancel: Option, + on_resume: Option, +} + +impl SubscriptionCard { + pub fn new(id: impl Into, subscription: Subscription) -> Self { + Self { + id: id.into(), + subscription, + on_change: None, + on_cancel: None, + on_resume: None, + } + } + + /// Shows "Change plan". + pub fn on_change(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Shows "Cancel plan" while the plan runs. + pub fn on_cancel(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_cancel = Some(Rc::new(handler)); + self + } + + /// Shows "Keep plan" once canceled. + pub fn on_resume(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_resume = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SubscriptionCard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let (id, plan) = (self.id, self.subscription); + let theme = cx.theme(); + let (state, tone) = plan.standing.badge(); + let canceled = matches!(plan.standing, Standing::Canceled { .. }); + let period = if plan.yearly { "per year" } else { "per month" }; + let change = self.on_change.map(|run| { + Button::new((id.clone(), "change"), "Change plan") + .on_click(move |_, window, cx| run(window, cx)) + .into_any_element() + }); + let end = match canceled { + true => self.on_resume.map(|run| { + Button::new((id.clone(), "resume"), "Keep plan") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| run(window, cx)) + .into_any_element() + }), + false => self.on_cancel.map(|run| { + ConfirmButton::new((id.clone(), "cancel"), "Cancel plan", ConfirmMode::Twice) + .on_confirm(move |window, cx| { + log::info!("subscription card: cancel"); + run(window, cx); + }) + .into_any_element() + }), + }; + let actions: Vec = change.into_iter().chain(end).collect(); + Card::new().child( + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .flex_wrap() + .items_center() + .gap_2() + .child( + div() + .text_size(theme.text_size(TextSize::Md)) + .font_weight(FontWeight::SEMIBOLD) + .child(plan.plan), + ) + .child(Badge::new(state).tone(tone)), + ) + .child( + div() + .flex() + .flex_wrap() + .items_baseline() + .gap_1() + .child(tabular( + div() + .text_size(theme.text_size(TextSize::Xl)) + .font_weight(FontWeight::SEMIBOLD) + .child(currency(plan.price, &plan.currency)), + )) + .child(div().text_color(theme.colors.fg_muted).child(period)), + ) + .child( + div().flex().child( + div() + .flex_1() + .min_w_0() + .text_color(theme.colors.fg_muted) + .child(plan.standing.line()), + ), + ) + .children( + (!actions.is_empty()) + .then(|| div().flex().flex_wrap().gap_2().pt_1().children(actions)), + ), + ) + } +} + +/// A limit on a plan: its key and name, how much is used of how much, and the unit. +#[derive(Clone, Debug, PartialEq)] +pub struct Quota { + pub key: SharedString, + pub name: SharedString, + pub used: f64, + pub limit: f64, + pub unit: SharedString, +} + +/// An amount to a tenth, without a trailing zero: 8, 8.5, 1,200. +fn amount(value: f64) -> String { + let text = number(value, 1, Separators::EN); + text.strip_suffix(".0").map_or(text.clone(), str::to_string) +} + +/// The words under a quota's bar: "8.5 of 10 GB", or how far past its limit. +pub fn quota_line(quota: &Quota) -> String { + assert!(quota.limit > 0.0, "quota {}: no limit", quota.key); + match quota.used > quota.limit { + true => format!( + "{} past the {} {} limit", + amount(quota.used - quota.limit), + amount(quota.limit), + quota.unit + ), + false => format!( + "{} of {} {}", + amount(quota.used), + amount(quota.limit), + quota.unit + ), + } +} + +/// How much of each limit is used, a bar for each that turns amber near it and red at it, and when they start over. +#[derive(IntoElement)] +pub struct UsageQuota { + id: ElementId, + quotas: Vec, + resets: Option, +} + +impl UsageQuota { + pub fn new(id: impl Into, quotas: impl IntoIterator) -> Self { + Self { + id: id.into(), + quotas: quotas.into_iter().collect(), + resets: None, + } + } + + /// When the counts start over, as words. + pub fn resets(mut self, when: impl Into) -> Self { + self.resets = Some(when.into()); + self + } +} + +impl RenderOnce for UsageQuota { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let theme = cx.theme(); + div() + .flex() + .flex_col() + .gap_4() + .children(self.quotas.iter().map(|quota| { + let share = (quota.used / quota.limit).clamp(0.0, 1.0) as f32; + Meter::new( + (id.clone(), format!("quota-{}", quota.key)), + quota.name.clone(), + share, + ) + .detail(quota_line(quota)) + })) + .children(self.resets.map(|when| { + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(format!("Resets on {when}.")) + })) + } +} + +/// Where an invoice stands. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InvoiceState { + Paid, + Due, + Failed, + Refunded, +} + +/// A bill: its key, date and what it was for as words, its amount in a currency, and where it stands. +#[derive(Clone, Debug, PartialEq)] +pub struct Invoice { + pub key: SharedString, + pub date: SharedString, + pub description: SharedString, + pub amount: f64, + pub currency: SharedString, + pub state: InvoiceState, +} + +/// Past bills, newest first as given: what each was for and its amount, its date and state below, and a way to download it. +#[derive(IntoElement)] +pub struct BillingHistory { + id: ElementId, + invoices: Vec, + on_download: Option, +} + +impl BillingHistory { + pub fn new(id: impl Into, invoices: impl IntoIterator) -> Self { + Self { + id: id.into(), + invoices: invoices.into_iter().collect(), + on_download: None, + } + } + + /// Shows a download button on each. + pub fn on_download( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_download = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for BillingHistory { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let theme = cx.theme(); + let rows = self.invoices.iter().enumerate().map(|(ix, invoice)| { + let (state, tone) = match invoice.state { + InvoiceState::Paid => ("Paid", Tone::Success), + InvoiceState::Due => ("Due", Tone::Warning), + InvoiceState::Failed => ("Failed", Tone::Danger), + InvoiceState::Refunded => ("Refunded", Tone::Neutral), + }; + let download = self.on_download.clone().map(|run| { + let key = invoice.key.clone(); + div().flex_none().child( + IconButton::new( + (id.clone(), format!("download-{}", invoice.key)), + IconName::Download, + ) + .size(ControlSize::Sm) + .tooltip("Download") + .on_click(move |_, window, cx| { + log::info!("billing history: download {key}"); + run(&key, window, cx); + }), + ) + }); + div() + .flex() + .items_center() + .gap_3() + .py_2p5() + .when(ix > 0, |row| { + row.border_t_1().border_color(theme.colors.border) + }) + .child( + div() + .flex_1() + .min_w_0() + .child( + div() + .flex() + .gap_2() + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(invoice.description.clone())), + ) + .child(tabular( + div() + .flex_none() + .font_weight(FontWeight::MEDIUM) + .child(currency(invoice.amount, &invoice.currency)), + )), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(invoice.date.clone())), + ) + .child(div().flex_none().child(Badge::new(state).tone(tone))), + ), + ) + .children(download) + }); + let empty = self.invoices.is_empty().then(|| { + div() + .py_2() + .text_color(theme.colors.fg_muted) + .child("No bills yet.") + }); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + .children(empty) + } +} + +#[cfg(test)] +mod tests { + use super::{Quota, quota_line}; + + fn quota(used: f64, limit: f64) -> Quota { + Quota { + key: "storage".into(), + name: "Storage".into(), + used, + limit, + unit: "GB".into(), + } + } + + #[test] + fn a_quota_reads_its_use_or_how_far_past_it_went() { + assert_eq!(quota_line("a(8.5, 10.0)), "8.5 of 10 GB"); + assert_eq!(quota_line("a(1_250.0, 2_000.0)), "1,250 of 2,000 GB"); + assert_eq!(quota_line("a(12.4, 10.0)), "2.4 past the 10 GB limit"); + } +} diff --git a/src/account/mod.rs b/src/account/mod.rs index c83117c..b06e316 100644 --- a/src/account/mod.rs +++ b/src/account/mod.rs @@ -1,22 +1,33 @@ +mod billing; mod factor; mod keys; mod login; mod mail; mod oauth; +mod payment; mod profile; mod sessions; mod signup; mod switcher; +mod team; +mod upgrade; #[cfg(all(test, feature = "test-support"))] mod tests; +pub use billing::{ + BillingHistory, Invoice, InvoiceState, Quota, Standing, Subscription, SubscriptionCard, + UsageQuota, quota_line, +}; pub use factor::TwoFactorInput; pub use keys::{ApiKey, ApiKeyManager}; pub use login::{Login, LoginForm}; pub use mail::{ForgotPassword, MagicLinkForm}; pub use oauth::OAuthButtons; +pub use payment::{CardBrand, CardDetails, PaymentMethodForm, expiry, luhn}; pub use profile::{Profile, ProfileCard, ProfileEditor}; pub use sessions::{Session, SessionList}; pub use signup::{Signup, SignupForm, password_rules}; pub use switcher::{Account, AccountSwitcher, UserMenu, Workspace, WorkspaceSwitcher}; +pub use team::{Invitation, InvitationList, Member, Role, RoleSelector, TeamMemberTable}; +pub use upgrade::UpgradePrompt; diff --git a/src/account/payment.rs b/src/account/payment.rs new file mode 100644 index 0000000..796131b --- /dev/null +++ b/src/account/payment.rs @@ -0,0 +1,290 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; +use jiff::Timestamp; + +use super::login::{Run, field}; +use crate::{ + buttons::{Button, ButtonVariant}, + feedback::InlineMessage, + forms::{Enter, FormField, Input, MaskedInput}, + primitives::{Icon, IconName, Severity}, + theme::{ActiveTheme, IconSize}, + typography::format::system_zone, +}; + +/// A card network, told by a number's first digits. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CardBrand { + Visa, + Mastercard, + Amex, + Discover, + Other, +} + +impl CardBrand { + pub fn of(digits: &str) -> Self { + let lead = |n: usize| digits.get(..n).and_then(|lead| lead.parse::().ok()); + if digits.starts_with('4') { + CardBrand::Visa + } else if lead(2).is_some_and(|lead| (51..=55).contains(&lead)) + || lead(4).is_some_and(|lead| (2221..=2720).contains(&lead)) + { + CardBrand::Mastercard + } else if lead(2).is_some_and(|lead| lead == 34 || lead == 37) { + CardBrand::Amex + } else if digits.starts_with("6011") || digits.starts_with("65") { + CardBrand::Discover + } else { + CardBrand::Other + } + } + + pub fn name(self) -> &'static str { + match self { + CardBrand::Visa => "Visa", + CardBrand::Mastercard => "Mastercard", + CardBrand::Amex => "American Express", + CardBrand::Discover => "Discover", + CardBrand::Other => "Card", + } + } + + /// The number's pattern and its digits, and the digits of its security code. + fn shape(self) -> (&'static str, usize, usize) { + match self { + CardBrand::Amex => ("9999 999999 99999", 15, 4), + _ => ("9999 9999 9999 9999", 16, 3), + } + } +} + +/// Whether `digits` pass the Luhn check that card numbers carry. +pub fn luhn(digits: &str) -> bool { + let mut sum = 0; + for (ix, ch) in digits.chars().rev().enumerate() { + let Some(mut digit) = ch.to_digit(10) else { + return false; + }; + if ix % 2 == 1 { + digit *= 2; + if digit > 9 { + digit -= 9; + } + } + sum += digit; + } + !digits.is_empty() && sum % 10 == 0 +} + +/// The month and year of an expiry written "MM/YY", when it reads and has not passed `now`, a year and month. +pub fn expiry(text: &str, now: (i32, i8)) -> Option<(i8, i32)> { + let (month, year) = text.split_once('/')?; + let (month, year) = ( + month.trim().parse::().ok()?, + year.trim().parse::().ok()?, + ); + let year = 2000 + year; + ((1..=12).contains(&month) && (year, month) >= now).then_some((month, year)) +} + +/// A card as its form hands it over. +#[derive(Clone, Debug, PartialEq)] +pub struct CardDetails { + pub brand: CardBrand, + pub number: SharedString, + pub month: i8, + pub year: i32, + pub cvc: SharedString, + pub name: SharedString, +} + +type OnCard = Rc; + +fn digits(text: &str) -> String { + text.chars().filter(char::is_ascii_digit).collect() +} + +/// A card to pay with: its number, spaced as its network writes it and named once told, the month and year it runs out, its security code and the name on it. Save waits for a number that checks out, a month ahead, a full code and a name. +#[derive(IntoElement)] +pub struct PaymentMethodForm { + id: ElementId, + error: Option, + busy: bool, + on_submit: Option, +} + +impl PaymentMethodForm { + pub fn new(id: impl Into) -> Self { + Self { + id: id.into(), + error: None, + busy: false, + on_submit: None, + } + } + + /// Why the card was refused, shown above Save. + pub fn error(mut self, error: impl Into) -> Self { + self.error = Some(error.into()); + self + } + + /// While the owner checks the card. + pub fn busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + + pub fn on_submit( + mut self, + handler: impl Fn(&CardDetails, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_submit = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for PaymentMethodForm { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_submit = self + .on_submit + .unwrap_or_else(|| panic!("payment method form {id:?} has no on_submit")); + let number = field(&id, "number", "", false, window, cx); + let until = field(&id, "expiry", "", false, window, cx); + let code = field(&id, "cvc", "", false, window, cx); + let name = field(&id, "name", "As printed on the card", false, window, cx); + let typed = digits(number.read(cx).text()); + let brand = CardBrand::of(&typed); + let (mask, length, code_length) = brand.shape(); + let complete = typed.len() == length; + let checks = complete && luhn(&typed); + let now = Timestamp::now().to_zoned(system_zone("payment method form")); + let expires = expiry(until.read(cx).text(), (now.year() as i32, now.month())); + let cvc = digits(code.read(cx).text()); + let holder: SharedString = name.read(cx).text().trim().to_string().into(); + let card = expires + .filter(|_| checks && cvc.len() == code_length && !holder.is_empty()) + .map(|(month, year)| CardDetails { + brand, + number: typed.clone().into(), + month, + year, + cvc: cvc.clone().into(), + name: holder, + }); + let ready = card.is_some() && !self.busy; + let submit: Run = Rc::new(move |window, cx| { + let card = card.as_ref().expect("Save rests until the card reads"); + log::info!("payment method form: save {}", card.brand.name()); + on_submit(card, window, cx); + }); + let enter = submit.clone(); + let theme = cx.theme(); + let mut number_field = FormField::new((id.clone(), "number-field"), "Card number"); + if complete && !checks { + number_field = number_field.error("This number does not check out."); + } else if brand != CardBrand::Other { + number_field = number_field.description(brand.name()); + } + let late = until.read(cx).text().len() == "MM/YY".len() && expires.is_none(); + let mut until_field = FormField::new((id.clone(), "expiry-field"), "Expires"); + if late { + until_field = until_field.error("This date has passed."); + } + div() + .flex() + .flex_col() + .gap_4() + .capture_action(move |_: &Enter, window, cx| { + if ready { + cx.stop_propagation(); + enter(window, cx); + } + }) + .child( + number_field.child( + MaskedInput::new((id.clone(), "number"), &number, mask).prefix( + Icon::new(IconName::CreditCard) + .size(IconSize::Sm) + .color(theme.colors.fg_subtle), + ), + ), + ) + .child( + div() + .flex() + .flex_wrap() + .gap_3() + .child(div().flex_1().min_w(theme.label_width() * 0.6).child( + until_field.child(MaskedInput::new( + (id.clone(), "expiry"), + &until, + "99/99", + )), + )) + .child(div().flex_1().min_w(theme.label_width() * 0.6).child( + FormField::new((id.clone(), "cvc-field"), "Security code").child( + MaskedInput::new( + (id.clone(), "cvc"), + &code, + if code_length == 4 { "9999" } else { "999" }, + ), + ), + )), + ) + .child( + FormField::new((id.clone(), "name-field"), "Name on card").child(Input::new(&name)), + ) + .children( + self.error + .map(|error| InlineMessage::new(Severity::Danger, error)), + ) + .child( + Button::new((id.clone(), "save"), "Save card") + .variant(ButtonVariant::Primary) + .full_width() + .loading(self.busy) + .disabled(!ready) + .on_click(move |_, window, cx| submit(window, cx)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{CardBrand, expiry, luhn}; + + #[test] + fn a_number_tells_its_network_and_checks_out() { + assert_eq!(CardBrand::of("4242"), CardBrand::Visa); + assert_eq!(CardBrand::of("5555"), CardBrand::Mastercard); + assert_eq!(CardBrand::of("2221"), CardBrand::Mastercard); + assert_eq!(CardBrand::of("3782"), CardBrand::Amex); + assert_eq!(CardBrand::of("6011"), CardBrand::Discover); + assert_eq!(CardBrand::of("9"), CardBrand::Other); + assert!(luhn("4242424242424242")); + assert!(luhn("378282246310005")); + assert!(!luhn("4242424242424241")); + assert!(!luhn("")); + } + + #[test] + fn an_expiry_reads_and_has_not_passed() { + let now = (2026, 9); + assert_eq!( + expiry("09/26", now), + Some((9, 2026)), + "this month still counts" + ); + assert_eq!(expiry("12/31", now), Some((12, 2031))); + assert_eq!(expiry("08/26", now), None, "last month has passed"); + assert_eq!(expiry("13/30", now), None, "there is no thirteenth month"); + assert_eq!(expiry("1230", now), None); + } +} diff --git a/src/account/team.rs b/src/account/team.rs new file mode 100644 index 0000000..1646f33 --- /dev/null +++ b/src/account/team.rs @@ -0,0 +1,475 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, ImageSource, InteractiveElement, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, prelude::*, +}; + +use super::login::field; +use crate::{ + buttons::{Button, ButtonVariant, ConfirmButton, ConfirmMode}, + data_display::{Avatar, Badge, Tone}, + forms::{Choice, EmailInput, Enter, Select, is_email}, + theme::{ActiveTheme, AvatarSize, ControlSize, TextSize}, + typography::Ellipsis, +}; + +type OnKey = Rc; +type OnPair = Rc; + +/// A role: its key, its name and what it may do. +#[derive(Clone, Debug, PartialEq)] +pub struct Role { + pub key: SharedString, + pub name: SharedString, + pub description: SharedString, +} + +fn role_named<'a>(roles: &'a [Role], key: &SharedString, owner: &str) -> &'a Role { + roles + .iter() + .find(|role| role.key == *key) + .unwrap_or_else(|| panic!("{owner}: no role {key}")) +} + +/// A role to pick, each with what it may do beneath its name. +#[derive(IntoElement)] +pub struct RoleSelector { + id: ElementId, + roles: Vec, + selected: SharedString, + disabled: bool, + on_change: Option, +} + +impl RoleSelector { + pub fn new( + id: impl Into, + roles: impl IntoIterator, + selected: impl Into, + ) -> Self { + let (roles, selected): (Vec<_>, SharedString) = + (roles.into_iter().collect(), selected.into()); + role_named(&roles, &selected, "role selector"); + Self { + id: id.into(), + roles, + selected, + disabled: false, + on_change: None, + } + } + + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + pub fn on_change( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_change = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for RoleSelector { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let id = self.id; + let on_change = self + .on_change + .unwrap_or_else(|| panic!("role selector {id:?} has no on_change")); + let choices = self + .roles + .into_iter() + .map(|role| Choice::new(role.key, role.name).note(role.description)); + Select::new(id, choices) + .selected(self.selected) + .disabled(self.disabled) + .size(ControlSize::Sm) + .on_change(move |key, window, cx| { + log::info!("role selector: {key}"); + on_change(key, window, cx); + }) + } +} + +/// Someone on a team: their key, name and email, their role's key, when they were last seen as words, a picture when there is one, and whether they own the team. +#[derive(Clone)] +pub struct Member { + pub key: SharedString, + pub name: SharedString, + pub email: SharedString, + pub role: SharedString, + pub seen: SharedString, + pub image: Option, + pub owner: bool, +} + +/// A team's members: each with their picture, name and email and when last seen, a role to change and a Remove that asks twice. The owner's role stays, and the owner stays. +#[derive(IntoElement)] +pub struct TeamMemberTable { + id: ElementId, + members: Vec, + roles: Vec, + on_role: Option, + on_remove: Option, +} + +impl TeamMemberTable { + pub fn new( + id: impl Into, + members: impl IntoIterator, + roles: impl IntoIterator, + ) -> Self { + let (members, roles): (Vec, Vec) = + (members.into_iter().collect(), roles.into_iter().collect()); + for member in members.iter().filter(|member| !member.owner) { + role_named(&roles, &member.role, "team member table"); + } + Self { + id: id.into(), + members, + roles, + on_role: None, + on_remove: None, + } + } + + /// Runs with a member's key and their new role's. + pub fn on_role( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_role = Some(Rc::new(handler)); + self + } + + pub fn on_remove( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_remove = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for TeamMemberTable { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_role = self + .on_role + .unwrap_or_else(|| panic!("team member table {id:?} has no on_role")); + let on_remove = self + .on_remove + .unwrap_or_else(|| panic!("team member table {id:?} has no on_remove")); + let theme = cx.theme(); + let rows = self.members.into_iter().enumerate().map(|(ix, member)| { + let mut avatar = Avatar::new( + (id.clone(), format!("avatar-{}", member.key)), + member.name.clone(), + ) + .size(AvatarSize::Sm); + if let Some(image) = member.image { + avatar = avatar.image(image); + } + let (role, remove) = match member.owner { + true => ( + div() + .text_color(theme.colors.fg_muted) + .child("Owner") + .into_any_element(), + None, + ), + false => { + let [changed, removed] = [(); 2].map(|_| member.key.clone()); + let (set, drop) = (on_role.clone(), on_remove.clone()); + let select = RoleSelector::new( + (id.clone(), format!("role-{}", member.key)), + self.roles.clone(), + member.role.clone(), + ) + .on_change(move |role, window, cx| set(&changed, role, window, cx)); + let button = ConfirmButton::new( + (id.clone(), format!("remove-{}", member.key)), + "Remove", + ConfirmMode::Twice, + ) + .size(ControlSize::Sm) + .on_confirm(move |window, cx| { + log::info!("team member table: remove {removed}"); + drop(&removed, window, cx); + }); + (select.into_any_element(), Some(button)) + } + }; + div() + .flex() + .flex_wrap() + .items_center() + .gap_x_3() + .gap_y_2() + .py_2p5() + .when(ix > 0, |row| { + row.border_t_1().border_color(theme.colors.border) + }) + .child( + div() + .flex() + .items_center() + .gap_3() + .flex_1() + .min_w(theme.label_width()) + .child(div().flex_none().child(avatar)) + .child( + div() + .flex_1() + .min_w_0() + .child(Ellipsis::new(member.name)) + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(Ellipsis::new(format!( + "{} · {}", + member.email, member.seen + ))), + ), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .flex_none() + .child(div().w(theme.label_width()).child(role)) + .children(remove), + ) + }); + div() + .flex() + .flex_col() + .text_size(theme.text_size(TextSize::Sm)) + .children(rows) + } +} + +/// An invitation on its way: its key, the address, the role's key, when it went as words, and whether it has lapsed. +#[derive(Clone, Debug, PartialEq)] +pub struct Invitation { + pub key: SharedString, + pub email: SharedString, + pub role: SharedString, + pub sent: SharedString, + pub expired: bool, +} + +/// Invitations out, each with its role and when it went, and a Resend and a Revoke; above them, an address and a role to invite someone new. Invite waits for an address that reads. +#[derive(IntoElement)] +pub struct InvitationList { + id: ElementId, + invitations: Vec, + roles: Vec, + on_invite: Option, + on_resend: Option, + on_revoke: Option, +} + +impl InvitationList { + pub fn new( + id: impl Into, + invitations: impl IntoIterator, + roles: impl IntoIterator, + ) -> Self { + let (invitations, roles): (Vec, Vec) = ( + invitations.into_iter().collect(), + roles.into_iter().collect(), + ); + assert!(!roles.is_empty(), "invitation list: no role to invite as"); + for invitation in &invitations { + role_named(&roles, &invitation.role, "invitation list"); + } + Self { + id: id.into(), + invitations, + roles, + on_invite: None, + on_resend: None, + on_revoke: None, + } + } + + /// Runs with the address and the role's key. + pub fn on_invite( + mut self, + handler: impl Fn(&SharedString, &SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_invite = Some(Rc::new(handler)); + self + } + + pub fn on_resend( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_resend = Some(Rc::new(handler)); + self + } + + pub fn on_revoke( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_revoke = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for InvitationList { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_invite = self + .on_invite + .unwrap_or_else(|| panic!("invitation list {id:?} has no on_invite")); + let on_resend = self + .on_resend + .unwrap_or_else(|| panic!("invitation list {id:?} has no on_resend")); + let on_revoke = self + .on_revoke + .unwrap_or_else(|| panic!("invitation list {id:?} has no on_revoke")); + let email = field(&id, "email", "name@example.com", false, window, cx); + let first = self.roles[0].key.clone(); + let role = window.use_keyed_state((id.clone(), "role"), cx, move |_, _| first); + let (address, chosen): (SharedString, SharedString) = ( + email.read(cx).text().trim().to_string().into(), + role.read(cx).clone(), + ); + let ready = is_email(&address); + let clear = email.clone(); + let invite = Rc::new(move |window: &mut Window, cx: &mut App| { + log::info!("invitation list: invite as {chosen}"); + on_invite(&address, &chosen, window, cx); + clear.update(cx, |field, cx| field.set_text("", cx)); + }); + let enter = invite.clone(); + let theme = cx.theme(); + let rows = + self.invitations.iter().map(|invitation| { + let named = role_named(&self.roles, &invitation.role, "invitation list") + .name + .clone(); + let [again, gone] = [(); 2].map(|_| invitation.key.clone()); + let (resend, revoke) = (on_resend.clone(), on_revoke.clone()); + div() + .flex() + .flex_wrap() + .items_center() + .gap_x_3() + .gap_y_2() + .py_2p5() + .border_t_1() + .border_color(theme.colors.border) + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .child(Ellipsis::new(invitation.email.clone())) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_size(theme.text_size(TextSize::Xs)) + .text_color(theme.colors.fg_muted) + .child(div().flex_1().min_w_0().child(Ellipsis::new(format!( + "{named} · {}", + invitation.sent + )))) + .children(invitation.expired.then(|| { + div() + .flex_none() + .child(Badge::new("Expired").tone(Tone::Warning)) + })), + ), + ) + .child( + div() + .flex() + .flex_none() + .gap_1() + .child( + Button::new( + (id.clone(), format!("resend-{}", invitation.key)), + "Resend", + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("invitation list: resend {again}"); + resend(&again, window, cx); + }), + ) + .child( + Button::new( + (id.clone(), format!("revoke-{}", invitation.key)), + "Revoke", + ) + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("invitation list: revoke {gone}"); + revoke(&gone, window, cx); + }), + ), + ) + }); + let picked = role.clone(); + div() + .flex() + .flex_col() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child( + div() + .flex() + .flex_wrap() + .gap_2() + .capture_action(move |_: &Enter, window, cx| { + if ready { + cx.stop_propagation(); + enter(window, cx); + } + }) + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .child(EmailInput::new(&email)), + ) + .child( + div().w(theme.label_width()).child( + RoleSelector::new( + (id.clone(), "invite-role"), + self.roles.clone(), + role.read(cx).clone(), + ) + .on_change(move |key, _, cx| { + picked.update(cx, |role, cx| { + *role = key.clone(); + cx.notify(); + }) + }), + ), + ) + .child( + Button::new((id.clone(), "invite"), "Invite") + .variant(ButtonVariant::Primary) + .disabled(!ready) + .on_click(move |_, window, cx| invite(window, cx)), + ), + ) + .child(div().flex().flex_col().children(rows)) + } +} diff --git a/src/account/tests/billing.rs b/src/account/tests/billing.rs new file mode 100644 index 0000000..69e6d1a --- /dev/null +++ b/src/account/tests/billing.rs @@ -0,0 +1,277 @@ +use gpui::{AnyElement, Entity, IntoElement, TestAppContext}; + +use super::{Bench, bench, said, say, settle, tab, tap, write}; +use crate::account::{ + BillingHistory, Invitation, InvitationList, Invoice, InvoiceState, Member, PaymentMethodForm, + Role, Standing, Subscription, SubscriptionCard, TeamMemberTable, UpgradePrompt, +}; + +fn plan(bench: &Bench, owner: Entity) -> AnyElement { + let standing = match bench.sent { + true => Standing::Canceled { + ends: "1 Oct 2026".into(), + }, + false => Standing::Active { + renews: "1 Oct 2026".into(), + }, + }; + let [cancel, resume] = [(); 2].map(|_| owner.clone()); + SubscriptionCard::new( + "plan", + Subscription { + plan: "Pro".into(), + price: 20.0, + currency: "USD".into(), + yearly: false, + standing, + }, + ) + .on_cancel(move |_, cx| say(&cancel, "cancel".into(), cx)) + .on_resume(move |_, cx| say(&resume, "resume".into(), cx)) + .into_any_element() +} + +/// Stops: Cancel plan while it runs; Keep plan once canceled. +#[gpui::test] +fn a_plan_cancels_on_the_second_press_and_comes_back(cx: &mut TestAppContext) { + let (host, cx) = bench(plan, cx); + tab(1, cx); + tap("space", cx); + assert!(said(&host, cx).is_empty(), "the first press only arms"); + tap("space", cx); + host.update(cx, |bench, cx| { + bench.sent = true; + cx.notify(); + }); + settle(cx); + tab(1, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["cancel", "resume"]); +} + +fn card(_: &Bench, owner: Entity) -> AnyElement { + PaymentMethodForm::new("card") + .on_submit(move |card, _, cx| { + say( + &owner, + format!( + "{} {} {}/{} {}", + card.brand.name(), + card.number, + card.month, + card.year, + card.name + ), + cx, + ) + }) + .into_any_element() +} + +/// Stops: number, expiry, code, name, then Save once the card reads. +#[gpui::test] +fn a_card_saves_once_its_number_checks_out(cx: &mut TestAppContext) { + let (host, cx) = bench(card, cx); + tab(1, cx); + write("4242424242424241", cx); + tab(2, cx); + write("1240", cx); + tab(3, cx); + write("123", cx); + tab(4, cx); + write("Ada Lovelace", cx); + tap("enter", cx); + assert!( + said(&host, cx).is_empty(), + "a number that fails its check keeps Save at rest" + ); + tab(1, cx); + tap("backspace", cx); + write("2", cx); + tab(4, cx); + tap("enter", cx); + assert_eq!( + said(&host, cx), + ["Visa 4242424242424242 12/2040 Ada Lovelace"] + ); +} + +fn roles() -> Vec { + let role = |key: &str, name: &str| Role { + key: key.to_string().into(), + name: name.to_string().into(), + description: "Can do things".into(), + }; + vec![role("member", "Member"), role("admin", "Admin")] +} + +fn invites(_: &Bench, owner: Entity) -> AnyElement { + let sent = Invitation { + key: "bo".into(), + email: "bo@example.com".into(), + role: "admin".into(), + sent: "Sent 2 days ago".into(), + expired: false, + }; + let [invited, again, gone] = [(); 3].map(|_| owner.clone()); + InvitationList::new("invites", [sent], roles()) + .on_invite(move |email, role, _, cx| say(&invited, format!("invite {email} {role}"), cx)) + .on_resend(move |key, _, cx| say(&again, format!("resend {key}"), cx)) + .on_revoke(move |key, _, cx| say(&gone, format!("revoke {key}"), cx)) + .into_any_element() +} + +/// Stops: the address, the role, then Resend and Revoke while Invite rests. +#[gpui::test] +fn an_invitation_goes_as_a_member_and_one_out_resends(cx: &mut TestAppContext) { + let (host, cx) = bench(invites, cx); + tab(3, cx); + tap("space", cx); + tab(1, cx); + write("cy@example.com", cx); + tap("enter", cx); + assert_eq!( + said(&host, cx), + ["resend bo", "invite cy@example.com member"] + ); +} + +fn team(_: &Bench, owner: Entity) -> AnyElement { + let member = |key: &str, owner: bool| Member { + key: key.to_string().into(), + name: key.to_string().into(), + email: format!("{key}@example.com").into(), + role: "member".into(), + seen: "Active today".into(), + image: None, + owner, + }; + let removed = owner.clone(); + TeamMemberTable::new("team", [member("ada", true), member("bo", false)], roles()) + .on_role(move |key, role, _, cx| say(&owner, format!("{key} {role}"), cx)) + .on_remove(move |key, _, cx| say(&removed, format!("remove {key}"), cx)) + .into_any_element() +} + +/// Stops: bo's role, then bo's Remove; the owner has neither. +#[gpui::test] +fn a_member_leaves_on_the_second_press_and_the_owner_stays(cx: &mut TestAppContext) { + let (host, cx) = bench(team, cx); + tab(2, cx); + tap("space", cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["remove bo"]); +} + +fn wall(_: &Bench, owner: Entity) -> AnyElement { + let later = owner.clone(); + UpgradePrompt::new("wall", "Unlock history") + .benefit("Every message, kept") + .on_upgrade(move |_, cx| say(&owner, "upgrade".into(), cx)) + .on_dismiss(move |_, cx| say(&later, "later".into(), cx)) + .into_any_element() +} + +/// Stops: Upgrade, then Not now. +#[gpui::test] +fn a_paywall_upgrades_or_waits(cx: &mut TestAppContext) { + let (host, cx) = bench(wall, cx); + tab(1, cx); + tap("space", cx); + tab(2, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["upgrade", "later"]); +} + +fn busy_card(_: &Bench, owner: Entity) -> AnyElement { + PaymentMethodForm::new("card") + .busy(true) + .on_submit(move |card, _, cx| say(&owner, card.number.to_string(), cx)) + .into_any_element() +} + +/// Stops: number, expiry, code, name; Save rests while the owner checks the card. +#[gpui::test] +fn a_card_waits_while_the_owner_checks_it(cx: &mut TestAppContext) { + let (host, cx) = bench(busy_card, cx); + tab(1, cx); + write("4242424242424242", cx); + tab(2, cx); + write("1240", cx); + tab(3, cx); + write("123", cx); + tab(4, cx); + write("Ada Lovelace", cx); + tap("enter", cx); + assert!(said(&host, cx).is_empty()); +} + +/// Stops: the address, the role; Invite waits for an address that reads. +#[gpui::test] +fn an_invitation_waits_for_an_address(cx: &mut TestAppContext) { + let (host, cx) = bench(invites, cx); + tab(1, cx); + write("cy@example", cx); + tap("enter", cx); + assert!(said(&host, cx).is_empty()); +} + +/// Stops: bo's role first; Enter opens its list, Down reaches Admin and Enter picks it. +#[gpui::test] +fn a_members_role_changes_from_its_row(cx: &mut TestAppContext) { + let (host, cx) = bench(team, cx); + tab(1, cx); + for key in ["enter", "down", "enter"] { + tap(key, cx); + } + assert_eq!(said(&host, cx), ["bo admin"]); +} + +fn bills(_: &Bench, owner: Entity) -> AnyElement { + let invoice = Invoice { + key: "sep".into(), + date: "1 Sep 2026".into(), + description: "Pro, monthly".into(), + amount: 20.0, + currency: "USD".into(), + state: InvoiceState::Paid, + }; + BillingHistory::new("bills", [invoice]) + .on_download(move |key, _, cx| say(&owner, format!("download {key}"), cx)) + .into_any_element() +} + +/// Stops: the bill's download. +#[gpui::test] +fn a_bill_downloads_from_its_row(cx: &mut TestAppContext) { + let (host, cx) = bench(bills, cx); + tab(1, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["download sep"]); +} + +/// Stops: number, expiry, code, name, then Save once the card reads. +#[gpui::test] +fn a_card_waits_for_a_whole_code_and_a_name(cx: &mut TestAppContext) { + let (host, cx) = bench(card, cx); + tab(1, cx); + write("4242424242424242", cx); + tab(2, cx); + write("1240", cx); + tab(3, cx); + write("12", cx); + tab(4, cx); + write("Ada", cx); + tap("enter", cx); + assert!(said(&host, cx).is_empty(), "a code of two digits"); + tab(3, cx); + write("3", cx); + tab(4, cx); + tap("cmd-a", cx); + tap("backspace", cx); + tap("enter", cx); + assert!(said(&host, cx).is_empty(), "no name on the card"); + write("Ada", cx); + tap("enter", cx); + assert_eq!(said(&host, cx), ["Visa 4242424242424242 12/2040 Ada"]); +} diff --git a/src/account/tests/mod.rs b/src/account/tests/mod.rs index 3e865d3..7be832d 100644 --- a/src/account/tests/mod.rs +++ b/src/account/tests/mod.rs @@ -83,4 +83,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) { } mod access; +mod billing; mod sign_in; diff --git a/src/account/upgrade.rs b/src/account/upgrade.rs new file mode 100644 index 0000000..df1bf23 --- /dev/null +++ b/src/account/upgrade.rs @@ -0,0 +1,136 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled, + Window, div, +}; + +use super::login::Run; +use crate::{ + buttons::{Button, ButtonVariant}, + layout::Card, + primitives::{Icon, IconName}, + theme::{ActiveTheme, IconSize, TextSize}, +}; + +/// A way past a limit: why, what the next plan brings, and Upgrade. With benefits it stands as a paywall; without, as one line. +#[derive(IntoElement)] +pub struct UpgradePrompt { + id: ElementId, + title: SharedString, + body: Option, + benefits: Vec, + on_upgrade: Option, + on_dismiss: Option, +} + +impl UpgradePrompt { + pub fn new(id: impl Into, title: impl Into) -> Self { + Self { + id: id.into(), + title: title.into(), + body: None, + benefits: Vec::new(), + on_upgrade: None, + on_dismiss: None, + } + } + + pub fn body(mut self, body: impl Into) -> Self { + self.body = Some(body.into()); + self + } + + /// Something the next plan brings; any makes this a paywall. + pub fn benefit(mut self, benefit: impl Into) -> Self { + self.benefits.push(benefit.into()); + self + } + + pub fn on_upgrade(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_upgrade = Some(Rc::new(handler)); + self + } + + /// Shows "Not now". + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for UpgradePrompt { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let upgrade = self + .on_upgrade + .unwrap_or_else(|| panic!("upgrade prompt {id:?} has no on_upgrade")); + let theme = cx.theme(); + let wall = !self.benefits.is_empty(); + let actions = div() + .flex() + .flex_wrap() + .gap_2() + .child( + Button::new((id.clone(), "upgrade"), "Upgrade") + .variant(ButtonVariant::Primary) + .on_click(move |_, window, cx| { + log::info!("upgrade prompt: upgrade"); + upgrade(window, cx); + }), + ) + .children(self.on_dismiss.map(|run| { + Button::new((id.clone(), "dismiss"), "Not now") + .variant(ButtonVariant::Ghost) + .on_click(move |_, window, cx| run(window, cx)) + })); + let words = div() + .flex_1() + .min_w(theme.label_width()) + .child(div().font_weight(FontWeight::SEMIBOLD).child(self.title)) + .children( + self.body + .map(|body| div().text_color(theme.colors.fg_muted).child(body)), + ); + let benefits = self.benefits.into_iter().map(|benefit| { + div() + .flex() + .items_center() + .gap_2() + .child( + Icon::new(IconName::Check) + .size(IconSize::Sm) + .color(theme.colors.success), + ) + .child(div().flex_1().min_w_0().child(benefit)) + }); + let body = match wall { + true => div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .items_start() + .gap_3() + .child( + Icon::new(IconName::Lock) + .size(IconSize::Md) + .color(theme.colors.fg_muted), + ) + .child(words), + ) + .child(div().flex().flex_col().gap_1p5().children(benefits)) + .child(actions), + false => div() + .flex() + .flex_wrap() + .items_center() + .gap_3() + .child(words) + .child(actions), + }; + Card::new().child(div().text_size(theme.text_size(TextSize::Sm)).child(body)) + } +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index e64b193..572a666 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -189,14 +189,14 @@ ## T36c Account · Billing and team -- [ ] SubscriptionCard / PlanBadge -- [ ] UsageQuota -- [ ] BillingHistory -- [ ] PaymentMethodForm -- [ ] UpgradePrompt / Paywall -- [ ] TeamMemberTable -- [ ] RoleSelector -- [ ] InvitationList +- [x] SubscriptionCard / PlanBadge +- [x] UsageQuota +- [x] BillingHistory +- [x] PaymentMethodForm +- [x] UpgradePrompt / Paywall +- [x] TeamMemberTable +- [x] RoleSelector +- [x] InvitationList ## T37 Onboarding & Help — `src/onboarding`