Browse Source

Upgrade dialog looks nice now

binwiederhier 3 years ago
parent
commit
4092f7fd51

+ 0 - 1
server/server_account.go

@@ -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),

+ 43 - 12
server/server_payments.go

@@ -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
@@ -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
 			s.priceCache[tier.StripePriceID] = priceStr // FIXME race, make this sync.Map or something
 		}
 		}
 		response = append(response, &apiAccountBillingTier{
 		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")
 	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
-	}
-	if tier.StripePriceID == "" {
-		return errors.New("invalid tier") //FIXME
+	} else if tier.StripePriceID == "" {
+		return errNotAPaidTier
 	}
 	}
 	log.Info("Stripe: No existing subscription, creating checkout flow")
 	log.Info("Stripe: No existing subscription, creating checkout flow")
 	var stripeCustomerID *string
 	var stripeCustomerID *string
@@ -92,10 +117,11 @@ func (s *Server) handleAccountBillingSubscriptionCreate(w http.ResponseWriter, r
 	}
 	}
 	successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
 	successURL := s.config.BaseURL + apiAccountBillingSubscriptionCheckoutSuccessTemplate
 	params := &stripe.CheckoutSessionParams{
 	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{
 		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
 }
 }
 
 

+ 5 - 5
server/types.go

@@ -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"`
-	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 {
 type apiAccountBillingSubscriptionCreateResponse struct {

+ 14 - 10
server/visitor.go

@@ -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,
+	}
+}

+ 10 - 13
user/manager.go

@@ -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
 }
 }

+ 0 - 1
user/types.go

@@ -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
 }
 }
 
 

+ 11 - 2
web/public/static/langs/en.json

@@ -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_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 <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": "<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",

+ 7 - 0
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];
     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");
 };
 };

+ 70 - 41
web/src/components/UpgradeDialog.js

@@ -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 {useTranslation} from "react-i18next";
+import {formatBytes, formatNumber, formatShortDate} from "../app/utils";
+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) {
-        submitButtonLabel = "Pay $5 now and subscribe";
+    } else if (!currentTier) {
+        submitButtonLabel = t("account_upgrade_dialog_button_pay_now");
         submitButtonEnabled = true;
         submitButtonEnabled = true;
         action = Action.CREATE;
         action = Action.CREATE;
-    } else if (newTier === null) {
-        submitButtonLabel = "Cancel subscription";
+    } else if (!newTier) {
+        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}`}
-                            code={tier.code}
-                            name={tier.name}
-                            price={tier.price}
-                            features={tier.features}
-                            selected={newTier === tier.code}
-                            onClick={() => 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!
                         />
                         />
                     )}
                     )}
                 </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">
-                        {props.name}
+                    <Typography variant="h5" component="div">
+                        {tier.name || t("account_usage_tier_free")}
                     </Typography>
                     </Typography>
-                    {props.features &&
-                        <Typography variant="body2" color="text.secondary" sx={{whiteSpace: "pre-wrap"}}>
-                            {props.features}
-                        </Typography>
-                    }
-                    {props.price &&
-                        <Typography variant="subtitle1" sx={{mt: 1}}>
-                            {props.price} / month
+                    <List dense>
+                        {tier.limits.reservations > 0 && <FeatureItem>{t("account_upgrade_dialog_tier_features_reservations", { reservations: tier.limits.reservations })}</FeatureItem>}
+                        <FeatureItem>{t("account_upgrade_dialog_tier_features_messages", { messages: formatNumber(tier.limits.messages) })}</FeatureItem>
+                        <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>
+                        <FeatureItem>{t("account_upgrade_dialog_tier_features_attachment_total_size", { totalsize: formatBytes(tier.limits.attachment_total_size, 0) })}</FeatureItem>
+                    </List>
+                    {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,