Upgrade dialog looks nice now

new-homepage-design
binwiederhier 2023-01-17 19:40:03 -05:00
parent 695c1349e8
commit 4092f7fd51
9 changed files with 160 additions and 85 deletions

View File

@ -49,7 +49,6 @@ func (s *Server) handleAccountGet(w http.ResponseWriter, _ *http.Request, v *vis
return err return err
} }
limits, stats := info.Limits, info.Stats limits, stats := info.Limits, info.Stats
response := &apiAccountResponse{ response := &apiAccountResponse{
Limits: &apiAccountLimits{ Limits: &apiAccountLimits{
Basis: string(limits.Basis), Basis: string(limits.Basis),

View File

@ -24,12 +24,30 @@ const (
stripeBodyBytesLimit = 16384 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 { func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Request, v *visitor) error {
tiers, err := v.userManager.Tiers() tiers, err := v.userManager.Tiers()
if err != nil { if err != nil {
return err 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 { for _, tier := range tiers {
if tier.StripePriceID == "" { if tier.StripePriceID == "" {
continue continue
@ -51,7 +69,15 @@ func (s *Server) handleAccountBillingTiersGet(w http.ResponseWriter, r *http.Req
Code: tier.Code, Code: tier.Code,
Name: tier.Name, Name: tier.Name,
Price: priceStr, Price: priceStr,
Features: tier.Features, 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") 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) tier, err := s.userManager.Tier(req.Tier)
if err != nil { if err != nil {
return err return err
} } else if tier.StripePriceID == "" {
if tier.StripePriceID == "" { return errNotAPaidTier
return errors.New("invalid tier") //FIXME
} }
log.Info("Stripe: No existing subscription, creating checkout flow") log.Info("Stripe: No existing subscription, creating checkout flow")
var stripeCustomerID *string var stripeCustomerID *string
@ -96,6 +121,7 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
ClientReferenceID: &v.user.Name, ClientReferenceID: &v.user.Name,
SuccessURL: &successURL, SuccessURL: &successURL,
Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)), Mode: stripe.String(string(stripe.CheckoutSessionModeSubscription)),
AllowPromotionCodes: stripe.Bool(true),
LineItems: []*stripe.CheckoutSessionLineItemParams{ LineItems: []*stripe.CheckoutSessionLineItemParams{
{ {
Price: stripe.String(tier.StripePriceID), Price: stripe.String(tier.StripePriceID),
@ -212,6 +238,11 @@ func (s *Server) handleAccountBillingSubscriptionDelete(w http.ResponseWriter, r
return err 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 return nil
} }

View File

@ -241,7 +241,7 @@ type apiAccountTier struct {
} }
type apiAccountLimits 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"` Messages int64 `json:"messages"`
MessagesExpiryDuration int64 `json:"messages_expiry_duration"` MessagesExpiryDuration int64 `json:"messages_expiry_duration"`
Emails int64 `json:"emails"` Emails int64 `json:"emails"`
@ -305,10 +305,10 @@ type apiConfigResponse struct {
} }
type apiAccountBillingTier struct { type apiAccountBillingTier struct {
Code string `json:"code"` Code string `json:"code,omitempty"`
Name string `json:"name"` Name string `json:"name,omitempty"`
Price string `json:"price"` Price string `json:"price,omitempty"`
Features string `json:"features"` Limits *apiAccountLimits `json:"limits"`
} }
type apiAccountBillingSubscriptionCreateResponse struct { type apiAccountBillingSubscriptionCreateResponse struct {

View File

@ -212,7 +212,7 @@ func (v *visitor) ResetStats() {
} }
func (v *visitor) Limits() *visitorLimits { func (v *visitor) Limits() *visitorLimits {
limits := &visitorLimits{} limits := defaultVisitorLimits(v.config)
if v.user != nil && v.user.Tier != nil { if v.user != nil && v.user.Tier != nil {
limits.Basis = visitorLimitBasisTier limits.Basis = visitorLimitBasisTier
limits.MessagesLimit = v.user.Tier.MessagesLimit limits.MessagesLimit = v.user.Tier.MessagesLimit
@ -222,15 +222,6 @@ func (v *visitor) Limits() *visitorLimits {
limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit limits.AttachmentTotalSizeLimit = v.user.Tier.AttachmentTotalSizeLimit
limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit limits.AttachmentFileSizeLimit = v.user.Tier.AttachmentFileSizeLimit
limits.AttachmentExpiryDuration = v.user.Tier.AttachmentExpiryDuration 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 return limits
} }
@ -288,3 +279,16 @@ func replenishDurationToDailyLimit(duration time.Duration) int64 {
func dailyLimitToRate(limit int64) rate.Limit { func dailyLimitToRate(limit int64) rate.Limit {
return rate.Limit(limit) * rate.Every(24*time.Hour) 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,
}
}

View File

@ -45,7 +45,6 @@ const (
attachment_file_size_limit INT NOT NULL, attachment_file_size_limit INT NOT NULL,
attachment_total_size_limit INT NOT NULL, attachment_total_size_limit INT NOT NULL,
attachment_expiry_duration INT NOT NULL, attachment_expiry_duration INT NOT NULL,
features TEXT,
stripe_price_id TEXT stripe_price_id TEXT
); );
CREATE UNIQUE INDEX idx_tier_code ON tier (code); CREATE UNIQUE INDEX idx_tier_code ON tier (code);
@ -104,20 +103,20 @@ const (
` `
selectUserByNameQuery = ` 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 FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE user = ? WHERE user = ?
` `
selectUserByTokenQuery = ` 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 FROM user u
JOIN user_token t on u.id = t.user_id JOIN user_token t on u.id = t.user_id
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE t.token = ? AND t.expires >= ? WHERE t.token = ? AND t.expires >= ?
` `
selectUserByStripeCustomerIDQuery = ` 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 FROM user u
LEFT JOIN tier t on t.id = u.tier_id LEFT JOIN tier t on t.id = u.tier_id
WHERE u.stripe_customer_id = ? WHERE u.stripe_customer_id = ?
@ -223,16 +222,16 @@ const (
` `
selectTierIDQuery = `SELECT id FROM tier WHERE code = ?` selectTierIDQuery = `SELECT id FROM tier WHERE code = ?`
selectTiersQuery = ` 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 FROM tier
` `
selectTierByCodeQuery = ` 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 FROM tier
WHERE code = ? WHERE code = ?
` `
selectTierByPriceIDQuery = ` 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 FROM tier
WHERE stripe_price_id = ? 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) { func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
defer rows.Close() defer rows.Close()
var username, hash, role, prefs, syncTopic string 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 messages, emails int64
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration, stripeSubscriptionPaidUntil, stripeSubscriptionCancelAt sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrUserNotFound 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 return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -654,7 +653,6 @@ func (a *Manager) readUser(rows *sql.Rows) (*User, error) {
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
Features: tierFeatures.String, // May be empty
StripePriceID: stripePriceID.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) { func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
var code, name string var code, name string
var features, stripePriceID sql.NullString var stripePriceID sql.NullString
var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64 var messagesLimit, messagesExpiryDuration, emailsLimit, reservationsLimit, attachmentFileSizeLimit, attachmentTotalSizeLimit, attachmentExpiryDuration sql.NullInt64
if !rows.Next() { if !rows.Next() {
return nil, ErrTierNotFound 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 return nil, err
} else if err := rows.Err(); err != nil { } else if err := rows.Err(); err != nil {
return nil, err return nil, err
@ -948,7 +946,6 @@ func (a *Manager) readTier(rows *sql.Rows) (*Tier, error) {
AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64, AttachmentFileSizeLimit: attachmentFileSizeLimit.Int64,
AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64, AttachmentTotalSizeLimit: attachmentTotalSizeLimit.Int64,
AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second, AttachmentExpiryDuration: time.Duration(attachmentExpiryDuration.Int64) * time.Second,
Features: features.String, // May be empty
StripePriceID: stripePriceID.String, // May be empty StripePriceID: stripePriceID.String, // May be empty
}, nil }, nil
} }

View File

@ -60,7 +60,6 @@ type Tier struct {
AttachmentFileSizeLimit int64 AttachmentFileSizeLimit int64
AttachmentTotalSizeLimit int64 AttachmentTotalSizeLimit int64
AttachmentExpiryDuration time.Duration AttachmentExpiryDuration time.Duration
Features string
StripePriceID string StripePriceID string
} }

View File

@ -202,8 +202,17 @@
"account_delete_dialog_button_cancel": "Cancel", "account_delete_dialog_button_cancel": "Cancel",
"account_delete_dialog_button_submit": "Permanently delete account", "account_delete_dialog_button_submit": "Permanently delete account",
"account_upgrade_dialog_title": "Change account tier", "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_cancel_warning": "This will <strong>cancel your subscription</strong>, and downgrade your account on {{date}}. On that date, topic reservations as well as messages cached on the server <strong>will be deleted</strong>.",
"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_proration_info": "<strong>Proration</strong>: 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_title": "Notifications",
"prefs_notifications_sound_title": "Notification sound", "prefs_notifications_sound_title": "Notification sound",
"prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive", "prefs_notifications_sound_description_none": "Notifications do not play any sound when they arrive",

View File

@ -199,6 +199,13 @@ export const formatBytes = (bytes, decimals = 2) => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 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) => { export const openUrl = (url) => {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
}; };

View File

@ -2,7 +2,7 @@ import * as React from 'react';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; 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 theme from "./theme";
import DialogFooter from "./DialogFooter"; import DialogFooter from "./DialogFooter";
import Button from "@mui/material/Button"; import Button from "@mui/material/Button";
@ -13,16 +13,20 @@ import {useContext, useEffect, useState} from "react";
import Card from "@mui/material/Card"; import Card from "@mui/material/Card";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import {AccountContext} from "./App"; import {AccountContext} from "./App";
import {formatShortDate} from "../app/utils"; import {formatBytes, formatNumber, formatShortDate} from "../app/utils";
import {useTranslation} from "react-i18next"; import {Trans, useTranslation} from "react-i18next";
import subscriptionManager from "../app/SubscriptionManager"; 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 UpgradeDialog = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { account } = useContext(AccountContext); const { account } = useContext(AccountContext);
const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); const fullScreen = useMediaQuery(theme.breakpoints.down('sm'));
const [tiers, setTiers] = useState(null); 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(""); const [errorText, setErrorText] = useState("");
useEffect(() => { useEffect(() => {
@ -35,22 +39,22 @@ const UpgradeDialog = (props) => {
return <></>; return <></>;
} }
const currentTier = account.tier?.code || null; const currentTier = account.tier?.code; // May be undefined
let action, submitButtonLabel, submitButtonEnabled; let action, submitButtonLabel, submitButtonEnabled;
if (currentTier === newTier) { if (currentTier === newTier) {
submitButtonLabel = "Update subscription"; submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitButtonEnabled = false; submitButtonEnabled = false;
action = null; action = null;
} else if (currentTier === null) { } else if (!currentTier) {
submitButtonLabel = "Pay $5 now and subscribe"; submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
submitButtonEnabled = true; submitButtonEnabled = true;
action = Action.CREATE; action = Action.CREATE;
} else if (newTier === null) { } else if (!newTier) {
submitButtonLabel = "Cancel subscription"; submitButtonLabel = t("account_upgrade_dialog_button_cancel_subscription");
submitButtonEnabled = true; submitButtonEnabled = true;
action = Action.CANCEL; action = Action.CANCEL;
} else { } else {
submitButtonLabel = "Update subscription"; submitButtonLabel = t("account_upgrade_dialog_button_update_subscription");
submitButtonEnabled = true; submitButtonEnabled = true;
action = Action.UPDATE; action = Action.UPDATE;
} }
@ -76,7 +80,13 @@ const UpgradeDialog = (props) => {
} }
return ( return (
<Dialog open={props.open} onClose={props.onCancel} maxWidth="md" fullScreen={fullScreen}> <Dialog
open={props.open}
onClose={props.onCancel}
maxWidth="md"
fullWidth
fullScreen={fullScreen}
>
<DialogTitle>{t("account_upgrade_dialog_title")}</DialogTitle> <DialogTitle>{t("account_upgrade_dialog_title")}</DialogTitle>
<DialogContent> <DialogContent>
<div style={{ <div style={{
@ -85,33 +95,25 @@ const UpgradeDialog = (props) => {
marginBottom: "8px", marginBottom: "8px",
width: "100%" width: "100%"
}}> }}>
<TierCard
code={null}
name={t("account_usage_tier_free")}
price={null}
selected={newTier === null}
onClick={() => setNewTier(null)}
/>
{tiers.map(tier => {tiers.map(tier =>
<TierCard <TierCard
key={`tierCard${tier.code}`} key={`tierCard${tier.code || '_free'}`}
code={tier.code} tier={tier}
name={tier.name} selected={newTier === tier.code} // tier.code may be undefined!
price={tier.price} onClick={() => setNewTier(tier.code)} // tier.code may be undefined!
features={tier.features}
selected={newTier === tier.code}
onClick={() => setNewTier(tier.code)}
/> />
)} )}
</div> </div>
{action === Action.CANCEL && {action === Action.CANCEL &&
<Alert severity="warning"> <Alert severity="warning">
{t("account_upgrade_dialog_cancel_warning", { date: formatShortDate(account.billing.paid_until) })} <Trans
i18nKey="account_upgrade_dialog_cancel_warning"
values={{ date: formatShortDate(account.billing.paid_until) }} />
</Alert> </Alert>
} }
{action === Action.UPDATE && {currentTier && (!action || action === Action.UPDATE) &&
<Alert severity="info"> <Alert severity="info">
{t("account_upgrade_dialog_proration_info")} <Trans i18nKey="account_upgrade_dialog_proration_info" />
</Alert> </Alert>
} }
</DialogContent> </DialogContent>
@ -124,12 +126,18 @@ const UpgradeDialog = (props) => {
}; };
const TierCard = (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 ( return (
<Card sx={{ <Card sx={{
m: 1, m: 1,
minWidth: "190px", minWidth: "190px",
maxWidth: "250px", maxWidth: "250px",
flexGrow: 1,
flexShrink: 1,
flexBasis: 0,
"&:first-child": { ml: 0 }, "&:first-child": { ml: 0 },
"&:last-child": { mr: 0 }, "&:last-child": { mr: 0 },
...cardStyle ...cardStyle
@ -145,19 +153,21 @@ const TierCard = (props) => {
background: "#338574", background: "#338574",
color: "white", color: "white",
borderRadius: "3px", borderRadius: "3px",
}}>Selected</div> }}>{t("account_upgrade_dialog_tier_selected_label")}</div>
} }
<Typography gutterBottom variant="h5" component="div"> <Typography variant="h5" component="div">
{props.name} {tier.name || t("account_usage_tier_free")}
</Typography> </Typography>
{props.features && <List dense>
<Typography variant="body2" color="text.secondary" sx={{whiteSpace: "pre-wrap"}}> {tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>}
{props.features} <FeatureItem>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })}</FeatureItem>
</Typography> <FeatureItem>{t("account_upgrade_dialog_tier_features_emails", { emails: formatNumber(tier.limits.emails) })}</FeatureItem>
} <FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_file_size", { filesize: formatBytes(tier.limits.attachment_file_size, 0) })}</FeatureItem>
{props.price && <FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</FeatureItem>
<Typography variant="subtitle1" sx={{mt: 1}}> </List>
{props.price} / month {tier.price &&
<Typography variant="subtitle1" sx={{fontWeight: 500}}>
{tier.price} / month
</Typography> </Typography>
} }
</CardContent> </CardContent>
@ -166,6 +176,25 @@ const TierCard = (props) => {
); );
} }
const FeatureItem = (props) => {
return (
<ListItem disableGutters sx={{m: 0, p: 0}}>
<ListItemIcon sx={{minWidth: "24px"}}>
<Check fontSize="small" sx={{ color: "#338574" }}/>
</ListItemIcon>
<ListItemText
sx={{mt: "2px", mb: "2px"}}
primary={
<Typography variant="body2">
{props.children}
</Typography>
}
/>
</ListItem>
);
};
const Action = { const Action = {
CREATE: 1, CREATE: 1,
UPDATE: 2, UPDATE: 2,