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:
1 parent
bade1ef0d5
commit
431aab594f
10 files changed
+1863
-8
No files matched your search
@@ -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))
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user