Account · Billing and team: plans, quotas, cards, bills, prompts, members, invitations

SubscriptionCard shows a plan with its state, price and what comes
next, a way to change it, and Cancel after a second press or Keep plan
once canceled. UsageQuota gives each limit a meter and a line of how
much, or how far past. PaymentMethodForm tells a card's network from
its number, checks it with Luhn, and saves once the number checks out,
the month is ahead, the code is whole and a name is given; it rests
while the owner checks. BillingHistory lists bills with their state and
a download; UpgradePrompt stands as one line or as a paywall.
TeamMemberTable changes a member's role through RoleSelector and
removes after a second press, the owner staying; InvitationList
invites by a reading address with a role and resends or revokes.
This commit is contained in:
ZacharyZhang-NY committed 2026-09-27 13:49:17 -04:00
1 parent bade1ef0d5
commit 431aab594f
10 files changed
+1863 -8

No files matched your search

+236
View File
@@ -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<SharedString>,
}
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<Role> {
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<Member>,
invitations: Vec<Invitation>,
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))
}),
),
)
}
+4
View File
@@ -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()
}
+425
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
/// 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<Run>,
on_cancel: Option<Run>,
on_resume: Option<Run>,
}
impl SubscriptionCard {
pub fn new(id: impl Into<ElementId>, 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<AnyElement> = 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<Quota>,
resets: Option<SharedString>,
}
impl UsageQuota {
pub fn new(id: impl Into<ElementId>, quotas: impl IntoIterator<Item = Quota>) -> 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<SharedString>) -> 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<Invoice>,
on_download: Option<OnKey>,
}
impl BillingHistory {
pub fn new(id: impl Into<ElementId>, invoices: impl IntoIterator<Item = Invoice>) -> 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(&quota(8.5, 10.0)), "8.5 of 10 GB");
assert_eq!(quota_line(&quota(1_250.0, 2_000.0)), "1,250 of 2,000 GB");
assert_eq!(quota_line(&quota(12.4, 10.0)), "2.4 past the 10 GB limit");
}
}
+11
View File
@@ -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;
+290
View File
@@ -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::<u32>().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::<i8>().ok()?,
year.trim().parse::<i32>().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<dyn Fn(&CardDetails, &mut Window, &mut App)>;
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<SharedString>,
busy: bool,
on_submit: Option<OnCard>,
}
impl PaymentMethodForm {
pub fn new(id: impl Into<ElementId>) -> 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<SharedString>) -> 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);
}
}
+475
View File
@@ -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<dyn Fn(&SharedString, &mut Window, &mut App)>;
type OnPair = Rc<dyn Fn(&SharedString, &SharedString, &mut Window, &mut App)>;
/// 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<Role>,
selected: SharedString,
disabled: bool,
on_change: Option<OnKey>,
}
impl RoleSelector {
pub fn new(
id: impl Into<ElementId>,
roles: impl IntoIterator<Item = Role>,
selected: impl Into<SharedString>,
) -> 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<ImageSource>,
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<Member>,
roles: Vec<Role>,
on_role: Option<OnPair>,
on_remove: Option<OnKey>,
}
impl TeamMemberTable {
pub fn new(
id: impl Into<ElementId>,
members: impl IntoIterator<Item = Member>,
roles: impl IntoIterator<Item = Role>,
) -> Self {
let (members, roles): (Vec<Member>, Vec<Role>) =
(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<Invitation>,
roles: Vec<Role>,
on_invite: Option<OnPair>,
on_resend: Option<OnKey>,
on_revoke: Option<OnKey>,
}
impl InvitationList {
pub fn new(
id: impl Into<ElementId>,
invitations: impl IntoIterator<Item = Invitation>,
roles: impl IntoIterator<Item = Role>,
) -> Self {
let (invitations, roles): (Vec<Invitation>, Vec<Role>) = (
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))
}
}
+277
View File
@@ -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<Bench>) -> 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<Bench>) -> 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<Role> {
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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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<Bench>) -> 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"]);
}
+1
View File
@@ -83,4 +83,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) {
}
mod access;
mod billing;
mod sign_in;
+136
View File
@@ -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<SharedString>,
benefits: Vec<SharedString>,
on_upgrade: Option<Run>,
on_dismiss: Option<Run>,
}
impl UpgradePrompt {
pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> 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<SharedString>) -> 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<SharedString>) -> 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))
}
}
+8 -8
View File
@@ -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`