diff --git a/server/server_account.go b/server/server_account.go index f256b16..db3adac 100644 --- a/server/server_account.go +++ b/server/server_account.go @@ -49,7 +49,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis return err } limits, stats := info.Limits, info.Stats - response := &apiAccountResponse{ Limits: &apiAccountLimits{ Basis: string(limits.Basis), diff --git a/server/server_payments.go b/server/server_payments.go index 9476a62..0bdd9dc 100644 --- a/server/server_payments.go +++ b/server/server_payments.go @@ -24,12 +24,30 @@ const ( stripeBodyBytesLimit = 16384 ) +var ( + errNotAPaidTier = errors.New("tier does not have Stripe price identifier") +) + func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Request, v *visitor) error { tiers, err := v.userManager.Tiers() if err != nil { return err } - response := make([]*apiAccountBillingTier, 0) + freeTier := defaultVisitorLimits(s.config) + response := []*apiAccountBillingTier{ + { + // Free tier: no code, name or price + Limits: &apiAccountLimits{ + Messages: freeTier.MessagesLimit, + MessagesExpiryDuration: int64(freeTier.MessagesExpiryDuration.Seconds()), + Emails: freeTier.EmailsLimit, + Reservations: freeTier.ReservationsLimit, + AttachmentTotalSize: freeTier.AttachmentTotalSizeLimit, + AttachmentFileSize: freeTier.AttachmentFileSizeLimit, + AttachmentExpiryDuration: int64(freeTier.AttachmentExpiryDuration.Seconds()), + }, + }, + } for _, tier := range tiers { if tier.StripePriceID == "" { continue @@ -48,10 +66,18 @@ func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Req s.priceCache[tier.StripePriceID] = priceStr // FIXME race, make this sync.Map or something } response = append(response, &apiAccountBillingTier{ - Code: tier.Code, - Name: tier.Name, - Price: priceStr, - Features: tier.Features, + Code: tier.Code, + Name: tier.Name, + Price: priceStr, + Limits: &apiAccountLimits{ + Messages: tier.MessagesLimit, + MessagesExpiryDuration: int64(tier.MessagesExpiryDuration.Seconds()), + Emails: tier.EmailsLimit, + Reservations: tier.ReservationsLimit, + AttachmentTotalSize: tier.AttachmentTotalSizeLimit, + AttachmentFileSize: tier.AttachmentFileSizeLimit, + AttachmentExpiryDuration: int64(tier.AttachmentExpiryDuration.Seconds()), + }, }) } w.Header().Set("Content-Type", "application/json") @@ -75,9 +101,8 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r tier, err := s.userManager.Tier(req.Tier) if err != nil { return err - } - if tier.StripePriceID == "" { - return errors.New("invalid tier") //FIXME + } else if tier.StripePriceID == "" { + return errNotAPaidTier } log.Info("Stripe: No existing subscription, creating checkout flow") var stripeCustomerID *string @@ -92,10 +117,11 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r } successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate params := &stripe.CheckoutSessionParams{ - Customer: stripeCustomerID, // A user may have previously deleted their subscription - ClientReferenceID: &v.user.Name, - SuccessURL: &successURL, - Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + Customer: stripeCustomerID, // A user may have previously deleted their subscription + ClientReferenceID: &v.user.Name, + SuccessURL: &successURL, + Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), + AllowPromotionCodes: stripe.Bool(true), LineItems: []*stripe.CheckoutSessionLineItemParams{ { Price: stripe.String(tier.StripePriceID), @@ -212,6 +238,11 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r return err } } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") // FIXME remove this + if err := json.NewEncoder(w).Encode(newSuccessResponse()); err != nil { + return err + } return nil } diff --git a/server/types.go b/server/types.go index 99d521e..76ba6d4 100644 --- a/server/types.go +++ b/server/types.go @@ -241,7 +241,7 @@ type apiAccountTier struct { } type apiAccountLimits struct { - Basis string `json:"basis"` // "ip", "role" or "tier" + Basis string `json:"basis,omitempty"` // "ip", "role" or "tier" Messages int64 `json:"messages"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"` Emails int64 `json:"emails"` @@ -305,10 +305,10 @@ type apiConfigResponse struct { } type apiAccountBillingTier struct { - Code string `json:"code"` - Name string `json:"name"` - Price string `json:"price"` - Features string `json:"features"` + Code string `json:"code,omitempty"` + Name string `json:"name,omitempty"` + Price string `json:"price,omitempty"` + Limits *apiAccountLimits `json:"limits"` } type apiAccountBillingSubscriptionCreateResponse struct { diff --git a/server/visitor.go b/server/visitor.go index 999e5c5..e8c33ed 100644 --- a/server/visitor.go +++ b/server/visitor.go @@ -212,7 +212,7 @@ func (v *visitor) ResetStats() { } func (v *visitor) Limits() *visitorLimits { - limits := &visitorLimits{} + limits := defaultVisitorLimits(v.config) if v.user != nil && v.user.Tier != nil { limits.Basis = visitorLimitBasisTier limits.MessagesLimit = v.user.Tier.MessagesLimit @@ -222,15 +222,6 @@ func (v *visitor) Limits() *visitorLimits { limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration - } else { - limits.Basis = visitorLimitBasisIP - limits.MessagesLimit = replenishDurationToDailyLimit(v.config.VisitorRequestLimitReplenish) - limits.MessagesExpiryDuration = v.config.CacheDuration - limits.EmailsLimit = replenishDurationToDailyLimit(v.config.VisitorEmailLimitReplenish) - limits.ReservationsLimit = 0 // No reservations for anonymous users, or users without a tier - limits.AttachmentTotalSizeLimit = v.config.VisitorAttachmentTotalSizeLimit - limits.AttachmentFileSizeLimit = v.config.AttachmentFileSizeLimit - limits.AttachmentExpiryDuration = v.config.AttachmentExpiryDuration } return limits } @@ -288,3 +279,16 @@ func replenishDurationToDailyLimit(duration time.Duration) int64 { func dailyLimitToRate(limit int64) rate.Limit { return rate.Limit(limit) * rate.Every(24*time.Hour) } + +func defaultVisitorLimits(conf *Config) *visitorLimits { + return &visitorLimits{ + Basis: visitorLimitBasisIP, + MessagesLimit: replenishDurationToDailyLimit(conf.VisitorRequestLimitReplenish), + MessagesExpiryDuration: conf.CacheDuration, + EmailsLimit: replenishDurationToDailyLimit(conf.VisitorEmailLimitReplenish), + ReservationsLimit: 0, // No reservations for anonymous users, or users without a tier + AttachmentTotalSizeLimit: conf.VisitorAttachmentTotalSizeLimit, + AttachmentFileSizeLimit: conf.AttachmentFileSizeLimit, + AttachmentExpiryDuration: conf.AttachmentExpiryDuration, + } +} diff --git a/user/manager.go b/user/manager.go index a6086f6..0aeb93f 100644 --- a/user/manager.go +++ b/user/manager.go @@ -45,7 +45,6 @@ const ( attachment_file_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL, attachment_expiry_duration INT NOT NULL, - features TEXT, stripe_price_id TEXT ); CREATE UNIQUE INDEX idx_tier_code ON tier (code); @@ -104,20 +103,20 @@ const ( ` selectUserByNameQuery = ` - SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.features, t.stripe_price_id + SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.stripe_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE user = ? ` selectUserByTokenQuery = ` - SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.features, t.stripe_price_id + SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.stripe_price_id FROM user u JOIN user_token t on u.id = t.user_id LEFT JOIN tier t on t.id = u.tier_id WHERE t.token = ? AND t.expires >= ? ` selectUserByStripeCustomerIDQuery = ` - SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.features, t.stripe_price_id + SELECT u.user, u.pass, u.role, u.prefs, u.sync_topic, u.stats_messages, u.stats_emails, u.stripe_customer_id, u.stripe_subscription_id, u.stripe_subscription_status, u.stripe_subscription_paid_until, u.stripe_subscription_cancel_at, t.code, t.name, t.messages_limit, t.messages_expiry_duration, t.emails_limit, t.reservations_limit, t.attachment_file_size_limit, t.attachment_total_size_limit, t.attachment_expiry_duration, t.stripe_price_id FROM user u LEFT JOIN tier t on t.id = u.tier_id WHERE u.stripe_customer_id = ? @@ -223,16 +222,16 @@ const ( ` selectTierIDQuery = `SELECT id FROM tier WHERE code = ?` selectTiersQuery = ` - SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, features, stripe_price_id + SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, stripe_price_id FROM tier ` selectTierByCodeQuery = ` - SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, features, stripe_price_id + SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, stripe_price_id FROM tier WHERE code = ? ` selectTierByPriceIDQuery = ` - SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, features, stripe_price_id + SELECT code, name, messages_limit, messages_expiry_duration, emails_limit, reservations_limit, attachment_file_size_limit, attachment_total_size_limit, attachment_expiry_duration, stripe_price_id FROM tier WHERE stripe_price_id = ? ` @@ -609,13 +608,13 @@ func (a *Manager) userByToken(token string) (*User, error) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) { defer rows.Close() var username, hash, role, prefs, syncTopic string - var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierCode, tierName, tierFeatures sql.NullString + var stripeCustomerID, stripeSubscriptionID, stripeSubscriptionStatus, stripePriceID, tierCode, tierName sql.NullString var messages, emails int64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64 if !rows.Next() { return nil, ErrUserNotFound } - if err := rows.Scan(&username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &tierFeatures, &stripePriceID); err != nil { + if err := rows.Scan(&username, &hash, &role, &prefs, &syncTopic, &messages, &emails, &stripeCustomerID, &stripeSubscriptionID, &stripeSubscriptionStatus, &stripeSubscriptionPaidUntil, &stripeSubscriptionCancelAt, &tierCode, &tierName, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &stripePriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -654,7 +653,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) { AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - Features: tierFeatures.String, // May be empty StripePriceID: stripePriceID.String, // May be empty } } @@ -926,12 +924,12 @@ func (a *Manager) TierByStripePrice(priceID string) (*Tier, error) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { var code, name string - var features, stripePriceID sql.NullString + var stripePriceID sql.NullString var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64 if !rows.Next() { return nil, ErrTierNotFound } - if err := rows.Scan(&code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &features, &stripePriceID); err != nil { + if err := rows.Scan(&code, &name, &messagesLimit, &messagesExpiryDuration, &emailsLimit, &reservationsLimit, &attachmentFileSizeLimit, &attachmentTotalSizeLimit, &attachmentExpiryDuration, &stripePriceID); err != nil { return nil, err } else if err := rows.Err(); err != nil { return nil, err @@ -948,7 +946,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) { AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, - Features: features.String, // May be empty StripePriceID: stripePriceID.String, // May be empty }, nil } diff --git a/user/types.go b/user/types.go index bc4b709..2aca565 100644 --- a/user/types.go +++ b/user/types.go @@ -60,7 +60,6 @@ type Tier struct { AttachmentFileSizeLimit int64 AttachmentTotalSizeLimit int64 AttachmentExpiryDuration time.Duration - Features string StripePriceID string } diff --git a/web/public/static/langs/en.json b/web/public/static/langs/en.json index 648b967..47c3fc3 100644 --- a/web/public/static/langs/en.json +++ b/web/public/static/langs/en.json @@ -202,8 +202,17 @@ "account_delete_dialog_button_cancel": "Cancel", "account_delete_dialog_button_submit": "Permanently delete account", "account_upgrade_dialog_title": "Change account tier", - "account_upgrade_dialog_cancel_warning": "This will cancel your subscription, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server will be deleted.", - "account_upgrade_dialog_proration_info": "When switching between paid plans, the price difference will be charged or refunded in the next invoice.", + "account_upgrade_dialog_cancel_warning": "This will cancel your subscription, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server will be deleted.", + "account_upgrade_dialog_proration_info": "Proration: When switching between paid plans, the price difference will be charged or refunded in the next invoice. You will not receive another invoice until the end of the next billing period.", + "account_upgrade_dialog_tier_features_reservations": "{{reservations}} reserved topics", + "account_upgrade_dialog_tier_features_messages": "{{messages}} daily messages", + "account_upgrade_dialog_tier_features_emails": "{{emails}} daily emails", + "account_upgrade_dialog_tier_features_attachment_file_size": "{{filesize}} per file", + "account_upgrade_dialog_tier_features_attachment_total_size": "{{totalsize}} total storage", + "account_upgrade_dialog_tier_selected_label": "Selected", + "account_upgrade_dialog_button_pay_now": "Pay now and subscribe", + "account_upgrade_dialog_button_cancel_subscription": "Cancel subscription", + "account_upgrade_dialog_button_update_subscription": "Update subscription", "prefs_notifications_title": "Notifications", "prefs_notifications_sound_title": "Notification sound", "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", diff --git a/web/src/app/utils.js b/web/src/app/utils.js index 5e121cc..9d34654 100644 --- a/web/src/app/utils.js +++ b/web/src/app/utils.js @@ -199,6 +199,13 @@ export const formatBytes = (bytes, decimals = 2) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } +export const formatNumber = (n) => { + if (n % 1000 === 0) { + return `${n/1000}k`; + } + return n; +} + export const openUrl = (url) => { window.open(url, "_blank", "noopener,noreferrer"); }; diff --git a/web/src/components/UpgradeDialog.js b/web/src/components/UpgradeDialog.js index 9150453..0c3b8db 100644 --- a/web/src/components/UpgradeDialog.js +++ b/web/src/components/UpgradeDialog.js @@ -2,7 +2,7 @@ import * as React from 'react'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; -import {Alert, CardActionArea, CardContent, useMediaQuery} from "@mui/material"; +import {Alert, CardActionArea, CardContent, ListItem, useMediaQuery} from "@mui/material"; import theme from "./theme"; import DialogFooter from "./DialogFooter"; import Button from "@mui/material/Button"; @@ -13,16 +13,20 @@ import {useContext, useEffect, useState} from "react"; import Card from "@mui/material/Card"; import Typography from "@mui/material/Typography"; import {AccountContext} from "./App"; -import {formatShortDate} from "../app/utils"; -import {useTranslation} from "react-i18next"; +import {formatBytes, formatNumber, formatShortDate} from "../app/utils"; +import {Trans, useTranslation} from "react-i18next"; import subscriptionManager from "../app/SubscriptionManager"; +import List from "@mui/material/List"; +import {Check} from "@mui/icons-material"; +import ListItemIcon from "@mui/material/ListItemIcon"; +import ListItemText from "@mui/material/ListItemText"; const UpgradeDialog = (props) => { const { t } = useTranslation(); const { account } = useContext(AccountContext); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const [tiers, setTiers] = useState(null); - const [newTier, setNewTier] = useState(account?.tier?.code || null); + const [newTier, setNewTier] = useState(account?.tier?.code); // May be undefined const [errorText, setErrorText] = useState(""); useEffect(() => { @@ -35,22 +39,22 @@ const UpgradeDialog = (props) => { return <>; } - const currentTier = account.tier?.code || null; + const currentTier = account.tier?.code; // May be undefined let action, submitButtonLabel, submitButtonEnabled; if (currentTier === newTier) { - submitButtonLabel = "Update subscription"; + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); submitButtonEnabled = false; action = null; - } else if (currentTier === null) { - submitButtonLabel = "Pay $5 now and subscribe"; + } else if (!currentTier) { + submitButtonLabel = t("account_upgrade_dialog_button_pay_now"); submitButtonEnabled = true; action = Action.CREATE; - } else if (newTier === null) { - submitButtonLabel = "Cancel subscription"; + } else if (!newTier) { + submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription"); submitButtonEnabled = true; action = Action.CANCEL; } else { - submitButtonLabel = "Update subscription"; + submitButtonLabel = t("account_upgrade_dialog_button_update_subscription"); submitButtonEnabled = true; action = Action.UPDATE; } @@ -76,7 +80,13 @@ const UpgradeDialog = (props) => { } return ( - + {t("account_upgrade_dialog_title")}
{ marginBottom: "8px", width: "100%" }}> - setNewTier(null)} - /> {tiers.map(tier => setNewTier(tier.code)} + key={`tierCard${tier.code || '_free'}`} + tier={tier} + selected={newTier === tier.code} // tier.code may be undefined! + onClick={() => setNewTier(tier.code)} // tier.code may be undefined! /> )}
{action === Action.CANCEL && - {t("account_upgrade_dialog_cancel_warning", { date: formatShortDate(account.billing.paid_until) })} + } - {action === Action.UPDATE && + {currentTier && (!action || action === Action.UPDATE) && - {t("account_upgrade_dialog_proration_info")} + }
@@ -124,12 +126,18 @@ const UpgradeDialog = (props) => { }; const TierCard = (props) => { - const cardStyle = (props.selected) ? { background: "#eee", border: "2px solid #338574" } : {}; + const { t } = useTranslation(); + const cardStyle = (props.selected) ? { background: "#eee", border: "2px solid #338574" } : { border: "2px solid transparent" }; + const tier = props.tier; + return ( { background: "#338574", color: "white", borderRadius: "3px", - }}>Selected + }}>{t("account_upgrade_dialog_tier_selected_label")} } - - {props.name} + + {tier.name || t("account_usage_tier_free")} - {props.features && - - {props.features} - - } - {props.price && - - {props.price} / month + + {tier.limits.reservations > 0 && {t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}} + {t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })} + {t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })} + {t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })} + {t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })} + + {tier.price && + + {tier.price} / month } @@ -166,6 +176,25 @@ const TierCard = (props) => { ); } +const FeatureItem = (props) => { + return ( + + + + + + {props.children} + + } + /> + + + ); +}; + const Action = { CREATE: 1, UPDATE: 2,