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()
|
||||
}
|
||||
@@ -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("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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -83,4 +83,5 @@ pub(super) fn write(text: &str, cx: &mut VisualTestContext) {
|
||||
}
|
||||
|
||||
mod access;
|
||||
mod billing;
|
||||
mod sign_in;
|
||||
@@ -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
@@ -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`
|
||||
|
||||
|
||||
Reference in new issue
Block a user