From 437f90e184cda93256dbb17e43529be7f0bc1310 Mon Sep 17 00:00:00 2001 From: Nick Misasi Date: Tue, 23 Apr 2024 14:25:37 -0400 Subject: [PATCH] [CLD-7421][CLD-7420] Deprecate Self Serve: First Pass (#26668) * Deprecate Self Serve: First Pass * Fix ci * Fix more ci * Remmove outdated server tests * Fix a missed spot opening purchase modal in Self Hosted * Fix i18n * Clean up some more server code, fix webapp test * Fix alignment of button * Fix linter * Fix i18n server side * Add back translation * Remove client functions * Put back client functions --------- Co-authored-by: Mattermost Build --- server/channels/api4/cloud.go | 296 ------ server/channels/api4/cloud_test.go | 278 ------ server/channels/api4/hosted_customer.go | 352 +------ server/channels/api4/hosted_customer_test.go | 145 --- server/channels/app/app_iface.go | 6 - server/channels/app/cloud.go | 309 ------- server/channels/app/email/email.go | 410 --------- server/channels/app/email/email_test.go | 80 -- .../app/email/mocks/ServiceInterface.go | 244 ----- server/channels/app/email/service.go | 14 - .../app/opentracing/opentracing_layer.go | 103 --- server/channels/app/server.go | 6 - server/i18n/en.json | 300 ------ .../admin_console/admin_definition.tsx | 26 - .../billing_subscriptions.test.tsx | 108 --- .../billing_subscriptions.tsx | 66 +- .../billing/billing_subscriptions/index.tsx | 67 +- .../limit_reached_banner.scss | 28 - .../limit_reached_banner.test.tsx | 193 ---- .../limit_reached_banner.tsx | 105 --- .../to_paid_plan_nudge_banner.test.tsx | 67 +- .../to_paid_plan_nudge_banner.tsx | 75 +- .../billing_summary/billing_summary.tsx | 191 ++-- .../billing/billing_summary/index.tsx | 65 +- .../billing/billing_summary/upsell_card.tsx | 24 - .../admin_console/billing/payment_info.scss | 3 - .../admin_console/billing/payment_info.tsx | 98 -- .../billing/payment_info_display.scss | 149 --- .../billing/payment_info_display.tsx | 123 --- .../billing/payment_info_edit.scss | 57 -- .../billing/payment_info_edit.tsx | 194 ---- .../feature_discovery/feature_discovery.tsx | 61 +- .../enterprise_edition_left_panel.tsx | 55 +- .../starter_edition/starter_edition.scss | 4 +- .../starter_edition/starter_right_panel.tsx | 10 - .../announcement_bar_controller.tsx | 13 +- ...d_annual_renewal_announcement_bar.test.tsx | 374 -------- .../cloud_annual_renewal/index.tsx | 143 --- ...loud_delinquency_announcement_bar.test.tsx | 124 --- .../cloud_delinquency/index.tsx | 67 -- .../purchase_link/purchase_link.tsx | 40 +- .../useControlSelfHostedExpansionModal.ts | 91 -- .../useControlSelfHostedPurchaseModal.ts | 114 --- .../common/hooks/useOpenCloudPurchaseModal.ts | 37 - .../index.tsx | 293 ------ .../src/components/pricing_modal/card.tsx | 26 +- .../src/components/pricing_modal/content.tsx | 301 +----- .../pricing_modal/self_hosted_content.tsx | 56 +- .../purchase_in_progress_modal/index.scss | 14 - .../purchase_in_progress_modal/index.test.tsx | 88 -- .../purchase_in_progress_modal/index.tsx | 104 --- .../purchase_modal/delinquency_card.tsx | 119 --- .../purchase_modal/icon_message.scss | 2 +- .../src/components/purchase_modal/index.ts | 91 -- .../purchase_modal/process_payment.css | 19 - .../purchase_modal/process_payment_setup.tsx | 420 --------- .../components/purchase_modal/purchase.scss | 547 ----------- .../purchase_modal/purchase_modal.tsx | 871 ------------------ .../purchase_modal/renewal_card.scss | 257 ------ .../purchase_modal/renewal_card.tsx | 311 ------- .../src/components/purchase_modal/utils.ts | 42 - webapp/channels/src/components/root/root.tsx | 2 - .../self_hosted_purchases/address.tsx | 115 --- .../self_hosted_purchases/constants.ts | 5 - .../contact_sales_link.tsx | 34 - .../error_page.scss | 3 - .../error_page.tsx | 89 -- .../expansion_card.scss | 131 --- .../expansion_card.tsx | 244 ----- .../index.test.tsx | 505 ---------- .../self_hosted_expansion_modal/index.tsx | 536 ----------- .../self_hosted_expansion_modal.scss | 183 ---- .../submitting.tsx | 123 --- .../submitting_page.scss | 7 - .../success_page.scss | 23 - .../success_page.tsx | 79 -- .../self_hosted_purchase_modal/error.tsx | 103 --- .../self_hosted_purchase_modal/index.test.tsx | 445 --------- .../self_hosted_purchase_modal/index.tsx | 742 --------------- .../self_hosted_card.tsx | 130 --- .../self_hosted_purchase_modal.scss | 552 ----------- .../self_hosted_purchase_modal/submitting.tsx | 106 --- .../success_page.scss | 16 - .../success_page.tsx | 58 -- .../self_hosted_purchase_modal/terms.tsx | 51 - .../self_hosted_purchase_modal/types.ts | 14 - .../self_hosted_purchase_modal/useNoEscape.ts | 29 - .../self_hosted_purchases/stripe_provider.tsx | 23 - .../widgets/links/upgrade_link.test.tsx | 15 +- .../components/widgets/links/upgrade_link.tsx | 22 +- webapp/channels/src/i18n/en.json | 121 --- webapp/channels/src/plugins/export.js | 2 - 92 files changed, 173 insertions(+), 13211 deletions(-) delete mode 100644 server/channels/api4/hosted_customer_test.go delete mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx delete mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.scss delete mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.test.tsx delete mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.tsx delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info.scss delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info.tsx delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info_display.scss delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info_display.tsx delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info_edit.scss delete mode 100644 webapp/channels/src/components/admin_console/billing/payment_info_edit.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx delete mode 100644 webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx delete mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts delete mode 100644 webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts delete mode 100644 webapp/channels/src/components/common/hooks/useOpenCloudPurchaseModal.ts delete mode 100644 webapp/channels/src/components/custom_open_pricing_modal_post_renderer/index.tsx delete mode 100644 webapp/channels/src/components/purchase_in_progress_modal/index.scss delete mode 100644 webapp/channels/src/components/purchase_in_progress_modal/index.test.tsx delete mode 100644 webapp/channels/src/components/purchase_in_progress_modal/index.tsx delete mode 100644 webapp/channels/src/components/purchase_modal/delinquency_card.tsx delete mode 100644 webapp/channels/src/components/purchase_modal/index.ts delete mode 100644 webapp/channels/src/components/purchase_modal/process_payment.css delete mode 100644 webapp/channels/src/components/purchase_modal/process_payment_setup.tsx delete mode 100644 webapp/channels/src/components/purchase_modal/purchase.scss delete mode 100644 webapp/channels/src/components/purchase_modal/purchase_modal.tsx delete mode 100644 webapp/channels/src/components/purchase_modal/renewal_card.scss delete mode 100644 webapp/channels/src/components/purchase_modal/renewal_card.tsx delete mode 100644 webapp/channels/src/components/purchase_modal/utils.ts delete mode 100644 webapp/channels/src/components/self_hosted_purchases/address.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/constants.ts delete mode 100644 webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts delete mode 100644 webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts delete mode 100644 webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index d37915173b..ca9710b108 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -13,7 +13,6 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/v8/channels/audit" "github.com/mattermost/mattermost/server/v8/platform/shared/web" ) @@ -25,11 +24,6 @@ func (api *API) InitCloud() { api.BaseRoutes.Cloud.Handle("/products/selfhosted", api.APISessionRequired(getSelfHostedProducts)).Methods("GET") - // POST /api/v4/cloud/payment - // POST /api/v4/cloud/payment/confirm - api.BaseRoutes.Cloud.Handle("/payment", api.APISessionRequired(createCustomerPayment)).Methods("POST") - api.BaseRoutes.Cloud.Handle("/payment/confirm", api.APISessionRequired(confirmCustomerPayment)).Methods("POST") - // GET /api/v4/cloud/customer // PUT /api/v4/cloud/customer // PUT /api/v4/cloud/customer/address @@ -42,10 +36,6 @@ func (api *API) InitCloud() { api.BaseRoutes.Cloud.Handle("/subscription/invoices", api.APISessionRequired(getInvoicesForSubscription)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/invoices/{invoice_id:[_A-Za-z0-9]+}/pdf", api.APISessionRequired(getSubscriptionInvoicePDF)).Methods("GET") api.BaseRoutes.Cloud.Handle("/subscription/self-serve-status", api.APISessionRequired(getLicenseSelfServeStatus)).Methods("GET") - api.BaseRoutes.Cloud.Handle("/subscription", api.APISessionRequired(changeSubscription)).Methods("PUT") - - // GET /api/v4/cloud/request-trial - api.BaseRoutes.Cloud.Handle("/request-trial", api.APISessionRequired(requestCloudTrial)).Methods("PUT") // GET /api/v4/cloud/validate-business-email api.BaseRoutes.Cloud.Handle("/validate-business-email", api.APISessionRequired(validateBusinessEmail)).Methods("POST") @@ -59,8 +49,6 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/cws-health-check api.BaseRoutes.Cloud.Handle("/check-cws-connection", api.APIHandler(handleCheckCWSConnection)).Methods("GET") - - api.BaseRoutes.Cloud.Handle("/delete-workspace", api.APISessionRequired(selfServeDeleteWorkspace)).Methods("DELETE") } func ensureCloudInterface(c *Context, where string) bool { @@ -134,131 +122,6 @@ func getSubscription(c *Context, w http.ResponseWriter, r *http.Request) { w.Write(json) } -func changeSubscription(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.changeSubscription") - if !ensured { - return - } - userId := c.AppContext.Session().UserId - - if !c.App.Channels().License().IsCloud() { - c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.license_error", nil, "", http.StatusInternalServerError) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - var subscriptionChange *model.SubscriptionChange - if err = json.Unmarshal(bodyBytes, &subscriptionChange); err != nil || subscriptionChange == nil { - c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - currentSubscription, appErr := c.App.Cloud().GetSubscription(userId) - if appErr != nil { - c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(appErr) - return - } - - changedSub, err := c.App.Cloud().ChangeSubscription(userId, currentSubscription.ID, subscriptionChange) - if err != nil { - appErr := model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - if err.Error() == "compliance-failed" { - c.Logger.Error("Compliance check failed", mlog.Err(err)) - appErr.StatusCode = http.StatusUnprocessableEntity - } - - c.Err = appErr - return - } - - if subscriptionChange.Feedback != nil { - c.App.Srv().GetTelemetryService().SendTelemetry("downgrade_feedback", subscriptionChange.Feedback.ToMap()) - } - - json, err := json.Marshal(changedSub) - if err != nil { - c.Err = model.NewAppError("Api4.changeSubscription", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - product, err := c.App.Cloud().GetCloudProduct(c.AppContext.Session().UserId, subscriptionChange.ProductID) - if err != nil || product == nil { - c.Logger.Error("Error finding the new cloud product", mlog.Err(err)) - } - - if product.SKU == string(model.SkuCloudStarter) { - w.Write(json) - return - } - - isYearly := product.IsYearly() - - // Log failures for purchase confirmation email, but don't show an error to the user so as not to confuse them - // At this point, the upgrade is complete. - if appErr := c.App.SendUpgradeConfirmationEmail(isYearly); appErr != nil { - c.Logger.Error("Error sending purchase confirmation email", mlog.Err(appErr)) - } - - w.Write(json) -} - -func requestCloudTrial(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.requestCloudTrial") - if !ensured { - return - } - - if !c.App.Channels().License().IsCloud() { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.license_error", nil, "", http.StatusForbidden) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } - - // check if the email needs to be set - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - // this value will not be empty when both emails (user admin and CWS customer) are not business email and - // a new business email was provided via the request business email modal - var startTrialRequest *model.StartCloudTrialRequest - if err = json.Unmarshal(bodyBytes, &startTrialRequest); err != nil || startTrialRequest == nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - changedSub, err := c.App.Cloud().RequestCloudTrial(c.AppContext.Session().UserId, startTrialRequest.SubscriptionID, startTrialRequest.Email) - if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - json, err := json.Marshal(changedSub) - if err != nil { - c.Err = model.NewAppError("Api4.requestCloudTrial", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - defer c.App.Srv().Cloud.InvalidateCaches() - - w.Write(json) -} - func validateBusinessEmail(c *Context, w http.ResponseWriter, r *http.Request) { ensured := ensureCloudInterface(c, "Api4.validateBusinessEmail") if !ensured { @@ -635,84 +498,6 @@ func updateCloudCustomerAddress(c *Context, w http.ResponseWriter, r *http.Reque w.Write(json) } -func createCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.createCustomerPayment") - if !ensured { - return - } - - if !c.App.Channels().License().IsCloud() { - c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.license_error", nil, "", http.StatusForbidden) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } - - auditRec := c.MakeAuditRecord("createCustomerPayment", audit.Fail) - defer c.LogAuditRec(auditRec) - - intent, err := c.App.Cloud().CreateCustomerPayment(c.AppContext.Session().UserId) - if err != nil { - c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - json, err := json.Marshal(intent) - if err != nil { - c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - auditRec.Success() - - w.Write(json) -} - -func confirmCustomerPayment(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.confirmCustomerPayment") - if !ensured { - return - } - - if !c.App.Channels().License().IsCloud() { - c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.license_error", nil, "", http.StatusForbidden) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } - - auditRec := c.MakeAuditRecord("confirmCustomerPayment", audit.Fail) - defer c.LogAuditRec(auditRec) - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - var confirmRequest *model.ConfirmPaymentMethodRequest - if err = json.Unmarshal(bodyBytes, &confirmRequest); err != nil || confirmRequest == nil { - c.Err = model.NewAppError("Api4.confirmCustomerPayment", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - err = c.App.Cloud().ConfirmCustomerPayment(c.AppContext.Session().UserId, confirmRequest) - if err != nil { - c.Err = model.NewAppError("Api4.createCustomerPayment", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - auditRec.Success() - - ReturnStatusOK(w) -} - func getInvoicesForSubscription(c *Context, w http.ResponseWriter, r *http.Request) { ensured := ensureCloudInterface(c, "Api4.getInvoicesForSubscription") if !ensured { @@ -809,40 +594,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { } switch event.Event { - case model.EventTypeFailedPayment: - if nErr := c.App.SendPaymentFailedEmail(event.FailedPayment); nErr != nil { - c.Err = nErr - return - } - case model.EventTypeFailedPaymentNoCard: - if nErr := c.App.SendNoCardPaymentFailedEmail(); nErr != nil { - c.Err = nErr - return - } - case model.EventTypeSendUpgradeConfirmationEmail: - - // isYearly determines whether to send the yearly or monthly Upgrade email - isYearly := false - if event.Subscription != nil && event.CloudWorkspaceOwner != nil { - user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName) - if appErr != nil { - c.Err = model.NewAppError("Api4.handleCWSWebhook", appErr.Id, nil, "", appErr.StatusCode).Wrap(appErr) - return - } - - // Get the current cloud product to determine whether it's a monthly or yearly product - product, err := c.App.Cloud().GetCloudProduct(user.Id, event.Subscription.ProductID) - if err != nil { - c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - isYearly = product.IsYearly() - } - - if nErr := c.App.SendUpgradeConfirmationEmail(isYearly); nErr != nil { - c.Err = nErr - return - } case model.EventTypeSendAdminWelcomeEmail: user, appErr := c.App.GetUserByUsername(event.CloudWorkspaceOwner.UserName) if appErr != nil { @@ -868,19 +619,6 @@ func handleCWSWebhook(c *Context, w http.ResponseWriter, r *http.Request) { c.Err = model.NewAppError("SendCloudWelcomeEmail", "api.user.send_cloud_welcome_email.error", nil, "", http.StatusInternalServerError).Wrap(err) return } - case model.EventTypeTriggerDelinquencyEmail: - var emailToTrigger model.DelinquencyEmail - if event.DelinquencyEmail != nil { - emailToTrigger = model.DelinquencyEmail(event.DelinquencyEmail.EmailToTrigger) - } else { - c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.delinquency_email.missing_email_to_trigger", nil, "", http.StatusInternalServerError) - return - } - if nErr := c.App.SendDelinquencyEmail(emailToTrigger); nErr != nil { - c.Err = nErr - return - } - default: c.Err = model.NewAppError("Api4.handleCWSWebhook", "api.cloud.cws_webhook_event_missing_error", nil, "", http.StatusNotFound) return @@ -902,37 +640,3 @@ func handleCheckCWSConnection(c *Context, w http.ResponseWriter, r *http.Request ReturnStatusOK(w) } - -func selfServeDeleteWorkspace(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.selfServeDeleteWorkspace") - if !ensured { - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - defer r.Body.Close() - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } - - var deleteRequest *model.WorkspaceDeletionRequest - if err = json.Unmarshal(bodyBytes, &deleteRequest); err != nil || deleteRequest == nil { - c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - if err := c.App.Cloud().SelfServeDeleteWorkspace(c.AppContext.Session().UserId, deleteRequest); err != nil { - c.Err = model.NewAppError("Api4.selfServeDeleteWorkspace", "api.server.cws.delete_workspace.app_error", nil, "CWS Server failed to delete workspace.", http.StatusInternalServerError) - return - } - - c.App.Srv().GetTelemetryService().SendTelemetry("delete_workspace_feedback", deleteRequest.Feedback.ToMap()) - - ReturnStatusOK(w) -} diff --git a/server/channels/api4/cloud_test.go b/server/channels/api4/cloud_test.go index 376f209267..17b3aafe21 100644 --- a/server/channels/api4/cloud_test.go +++ b/server/channels/api4/cloud_test.go @@ -16,119 +16,6 @@ import ( "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" ) -func Test_getCloudLimits(t *testing.T) { - t.Run("no license returns not implemented", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - cloud := &mocks.CloudInterface{} - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits")) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - th.App.Srv().RemoveLicense() - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - limits, r, err := th.Client.GetProductLimits(context.Background()) - require.Error(t, err) - require.Nil(t, limits) - require.Equal(t, http.StatusForbidden, r.StatusCode, "Expected 403 forbidden") - }) - - t.Run("non cloud license returns not implemented", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - cloud := &mocks.CloudInterface{} - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits")) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - th.App.Srv().SetLicense(model.NewTestLicense()) - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - limits, r, err := th.Client.GetProductLimits(context.Background()) - require.Error(t, err) - require.Nil(t, limits) - require.Equal(t, http.StatusForbidden, r.StatusCode, "Expected 403 forbidden") - }) - - t.Run("error fetching limits returns internal server error", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - cloud := &mocks.CloudInterface{} - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(nil, errors.New("Unable to get limits")) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - limits, r, err := th.Client.GetProductLimits(context.Background()) - require.Error(t, err) - require.Nil(t, limits) - require.Equal(t, http.StatusInternalServerError, r.StatusCode, "Expected 500 Internal Server Error") - }) - - t.Run("unauthenticated users can not access", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Logout(context.Background()) - - limits, r, err := th.Client.GetProductLimits(context.Background()) - require.Error(t, err) - require.Nil(t, limits) - require.Equal(t, http.StatusUnauthorized, r.StatusCode, "Expected 401 Unauthorized") - }) - - t.Run("good request with cloud server", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - cloud := &mocks.CloudInterface{} - ten := 10 - mockLimits := &model.ProductLimits{ - Messages: &model.MessagesLimits{ - History: &ten, - }, - } - cloud.Mock.On("GetCloudLimits", mock.Anything).Return(mockLimits, nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = cloud - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - limits, r, err := th.Client.GetProductLimits(context.Background()) - require.NoError(t, err) - require.Equal(t, http.StatusOK, r.StatusCode, "Expected 200 OK") - require.Equal(t, mockLimits, limits) - require.Equal(t, *mockLimits.Messages.History, *limits.Messages.History) - }) -} - func Test_GetSubscription(t *testing.T) { deliquencySince := int64(2000000000) @@ -215,119 +102,6 @@ func Test_GetSubscription(t *testing.T) { }) } -func Test_requestTrial(t *testing.T) { - subscription := &model.Subscription{ - ID: "MySubscriptionID", - CustomerID: "MyCustomer", - ProductID: "SomeProductId", - AddOns: []string{}, - StartAt: 1000000000, - EndAt: 2000000000, - CreateAt: 1000000000, - Seats: 10, - DNS: "some.dns.server", - } - - newValidBusinessEmail := model.StartCloudTrialRequest{Email: ""} - - t.Run("NON Admin users are UNABLE to request the trial", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - cloud := mocks.CloudInterface{} - - cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) - cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "").Return(subscription, nil) - cloud.Mock.On("InvalidateCaches").Return(nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - subscriptionChanged, r, err := th.Client.RequestCloudTrial(context.Background(), &newValidBusinessEmail) - require.Error(t, err) - require.Nil(t, subscriptionChanged) - require.Equal(t, http.StatusForbidden, r.StatusCode, "403 Forbidden") - }) - - t.Run("ADMIN user are ABLE to request the trial", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - cloud := mocks.CloudInterface{} - - cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) - cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "").Return(subscription, nil) - cloud.Mock.On("InvalidateCaches").Return(nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - subscriptionChanged, r, err := th.SystemAdminClient.RequestCloudTrial(context.Background(), &newValidBusinessEmail) - - require.NoError(t, err) - require.Equal(t, subscriptionChanged, subscription) - require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") - }) - - t.Run("ADMIN user are ABLE to request the trial with valid business email", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - // patch the customer with the additional contact updated with the valid business email - newValidBusinessEmail.Email = *model.NewString("valid.email@mattermost.com") - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - cloud := mocks.CloudInterface{} - - cloud.Mock.On("GetSubscription", mock.Anything).Return(subscription, nil) - cloud.Mock.On("RequestCloudTrial", mock.Anything, mock.Anything, "valid.email@mattermost.com").Return(subscription, nil) - cloud.Mock.On("InvalidateCaches").Return(nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - subscriptionChanged, r, err := th.SystemAdminClient.RequestCloudTrial(context.Background(), &newValidBusinessEmail) - - require.NoError(t, err) - require.Equal(t, subscriptionChanged, subscription) - require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") - }) - - t.Run("Empty body returns bad request", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - - r, err := th.SystemAdminClient.DoAPIPutBytes(context.Background(), "/cloud/request-trial", nil) - require.Error(t, err) - closeBody(r) - require.Equal(t, http.StatusBadRequest, r.StatusCode, "Status Bad Request") - }) -} - func Test_validateBusinessEmail(t *testing.T) { t.Run("Returns forbidden for invalid business email", func(t *testing.T) { th := Setup(t).InitBasic() @@ -643,58 +417,6 @@ func TestGetCloudProducts(t *testing.T) { }) } -func Test_GetExpandStatsForSubscription(t *testing.T) { - status := &model.SubscriptionLicenseSelfServeStatusResponse{ - IsExpandable: true, - } - - licenseId := "licenseID" - - t.Run("NON Admin users are UNABLE to request expand stats for the subscription", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - cloud := mocks.CloudInterface{} - - cloud.Mock.On("GetLicenseSelfServeStatus", mock.Anything).Return(status, nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - checksMade, r, err := th.Client.GetSubscriptionStatus(context.Background(), licenseId) - require.Error(t, err) - require.Nil(t, checksMade) - require.Equal(t, http.StatusForbidden, r.StatusCode, "403 Forbidden") - }) - - t.Run("Admin users are UNABLE to request licenses is expendable due missing the id", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) - - cloud := mocks.CloudInterface{} - - cloud.Mock.On("GetLicenseSelfServeStatus", mock.Anything).Return(status, nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - checks, r, err := th.Client.GetSubscriptionStatus(context.Background(), "") - require.Error(t, err) - require.Nil(t, checks) - require.Equal(t, http.StatusBadRequest, r.StatusCode, "400 Bad Request") - }) -} - func TestGetSelfHostedProducts(t *testing.T) { products := []*model.Product{ { diff --git a/server/channels/api4/hosted_customer.go b/server/channels/api4/hosted_customer.go index fa967cc17b..2ea3b38d08 100644 --- a/server/channels/api4/hosted_customer.go +++ b/server/channels/api4/hosted_customer.go @@ -4,20 +4,11 @@ package api4 import ( - "bytes" - "encoding/binary" "encoding/json" "io" "net/http" - "strconv" - "time" - - "github.com/pkg/errors" "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/v8/channels/utils" - "github.com/mattermost/mattermost/server/v8/platform/shared/web" ) // APIs for self-hosted workspaces to communicate with the backing customer & payments system. @@ -25,276 +16,12 @@ import ( func (api *API) InitHostedCustomer() { // POST /api/v4/hosted_customer/available api.BaseRoutes.HostedCustomer.Handle("/signup_available", api.APISessionRequired(handleSignupAvailable)).Methods("GET") - // POST /api/v4/hosted_customer/bootstrap - api.BaseRoutes.HostedCustomer.Handle("/bootstrap", api.APISessionRequired(selfHostedBootstrap)).Methods("POST") - // POST /api/v4/hosted_customer/customer - api.BaseRoutes.HostedCustomer.Handle("/customer", api.APISessionRequired(selfHostedCustomer)).Methods("POST") - // POST /api/v4/hosted_customer/confirm - api.BaseRoutes.HostedCustomer.Handle("/confirm", api.APISessionRequired(selfHostedConfirm)).Methods("POST") - // POST /api.v4/hosted_customer/confirm-expand - api.BaseRoutes.HostedCustomer.Handle("/confirm-expand", api.APISessionRequired(selfHostedConfirmExpand)).Methods("POST") - // GET /api/v4/hosted_customer/invoices - api.BaseRoutes.HostedCustomer.Handle("/invoices", api.APISessionRequired(selfHostedInvoices)).Methods("GET") - // GET /api/v4/hosted_customer/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf - api.BaseRoutes.HostedCustomer.Handle("/invoices/{invoice_id:in_[A-Za-z0-9]+}/pdf", api.APISessionRequired(selfHostedInvoicePDF)).Methods("GET") - api.BaseRoutes.HostedCustomer.Handle("/subscribe-newsletter", api.APIHandler(handleSubscribeToNewsletter)).Methods("POST") } -func ensureSelfHostedAdmin(c *Context, where string) { - ensured := ensureCloudInterface(c, where) - if !ensured { - return - } - - license := c.App.Channels().License() - - if license.IsCloud() { - c.Err = model.NewAppError(where, "api.cloud.license_error", nil, "Cloud installations do not use this endpoint", http.StatusBadRequest) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleWriteBilling) { - c.SetPermissionError(model.PermissionSysconsoleWriteBilling) - return - } -} - -func checkSelfHostedPurchaseEnabled(c *Context) bool { - config := c.App.Config() - if config == nil { - return false - } - enabled := config.ServiceSettings.SelfHostedPurchase - return enabled != nil && *enabled -} - -func selfHostedBootstrap(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedBootstrap" - if !checkSelfHostedPurchaseEnabled(c) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) - return - } - reset := r.URL.Query().Get("reset") == "true" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) - if userErr != nil { - c.Err = userErr - return - } - - signupProgress, err := c.App.Cloud().BootstrapSelfHostedSignup(model.BootstrapSelfHostedSignupRequest{Email: user.Email, Reset: reset}) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) - return - } - json, err := json.Marshal(signupProgress) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError) - return - } - - w.Write(json) -} - -func selfHostedCustomer(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedCustomer" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - if !checkSelfHostedPurchaseEnabled(c) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - var form *model.SelfHostedCustomerForm - if err = json.Unmarshal(bodyBytes, &form); err != nil || form == nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) - if userErr != nil { - c.Err = userErr - return - } - customerResponse, err := c.App.Cloud().CreateCustomerSelfHostedSignup(*form, user.Email) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - json, err := json.Marshal(customerResponse) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) -} - -func selfHostedConfirm(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedConfirm" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - if !checkSelfHostedPurchaseEnabled(c) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - var confirm model.SelfHostedConfirmPaymentMethodRequest - err = json.Unmarshal(bodyBytes, &confirm) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) - if userErr != nil { - c.Err = userErr - return - } - - confirmResponse, err := c.App.Cloud().ConfirmSelfHostedSignup(confirm, user.Email) - if err != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - - if err.Error() == strconv.Itoa(http.StatusUnprocessableEntity) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) - return - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) - if appErr != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{ - License: utils.GetClientLicense(license), - Progress: confirmResponse.Progress, - }) - if err != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - go func() { - err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication() - if err != nil { - c.Logger.Warn("Unable to confirm license application", mlog.Err(err)) - } - }() - - _, _ = w.Write(clientResponse) -} - func handleSignupAvailable(c *Context, w http.ResponseWriter, r *http.Request) { const where = "Api4.handleSignupAvailable" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - if !checkSelfHostedPurchaseEnabled(c) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) - return - } - if err := c.App.Cloud().SelfHostedSignupAvailable(); err != nil { - if err.Error() == "upstream_off" { - c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusServiceUnavailable) - } else { - c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented) - } - return - } - systemValue, err := c.App.Srv().Store().System().GetByName(model.SystemHostedPurchaseNeedsScreening) - if err == nil && systemValue != nil { - c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusTooEarly) - return - } - - ReturnStatusOK(w) -} - -func selfHostedInvoices(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedInvoices" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - - invoices, err := c.App.Cloud().GetSelfHostedInvoices(c.AppContext) - - if err != nil { - if err.Error() == "404" { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotFound).Wrap(errors.New("invoices for license not found")) - return - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - json, err := json.Marshal(invoices) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(json) -} - -func selfHostedInvoicePDF(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedInvoicePDF" - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - - pdfData, filename, appErr := c.App.Cloud().GetSelfHostedInvoicePDF(c.Params.InvoiceId) - if appErr != nil { - c.Err = model.NewAppError("Api4.getSubscriptionInvoicePDF", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(appErr) - return - } - - web.WriteFileResponse( - filename, - "application/pdf", - int64(binary.Size(pdfData)), - time.Now(), - *c.App.Config().ServiceSettings.WebserverMode, - bytes.NewReader(pdfData), - false, - w, - r, - ) + c.Err = model.NewAppError(where, "api.server.hosted_signup_unavailable.error", nil, "", http.StatusNotImplemented) } func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Request) { @@ -326,80 +53,3 @@ func handleSubscribeToNewsletter(c *Context, w http.ResponseWriter, r *http.Requ ReturnStatusOK(w) } - -func selfHostedConfirmExpand(c *Context, w http.ResponseWriter, r *http.Request) { - const where = "Api4.selfHostedConfirmExpand" - - ensureSelfHostedAdmin(c, where) - if c.Err != nil { - return - } - - if !checkSelfHostedPurchaseEnabled(c) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusNotImplemented) - return - } - - bodyBytes, err := io.ReadAll(r.Body) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - var confirm model.SelfHostedConfirmPaymentMethodRequest - err = json.Unmarshal(bodyBytes, &confirm) - if err != nil { - c.Err = model.NewAppError(where, "api.cloud.request_error", nil, "", http.StatusBadRequest).Wrap(err) - return - } - - user, userErr := c.App.GetUser(c.AppContext.Session().UserId) - if userErr != nil { - c.Err = userErr - return - } - - confirmResponse, err := c.App.Cloud().ConfirmSelfHostedExpansion(confirm, user.Email) - if err != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - - if err.Error() == strconv.Itoa(http.StatusUnprocessableEntity) { - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusUnprocessableEntity).Wrap(err) - return - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - license, appErr := c.App.Srv().Platform().SaveLicense([]byte(confirmResponse.License)) - // dealing with an AppError - if appErr != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - clientResponse, err := json.Marshal(model.SelfHostedSignupConfirmClientResponse{ - License: utils.GetClientLicense(license), - Progress: confirmResponse.Progress, - }) - if err != nil { - if confirmResponse != nil { - c.App.NotifySelfHostedSignupProgress(confirmResponse.Progress, user.Id) - } - c.Err = model.NewAppError(where, "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - go func() { - err := c.App.Cloud().ConfirmSelfHostedSignupLicenseApplication() - if err != nil { - c.Logger.Warn("Unable to confirm license application", mlog.Err(err)) - } - }() - - _, _ = w.Write(clientResponse) -} diff --git a/server/channels/api4/hosted_customer_test.go b/server/channels/api4/hosted_customer_test.go deleted file mode 100644 index a6a9aa9351..0000000000 --- a/server/channels/api4/hosted_customer_test.go +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -package api4 - -import ( - "context" - "net/http" - "os" - "testing" - - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/v8/einterfaces/mocks" -) - -var valFalse = false -var valTrue = true - -func TestSelfHostedBootstrap(t *testing.T) { - t.Run("feature flag off returns not implemented", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - cloud := mocks.CloudInterface{} - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) - - os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "false") - defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valFalse }) - th.App.ReloadConfig() - - _, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) - - require.Equal(t, http.StatusNotImplemented, r.StatusCode) - require.Error(t, err) - }) - - t.Run("cloud instances not allowed to bootstrap self-hosted signup", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - cloud := mocks.CloudInterface{} - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) - - th.App.Srv().SetLicense(model.NewTestLicense("cloud")) - os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") - defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) - th.App.ReloadConfig() - - _, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) - - require.Equal(t, http.StatusBadRequest, r.StatusCode) - require.Error(t, err) - }) - - t.Run("non-admins not allowed to bootstrap self-hosted signup", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - cloud := mocks.CloudInterface{} - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - th.Client.Login(context.Background(), th.BasicUser.Email, th.BasicUser.Password) - - os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") - defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) - th.App.ReloadConfig() - - _, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) - - require.Equal(t, http.StatusForbidden, r.StatusCode) - require.Error(t, err) - }) - - t.Run("self-hosted admins can bootstrap self-hosted signup", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) - - os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") - defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) - th.App.ReloadConfig() - cloud := mocks.CloudInterface{} - - cloud.Mock.On("BootstrapSelfHostedSignup", mock.Anything).Return(&model.BootstrapSelfHostedSignupResponse{Progress: "START"}, nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - response, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) - - require.Equal(t, http.StatusOK, r.StatusCode) - require.NoError(t, err) - require.Equal(t, "START", response.Progress) - }) - - t.Run("team edition returns bad request instead of panicking", func(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = nil - - th.Client.Login(context.Background(), th.SystemAdminUser.Email, th.SystemAdminUser.Password) - - os.Setenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE", "true") - defer os.Unsetenv("MM_SERVICESETTINGS_SELFHOSTEDFIRSTTIMEPURCHASE") - th.App.UpdateConfig(func(cfg *model.Config) { cfg.ServiceSettings.SelfHostedPurchase = &valTrue }) - th.App.ReloadConfig() - - _, r, err := th.Client.BootstrapSelfHostedSignup(context.Background(), model.BootstrapSelfHostedSignupRequest{Email: th.SystemAdminUser.Email}) - - require.Equal(t, http.StatusBadRequest, r.StatusCode) - require.Error(t, err) - }) -} diff --git a/server/channels/app/app_iface.go b/server/channels/app/app_iface.go index 57c2826340..b89f743f6d 100644 --- a/server/channels/app/app_iface.go +++ b/server/channels/app/app_iface.go @@ -325,8 +325,6 @@ type AppIface interface { SearchAllChannels(c request.CTX, term string, opts model.ChannelSearchOpts) (model.ChannelListWithTeamData, int64, *model.AppError) // SearchAllTeams returns a team list and the total count of the results SearchAllTeams(searchOpts *model.TeamSearch) ([]*model.Team, int64, *model.AppError) - // SendNoCardPaymentFailedEmail - SendNoCardPaymentFailedEmail() *model.AppError // SessionHasPermissionToChannels returns true only if user has access to all channels. SessionHasPermissionToChannels(c request.CTX, session model.Session, channelIDs []string, permission *model.Permission) bool // SessionHasPermissionToManageBot returns nil if the session has access to manage the given bot. @@ -595,7 +593,6 @@ type AppIface interface { DoLocalRequest(c request.CTX, rawURL string, body []byte) (*http.Response, *model.AppError) DoLogin(c request.CTX, w http.ResponseWriter, r *http.Request, user *model.User, deviceID string, isMobile, isOAuthUser, isSaml bool) (*model.Session, *model.AppError) DoPostActionWithCookie(c request.CTX, postID, actionId, userID, selectedOption string, cookie *model.PostActionCookie) (string, *model.AppError) - DoSubscriptionRenewalCheck() DoSystemConsoleRolesCreationMigration() DoUploadFile(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte, extractContent bool) (*model.FileInfo, *model.AppError) DoUploadFileExpectModification(c request.CTX, now time.Time, rawTeamId string, rawChannelId string, rawUserId string, rawFilename string, data []byte, extractContent bool) (*model.FileInfo, []byte, *model.AppError) @@ -1086,18 +1083,15 @@ type AppIface interface { SendAckToPushProxy(ack *model.PushNotificationAck) error SendAutoResponse(rctx request.CTX, channel *model.Channel, receiver *model.User, post *model.Post) (bool, *model.AppError) SendAutoResponseIfNecessary(rctx request.CTX, channel *model.Channel, sender *model.User, post *model.Post) (bool, *model.AppError) - SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError SendEmailVerification(user *model.User, newEmail, redirect string) *model.AppError SendEphemeralPost(c request.CTX, userID string, post *model.Post) *model.Post SendIPFiltersChangedEmail(c request.CTX, userID string) error SendNotifications(c request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) SendNotifyAdminPosts(c request.CTX, workspaceName string, currentSKU string, trial bool) *model.AppError SendPasswordReset(email string, siteURL string) (bool, *model.AppError) - SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError SendPersistentNotifications() error SendReportToUser(rctx request.CTX, job *model.Job, format string) *model.AppError SendTestPushNotification(deviceID string) string - SendUpgradeConfirmationEmail(isYearly bool) *model.AppError ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId, destinationPluginId string) SessionHasPermissionTo(session model.Session, permission *model.Permission) bool SessionHasPermissionToAny(session model.Session, permissions []*model.Permission) bool diff --git a/server/channels/app/cloud.go b/server/channels/app/cloud.go index 1c164a9766..9caf7a3bc8 100644 --- a/server/channels/app/cloud.go +++ b/server/channels/app/cloud.go @@ -4,135 +4,9 @@ package app import ( - "bytes" - "fmt" - "io" - "net/http" - "strconv" - "time" - "github.com/mattermost/mattermost/server/public/model" - "github.com/mattermost/mattermost/server/public/shared/mlog" - "github.com/mattermost/mattermost/server/v8/channels/store" ) -func getCurrentPlanName(a *App) (string, *model.AppError) { - subscription, err := a.Cloud().GetSubscription("") - if err != nil { - return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - if subscription == nil { - return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError) - } - - products, err := a.Cloud().GetCloudProducts("", false) - if err != nil { - return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_cloud_products.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - if products == nil { - return "", model.NewAppError("getCurrentPlanName", "app.cloud.get_cloud_products.app_error", nil, "", http.StatusInternalServerError) - } - - planName := getCurrentProduct(subscription.ProductID, products).Name - return planName, nil -} - -func (a *App) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError { - sysAdmins, err := a.getAllSystemAdmins() - if err != nil { - return err - } - - planName, err := getCurrentPlanName(a) - if err != nil { - return model.NewAppError("SendPaymentFailedEmail", "app.cloud.get_current_plan_name.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - - for _, admin := range sysAdmins { - _, err := a.Srv().EmailService.SendPaymentFailedEmail(admin.Email, admin.Locale, failedPayment, planName, *a.Config().ServiceSettings.SiteURL) - if err != nil { - a.Log().Error("Error sending payment failed email", mlog.Err(err)) - } - } - return nil -} - -func getCurrentProduct(subscriptionProductID string, products []*model.Product) *model.Product { - for _, product := range products { - if product.ID == subscriptionProductID { - return product - } - } - return nil -} - -func (a *App) SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError { - sysAdmins, aErr := a.getAllSystemAdmins() - if aErr != nil { - return aErr - } - planName, aErr := getCurrentPlanName(a) - if aErr != nil { - return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_current_plan_name.app_error", nil, "", http.StatusInternalServerError).Wrap(aErr) - } - - subscription, err := a.Cloud().GetSubscription("") - if err != nil { - return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - } - if subscription == nil { - return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription.app_error", nil, "", http.StatusInternalServerError) - } - - if subscription.DelinquentSince == nil { - return model.NewAppError("SendDelinquencyEmail", "app.cloud.get_subscription_delinquency_date.app_error", nil, "", http.StatusInternalServerError) - } - - delinquentSince := time.Unix(*subscription.DelinquentSince, 0) - - delinquencyDate := delinquentSince.Format("01/02/2006") - for _, admin := range sysAdmins { - switch emailToSend { - case model.DelinquencyEmail7: - err := a.Srv().EmailService.SendDelinquencyEmail7(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName) - if err != nil { - a.Log().Error("Error sending delinquency email 7", mlog.Err(err)) - } - case model.DelinquencyEmail14: - err := a.Srv().EmailService.SendDelinquencyEmail14(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName) - if err != nil { - a.Log().Error("Error sending delinquency email 14", mlog.Err(err)) - } - case model.DelinquencyEmail30: - err := a.Srv().EmailService.SendDelinquencyEmail30(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName) - if err != nil { - a.Log().Error("Error sending delinquency email 30", mlog.Err(err)) - } - case model.DelinquencyEmail45: - err := a.Srv().EmailService.SendDelinquencyEmail45(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName, delinquencyDate) - if err != nil { - a.Log().Error("Error sending delinquency email 45", mlog.Err(err)) - } - case model.DelinquencyEmail60: - err := a.Srv().EmailService.SendDelinquencyEmail60(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL) - if err != nil { - a.Log().Error("Error sending delinquency email 60", mlog.Err(err)) - } - case model.DelinquencyEmail75: - err := a.Srv().EmailService.SendDelinquencyEmail75(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL, planName, delinquencyDate) - if err != nil { - a.Log().Error("Error sending delinquency email 75", mlog.Err(err)) - } - case model.DelinquencyEmail90: - err := a.Srv().EmailService.SendDelinquencyEmail90(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL) - if err != nil { - a.Log().Error("Error sending delinquency email 90", mlog.Err(err)) - } - } - } - return nil -} - func (a *App) AdjustInProductLimits(limits *model.ProductLimits, subscription *model.Subscription) *model.AppError { if limits.Teams != nil && limits.Teams.Active != nil && *limits.Teams.Active > 0 { err := a.AdjustTeamsFromProductLimits(limits.Teams) @@ -144,86 +18,6 @@ func (a *App) AdjustInProductLimits(limits *model.ProductLimits, subscription *m return nil } -func getNextBillingDateString() string { - now := time.Now() - t := time.Date(now.Year(), now.Month()+1, 1, 0, 0, 0, 0, time.UTC) - return fmt.Sprintf("%s %d, %d", t.Month(), t.Day(), t.Year()) -} - -func (a *App) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { - sysAdmins, e := a.getAllSystemAdmins() - if e != nil { - return e - } - - if len(sysAdmins) == 0 { - return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError) - } - - subscription, err := a.Cloud().GetSubscription("") - if err != nil { - return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError) - } - - billingDate := getNextBillingDateString() - - // we want to at least have one email sent out to an admin - countNotOks := 0 - - embeddedFiles := make(map[string]io.Reader) - if isYearly { - lastInvoice := subscription.LastInvoice - if lastInvoice == nil { - a.Log().Error("Last invoice not defined for the subscription", mlog.String("subscription", subscription.ID)) - } else { - pdf, filename, pdfErr := a.Cloud().GetInvoicePDF("", lastInvoice.ID) - if pdfErr != nil { - a.Log().Error("Error retrieving the invoice for subscription id", mlog.String("subscription", subscription.ID), mlog.Err(pdfErr)) - } else { - embeddedFiles = map[string]io.Reader{ - filename: bytes.NewReader(pdf), - } - } - } - } - - for _, admin := range sysAdmins { - name := admin.FirstName - if name == "" { - name = admin.Username - } - - err := a.Srv().EmailService.SendCloudUpgradeConfirmationEmail(admin.Email, name, billingDate, admin.Locale, *a.Config().ServiceSettings.SiteURL, subscription.GetWorkSpaceNameFromDNS(), isYearly, embeddedFiles) - if err != nil { - a.Log().Error("Error sending trial ended email to", mlog.String("email", admin.Email), mlog.Err(err)) - countNotOks++ - } - } - - // if not even one admin got an email, we consider that this operation errored - if countNotOks == len(sysAdmins) { - return model.NewAppError("app.SendCloudUpgradeConfirmationEmail", "app.user.send_emails.app_error", nil, "", http.StatusInternalServerError) - } - - return nil -} - -// SendNoCardPaymentFailedEmail -func (a *App) SendNoCardPaymentFailedEmail() *model.AppError { - sysAdmins, err := a.getAllSystemAdmins() - if err != nil { - return err - } - - for _, admin := range sysAdmins { - err := a.Srv().EmailService.SendNoCardPaymentFailedEmail(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL) - if err != nil { - a.Log().Error("Error sending payment failed email", mlog.Err(err)) - } - } - return nil -} - // Create/ Update a subscription history event func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHistory, error) { license := a.Srv().License() @@ -240,106 +34,3 @@ func (a *App) SendSubscriptionHistoryEvent(userID string) (*model.SubscriptionHi } return a.Cloud().CreateOrUpdateSubscriptionHistoryEvent(userID, int(userCount)) } - -func (a *App) DoSubscriptionRenewalCheck() { - if !a.License().IsCloud() || !a.Config().FeatureFlags.CloudAnnualRenewals { - return - } - - subscription, err := a.Cloud().GetSubscription("") - if err != nil { - a.Log().Error("Error getting subscription", mlog.Err(err)) - return - } - - if subscription == nil { - a.Log().Error("Subscription not found") - return - } - - if subscription.IsFreeTrial == "true" { - return // Don't send renewal emails for free trials - } - - if model.BillingType(subscription.BillingType) == model.BillingTypeLicensed || model.BillingType(subscription.BillingType) == model.BillingTypeInternal { - return // Don't send renewal emails for licensed or internal billing - } - - sysVar, err := a.Srv().Store().System().GetByName(model.CloudRenewalEmail) - if err != nil { - // We only care about the error if it wasn't a not found error - if _, ok := err.(*store.ErrNotFound); !ok { - a.Log().Error(err.Error()) - } - } - - prevSentEmail := int64(0) - if sysVar != nil { - // We don't care about parse errors because it's possible the value is empty, and we've already defaulted to 0 - prevSentEmail, _ = strconv.ParseInt(sysVar.Value, 10, 64) - } - - if subscription.WillRenew == "true" { - // They've already completed the renewal process so no need to email them. - // We can zero out the system variable so that this process will work again next year - if prevSentEmail != 0 { - sysVar.Value = "0" - err = a.Srv().Store().System().SaveOrUpdate(sysVar) - if err != nil { - a.Log().Error("Error saving system variable", mlog.Err(err)) - } - } - return - } - - var emailFunc func(email, locale, siteURL string) error - - daysToExpiration := subscription.DaysToExpiration() - - // Only send the email if within the period and it's not already been sent - // This allows the email to send on day 59 if for whatever reason it was unable to on day 60 - if daysToExpiration <= 60 && daysToExpiration > 30 && prevSentEmail != 60 && !(prevSentEmail < 60) { - emailFunc = a.Srv().EmailService.SendCloudRenewalEmail60 - prevSentEmail = 60 - } else if daysToExpiration <= 30 && daysToExpiration > 7 && prevSentEmail != 30 && !(prevSentEmail < 30) { - emailFunc = a.Srv().EmailService.SendCloudRenewalEmail30 - prevSentEmail = 30 - } else if daysToExpiration <= 7 && daysToExpiration >= 0 && prevSentEmail != 7 { - emailFunc = a.Srv().EmailService.SendCloudRenewalEmail7 - prevSentEmail = 7 - } - - if emailFunc == nil { - return - } - - sysAdmins, aErr := a.getAllSystemAdmins() - if aErr != nil { - a.Log().Error("Error getting sys admins", mlog.Err(aErr)) - return - } - - numFailed := 0 - for _, admin := range sysAdmins { - err = emailFunc(admin.Email, admin.Locale, *a.Config().ServiceSettings.SiteURL) - if err != nil { - a.Log().Error("Error sending renewal email", mlog.Err(err)) - numFailed += 1 - } - } - - if numFailed == len(sysAdmins) { - // If all emails failed, we don't want to update the system variable - return - } - - updatedSysVar := &model.System{ - Name: model.CloudRenewalEmail, - Value: strconv.FormatInt(prevSentEmail, 10), - } - - err = a.Srv().Store().System().SaveOrUpdate(updatedSysVar) - if err != nil { - a.Log().Error("Error saving system variable", mlog.Err(err)) - } -} diff --git a/server/channels/app/email/email.go b/server/channels/app/email/email.go index c48620be1f..46ed7ca59e 100644 --- a/server/channels/app/email/email.go +++ b/server/channels/app/email/email.go @@ -239,44 +239,6 @@ func (es *Service) SendWelcomeEmail(userID string, email string, verified bool, return nil } -func (es *Service) SendCloudUpgradeConfirmationEmail(userEmail, name, date, locale, siteURL, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error { - T := i18n.GetUserTranslations(locale) - subject := T("api.templates.cloud_upgrade_confirmation.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["Title"] = T("api.templates.cloud_upgrade_confirmation.title") - data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_monthly.subtitle", map[string]any{"WorkspaceName": workspaceName, "Date": date}) - data.Props["SiteURL"] = siteURL - data.Props["ButtonURL"] = siteURL - data.Props["Button"] = T("api.templates.cloud_welcome_email.button") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - - if isYearly { - data.Props["SubTitle"] = T("api.templates.cloud_upgrade_confirmation_yearly.subtitle", map[string]any{"WorkspaceName": workspaceName}) - data.Props["ButtonURL"] = siteURL + "/admin_console/billing/billing_history" - data.Props["Button"] = T("api.templates.cloud_welcome_email.yearly_plan_button") - } - - body, err := es.templatesContainer.RenderToString("cloud_upgrade_confirmation", data) - if err != nil { - return err - } - - if isYearly { - if err := es.SendMailWithEmbeddedFilesAndCustomReplyTo(userEmail, subject, body, *es.config().SupportSettings.SupportEmail, embeddedFiles, "CloudUpgradeConfirmationEmail"); err != nil { - return err - } - } else { - if err := es.sendEmailWithCustomReplyTo(userEmail, subject, body, *es.config().SupportSettings.SupportEmail, "CloudUpgradeConfirmationEmail"); err != nil { - return err - } - } - - return nil -} - // SendCloudWelcomeEmail sends the cloud version of the welcome email func (es *Service) SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error { T := i18n.GetUserTranslations(locale) @@ -972,378 +934,6 @@ func (es *Service) SendLicenseUpForRenewalEmail(email, name, locale, siteURL, ct return nil } -func (es *Service) SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, planName, siteURL string) (bool, error) { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.payment_failed.title") - data.Props["SubTitle1"] = T("api.templates.payment_failed.info1", map[string]any{"CardBrand": failedPayment.CardBrand, "LastFour": failedPayment.LastFour}) - data.Props["SubTitle2"] = T("api.templates.payment_failed.info2") - data.Props["FailedReason"] = failedPayment.FailureMessage - data.Props["SubTitle3"] = T("api.templates.payment_failed.info3", map[string]any{"Plan": planName}) - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_45.button") - data.Props["IncludeSecondaryActionButton"] = false - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("payment_failed_body", data) - if err != nil { - return false, err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "PaymentFailed"); err != nil { - return false, err - } - - return true, nil -} - -func (es *Service) SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.payment_failed_no_card.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.payment_failed_no_card.title") - data.Props["Info1"] = T("api.templates.payment_failed_no_card.info1") - data.Props["Info3"] = T("api.templates.payment_failed_no_card.info3") - data.Props["Button"] = T("api.templates.payment_failed_no_card.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("payment_failed_no_card_body", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "NoCardPaymentFailed"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendDelinquencyEmail7(email, locale, siteURL, planName string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.payment_failed.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_7.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_7.subtitle1") - data.Props["SubTitle2"] = T("api.templates.delinquency_7.subtitle2", map[string]any{"Plan": planName}) - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_7.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("cloud_7_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency7"); err != nil { - return err - } - - return nil -} -func (es *Service) SendDelinquencyEmail14(email, locale, siteURL, planName string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_14.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_14.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_14.subtitle1") - data.Props["SubTitle2"] = T("api.templates.delinquency_14.subtitle2") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_14.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("cloud_14_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency14"); err != nil { - return err - } - - return nil -} -func (es *Service) SendDelinquencyEmail30(email, locale, siteURL, planName string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_30.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_30.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_30.subtitle1", map[string]any{"Plan": planName}) - data.Props["SubTitle2"] = T("api.templates.delinquency_30.subtitle2") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_30.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["BulletListItems"] = []string{T("api.templates.delinquency_30.bullet.message_history"), T("api.templates.delinquency_30.bullet.files")} - data.Props["LimitsDocs"] = T("api.templates.delinquency_30.limits_documentation") - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("cloud_30_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency30"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendDelinquencyEmail45(email, locale, siteURL, planName, delinquencyDate string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_45.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_45.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_45.subtitle1", map[string]any{"DelinquencyDate": delinquencyDate}) - data.Props["SubTitle2"] = T("api.templates.delinquency_45.subtitle2") - data.Props["SubTitle3"] = T("api.templates.delinquency_45.subtitle3") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_45.button") - data.Props["IncludeSecondaryActionButton"] = false - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency45"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendDelinquencyEmail60(email, locale, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_60.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_60.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_60.subtitle1") - data.Props["SubTitle2"] = T("api.templates.delinquency_60.subtitle2") - data.Props["SubTitle3"] = T("api.templates.delinquency_60.subtitle3") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_60.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["IncludeSecondaryActionButton"] = true - data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_60.downgrade_to_free") - data.Props["Footer"] = T("api.templates.copyright") - - // 45 day template is the same as the 60 day one so its reused - body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency60"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_75.subject", map[string]any{"Plan": planName}) - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_75.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_75.subtitle1", map[string]any{"DelinquencyDate": delinquencyDate}) - data.Props["SubTitle2"] = T("api.templates.delinquency_75.subtitle2", map[string]any{"Plan": planName}) - data.Props["SubTitle3"] = T("api.templates.delinquency_75.subtitle3") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_75.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["IncludeSecondaryActionButton"] = true - data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_75.downgrade_to_free") - data.Props["Footer"] = T("api.templates.copyright") - - // 45 day template is the same as the 75 day one so its reused - body, err := es.templatesContainer.RenderToString("cloud_45_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency75"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendDelinquencyEmail90(email, locale, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.delinquency_90.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.delinquency_90.title") - data.Props["SubTitle1"] = T("api.templates.delinquency_90.subtitle1", map[string]any{"SiteURL": siteURL}) - data.Props["SubTitle2"] = T("api.templates.delinquency_90.subtitle2") - data.Props["SubTitle3"] = T("api.templates.delinquency_90.subtitle3") - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["Button"] = T("api.templates.delinquency_90.button") - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["IncludeSecondaryActionButton"] = true - data.Props["SecondaryActionButtonText"] = T("api.templates.delinquency_90.secondary_action_button") - data.Props["Footer"] = T("api.templates.copyright") - - body, err := es.templatesContainer.RenderToString("cloud_90_day_arrears", data) - if err != nil { - return err - } - - if err := es.sendEmailWithCustomReplyTo(email, subject, body, *es.config().SupportSettings.SupportEmail, "Delinquency90"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendCloudRenewalEmail60(email, locale, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.cloud_renewal_60.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.cloud_renewal_60.title") - data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") - // TODO: use the open delinquency modal action - data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" - data.Props["Button"] = T("api.templates.cloud_renewal.button") - - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["Image"] = "payment_processing.png" - - body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) - if err != nil { - return err - } - - if err := es.sendMail(email, subject, body, "CloudRenewal60"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendCloudRenewalEmail30(email, locale, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.cloud_renewal_30.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.cloud_renewal_30.title") - data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") - // TODO: use the open delinquency modal action - data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" - data.Props["Button"] = T("api.templates.cloud_renewal.button") - - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["Image"] = "payment_processing.png" - - body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) - if err != nil { - return err - } - - if err := es.sendMail(email, subject, body, "CloudRenewal30"); err != nil { - return err - } - - return nil -} - -func (es *Service) SendCloudRenewalEmail7(email, locale, siteURL string) error { - T := i18n.GetUserTranslations(locale) - - subject := T("api.templates.cloud_renewal_7.subject") - - data := es.NewEmailTemplateData(locale) - data.Props["SiteURL"] = siteURL - data.Props["Title"] = T("api.templates.cloud_renewal_7.title") - data.Props["SubTitle"] = T("api.templates.cloud_renewal.subtitle") - // TODO: use the open delinquency modal action - data.Props["ButtonURL"] = siteURL + "/admin_console/billing/subscription" - data.Props["Button"] = T("api.templates.cloud_renewal.button") - - data.Props["QuestionTitle"] = T("api.templates.questions_footer.title") - data.Props["QuestionInfo"] = T("api.templates.questions_footer.info") - data.Props["SupportEmail"] = *es.config().SupportSettings.SupportEmail - data.Props["EmailUs"] = T("api.templates.email_us_anytime_at") - data.Props["Image"] = "purchase_alert.png" - - body, err := es.templatesContainer.RenderToString("cloud_renewal_notification", data) - if err != nil { - return err - } - - if err := es.sendMail(email, subject, body, "CloudRenewal7"); err != nil { - return err - } - - return nil -} - // SendRemoveExpiredLicenseEmail formats an email and uses the email service to send the email to user with link pointing to CWS // to renew the user license func (es *Service) SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error { diff --git a/server/channels/app/email/email_test.go b/server/channels/app/email/email_test.go index e4329c0f23..4ee3f8e222 100644 --- a/server/channels/app/email/email_test.go +++ b/server/channels/app/email/email_test.go @@ -4,10 +4,7 @@ package email import ( - "bytes" - "io" "os" - "strings" "testing" "github.com/stretchr/testify/require" @@ -251,83 +248,6 @@ func TestSendInviteEmails(t *testing.T) { }) } -func TestSendCloudUpgradedEmail(t *testing.T) { - th := Setup(t).InitBasic() - defer th.TearDown() - th.ConfigureInbucketMail() - - emailTo := "testclouduser@example.com" - emailToUsername := strings.Split(emailTo, "@")[0] - - t.Run("SendCloudMonthlyUpgradedEmail", func(t *testing.T) { - verifyMailbox := func(t *testing.T) { - t.Helper() - - var resultsMailbox mail.JSONMessageHeaderInbucket - err2 := mail.RetryInbucket(5, func() error { - var err error - resultsMailbox, err = mail.GetMailBox(emailTo) - return err - }) - if err2 != nil { - t.Skipf("No email was received, maybe due load on the server: %v", err2) - } - - require.Len(t, resultsMailbox, 1) - require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") - resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) - require.NoError(t, err, "Could not get message from mailbox") - require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "You'll be billed from", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "Open Mattermost", "Wrong received message %s", resultsEmail.Body.Text) - require.Len(t, resultsEmail.Attachments, 0) - } - mail.DeleteMailBox(emailTo) - - // Send Update to Monthly Plan email - err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", false, make(map[string]io.Reader)) - require.NoError(t, err) - - verifyMailbox(t) - }) - - t.Run("SendCloudYearlyUpgradedEmail", func(t *testing.T) { - verifyMailbox := func(t *testing.T) { - t.Helper() - - var resultsMailbox mail.JSONMessageHeaderInbucket - err2 := mail.RetryInbucket(5, func() error { - var err error - resultsMailbox, err = mail.GetMailBox(emailTo) - return err - }) - if err2 != nil { - t.Skipf("No email was received, maybe due load on the server: %v", err2) - } - - require.Len(t, resultsMailbox, 1) - require.Contains(t, resultsMailbox[0].To[0], emailTo, "Wrong To: recipient") - resultsEmail, err := mail.GetMessageFromMailbox(emailTo, resultsMailbox[0].ID) - require.NoError(t, err, "Could not get message from mailbox") - require.Contains(t, resultsEmail.Body.Text, "You are now upgraded!", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "SomeName workspace has now been upgraded", "Wrong received message %s", resultsEmail.Body.Text) - require.Contains(t, resultsEmail.Body.Text, "View your invoice", "Wrong received message %s", resultsEmail.Body.Text) - require.Len(t, resultsEmail.Attachments, 1) - } - mail.DeleteMailBox(emailTo) - - // Send Update to Monthly Plan email - var embeddedFiles = map[string]io.Reader{ - "filename": bytes.NewReader([]byte("Test")), - } - err := th.service.SendCloudUpgradeConfirmationEmail(emailTo, emailToUsername, "June 23, 2200", th.BasicUser.Locale, "https://example.com", "SomeName", true, embeddedFiles) - require.NoError(t, err) - - verifyMailbox(t) - }) -} - func TestSendCloudWelcomeEmail(t *testing.T) { th := Setup(t).InitBasic() defer th.TearDown() diff --git a/server/channels/app/email/mocks/ServiceInterface.go b/server/channels/app/email/mocks/ServiceInterface.go index 8a8ee01420..f3c9756c50 100644 --- a/server/channels/app/email/mocks/ServiceInterface.go +++ b/server/channels/app/email/mocks/ServiceInterface.go @@ -182,78 +182,6 @@ func (_m *ServiceInterface) SendChangeUsernameEmail(newUsername string, _a1 stri return r0 } -// SendCloudRenewalEmail30 provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendCloudRenewalEmail30(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendCloudRenewalEmail30") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendCloudRenewalEmail60 provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendCloudRenewalEmail60(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendCloudRenewalEmail60") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendCloudRenewalEmail7 provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendCloudRenewalEmail7(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendCloudRenewalEmail7") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendCloudUpgradeConfirmationEmail provides a mock function with given fields: userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles -func (_m *ServiceInterface) SendCloudUpgradeConfirmationEmail(userEmail string, name string, trialEndDate string, locale string, siteURL string, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error { - ret := _m.Called(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles) - - if len(ret) == 0 { - panic("no return value specified for SendCloudUpgradeConfirmationEmail") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string, string, string, bool, map[string]io.Reader) error); ok { - r0 = rf(userEmail, name, trialEndDate, locale, siteURL, workspaceName, isYearly, embeddedFiles) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // SendCloudWelcomeEmail provides a mock function with given fields: userEmail, locale, teamInviteID, workSpaceName, dns, siteURL func (_m *ServiceInterface) SendCloudWelcomeEmail(userEmail string, locale string, teamInviteID string, workSpaceName string, dns string, siteURL string) error { ret := _m.Called(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL) @@ -290,132 +218,6 @@ func (_m *ServiceInterface) SendDeactivateAccountEmail(_a0 string, locale string return r0 } -// SendDelinquencyEmail14 provides a mock function with given fields: _a0, locale, siteURL, planName -func (_m *ServiceInterface) SendDelinquencyEmail14(_a0 string, locale string, siteURL string, planName string) error { - ret := _m.Called(_a0, locale, siteURL, planName) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail14") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL, planName) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail30 provides a mock function with given fields: _a0, locale, siteURL, planName -func (_m *ServiceInterface) SendDelinquencyEmail30(_a0 string, locale string, siteURL string, planName string) error { - ret := _m.Called(_a0, locale, siteURL, planName) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail30") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL, planName) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail45 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate -func (_m *ServiceInterface) SendDelinquencyEmail45(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error { - ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail45") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL, planName, delinquencyDate) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail60 provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendDelinquencyEmail60(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail60") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail7 provides a mock function with given fields: _a0, locale, siteURL, planName -func (_m *ServiceInterface) SendDelinquencyEmail7(_a0 string, locale string, siteURL string, planName string) error { - ret := _m.Called(_a0, locale, siteURL, planName) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail7") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL, planName) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail75 provides a mock function with given fields: _a0, locale, siteURL, planName, delinquencyDate -func (_m *ServiceInterface) SendDelinquencyEmail75(_a0 string, locale string, siteURL string, planName string, delinquencyDate string) error { - ret := _m.Called(_a0, locale, siteURL, planName, delinquencyDate) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail75") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL, planName, delinquencyDate) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// SendDelinquencyEmail90 provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendDelinquencyEmail90(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendDelinquencyEmail90") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // SendEmailChangeEmail provides a mock function with given fields: oldEmail, newEmail, locale, siteURL func (_m *ServiceInterface) SendEmailChangeEmail(oldEmail string, newEmail string, locale string, siteURL string) error { ret := _m.Called(oldEmail, newEmail, locale, siteURL) @@ -590,24 +392,6 @@ func (_m *ServiceInterface) SendMfaChangeEmail(_a0 string, activated bool, local return r0 } -// SendNoCardPaymentFailedEmail provides a mock function with given fields: _a0, locale, siteURL -func (_m *ServiceInterface) SendNoCardPaymentFailedEmail(_a0 string, locale string, siteURL string) error { - ret := _m.Called(_a0, locale, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendNoCardPaymentFailedEmail") - } - - var r0 error - if rf, ok := ret.Get(0).(func(string, string, string) error); ok { - r0 = rf(_a0, locale, siteURL) - } else { - r0 = ret.Error(0) - } - - return r0 -} - // SendNotificationMail provides a mock function with given fields: to, subject, htmlBody func (_m *ServiceInterface) SendNotificationMail(to string, subject string, htmlBody string) error { ret := _m.Called(to, subject, htmlBody) @@ -672,34 +456,6 @@ func (_m *ServiceInterface) SendPasswordResetEmail(_a0 string, token *model.Toke return r0, r1 } -// SendPaymentFailedEmail provides a mock function with given fields: _a0, locale, failedPayment, planName, siteURL -func (_m *ServiceInterface) SendPaymentFailedEmail(_a0 string, locale string, failedPayment *model.FailedPayment, planName string, siteURL string) (bool, error) { - ret := _m.Called(_a0, locale, failedPayment, planName, siteURL) - - if len(ret) == 0 { - panic("no return value specified for SendPaymentFailedEmail") - } - - var r0 bool - var r1 error - if rf, ok := ret.Get(0).(func(string, string, *model.FailedPayment, string, string) (bool, error)); ok { - return rf(_a0, locale, failedPayment, planName, siteURL) - } - if rf, ok := ret.Get(0).(func(string, string, *model.FailedPayment, string, string) bool); ok { - r0 = rf(_a0, locale, failedPayment, planName, siteURL) - } else { - r0 = ret.Get(0).(bool) - } - - if rf, ok := ret.Get(1).(func(string, string, *model.FailedPayment, string, string) error); ok { - r1 = rf(_a0, locale, failedPayment, planName, siteURL) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - // SendRemoveExpiredLicenseEmail provides a mock function with given fields: ctaText, ctaLink, _a2, locale, siteURL func (_m *ServiceInterface) SendRemoveExpiredLicenseEmail(ctaText string, ctaLink string, _a2 string, locale string, siteURL string) error { ret := _m.Called(ctaText, ctaLink, _a2, locale, siteURL) diff --git a/server/channels/app/email/service.go b/server/channels/app/email/service.go index 4df5e608b8..1d40535567 100644 --- a/server/channels/app/email/service.go +++ b/server/channels/app/email/service.go @@ -134,7 +134,6 @@ type ServiceInterface interface { SendVerifyEmail(userEmail, locale, siteURL, token, redirect string) error SendSignInChangeEmail(email, method, locale, siteURL string) error SendWelcomeEmail(userID string, email string, verified bool, disableWelcomeEmail bool, locale, siteURL, redirect string) error - SendCloudUpgradeConfirmationEmail(userEmail, name, trialEndDate, locale, siteURL, workspaceName string, isYearly bool, embeddedFiles map[string]io.Reader) error SendCloudWelcomeEmail(userEmail, locale, teamInviteID, workSpaceName, dns, siteURL string) error SendPasswordChangeEmail(email, method, locale, siteURL string) error SendUserAccessTokenAddedEmail(email, locale, siteURL string) error @@ -147,19 +146,6 @@ type ServiceInterface interface { SendNotificationMail(to, subject, htmlBody string) error SendMailWithEmbeddedFiles(to, subject, htmlBody string, embeddedFiles map[string]io.Reader, messageID string, inReplyTo string, references string, category string) error SendLicenseUpForRenewalEmail(email, name, locale, siteURL, ctaTitle, ctaLink, ctaText string, daysToExpiration int) error - SendPaymentFailedEmail(email string, locale string, failedPayment *model.FailedPayment, planName, siteURL string) (bool, error) - // Cloud delinquency email sequence - SendDelinquencyEmail7(email, locale, siteURL, planName string) error - SendDelinquencyEmail14(email, locale, siteURL, planName string) error - SendDelinquencyEmail30(email, locale, siteURL, planName string) error - SendDelinquencyEmail45(email, locale, siteURL, planName, delinquencyDate string) error - SendDelinquencyEmail60(email, locale, siteURL string) error - SendDelinquencyEmail75(email, locale, siteURL, planName, delinquencyDate string) error - SendDelinquencyEmail90(email, locale, siteURL string) error - SendCloudRenewalEmail60(email, locale, siteURL string) error - SendCloudRenewalEmail30(email, locale, siteURL string) error - SendCloudRenewalEmail7(email, locale, siteURL string) error - SendNoCardPaymentFailedEmail(email string, locale string, siteURL string) error SendRemoveExpiredLicenseEmail(ctaText, ctaLink, email, locale, siteURL string) error AddNotificationEmailToBatch(user *model.User, post *model.Post, team *model.Team) *model.AppError GetMessageForNotification(post *model.Post, teamName, siteUrl string, translateFunc i18n.TranslateFunc) string diff --git a/server/channels/app/opentracing/opentracing_layer.go b/server/channels/app/opentracing/opentracing_layer.go index ed579014c7..35ee258e90 100644 --- a/server/channels/app/opentracing/opentracing_layer.go +++ b/server/channels/app/opentracing/opentracing_layer.go @@ -4088,21 +4088,6 @@ func (a *OpenTracingAppLayer) DoPostActionWithCookie(c request.CTX, postID strin return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) DoSubscriptionRenewalCheck() { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSubscriptionRenewalCheck") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - a.app.DoSubscriptionRenewalCheck() -} - func (a *OpenTracingAppLayer) DoSystemConsoleRolesCreationMigration() { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.DoSystemConsoleRolesCreationMigration") @@ -15956,28 +15941,6 @@ func (a *OpenTracingAppLayer) SendAutoResponseIfNecessary(rctx request.CTX, chan return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) SendDelinquencyEmail(emailToSend model.DelinquencyEmail) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendDelinquencyEmail") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.SendDelinquencyEmail(emailToSend) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) SendEmailVerification(user *model.User, newEmail string, redirect string) *model.AppError { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendEmailVerification") @@ -16039,28 +16002,6 @@ func (a *OpenTracingAppLayer) SendIPFiltersChangedEmail(c request.CTX, userID st return resultVar0 } -func (a *OpenTracingAppLayer) SendNoCardPaymentFailedEmail() *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendNoCardPaymentFailedEmail") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.SendNoCardPaymentFailedEmail() - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) SendNotifications(c request.CTX, post *model.Post, team *model.Team, channel *model.Channel, sender *model.User, parentPostList *model.PostList, setOnline bool) ([]string, error) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendNotifications") @@ -16127,28 +16068,6 @@ func (a *OpenTracingAppLayer) SendPasswordReset(email string, siteURL string) (b return resultVar0, resultVar1 } -func (a *OpenTracingAppLayer) SendPaymentFailedEmail(failedPayment *model.FailedPayment) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPaymentFailedEmail") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.SendPaymentFailedEmail(failedPayment) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) SendPersistentNotifications() error { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendPersistentNotifications") @@ -16232,28 +16151,6 @@ func (a *OpenTracingAppLayer) SendTestPushNotification(deviceID string) string { return resultVar0 } -func (a *OpenTracingAppLayer) SendUpgradeConfirmationEmail(isYearly bool) *model.AppError { - origCtx := a.ctx - span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.SendUpgradeConfirmationEmail") - - a.ctx = newCtx - a.app.Srv().Store().SetContext(newCtx) - defer func() { - a.app.Srv().Store().SetContext(origCtx) - a.ctx = origCtx - }() - - defer span.Finish() - resultVar0 := a.app.SendUpgradeConfirmationEmail(isYearly) - - if resultVar0 != nil { - span.LogFields(spanlog.Error(resultVar0)) - ext.Error.Set(span, true) - } - - return resultVar0 -} - func (a *OpenTracingAppLayer) ServeInterPluginRequest(w http.ResponseWriter, r *http.Request, sourcePluginId string, destinationPluginId string) { origCtx := a.ctx span, newCtx := tracing.StartSpanWithParentByContext(a.ctx, "app.ServeInterPluginRequest") diff --git a/server/channels/app/server.go b/server/channels/app/server.go index 86157b44ad..a7fbeade32 100644 --- a/server/channels/app/server.go +++ b/server/channels/app/server.go @@ -1364,12 +1364,6 @@ func (s *Server) doLicenseExpirationCheck() { return } - if license.IsCloud() { - appInstance := New(ServerConnector(s.Channels())) - appInstance.DoSubscriptionRenewalCheck() - return - } - users, err := s.Store().User().GetSystemAdminProfiles() if err != nil { mlog.Error("Failed to get system admins for license expired message from Mattermost.") diff --git a/server/i18n/en.json b/server/i18n/en.json index 019f228ef0..a9d97f039e 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -555,10 +555,6 @@ "id": "api.cloud.cws_webhook_event_missing_error", "translation": "Webhook event was not handled. Either it is missing or it is not valid." }, - { - "id": "api.cloud.delinquency_email.missing_email_to_trigger", - "translation": "Missing required fields to send delinquency email." - }, { "id": "api.cloud.license_error", "translation": "Your license does not support cloud requests." @@ -2838,10 +2834,6 @@ "id": "api.scheme.patch_scheme.license.error", "translation": "Your license does not support update permissions schemes" }, - { - "id": "api.server.cws.delete_workspace.app_error", - "translation": "CWS Server failed to delete workspace." - }, { "id": "api.server.cws.disabled", "translation": "Interactions with the Mattermost Customer Portal have been disabled by the system admin." @@ -3262,54 +3254,6 @@ "id": "api.team.user.missing_account", "translation": "Unable to find the user." }, - { - "id": "api.templates.cloud_renewal.button", - "translation": "Renew now" - }, - { - "id": "api.templates.cloud_renewal.subtitle", - "translation": "Please renew to avoid any disruption" - }, - { - "id": "api.templates.cloud_renewal_30.subject", - "translation": "Annual bill due in 30 days" - }, - { - "id": "api.templates.cloud_renewal_30.title", - "translation": "Your annual bill is due in 30 days" - }, - { - "id": "api.templates.cloud_renewal_60.subject", - "translation": "Annual subscription renewal in 60 days" - }, - { - "id": "api.templates.cloud_renewal_60.title", - "translation": "Annual subscription renewal in 60 days" - }, - { - "id": "api.templates.cloud_renewal_7.subject", - "translation": "Action Required: Annual subscription renewal in 7 days" - }, - { - "id": "api.templates.cloud_renewal_7.title", - "translation": "You are about to lose access to your workspace in 7 days" - }, - { - "id": "api.templates.cloud_upgrade_confirmation.subject", - "translation": "Mattermost Upgrade Confirmation" - }, - { - "id": "api.templates.cloud_upgrade_confirmation.title", - "translation": "You are now upgraded!" - }, - { - "id": "api.templates.cloud_upgrade_confirmation_monthly.subtitle", - "translation": "Your {{.WorkspaceName}} workspace has now been upgraded. You'll be billed from {{.Date}}" - }, - { - "id": "api.templates.cloud_upgrade_confirmation_yearly.subtitle", - "translation": "Your {{.WorkspaceName}} workspace has now been upgraded." - }, { "id": "api.templates.cloud_welcome_email.add_apps_info", "translation": "Add apps to your workspace" @@ -3378,14 +3322,6 @@ "id": "api.templates.cloud_welcome_email.title", "translation": "Your workspace is ready to go!" }, - { - "id": "api.templates.cloud_welcome_email.yearly_plan_button", - "translation": "View your invoice" - }, - { - "id": "api.templates.copyright", - "translation": "© 2021 Mattermost, Inc. 530 Lytton Avenue, Second floor, Palo Alto, CA, 94301" - }, { "id": "api.templates.deactivate_body.info", "translation": "You deactivated your account on {{ .SiteURL }}." @@ -3402,182 +3338,6 @@ "id": "api.templates.deactivate_subject", "translation": "[{{ .SiteName }}] Your account at {{ .ServerURL }} has been deactivated" }, - { - "id": "api.templates.delinquency_14.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_14.subject", - "translation": "Payment is overdue for your Mattermost {{.Plan}}" - }, - { - "id": "api.templates.delinquency_14.subtitle1", - "translation": "We weren't able to charge the credit card we have on file. This means your workspace is at risk of being downgraded to Cloud Free." - }, - { - "id": "api.templates.delinquency_14.subtitle2", - "translation": "Please contact your financial institution to resolve any issues. Then, update your payment details as needed." - }, - { - "id": "api.templates.delinquency_14.title", - "translation": "Payment not received" - }, - { - "id": "api.templates.delinquency_30.bullet.files", - "translation": "Files" - }, - { - "id": "api.templates.delinquency_30.bullet.message_history", - "translation": "Message history" - }, - { - "id": "api.templates.delinquency_30.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_30.limits_documentation", - "translation": "View all limits documentation." - }, - { - "id": "api.templates.delinquency_30.subject", - "translation": "Act to keep your Mattermost {{.Plan}} Features" - }, - { - "id": "api.templates.delinquency_30.subtitle1", - "translation": "You have time to keep your Mattermost {{.Plan}} active but you'll need to resolve issues with your payment method." - }, - { - "id": "api.templates.delinquency_30.subtitle2", - "translation": "If no action is taken, your workspace will be downgraded and the following data may be archived:" - }, - { - "id": "api.templates.delinquency_30.title", - "translation": "Your workspace will be downgraded soon" - }, - { - "id": "api.templates.delinquency_45.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_45.subject", - "translation": "Notice: Your Mattermost {{.Plan}} will be downgraded soon" - }, - { - "id": "api.templates.delinquency_45.subtitle1", - "translation": "We've been unable to collect payment for outstanding invoices since {{.DelinquencyDate}}. Your workspace is at risk of being downgraded." - }, - { - "id": "api.templates.delinquency_45.subtitle2", - "translation": "A downgraded workspace might negatively affect critical workflows and other business critical activities carried at your workspace." - }, - { - "id": "api.templates.delinquency_45.subtitle3", - "translation": "Update your credit card information now." - }, - { - "id": "api.templates.delinquency_45.title", - "translation": "Your workspace will be downgraded soon" - }, - { - "id": "api.templates.delinquency_60.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_60.downgrade_to_free", - "translation": "Downgrade to Cloud Free" - }, - { - "id": "api.templates.delinquency_60.subject", - "translation": "Action Required: Workspace will be downgraded in 30 days" - }, - { - "id": "api.templates.delinquency_60.subtitle1", - "translation": "Please update your payment information soon to process your outstanding invoices." - }, - { - "id": "api.templates.delinquency_60.subtitle2", - "translation": "We will downgrade your workspace automatically in 30 days if we are unable to process your payment." - }, - { - "id": "api.templates.delinquency_60.subtitle3", - "translation": "Update your payment information now or downgrade to Cloud Free below." - }, - { - "id": "api.templates.delinquency_60.title", - "translation": "Your Mattermost workspace will be downgraded in 30 days" - }, - { - "id": "api.templates.delinquency_7.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_7.subtitle1", - "translation": "We couldn't process your most recent payment." - }, - { - "id": "api.templates.delinquency_7.subtitle2", - "translation": "To keep your {{.Plan}} plan active, please contact your financial institution as soon as possible. Then, update your payment details as needed." - }, - { - "id": "api.templates.delinquency_7.title", - "translation": "Your payment wasn't completed" - }, - { - "id": "api.templates.delinquency_75.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_75.downgrade_to_free", - "translation": "Downgrade to Cloud Free" - }, - { - "id": "api.templates.delinquency_75.subject", - "translation": "Your Mattermost {{.Plan}} will be downgraded in 15 days" - }, - { - "id": "api.templates.delinquency_75.subtitle1", - "translation": "This is a final reminder that we haven’t received payment for your Mattermost Cloud workspace since {{.DelinquencyDate}}." - }, - { - "id": "api.templates.delinquency_75.subtitle2", - "translation": "Your workspace will be downgraded to Cloud Free. Your {{.Plan}} features will be locked and some of your workspace data may be archived until your full outstanding balance is settled." - }, - { - "id": "api.templates.delinquency_75.subtitle3", - "translation": "Update your payment information now, or downgrade to Cloud Free." - }, - { - "id": "api.templates.delinquency_75.title", - "translation": "Your workspace will be downgraded in 15 days" - }, - { - "id": "api.templates.delinquency_90.button", - "translation": "Update payment" - }, - { - "id": "api.templates.delinquency_90.secondary_action_button", - "translation": "View Plans & Pricing" - }, - { - "id": "api.templates.delinquency_90.subject", - "translation": "Your Mattermost Cloud workspace has been downgraded" - }, - { - "id": "api.templates.delinquency_90.subtitle1", - "translation": "If you use Cloud Professional or Enterprise features for important business operations, these will no longer be available and you'll experience degraded performance." - }, - { - "id": "api.templates.delinquency_90.subtitle2", - "translation": "In addition, your data may have been archived due to Cloud Free limitations." - }, - { - "id": "api.templates.delinquency_90.subtitle3", - "translation": "To unarchive your data and keep paid features, update your payment information." - }, - { - "id": "api.templates.delinquency_90.title", - "translation": "Your Mattermost workspace has been downgraded" - }, { "id": "api.templates.email_change_body.info", "translation": "Your email address for {{.TeamDisplayName}} has been changed to {{.NewEmail}}." @@ -3782,46 +3542,6 @@ "id": "api.templates.password_change_subject", "translation": "[{{ .SiteName }}] Your password has been updated" }, - { - "id": "api.templates.payment_failed.info1", - "translation": "Your financial institution declined a payment from your {{.CardBrand}} ****{{.LastFour}} associated with your Mattermost Cloud workspace." - }, - { - "id": "api.templates.payment_failed.info2", - "translation": "They provided the following reason:" - }, - { - "id": "api.templates.payment_failed.info3", - "translation": "To ensure uninterrupted access to Mattermost {{.Plan}}, please either contact your financial institution to fix the underlying problem or update your payment information. Once payment information is updated, Mattermost will attempt to settle any outstanding balance." - }, - { - "id": "api.templates.payment_failed.subject", - "translation": "Action required: Payment failed for Mattermost {{.Plan}}" - }, - { - "id": "api.templates.payment_failed.title", - "translation": "The payment wasn't successful" - }, - { - "id": "api.templates.payment_failed_no_card.button", - "translation": "Pay now" - }, - { - "id": "api.templates.payment_failed_no_card.info1", - "translation": "Your Mattermost Cloud invoice for the most recent billing period has been processed. However, we don't have your payment details on file." - }, - { - "id": "api.templates.payment_failed_no_card.info3", - "translation": "To review your invoice and add a payment method, select Pay now." - }, - { - "id": "api.templates.payment_failed_no_card.subject", - "translation": "Payment is due for your Mattermost Cloud subscription" - }, - { - "id": "api.templates.payment_failed_no_card.title", - "translation": "Your Mattermost Cloud Invoice is due" - }, { "id": "api.templates.post_body.button", "translation": "Reply in Mattermost" @@ -5074,22 +4794,6 @@ "id": "app.channel_member_history.log_leave_event.internal_error", "translation": "Failed to record channel member history. Failed to update existing join record" }, - { - "id": "app.cloud.get_cloud_products.app_error", - "translation": "Couldn't retrieve cloud products" - }, - { - "id": "app.cloud.get_current_plan_name.app_error", - "translation": "Unable to get current plan name" - }, - { - "id": "app.cloud.get_subscription.app_error", - "translation": "Couldn't retrieve cloud subscription" - }, - { - "id": "app.cloud.get_subscription_delinquency_date.app_error", - "translation": "Subscription is not delinquent" - }, { "id": "app.cloud.trial_plan_bot_message", "translation": "{{.UsersNum}} members of the {{.WorkspaceName}} workspace have requested starting the Enterprise trial for access to: " @@ -7214,10 +6918,6 @@ "id": "app.user.send_auto_response.app_error", "translation": "Unable to send auto response from user." }, - { - "id": "app.user.send_emails.app_error", - "translation": "No emails were successfully sent" - }, { "id": "app.user.store_is_empty.app_error", "translation": "Failed to check if user store is empty." diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index e1d2897d2b..779fd850fb 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -45,8 +45,6 @@ import BillingHistory, {searchableStrings as billingHistorySearchableStrings} fr import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchableStrings} from './billing/billing_subscriptions'; import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info'; import CompanyInfoEdit from './billing/company_info_edit'; -import PaymentInfo, {searchableStrings as billingPaymentInfoSearchableStrings} from './billing/payment_info'; -import PaymentInfoEdit from './billing/payment_info_edit'; import BleveSettings, {searchableStrings as bleveSearchableStrings} from './bleve_settings'; import BrandImageSetting from './brand_image_setting/brand_image_setting'; import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings'; @@ -385,30 +383,6 @@ const AdminDefinition: AdminDefinitionType = { isHidden: it.not(it.licensedForFeature('Cloud')), isDisabled: it.not(it.userHasWritePermissionOnResource('billing')), }, - payment_info: { - url: 'billing/payment_info', - title: defineMessage({id: 'admin.sidebar.payment_info', defaultMessage: 'Payment Information'}), - isHidden: it.any( - it.hidePaymentInfo, - - // cloud only view - it.not(it.licensedForFeature('Cloud')), - ), - searchableStrings: billingPaymentInfoSearchableStrings, - schema: { - id: 'PaymentInfo', - component: PaymentInfo, - }, - isDisabled: it.not(it.userHasWritePermissionOnResource('billing')), - }, - payment_info_edit: { - url: 'billing/payment_info_edit', - schema: { - id: 'PaymentInfoEdit', - component: PaymentInfoEdit, - }, - isDisabled: it.not(it.userHasWritePermissionOnResource('billing')), - }, }, }, reporting: { diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx deleted file mode 100644 index ed6c74ebd2..0000000000 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {unixTimestampFromNow} from 'tests/helpers/date'; -import {renderWithContext} from 'tests/react_testing_utils'; -import {CloudProducts} from 'utils/constants'; - -import {CloudAnnualRenewalBanner} from './billing_subscriptions'; - -describe('CloudAnnualRenewalBanner', () => { - const initialState = { - entities: { - general: { - license: { - IsLicensed: 'true', - Cloud: 'true', - }, - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin'}, - }, - }, - cloud: { - subscription: { - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - cancel_at: 1652807380, - }, - products: { - test_prod_1: { - id: 'test_prod_1', - sku: CloudProducts.STARTER, - price_per_seat: 0, - }, - test_prod_2: { - id: 'test_prod_2', - sku: CloudProducts.ENTERPRISE, - price_per_seat: 0, - }, - test_prod_3: { - id: 'test_prod_3', - sku: CloudProducts.PROFESSIONAL, - price_per_seat: 0, - }, - }, - }, - }, - }; - - it('should not render if subscription is not available', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = null; - - const {queryByText} = renderWithContext(, state); - - expect(queryByText(/Your annual subscription expires in/)).not.toBeInTheDocument(); - }); - - it('should render with correct title and buttons', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - end_at: unixTimestampFromNow(30), - }; - const {getByText} = renderWithContext(, state); - - expect(getByText(/Your annual subscription expires in 30 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); - expect(getByText(/Renew/)).toBeInTheDocument(); - expect(getByText(/Contact Sales/)).toBeInTheDocument(); - - const renewButton = getByText(/Renew/); - renewButton.click(); - }); - - it('should render with danger mode if expiration is within 7 days', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - end_at: unixTimestampFromNow(4), - }; - const {getByText, getByTestId} = renderWithContext(, state); - - expect(getByText(/Your annual subscription expires in 4 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); - expect(getByText(/Renew/)).toBeInTheDocument(); - expect(getByText(/Contact Sales/)).toBeInTheDocument(); - expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument(); - }); - - it('should render with with different title when end_at time has passed', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - end_at: unixTimestampFromNow(-5), - cancel_at: unixTimestampFromNow(5), - }; - const {getByText, getByTestId} = renderWithContext(, state); - - expect(getByText(/Your subscription has expired. Your workspace will be deleted in 5 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); - expect(getByText(/Renew/)).toBeInTheDocument(); - expect(getByText(/Contact Sales/)).toBeInTheDocument(); - expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument(); - }); -}); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx index b734730963..add38033e2 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx @@ -2,16 +2,10 @@ // See LICENSE.txt for license information. import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import BlockableLink from 'components/admin_console/blockable_link'; -import type {ModeType} from 'components/alert_banner'; import AlertBanner from 'components/alert_banner'; -import useGetSubscription from 'components/common/hooks/useGetSubscription'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; - -import {daysToCancellation, daysToExpiration} from 'utils/cloud_utils'; export const creditCardExpiredBanner = (setShowCreditCardBanner: (value: boolean) => void) => { return ( @@ -59,61 +53,3 @@ export const paymentFailedBanner = () => { /> ); }; - -export const CloudAnnualRenewalBanner = () => { - const openPurchaseModal = useOpenCloudPurchaseModal({}); - const subscription = useGetSubscription(); - const {formatMessage} = useIntl(); - const [openSalesLink] = useOpenSalesLink(); - if (!subscription || !subscription.cancel_at || (subscription.will_renew === 'true' && !subscription.delinquent_since)) { - return null; - } - const daysUntilExpiration = daysToExpiration(subscription); - const daysUntilCancelation = daysToCancellation(subscription); - const renewButton = ( - - ); - - const contactSalesButton = ( - - ); - - const alertBannerProps = { - mode: 'info' as ModeType, - title: (<>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title', defaultMessage: 'Your annual subscription expires in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilExpiration})}), - actionButtonLeft: renewButton, - actionButtonRight: contactSalesButton, - message: <>, - }; - - // If outside the 60 day window or on a trial, don't show this banner. - if (daysUntilExpiration > 60 || subscription.is_free_trial === 'true') { - return null; - } - - if (daysUntilExpiration <= 7) { - alertBannerProps.mode = 'danger'; - } - - if (daysUntilExpiration <= 0) { - alertBannerProps.title = <>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired', defaultMessage: 'Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilCancelation})}; - } - - return ( - - - ); -}; diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx index 3a5470d2ef..486c4a99fd 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx @@ -1,7 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useEffect, useState} from 'react'; +import React, {useEffect} from 'react'; import {FormattedMessage, defineMessages} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; @@ -11,7 +11,6 @@ import {getCloudSubscription, getCloudProducts, getCloudCustomer} from 'mattermo import { getSubscriptionProduct, getCloudSubscription as selectCloudSubscription, - getCloudCustomer as selectCloudCustomer, getCloudErrors, } from 'mattermost-redux/selectors/entities/cloud'; @@ -19,29 +18,16 @@ import {pageVisited} from 'actions/telemetry_actions'; import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner'; import CloudFetchError from 'components/cloud_fetch_error'; -import useGetLimits from 'components/common/hooks/useGetLimits'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; import AdminHeader from 'components/widgets/admin_console/admin_header'; -import {isCustomerCardExpired} from 'utils/cloud_utils'; import { TrialPeriodDays, } from 'utils/constants'; import {useQuery} from 'utils/http_utils'; -import {hasSomeLimits} from 'utils/limits'; import {getRemainingDaysFromFutureTimestamp} from 'utils/utils'; -import { - CloudAnnualRenewalBanner, - creditCardExpiredBanner, - paymentFailedBanner, -} from './billing_subscriptions'; -import CancelSubscription from './cancel_subscription'; import ContactSalesCard from './contact_sales_card'; -import LimitReachedBanner from './limit_reached_banner'; -import Limits from './limits'; -import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner'; import BillingSummary from '../billing_summary'; import PlanDetails from '../plan_details'; @@ -59,18 +45,11 @@ export const searchableStrings = [ const BillingSubscriptions = () => { const dispatch = useDispatch(); const subscription = useSelector(selectCloudSubscription); - const [cloudLimits] = useGetLimits(); const errorLoadingData = useSelector((state: GlobalState) => { const errors = getCloudErrors(state); return Boolean(errors.limits || errors.subscription || errors.customer || errors.products); }); - - const isCardExpired = isCustomerCardExpired(useSelector(selectCloudCustomer)); - const trialEndDate = subscription?.trial_end_at || 0; - - const [showCreditCardBanner, setShowCreditCardBanner] = useState(true); - const query = useQuery(); const actionQueryParam = query.get('action'); @@ -78,13 +57,6 @@ const BillingSubscriptions = () => { const openPricingModal = useOpenPricingModal(); - const openCloudPurchaseModal = useOpenCloudPurchaseModal({}); - - // show the upgrade section when is a free tier customer - const onUpgradeMattermostCloud = (callerInfo: string) => { - openCloudPurchaseModal({trackingLocation: callerInfo}); - }; - let isFreeTrial = false; let daysLeftOnTrial = 0; if (subscription?.is_free_trial === 'true') { @@ -103,23 +75,11 @@ const BillingSubscriptions = () => { pageVisited('cloud_admin', 'pageview_billing_subscription'); - if (actionQueryParam === 'show_purchase_modal') { - onUpgradeMattermostCloud('billing_subscriptions_external_direct_link'); - } - if (actionQueryParam === 'show_pricing_modal') { openPricingModal({trackingLocation: 'billing_subscriptions_external_direct_link'}); } - - if (actionQueryParam === 'show_delinquency_modal') { - openCloudPurchaseModal({trackingLocation: 'billing_subscriptions_external_direct_link'}); - } }, []); - const shouldShowPaymentFailedBanner = () => { - return subscription?.last_invoice?.status === 'failed'; - }; - // handle not loaded yet here, failed to load handled below if ((!subscription || !product) && !errorLoadingData) { return null; @@ -134,15 +94,6 @@ const BillingSubscriptions = () => {
{errorLoadingData && } {!errorLoadingData && <> - - {shouldShowPaymentFailedBanner() && paymentFailedBanner()} - {} - {} - {showCreditCardBanner && - isCardExpired && - creditCardExpiredBanner(setShowCreditCardBanner)} {isFreeTrial && }
{
- {hasSomeLimits(cloudLimits) && !isFreeTrial ? ( - - ) : ( - - )} - + }
diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.scss b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.scss deleted file mode 100644 index 613c6fda4a..0000000000 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.scss +++ /dev/null @@ -1,28 +0,0 @@ -@import 'utils/mixins'; - -.LimitReachedBanner { - &__actions { - padding-top: 12px; - } - - &__primary { - font-size: 12px; - - @include primary-button; - - &:hover { - color: var(--button-color); - } - } - - &__contact-sales { - margin-left: 4px; - font-size: 12px; - - @include tertiary-button; - - &:hover { - color: var(--button-bg); - } - } -} diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.test.tsx deleted file mode 100644 index 95b7f3cfb9..0000000000 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.test.tsx +++ /dev/null @@ -1,193 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import type {GlobalState} from '@mattermost/types/store'; -import type {UserProfile, UsersState} from '@mattermost/types/users'; - -import {Preferences} from 'mattermost-redux/constants'; -import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils'; - -import * as useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas'; -import * as useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import * as useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import * as useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import * as useSaveBool from 'components/common/hooks/useSavePreferences'; - -import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils'; -import {CloudProducts} from 'utils/constants'; - -import LimitReachedBanner from './limit_reached_banner'; - -const upgradeCloudKey = getPreferenceKey(Preferences.CATEGORY_UPGRADE_CLOUD, Preferences.SYSTEM_CONSOLE_LIMIT_REACHED); - -const state: GlobalState = { - entities: { - users: { - currentUserId: 'userid', - profiles: { - userid: {} as UserProfile, - }, - } as unknown as UsersState, - preferences: { - myPreferences: { - [upgradeCloudKey]: {value: 'false'}, - }, - }, - cloud: { - limits: { - }, - products: { - }, - }, - general: { - license: { - }, - config: { - }, - }, - }, -} as GlobalState; - -const base = { - id: '', - name: '', - description: '', - price_per_seat: 0, - add_ons: [], - product_family: '', - billing_scheme: '', - recurring_interval: '', - cross_sells_to: '', -}; - -const free = {...base, sku: CloudProducts.STARTER}; -const enterprise = {...base, sku: CloudProducts.ENTERPRISE}; - -const noLimitReached = { - files: { - totalStorage: -1, - totalStorageLoaded: true, - }, - messages: { - history: -1, - historyLoaded: true, - }, - boards: { - cards: -1, - cardsLoaded: true, - }, - teams: { - active: -1, - cloudArchived: -1, - teamsLoaded: true, - }, - integrations: { - enabled: -1, - enabledLoaded: true, - }, -}; -const someLimitReached = { - ...noLimitReached, - integrations: { - ...noLimitReached.integrations, - enabled: 1, - }, -}; - -const titleFree = /Upgrade to one of our paid plans to avoid/; -const titleProfessional = /Upgrade to Enterprise to avoid Professional plan/; - -function makeSpies() { - const mockUseOpenSalesLink = jest.spyOn(useOpenSalesLink, 'default'); - const mockUseGetUsageDeltas = jest.spyOn(useGetUsageDeltas, 'default'); - const mockUseOpenCloudPurchaseModal = jest.spyOn(useOpenCloudPurchaseModal, 'default'); - const mockUseOpenPricingModal = jest.spyOn(useOpenPricingModal, 'default'); - const mockUseSaveBool = jest.spyOn(useSaveBool, 'useSaveBool'); - return { - useOpenSalesLink: mockUseOpenSalesLink, - useGetUsageDeltas: mockUseGetUsageDeltas, - useOpenCloudPurchaseModal: mockUseOpenCloudPurchaseModal, - useOpenPricingModal: mockUseOpenPricingModal, - useSaveBool: mockUseSaveBool, - }; -} - -describe('limits_reached_banner', () => { - test('does not render when product is enterprise', () => { - const spies = makeSpies(); - spies.useGetUsageDeltas.mockReturnValue(someLimitReached); - - renderWithContext(, state); - - expect(screen.queryByText(titleFree)).not.toBeInTheDocument(); - expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument(); - }); - - test('does not render when banner was dismissed', () => { - const myState = { - ...state, - entities: { - ...state.entities, - preferences: { - ...state.entities.preferences, - myPreferences: { - ...state.entities.preferences.myPreferences, - [upgradeCloudKey]: {value: 'true'}, - }, - }, - }, - }; - - const spies = makeSpies(); - spies.useGetUsageDeltas.mockReturnValue(someLimitReached); - - renderWithContext(, myState); - - expect(screen.queryByText(titleFree)).not.toBeInTheDocument(); - expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument(); - }); - - test('does not render when no limit reached', () => { - const spies = makeSpies(); - spies.useGetUsageDeltas.mockReturnValue(noLimitReached); - - renderWithContext(, state); - - expect(screen.queryByText(titleFree)).not.toBeInTheDocument(); - expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument(); - }); - - test('renders free banner', () => { - const spies = makeSpies(); - const mockOpenPricingModal = jest.fn(); - spies.useOpenPricingModal.mockReturnValue(mockOpenPricingModal); - spies.useGetUsageDeltas.mockReturnValue(someLimitReached); - - renderWithContext(, state); - - screen.getByText(titleFree); - expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByText('View plans')); - - expect(mockOpenPricingModal).toHaveBeenCalled(); - }); - - test('clicking Contact Sales opens sales link', () => { - const spies = makeSpies(); - const mockOpenSalesLink = jest.fn(); - spies.useOpenSalesLink.mockReturnValue([mockOpenSalesLink, '']); - spies.useGetUsageDeltas.mockReturnValue(someLimitReached); - - renderWithContext(, state); - - screen.getByText(titleFree); - expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument(); - - fireEvent.click(screen.getByText('Contact sales')); - - expect(mockOpenSalesLink).toHaveBeenCalled(); - }); -}); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.tsx deleted file mode 100644 index 572a1cb7c5..0000000000 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limit_reached_banner.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {useIntl, FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import type {Product} from '@mattermost/types/cloud'; - -import {Preferences} from 'mattermost-redux/constants'; -import {getHasDismissedSystemConsoleLimitReached} from 'mattermost-redux/selectors/entities/preferences'; - -import AlertBanner from 'components/alert_banner'; -import useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas'; -import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import {useSaveBool} from 'components/common/hooks/useSavePreferences'; - -import {CloudProducts} from 'utils/constants'; -import {anyUsageDeltaExceededLimit} from 'utils/limits'; - -import './limit_reached_banner.scss'; - -interface Props { - product?: Product; -} - -const LimitReachedBanner = (props: Props) => { - const intl = useIntl(); - const someLimitExceeded = anyUsageDeltaExceededLimit(useGetUsageDeltas()); - - const hasDismissedBanner = useSelector(getHasDismissedSystemConsoleLimitReached); - - const [openSalesLink] = useOpenSalesLink(); - const openPricingModal = useOpenPricingModal(); - const saveBool = useSaveBool(); - if (hasDismissedBanner || !someLimitExceeded || !props.product || (props.product.sku !== CloudProducts.STARTER)) { - return null; - } - - const title = ( - - ); - - const description = ( - - ); - - const upgradeMessage = { - id: 'workspace_limits.modals.view_plans', - defaultMessage: 'View plans', - }; - - const upgradeAction = () => openPricingModal({trackingLocation: 'limit_reached_banner'}); - - const onDismiss = () => { - saveBool({ - category: Preferences.CATEGORY_UPGRADE_CLOUD, - name: Preferences.SYSTEM_CONSOLE_LIMIT_REACHED, - value: true, - }); - }; - - return ( - -
- - -
-
- ); -}; - -export default LimitReachedBanner; diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx index 899993c9f9..714bb14dce 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import {renderWithContext, screen} from 'tests/react_testing_utils'; import {CloudProducts} from 'utils/constants'; -import {ToPaidNudgeBanner, ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner'; +import {ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner'; const initialState = { views: { @@ -171,68 +171,3 @@ describe('ToPaidPlanBannerDismissable', () => { expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow(); }); }); - -describe('ToPaidNudgeBanner', () => { - test('should show only for cloud free', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud = { - subscription: { - product_id: 'prod_starter', - is_free_trial: 'false', - trial_end_at: 1, - }, - products: { - prod_starter: { - id: 'prod_starter', - sku: CloudProducts.STARTER, - }, - }, - }; - - renderWithContext(, state, {useMockedStore: true}); - - screen.getByTestId('cloud-free-deprecation-alert-banner'); - }); - - test('should NOT show for cloud professional', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud = { - subscription: { - product_id: 'prod_pro', - is_free_trial: 'false', - trial_end_at: 1, - }, - products: { - prod_pro: { - id: 'prod_pro', - sku: CloudProducts.PROFESSIONAL, - }, - }, - }; - - renderWithContext(, state, {useMockedStore: true}); - - expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow(); - }); - - test('should NOT show for cloud enterprise', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud = { - subscription: { - product_id: 'prod_ent', - is_free_trial: 'false', - trial_end_at: 1, - }, - products: { - prod_ent: { - id: 'prod_ent', - sku: CloudProducts.ENTERPRISE, - }, - }, - }; - - renderWithContext(, state, {useMockedStore: true}); - - expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow(); - }); -}); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx index ba6eac9f15..0e39f409a1 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx @@ -3,7 +3,7 @@ import moment from 'moment'; import React, {useEffect} from 'react'; -import {useIntl, FormattedMessage} from 'react-intl'; +import {FormattedMessage} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import type {GlobalState} from '@mattermost/types/store'; @@ -13,11 +13,8 @@ import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-re import {deprecateCloudFree, get as getPreference} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; -import AlertBanner from 'components/alert_banner'; import AnnouncementBar from 'components/announcement_bar/default_announcement_bar'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants'; import {t} from 'utils/i18n'; @@ -174,73 +171,3 @@ export const ToPaidPlanBannerDismissable = () => { /> ); }; - -export const ToPaidNudgeBanner = () => { - const {formatMessage} = useIntl(); - - const [openSalesLink] = useOpenSalesLink(); - const openPurchaseModal = useOpenCloudPurchaseModal({}); - - const product = useSelector(selectSubscriptionProduct); - const cloudFreeDeprecated = useSelector(deprecateCloudFree); - const currentProductStarter = product?.sku === CloudProducts.STARTER; - - if (!cloudFreeDeprecated) { - return null; - } - - if (!currentProductStarter) { - return null; - } - - const now = moment(Date.now()); - const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD'); - const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days'); - - const title = ( - - ); - - const description = ( - - ); - - const viewPlansAction = ( - - ); - - const contactSalesAction = ( - - ); - - const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info'; - - return ( - - ); -}; diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx index ae53b2359e..a4ead23743 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {FormattedDate, FormattedMessage, FormattedNumber, defineMessages} from 'react-intl'; import {useDispatch} from 'react-redux'; -import {CheckCircleOutlineIcon, CheckIcon, ClockOutlineIcon} from '@mattermost/compass-icons/components'; +import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud'; import {Client4} from 'mattermost-redux/client'; @@ -15,6 +15,7 @@ import {openModal} from 'actions/views/modals'; import BlockableLink from 'components/admin_console/blockable_link'; import CloudInvoicePreview from 'components/cloud_invoice_preview'; +import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import EmptyBillingHistorySvg from 'components/common/svg_images_components/empty_billing_history_svg'; import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; import ExternalLink from 'components/external_link'; @@ -65,118 +66,81 @@ export const noBillingHistory = ( ); -export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void, daysLeftOnTrial: number, reverseTrial: boolean) => ( -
-
- { + const [openSalesLink] = useOpenSalesLink(); + return ( +
+
+ +
+
+ {daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && + + } + {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && + + } +
+
+ {daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD && + + } + {(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) && + + } + {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && + + } +
+ +
); +}; + +export const getPaymentStatus = () => { + return ( +
+ {' '} +
-
- {daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && - - } - {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && - - } -
-
- {daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD && - - } - {(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) && - - } - {(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) && - - } -
- -
-); - -export const getPaymentStatus = (status: string, willRenew?: boolean) => { - if (willRenew) { - return ( -
- {' '} - -
- ); - } - switch (status.toLowerCase()) { - case 'failed': - return ( -
- {' '} - -
- ); - case 'paid': - return ( -
- {' '} - -
- ); - default: - return ( -
- {' '} - -
- ); - } + ); }; type InvoiceInfoProps = { @@ -185,10 +149,9 @@ type InvoiceInfoProps = { fullCharges: InvoiceLineItem[]; partialCharges: InvoiceLineItem[]; hasMore?: number; - willRenew?: boolean; } -export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore, willRenew}: InvoiceInfoProps) => { +export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore}: InvoiceInfoProps) => { const dispatch = useDispatch(); const isUpcomingInvoice = invoice?.status.toLowerCase() === 'upcoming'; @@ -225,7 +188,7 @@ export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasM
{title()}
- {getPaymentStatus(invoice.status, willRenew)} + {getPaymentStatus()}
void; } -const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}: BillingSummaryProps) => { - const subscription = useSelector(getCloudSubscription); - const product = useSelector(getSubscriptionProduct); - const reverseTrial = useSelector(cloudReverseTrial); - +export default function BillingSummary({isFreeTrial, daysLeftOnTrial}: BillingSummaryProps) { let body = noBillingHistory; - const isPreTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at === 0; - const hasPriorTrial = useSelector(checkHadPriorTrial); - const isStarterPreTrial = product?.sku === CloudProducts.STARTER && isPreTrial; - const isStarterPostTrial = product?.sku === CloudProducts.STARTER && hasPriorTrial; - - if (isStarterPreTrial && reverseTrial) { - body = ; - } else if (isStarterPreTrial) { - body = tryEnterpriseCard; - } else if (isStarterPostTrial) { - body = ; - } else if (isFreeTrial) { - body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial, reverseTrial); - } else if (subscription?.last_invoice && !subscription?.upcoming_invoice) { - const invoice = subscription.last_invoice; - const fullCharges = invoice.line_items.filter((item) => item.type === 'full'); - const partialCharges = invoice.line_items.filter((item) => item.type === 'partial'); - - body = ( - - ); - } else if (subscription?.upcoming_invoice) { - const invoice = subscription.upcoming_invoice; - const {fullCharges, partialCharges, hasMore} = buildInvoiceSummaryPropsFromLineItems(invoice.line_items); - - body = ( - - ); + if (isFreeTrial) { + // eslint-disable-next-line new-cap + body = FreeTrial({daysLeftOnTrial}); } - return (
{body}
); -}; +} -export default BillingSummary; diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/upsell_card.tsx b/webapp/channels/src/components/admin_console/billing/billing_summary/upsell_card.tsx index a5ba8db050..1a92cbc11f 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/upsell_card.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/upsell_card.tsx @@ -6,7 +6,6 @@ import React from 'react'; import {useIntl} from 'react-intl'; import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg'; import StartTrialCaution from 'components/pricing_modal/start_trial_caution'; @@ -31,9 +30,6 @@ const enterpriseAdvantages = [ }, ]; -// Currently, these are the same. In the future, they may diverge. -const professionalAdvantages = enterpriseAdvantages; - interface Props { advantages: Message[]; title: Message; @@ -149,26 +145,6 @@ export const tryEnterpriseCard = ( /> ); -export const UpgradeToProfessionalCard = () => { - const openPurchaseModal = useOpenCloudPurchaseModal({}); - return ( - openPurchaseModal({trackingLocation: 'billing_summary_upsell_professional_card'})} - ctaPrimary={true} - andMore={true} - advantages={professionalAdvantages} - /> - ); -}; - export const ExploreEnterpriseCard = () => { return ( ; - -const messages = defineMessages({ - title: {id: 'admin.billing.payment_info.title', defaultMessage: 'Payment Information'}, -}); - -export const searchableStrings = [ - messages.title, -]; - -const PaymentInfo: React.FC = () => { - const dispatch = useDispatch(); - const {customer: customerError} = useSelector(getCloudErrors); - - const isCardAboutToExpire = useSelector((state: GlobalState) => { - const {customer} = state.entities.cloud; - if (!customer) { - return false; - } - - const expiryYear = customer.payment_method.exp_year; - - // If not expiry year, or its 0, it's not expired (because it probably isn't set) - if (!expiryYear) { - return false; - } - - // This works because we store the expiry month as the actual 1-12 base month, but Date uses a 0-11 base month - // But credit cards expire at the end of their expiry month, so we can just use that number. - const lastExpiryDate = new Date(expiryYear, customer.payment_method.exp_month, 1); - const currentDatePlus10Days = new Date(); - currentDatePlus10Days.setDate(currentDatePlus10Days.getDate() + 10); - return lastExpiryDate <= currentDatePlus10Days; - }); - - const [showCreditCardBanner, setShowCreditCardBanner] = useState(true); - - useEffect(() => { - dispatch(getCloudCustomer()); - - pageVisited('cloud_admin', 'pageview_billing_payment_info'); - }, []); - - return ( -
- - - -
-
- {showCreditCardBanner && isCardAboutToExpire && ( - - } - message={ - - } - onDismiss={() => setShowCreditCardBanner(false)} - /> - )} - {customerError ? : } -
-
-
- ); -}; - -export default PaymentInfo; diff --git a/webapp/channels/src/components/admin_console/billing/payment_info_display.scss b/webapp/channels/src/components/admin_console/billing/payment_info_display.scss deleted file mode 100644 index 352df888f2..0000000000 --- a/webapp/channels/src/components/admin_console/billing/payment_info_display.scss +++ /dev/null @@ -1,149 +0,0 @@ -.PaymentInfoDisplay { - border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); - border-radius: 4px; - background-color: var(--sys-center-channel-bg); - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); - color: var(--sys-center-channel-color); -} - -.PaymentInfoDisplay__header { - display: flex; - align-items: center; - padding: 28px 32px 24px 32px; - border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); -} - -.PaymentInfoDisplay__headerText-top { - font-size: 16px; - font-weight: 600; - line-height: 24px; -} - -.PaymentInfoDisplay__headerText-bottom { - font-size: 14px; - line-height: 20px; -} - -.PaymentInfoDisplay__addInfo { - margin-left: auto; -} - -.PaymentInfoDisplay__addInfoButton { - display: flex; - align-items: center; - padding: 8px 16px; - border-radius: 4px; - background: var(--sys-button-bg); - color: var(--sys-button-color); - - &:hover:not(.disabled) { - background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg); - color: var(--sys-button-color); - text-decoration: none; - } - - &:active { - background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.32), rgba(var(--sys-center-channel-color-rgb), 0.32)), var(--sys-button-bg); - color: var(--sys-button-color); - text-decoration: none; - } - - &:focus { - box-shadow: inset 0 0 0 2px var(--sys-sidebar-text-active-border); - color: var(--sys-button-color); - text-decoration: none; - } - - &.disabled { - background: rgba(var(--sys-center-channel-color-rgb), 0.08); - color: rgba(var(--sys-center-channel-color-rgb), 0.32); - } - - > i { - font-size: 14.4px; - line-height: 17px; - - &::before { - margin: 0; - } - } - - > span { - margin-left: 4px; - font-size: 12px; - font-weight: 600; - line-height: 9px; - } -} - -.PaymentInfoDisplay__noPaymentInfo { - display: flex; - flex-direction: column; - align-items: center; - padding: 48px; -} - -.PaymentInfoDisplay__noPaymentInfo-message { - margin-top: 24px; - color: var(--sys-center-channel-color); - font-size: 14px; - line-height: 20px; -} - -.PaymentInfoDisplay__noPaymentInfo-link { - margin-top: 20px; - margin-bottom: 12px; - color: var(--sys-button-bg); - font-size: 14px; - font-weight: 600; - line-height: 14px; -} - -.PaymentInfoDisplay__paymentInfo { - display: flex; - padding: 28px 45px 30px 32px; - color: var(--sys-center-channel-color); - font-size: 14px; - line-height: 20px; - - .CardImage { - max-width: 55px; - max-height: 37px; - } -} - -.PaymentInfoDisplay__paymentInfo-name { - font-weight: 600; -} - -.PaymentInfoDisplay__paymentInfo-addressTitle { - margin-top: 16px; - font-weight: 600; -} - -.PaymentInfoDisplay__paymentInfo-address > div { - margin-top: 4px; -} - -.PaymentInfoDisplay__paymentInfo-edit { - margin-left: auto; -} - -.PaymentInfoDisplay__paymentInfo-editButton { - padding: 2px; - border-radius: 4px; - margin-left: 12px; - color: rgba(var(--sys-center-channel-color-rgb), 0.75); - font-size: 18px; - - &:hover, - &:focus { - background: rgba(var(--sys-center-channel-color-rgb), 0.08); - color: rgba(var(--sys-center-channel-color-rgb), 0.75); - text-decoration: none; - } -} - -.PaymentInfoDisplay__paymentInfo-cardInfo::first-letter { - text-transform: capitalize; -} diff --git a/webapp/channels/src/components/admin_console/billing/payment_info_display.tsx b/webapp/channels/src/components/admin_console/billing/payment_info_display.tsx deleted file mode 100644 index 155fbf8b91..0000000000 --- a/webapp/channels/src/components/admin_console/billing/payment_info_display.tsx +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import BlockableLink from 'components/admin_console/blockable_link'; -import useGetSubscription from 'components/common/hooks/useGetSubscription'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; - -import type {GlobalState} from 'types/store'; - -import PaymentDetails from './payment_details'; - -import './payment_info_display.scss'; - -const addInfoButton = ( -
- trackEvent('cloud_admin', 'click_add_credit_card')} - > - - - -
-); - -const noPaymentInfoSection = ( -
- -
- -
- trackEvent('cloud_admin', 'click_add_credit_card')} - > - - -
-); - -const PaymentInfoDisplay: React.FC = () => { - const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer); - const subscription = useGetSubscription(); - const openPurchaseModal = useOpenCloudPurchaseModal({}); - if (!paymentInfo || !subscription) { - return null; - } - - let body = noPaymentInfoSection; - - if (paymentInfo?.payment_method && paymentInfo?.billing_address) { - body = ( -
- -
- {subscription.delinquent_since ? ( -
openPurchaseModal({trackingLocation: 'edit_payment_info'})} - > - -
- ) : ( - - - - )} -
-
- ); - } - - return ( -
-
-
-
- -
-
- -
-
- {!(paymentInfo?.payment_method && paymentInfo?.billing_address) && addInfoButton} -
-
- {body} -
-
- ); -}; - -export default PaymentInfoDisplay; diff --git a/webapp/channels/src/components/admin_console/billing/payment_info_edit.scss b/webapp/channels/src/components/admin_console/billing/payment_info_edit.scss deleted file mode 100644 index 74bd68a242..0000000000 --- a/webapp/channels/src/components/admin_console/billing/payment_info_edit.scss +++ /dev/null @@ -1,57 +0,0 @@ -.PaymentInfoEdit__card { - padding: 28px 32px; - border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); - border-radius: 4px; - margin-top: 20px; - background-color: var(--sys-center-channel-bg); - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); -} - -.PaymentInfoEdit__paymentForm { - max-width: 480px; - padding: 0; - margin: 0; - - .section-title:not(:first-child) { - font-size: 14px; - line-height: 20px; - } - - .section-title { - color: var(--sys-center-channel-text); - } - - .Input_fieldset { - background: var(--sys-center-channel-bg); - } - - &.Input_fieldset:focus-within { - box-shadow: inset 0 0 0 2px var(--sys-button-bg); - } - - &.Input_fieldset___error { - box-shadow: inset 0 0 0 1px var(--sys-error-text); - color: var(--sys-error-text); - } - - &.Input_fieldset___error:focus-within { - box-shadow: inset 0 0 0 2px var(--sys-error-text); - color: var(--sys-error-text); - } - - .Input::placeholder { - color: var(--sys-center-channel-color); - } -} - -.PaymentInfoEdit__error { - display: flex; - align-items: center; - color: var(--sys-error-text); - font-size: 12px; - - i { - margin-right: 4px; - font-size: 14.4px; - } -} diff --git a/webapp/channels/src/components/admin_console/billing/payment_info_edit.tsx b/webapp/channels/src/components/admin_console/billing/payment_info_edit.tsx deleted file mode 100644 index bc6844c0a5..0000000000 --- a/webapp/channels/src/components/admin_console/billing/payment_info_edit.tsx +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Elements} from '@stripe/react-stripe-js'; -import type {Stripe} from '@stripe/stripe-js'; -import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects -import React, {useEffect, useState} from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; -import {useHistory} from 'react-router-dom'; - -import {getCloudCustomer} from 'mattermost-redux/actions/cloud'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; - -import {completeStripeAddPaymentMethod} from 'actions/cloud'; -import {isCwsMockMode} from 'selectors/cloud'; - -import BlockableLink from 'components/admin_console/blockable_link'; -import AlertBanner from 'components/alert_banner'; -import ExternalLink from 'components/external_link'; -import FormattedMarkdownMessage from 'components/formatted_markdown_message'; -import PaymentForm from 'components/payment_form/payment_form'; -import {STRIPE_CSS_SRC, getStripePublicKey} from 'components/payment_form/stripe'; -import SaveButton from 'components/save_button'; -import AdminHeader from 'components/widgets/admin_console/admin_header'; - -import {CloudLinks} from 'utils/constants'; - -import {areBillingDetailsValid} from 'types/cloud/sku'; -import type {BillingDetails} from 'types/cloud/sku'; -import type {GlobalState} from 'types/store'; - -import './payment_info_edit.scss'; - -let stripePromise: Promise; - -const PaymentInfoEdit: React.FC = () => { - const dispatch = useDispatch(); - const history = useHistory(); - - const cwsMockMode = useSelector(isCwsMockMode); - const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer); - const theme = useSelector(getTheme); - - const [showCreditCardWarning, setShowCreditCardWarning] = useState(true); - const [isSaving, setIsSaving] = useState(false); - const [isValid, setIsValid] = useState(undefined); - const [isServerError, setIsServerError] = useState(false); - const [billingDetails, setBillingDetails] = useState({ - address: paymentInfo?.billing_address?.line1 || '', - address2: paymentInfo?.billing_address?.line2 || '', - city: paymentInfo?.billing_address?.city || '', - state: paymentInfo?.billing_address?.state || '', - country: paymentInfo?.billing_address?.country || '', - postalCode: paymentInfo?.billing_address?.postal_code || '', - name: '', - card: {} as any, - }); - - const stripePublicKey = useSelector((state: GlobalState) => getStripePublicKey(state)); - - useEffect(() => { - dispatch(getCloudCustomer()); - }, []); - - const onPaymentInput = (billing: BillingDetails) => { - setIsServerError(false); - setIsValid(areBillingDetailsValid(billing)); - setBillingDetails(billing); - }; - - const handleSubmit = async () => { - setIsSaving(true); - const setPaymentMethod = completeStripeAddPaymentMethod((await stripePromise)!, billingDetails!, cwsMockMode); - const success = await setPaymentMethod(); - - if (success) { - history.push('/admin_console/billing/payment_info'); - } else { - setIsServerError(true); - } - - setIsSaving(false); - }; - - if (!stripePromise) { - stripePromise = loadStripe(stripePublicKey); - } - - return ( -
- -
- - -
-
-
-
- {showCreditCardWarning && - - } - message={ - <> - - - - - - } - onDismiss={() => setShowCreditCardWarning(false)} - /> - } -
- - - -
-
-
-
- - )} - /> - - - - {isValid === false && - - - - - } - {isServerError && - - - - - } -
-
- ); -}; - -export default PaymentInfoEdit; diff --git a/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx b/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx index b89561f5cf..1bdf9b1cea 100644 --- a/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx +++ b/webapp/channels/src/components/admin_console/feature_discovery/feature_discovery.tsx @@ -12,17 +12,15 @@ import {trackEvent} from 'actions/telemetry_actions'; import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner'; import AlertBanner from 'components/alert_banner'; -import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us'; import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link'; import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn'; import ExternalLink from 'components/external_link'; import FormattedMarkdownMessage from 'components/formatted_markdown_message'; import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn'; -import PurchaseModal from 'components/purchase_modal'; import LoadingSpinner from 'components/widgets/loading/loading_spinner'; import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils'; -import {ModalIdentifiers, TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants'; +import {TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants'; import {goToMattermostContactSalesForm} from 'utils/contact_support_sales'; import * as Utils from 'utils/utils'; @@ -82,23 +80,6 @@ export default class FeatureDiscovery extends React.PureComponent this.props.actions.getPrevTrialLicense(); } - openUpgradeModal = (e: React.MouseEvent) => { - e.preventDefault(); - - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_ADMIN, - 'click_subscribe_from_feature_discovery', - ); - - this.props.actions.openModal({ - modalId: ModalIdentifiers.CLOUD_PURCHASE, - dialogType: PurchaseModal, - dialogProps: { - callerCTA: 'feature_discovery_subscribe_button', - }, - }); - }; - contactSalesFunc = () => { const {customer, isCloud} = this.props; const customerEmail = customer?.email || ''; @@ -157,9 +138,6 @@ export default class FeatureDiscovery extends React.PureComponent /> } /> -
@@ -172,7 +150,6 @@ export default class FeatureDiscovery extends React.PureComponent isCloudTrial, hadPrevCloudTrial, isPaidSubscription, - minimumSKURequiredForFeature, } = this.props; const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription; @@ -217,42 +194,6 @@ export default class FeatureDiscovery extends React.PureComponent ); } - } else if (hadPrevCloudTrial) { - // if it is cloud, but this account already had a free trial, then the cta button must be Upgrade now - ctaPrimaryButton = ( - - ); - - if (minimumSKURequiredForFeature === LicenseSkus.Enterprise) { - ctaPrimaryButton = ( - - ); - } } } diff --git a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx index fa6f6307c3..350a57414f 100644 --- a/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel.tsx @@ -5,23 +5,16 @@ import classNames from 'classnames'; import React, {useEffect, useState} from 'react'; import type {RefObject} from 'react'; import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, defineMessages, useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; import type {ClientLicense} from '@mattermost/types/config'; import {Client4} from 'mattermost-redux/client'; -import {getConfig} from 'mattermost-redux/selectors/entities/admin'; -import {trackEvent} from 'actions/telemetry_actions'; -import {getExpandSeatsLink} from 'selectors/cloud'; - -import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand'; -import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal'; import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; +import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import Tag from 'components/widgets/tag/tag'; -import {FileTypes, TELEMETRY_CATEGORIES} from 'utils/constants'; -import {useQuery} from 'utils/http_utils'; +import {FileTypes} from 'utils/constants'; import {calculateOverageUserActivated} from 'utils/overage_team'; import {getSkuDisplayName} from 'utils/subscription'; import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils'; @@ -63,20 +56,7 @@ const EnterpriseEditionLeftPanel = ({ const {formatMessage} = useIntl(); const [unsanitizedLicense, setUnsanitizedLicense] = useState(license); const openPricingModal = useOpenPricingModal(); - const canExpand = useCanSelfHostedExpand(); - const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'}); - const expandableLink = useSelector(getExpandSeatsLink); - const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; - - const query = useQuery(); - const actionQueryParam = query.get('action'); - - useEffect(() => { - if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) { - selfHostedExpansionModal.open(); - query.set('action', ''); - } - }, []); + const [openContactSales] = useOpenSalesLink(); useEffect(() => { async function fetchUnSanitizedLicense() { @@ -106,15 +86,6 @@ const EnterpriseEditionLeftPanel = ({ ); - const handleClickAddSeats = () => { - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'add_seats_clicked'); - if (!isSelfHostedPurchaseEnabled || !canExpand) { - window.open(expandableLink(unsanitizedLicense.Id), '_blank'); - } else { - selfHostedExpansionModal.open(); - } - }; - return (
{'License details'} - {canExpand && - - } +
{ renderLicenseContent( diff --git a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_edition.scss b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_edition.scss index f108b160e8..b8ef3a45e5 100644 --- a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_edition.scss +++ b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_edition.scss @@ -40,7 +40,7 @@ .purchase_buttons { display: flex; flex-direction: row; - justify-content: space-between; + justify-content: center; margin-top: 20px; button { @@ -54,7 +54,7 @@ &.contact-us { width: 130px; - margin-left: 15px; + margin-left: 0px !important; } } diff --git a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx index 9ab8e2f85f..79660b1584 100644 --- a/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx +++ b/webapp/channels/src/components/admin_console/license_settings/starter_edition/starter_right_panel.tsx @@ -5,7 +5,6 @@ import React, {memo} from 'react'; import {FormattedMessage} from 'react-intl'; import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us'; -import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link'; import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg'; const StarterRightPanel = () => { @@ -44,15 +43,6 @@ const StarterRightPanel = () => { })}
- - } - /> diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index a6363edf7b..79550b3f6c 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -9,8 +9,6 @@ import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/bill import PostLimitsAnnouncementBar from 'components/announcement_bar/post_limits_announcement_bar'; import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; -import CloudAnnualRenewalAnnouncementBar from './cloud_annual_renewal'; -import CloudDelinquencyAnnouncementBar from './cloud_delinquency'; import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar'; import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar'; import ConfigurationAnnouncementBar from './configuration_bar'; @@ -68,8 +66,6 @@ class AnnouncementBarController extends React.PureComponent { let paymentAnnouncementBar = null; let cloudTrialAnnouncementBar = null; let cloudTrialEndAnnouncementBar = null; - let cloudDelinquencyAnnouncementBar = null; - let cloudRenewalAnnouncementBar = null; const notifyAdminDowngradeDelinquencyBar = null; const toYearlyNudgeBannerDismissable = null; let toPaidPlanNudgeBannerDismissable = null; @@ -83,12 +79,7 @@ class AnnouncementBarController extends React.PureComponent { cloudTrialEndAnnouncementBar = ( ); - cloudDelinquencyAnnouncementBar = ( - - ); - cloudRenewalAnnouncementBar = ( - - ); + toPaidPlanNudgeBannerDismissable = (); } @@ -122,8 +113,6 @@ class AnnouncementBarController extends React.PureComponent { {paymentAnnouncementBar} {cloudTrialAnnouncementBar} {cloudTrialEndAnnouncementBar} - {cloudDelinquencyAnnouncementBar} - {cloudRenewalAnnouncementBar} {notifyAdminDowngradeDelinquencyBar} {toYearlyNudgeBannerDismissable} {toPaidPlanNudgeBannerDismissable} diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx deleted file mode 100644 index 92d043f5b0..0000000000 --- a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx +++ /dev/null @@ -1,374 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {unixTimestampFromNow} from 'tests/helpers/date'; -import {renderWithContext} from 'tests/react_testing_utils'; -import {CloudBanners, CloudProducts, Preferences} from 'utils/constants'; - -import CloudAnnualRenewalAnnouncementBar, {getCurrentYearAsString} from './index'; - -describe('components/announcement_bar/cloud_annual_renewal', () => { - const initialState = { - views: { - announcementBar: { - announcementBarState: { - announcementBarCount: 1, - }, - }, - }, - entities: { - admin: { - config: { - FeatureFlags: { - CloudAnnualRenewals: true, - }, - }, - }, - general: { - license: { - IsLicensed: 'true', - Cloud: 'true', - }, - - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin'}, - }, - }, - cloud: { - subscription: { - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - cancel_at: null, - }, - products: { - test_prod_1: { - id: 'test_prod_1', - sku: CloudProducts.STARTER, - price_per_seat: 0, - }, - test_prod_2: { - id: 'test_prod_2', - sku: CloudProducts.ENTERPRISE, - price_per_seat: 0, - }, - test_prod_3: { - id: 'test_prod_3', - sku: CloudProducts.PROFESSIONAL, - price_per_seat: 0, - }, - }, - }, - }, - }; - - it('Should not show banner when feature flag is disabled time set', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.general.config = { - ...state.entities.admin.config, - CloudAnnualRenewals: false, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should not show banner when no cancel_at time set', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: null, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should show 60 day banner to admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(55), - }; - - const {getByText} = renderWithContext( - , - state, - ); - - expect(getByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument(); - }); - - it('Should NOT show 60 day banner to non-admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(55), - }; - - state.entities.users = { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'user'}, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should show 30 day banner to admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(25), - }; - - const {getByText} = renderWithContext( - , - state, - ); - - expect(getByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument(); - }); - - it('Should NOT show 30 day banner to non-admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(25), - }; - - state.entities.users = { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'user'}, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should show 7 day banner to admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(5), - }; - - const {getByText} = renderWithContext( - , - state, - ); - - expect(getByText('Your annual subscription expires in 5 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument(); - }); - - it('Should NOT show 7 day banner to non admin when cancel_at time is set accordingly', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(5), - }; - - state.entities.users = { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'user'}, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should NOT show 7 day banner to admin when delinquent_since is set', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(5), - delinquent_since: unixTimestampFromNow(5), - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).not.toBeInTheDocument(); - }); - - it('Should NOT show 60 day banner to admin when they dismissed the banner', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(55), - }; - - state.entities.preferences = { - myPreferences: { - [`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`]: { - user_id: 'rq7fq4hfjp8ifywsfwk114545a', - category: 'cloud_annual_renewal_banner', - name: 'annual_renewal_60_day_2023', - value: 'true', - }, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should NOT show 30 day banner to admin when they dismissed the banner', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(25), - }; - - state.entities.preferences = { - myPreferences: { - [`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`]: { - user_id: 'rq7fq4hfjp8ifywsfwk114545a', - category: 'cloud_annual_renewal_banner', - name: 'annual_renewal_30_day_2023', - value: 'true', - }, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should show 60 day banner to admin in 2023 when they dismissed the banner in 2022', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(55), - }; - - state.entities.preferences = { - myPreferences: { - [`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_60_DAY}_2022`]: { - user_id: 'rq7fq4hfjp8ifywsfwk114545a', - category: 'cloud_annual_renewal_banner', - name: 'annual_renewal_60_day_2022', - value: 'true', - }, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument(); - }); - - it('Should show 30 day banner to admin in 2023 when they dismissed the banner in 2022', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(25), - }; - - state.entities.preferences = { - myPreferences: { - [`${Preferences.CLOUD_ANNUAL_RENEWAL_BANNER}--${CloudBanners.ANNUAL_RENEWAL_30_DAY}_2022`]: { - user_id: 'rq7fq4hfjp8ifywsfwk114545a', - category: 'cloud_annual_renewal_banner', - name: 'annual_renewal_30_day_2022', - value: 'true', - }, - }, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument(); - }); - - it('Should NOT show any banner if renewal date is more than 60 days away"', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(75), - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); - - it('Should NOT show any banner if within renewal period but will_renew is set to "true"', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: unixTimestampFromNow(69), - end_at: unixTimestampFromNow(25), - will_renew: 'true', - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription expires in')).not.toBeInTheDocument(); - }); -}); diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx deleted file mode 100644 index 0a75382b76..0000000000 --- a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect, useMemo} from 'react'; -import {useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; - -import {getConfig as adminGetConfig} from 'mattermost-redux/actions/admin'; -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {getConfig} from 'mattermost-redux/selectors/entities/admin'; -import {get} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUserId, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; - -import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription'; -import useGetSubscription from 'components/common/hooks/useGetSubscription'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; - -import {daysToExpiration} from 'utils/cloud_utils'; -import {Preferences, AnnouncementBarTypes, CloudBanners} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import AnnouncementBar from '../default_announcement_bar'; - -const between = (x: number, min: number, max: number) => { - return x >= min && x <= max; -}; - -export const getCurrentYearAsString = () => { - const now = new Date(); - const year = now.getFullYear(); - return year.toString(); -}; - -const CloudAnnualRenewalAnnouncementBar = () => { - const subscription = useGetSubscription(); - - const openPurchaseModal = useOpenCloudPurchaseModal({}); - const {formatMessage} = useIntl(); - const {isDelinquencySubscription} = useDelinquencySubscription(); - const isAdmin = useSelector(isCurrentUserSystemAdmin); - const dispatch = useDispatch(); - const currentUserId = useSelector(getCurrentUserId); - const hasDismissed60DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`)) === 'true'; - const hasDismissed30DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`)) === 'true'; - const config = useSelector(getConfig); - const cloudAnnualRenewalsEnabled = config.FeatureFlags?.CloudAnnualRenewals; - - useEffect(() => { - if (!config || !config.FeatureFlags) { - dispatch(adminGetConfig()); - } - }, []); - - const daysUntilExpiration = useMemo(() => { - if (!subscription || !subscription.end_at || !subscription.cancel_at) { - return 0; - } - - return daysToExpiration(subscription); - }, [subscription]); - - const handleDismiss = (banner: string) => { - // We store the preference name with the current year as a string appended to the end, - // so that next renewal period we can show the banner again despite the user having dismissed it in the previous year - dispatch(savePreferences(currentUserId, [{ - category: Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, - name: `${banner}_${getCurrentYearAsString()}`, - user_id: currentUserId, - value: 'true', - }])); - }; - - const getBanner = useMemo(() => { - const defaultProps = { - showLinkAsButton: true, - isTallBanner: true, - icon: , - modalButtonText: formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'}), - modalButtonDefaultText: 'Renew', - message: <>, - onButtonClick: openPurchaseModal, - handleClose: () => { }, - showCloseButton: true, - }; - let bannerProps = { - ...defaultProps, - type: '', - }; - if (between(daysUntilExpiration, 31, 60)) { - if (hasDismissed60DayBanner) { - return null; - } - bannerProps = { - ...defaultProps, - message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.60', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}), - icon: (), - type: AnnouncementBarTypes.ANNOUNCEMENT, - handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_60_DAY), - }; - } else if (between(daysUntilExpiration, 8, 30)) { - if (hasDismissed30DayBanner) { - return null; - } - bannerProps = { - ...defaultProps, - message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.30', defaultMessage: 'Your annual subscription expires in {days} days. Please renew to avoid any disruption.'}, {days: daysUntilExpiration})}), - icon: (), - type: AnnouncementBarTypes.ADVISOR, - handleClose: () => handleDismiss(CloudBanners.ANNUAL_RENEWAL_30_DAY), - }; - } else if (between(daysUntilExpiration, 0, 7) && !isDelinquencySubscription()) { - // This banner is not dismissable - bannerProps = { - ...defaultProps, - message: (<>{formatMessage({id: 'cloud_annual_renewal.banner.message.7', defaultMessage: 'Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.'}, {days: daysUntilExpiration})}), - icon: (), - type: AnnouncementBarTypes.CRITICAL, - showCloseButton: false, - }; - } else { - // If none of the above, return null, so that a blank announcement bar isn't visible - return null; - } - - return ; - }, [daysUntilExpiration, hasDismissed60DayBanner, hasDismissed30DayBanner]); - - // Delinquent subscriptions will have a cancel_at time, but the banner is handled separately - if (!cloudAnnualRenewalsEnabled || !subscription || !subscription.cancel_at || subscription.is_free_trial === 'true' || subscription.will_renew === 'true' || isDelinquencySubscription() || !isAdmin || daysUntilExpiration > 60) { - return null; - } - - return ( - <> - {getBanner} - - ); -}; - -export default CloudAnnualRenewalAnnouncementBar; diff --git a/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx deleted file mode 100644 index 565aaddb1a..0000000000 --- a/webapp/channels/src/components/announcement_bar/cloud_delinquency/cloud_delinquency_announcement_bar.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {renderWithContext} from 'tests/react_testing_utils'; -import {CloudProducts} from 'utils/constants'; - -import CloudDelinquencyAnnouncementBar from './index'; - -describe('components/announcement_bar/cloud_delinquency', () => { - const now = new Date(); - const fiveDaysAgo = new Date(now.getTime() - (5 * 24 * 60 * 60 * 1000)).getTime() / 1000; - const fiveDaysFromNow = new Date(now.getTime() + (5 * 24 * 60 * 60 * 1000)).getTime() / 1000; - const initialState = { - views: { - announcementBar: { - announcementBarState: { - announcementBarCount: 1, - }, - }, - }, - entities: { - general: { - license: { - IsLicensed: 'true', - Cloud: 'true', - }, - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin'}, - }, - }, - cloud: { - subscription: { - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - delinquent_since: fiveDaysAgo, // may 17 2022 - cancel_at: fiveDaysFromNow, // may 17 2022 - }, - products: { - test_prod_1: { - id: 'test_prod_1', - sku: CloudProducts.STARTER, - price_per_seat: 0, - }, - test_prod_2: { - id: 'test_prod_2', - sku: CloudProducts.ENTERPRISE, - price_per_seat: 0, - }, - test_prod_3: { - id: 'test_prod_3', - sku: CloudProducts.PROFESSIONAL, - price_per_seat: 0, - }, - }, - }, - }, - }; - - it('Should not show banner when not delinquent', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - delinquent_since: null, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument(); - }); - - it('Should not show banner when no cancel_at time is set', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - cancel_at: null, - }; - - const {queryByText} = renderWithContext( - , - state, - ); - - expect(queryByText('Your annual subscription has expired')).not.toBeInTheDocument(); - }); - - it('Should show banner when user is not admin, but should not show CTA', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.users = { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'user'}, - }, - }; - - const {queryByText, getByText} = renderWithContext( - , - state, - ); - - expect(getByText('Your annual subscription has expired. Please contact your System Admin to keep this workspace')).toBeInTheDocument(); - expect(queryByText('Update billing now')).not.toBeInTheDocument(); - }); - - it('Should show banner and CTA when user is admin', () => { - const state = JSON.parse(JSON.stringify(initialState)); - - const {getByText} = renderWithContext( - , - state, - ); - - expect(getByText('Your annual subscription has expired. Please renew now to keep this workspace')).toBeInTheDocument(); - expect(getByText('Update billing now')).toBeInTheDocument(); - }); -}); diff --git a/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx deleted file mode 100644 index ef55415ca9..0000000000 --- a/webapp/channels/src/components/announcement_bar/cloud_delinquency/index.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription'; -import useGetSubscription from 'components/common/hooks/useGetSubscription'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; - -import { - AnnouncementBarTypes, TELEMETRY_CATEGORIES, -} from 'utils/constants'; - -import AnnouncementBar from '../default_announcement_bar'; - -const CloudDelinquencyAnnouncementBar = () => { - const subscription = useGetSubscription(); - const openPurchaseModal = useOpenCloudPurchaseModal({}); - const {isDelinquencySubscription} = useDelinquencySubscription(); - const {formatMessage} = useIntl(); - const isAdmin = useSelector(isCurrentUserSystemAdmin); - - if (!isDelinquencySubscription() || !subscription?.cancel_at) { - return null; - } - - let props = { - message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.message', defaultMessage: 'Your annual subscription has expired. Please renew now to keep this workspace'})}), - modalButtonText: formatMessage({id: 'cloud_delinquency.banner.buttonText', defaultMessage: 'Update billing now'}), - modalButtonDefaultText: 'Update billing now', - showLinkAsButton: true, - isTallBanner: true, - icon: , - showCTA: true, - onButtonClick: () => { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_update_billing'); - openPurchaseModal({ - trackingLocation: - 'cloud_delinquency_announcement_bar', - }); - }, - type: AnnouncementBarTypes.CRITICAL, - showCloseButton: false, - }; - - if (!isAdmin) { - props = { - ...props, - message: (<>{formatMessage({id: 'cloud_annual_renewal_delinquency.banner.end_user.message', defaultMessage: 'Your annual subscription has expired. Please contact your System Admin to keep this workspace'})}), - showCTA: false, - }; - } - - return ( - - ); -}; - -export default CloudDelinquencyAnnouncementBar; diff --git a/webapp/channels/src/components/announcement_bar/purchase_link/purchase_link.tsx b/webapp/channels/src/components/announcement_bar/purchase_link/purchase_link.tsx index 73e214e927..10b40b135a 100644 --- a/webapp/channels/src/components/announcement_bar/purchase_link/purchase_link.tsx +++ b/webapp/channels/src/components/announcement_bar/purchase_link/purchase_link.tsx @@ -2,22 +2,10 @@ // See LICENSE.txt for license information. import React from 'react'; -import {useSelector} from 'react-redux'; - -import {getConfig} from 'mattermost-redux/selectors/entities/admin'; import {trackEvent} from 'actions/telemetry_actions'; -import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup'; -import { - useControlAirGappedSelfHostedPurchaseModal, - useControlScreeningInProgressModal, -} from 'components/common/hooks/useControlModal'; -import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal'; -import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; - -import {CloudLinks, SelfHostedProducts} from 'utils/constants'; -import {findSelfHostedProductBySku} from 'utils/hosted_customer'; +import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import './purchase_link.scss'; @@ -27,35 +15,13 @@ export interface Props { } const PurchaseLink: React.FC = (props: Props) => { - const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal(); - const controlScreeningInProgressModal = useControlScreeningInProgressModal(); - const selfHostedSignupAvailable = useCanSelfHostedSignup(); - const [products, productsLoaded] = useGetSelfHostedProducts(); - const professionalProductId = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL)?.id || ''; - const controlSelfHostedPurchaseModal = useControlSelfHostedPurchaseModal({productId: professionalProductId}); - - const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; + const [openSalesLink] = useOpenSalesLink(); const handlePurchaseLinkClick = async (e: React.MouseEvent) => { e.preventDefault(); trackEvent('admin', props.eventID || 'in_trial_purchase_license'); - if (!isSelfHostedPurchaseEnabled) { - window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank'); - return; - } - if (!selfHostedSignupAvailable.ok) { - if (selfHostedSignupAvailable.screeningInProgress) { - controlScreeningInProgressModal.open(); - } else { - controlAirgappedModal.open(); - } - return; - } - - if (productsLoaded && professionalProductId) { - controlSelfHostedPurchaseModal.open(); - } + openSalesLink(); }; return ( diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts deleted file mode 100644 index 51a4162a2c..0000000000 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedExpansionModal.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useMemo} from 'react'; -import {useDispatch, useSelector} from 'react-redux'; - -import {HostedCustomerTypes} from 'mattermost-redux/action_types'; -import {Client4} from 'mattermost-redux/client'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {openModal} from 'actions/views/modals'; - -import PurchaseInProgressModal from 'components/purchase_in_progress_modal'; -import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; -import SelfHostedExpansionModal from 'components/self_hosted_purchases/self_hosted_expansion_modal'; - -import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; - -import {useControlModal} from './useControlModal'; -import type {ControlModal} from './useControlModal'; - -interface HookOptions{ - trackingLocation?: string; -} - -export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal { - const dispatch = useDispatch(); - const currentUser = useSelector(getCurrentUser); - const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION, - dialogType: SelfHostedExpansionModal, - }); - - return useMemo(() => { - return { - ...controlModal, - open: async () => { - const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true'; - - // check if user already has an open purchase modal in current browser. - if (purchaseInProgress) { - // User within the same browser session - // is already trying to purchase. Notify them of this - // and request the exit that purchase flow before attempting again. - dispatch(openModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, - dialogType: PurchaseInProgressModal, - dialogProps: { - purchaserEmail: currentUser.email, - storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, - }, - })); - return; - } - - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', { - callerInfo: options.trackingLocation, - }); - - try { - const result = await Client4.bootstrapSelfHostedSignup(); - - if (result.email !== currentUser.email) { - // Token already exists and was created by another admin. - // Notify user of this and do not allow them to try to expand concurrently. - dispatch(openModal({ - modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS, - dialogType: PurchaseInProgressModal, - dialogProps: { - purchaserEmail: result.email, - storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS, - }, - })); - return; - } - - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: result.progress, - }); - - controlModal.open(); - } catch (e) { - // eslint-disable-next-line no-console - console.error('error bootstrapping self hosted purchase modal', e); - } - }, - }; - }, [controlModal, options.trackingLocation]); -} diff --git a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts deleted file mode 100644 index eccd97d043..0000000000 --- a/webapp/channels/src/components/common/hooks/useControlSelfHostedPurchaseModal.ts +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useMemo} from 'react'; -import {useDispatch, useSelector} from 'react-redux'; - -import {HostedCustomerTypes} from 'mattermost-redux/action_types'; -import {Client4} from 'mattermost-redux/client'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {closeModal, openModal} from 'actions/views/modals'; -import {isModalOpen} from 'selectors/views/modals'; - -import PurchaseInProgressModal from 'components/purchase_in_progress_modal'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchases/constants'; -import SelfHostedPurchaseModal from 'components/self_hosted_purchases/self_hosted_purchase_modal'; - -import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import {useControlModal} from './useControlModal'; -import type {ControlModal} from './useControlModal'; - -interface HookOptions{ - onClick?: () => void; - productId: string; - trackingLocation?: string; -} - -export default function useControlSelfHostedPurchaseModal(options: HookOptions): ControlModal { - const dispatch = useDispatch(); - const currentUser = useSelector(getCurrentUser); - const controlModal = useControlModal({ - modalId: ModalIdentifiers.SELF_HOSTED_PURCHASE, - dialogType: SelfHostedPurchaseModal, - dialogProps: { - productId: options.productId, - }, - }); - const pricingModalOpen = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.PRICING_MODAL)); - const purchaseModalOpen = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.SELF_HOSTED_PURCHASE)); - const comparingPlansWhilePurchasing = pricingModalOpen && purchaseModalOpen; - - return useMemo(() => { - return { - ...controlModal, - open: async () => { - // check if purchase modal is already open - // i.e. they are allowed to compare plans from within the purchase modal - // if so, all we need to do is close the compare plans modal so that - // the purchase modal is available again. - if (comparingPlansWhilePurchasing) { - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - return; - } - const purchaseInProgress = localStorage.getItem(STORAGE_KEY_PURCHASE_IN_PROGRESS) === 'true'; - - // check if user already has an open purchase modal in current browser. - if (purchaseInProgress) { - // User within the same browser session - // is already trying to purchase. Notify them of this - // and request the exit that purchase flow before attempting again. - dispatch(openModal({ - modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS, - dialogType: PurchaseInProgressModal, - dialogProps: { - purchaserEmail: currentUser.email, - storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, - }, - })); - return; - } - - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, 'click_open_purchase_modal', { - callerInfo: options.trackingLocation, - }); - if (options.onClick) { - options.onClick(); - } - try { - const result = await Client4.bootstrapSelfHostedSignup(); - - if (result.email !== currentUser.email) { - // JWT already exists and was created by another admin, - // meaning another admin is already trying to purchase. - // Notify user of this and do not allow them to try to purchase concurrently. - dispatch(openModal({ - modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS, - dialogType: PurchaseInProgressModal, - dialogProps: { - purchaserEmail: result.email, - storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS, - }, - })); - return; - } - - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: result.progress, - }); - - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - controlModal.open(); - } catch (e) { - // eslint-disable-next-line no-console - console.error('error bootstrapping self hosted purchase modal', e); - } - }, - }; - }, [controlModal, options.productId, options.onClick, options.trackingLocation, comparingPlansWhilePurchasing]); -} diff --git a/webapp/channels/src/components/common/hooks/useOpenCloudPurchaseModal.ts b/webapp/channels/src/components/common/hooks/useOpenCloudPurchaseModal.ts deleted file mode 100644 index 8e89ed1cfe..0000000000 --- a/webapp/channels/src/components/common/hooks/useOpenCloudPurchaseModal.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useDispatch} from 'react-redux'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {openModal} from 'actions/views/modals'; - -import PurchaseModal from 'components/purchase_modal'; - -import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; - -interface OpenPurchaseModalOptions{ - onClick?: () => void; - trackingLocation?: string; - isDelinquencyModal?: boolean; -} -type TelemetryProps = Pick - -export default function useOpenCloudPurchaseModal(options: OpenPurchaseModalOptions) { - const dispatch = useDispatch(); - return (telemetryProps: TelemetryProps) => { - if (options.onClick) { - options.onClick(); - } - trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, options.isDelinquencyModal ? 'click_open_delinquency_modal' : 'click_open_purchase_modal', { - callerInfo: telemetryProps.trackingLocation, - }); - dispatch(openModal({ - modalId: ModalIdentifiers.CLOUD_PURCHASE, - dialogType: PurchaseModal, - dialogProps: { - callerCTA: telemetryProps.trackingLocation, - }, - })); - }; -} diff --git a/webapp/channels/src/components/custom_open_pricing_modal_post_renderer/index.tsx b/webapp/channels/src/components/custom_open_pricing_modal_post_renderer/index.tsx deleted file mode 100644 index b79c4b771b..0000000000 --- a/webapp/channels/src/components/custom_open_pricing_modal_post_renderer/index.tsx +++ /dev/null @@ -1,293 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect} from 'react'; -import {useIntl} from 'react-intl'; -import {useSelector, useDispatch} from 'react-redux'; - -import type {Post} from '@mattermost/types/posts'; - -import {getMissingProfilesByIds} from 'mattermost-redux/actions/users'; -import {getUsers} from 'mattermost-redux/selectors/entities/users'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {openModal} from 'actions/views/modals'; - -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import LearnMoreTrialModal from 'components/learn_more_trial_modal/learn_more_trial_modal'; -import Markdown from 'components/markdown'; - -import {ModalIdentifiers, MattermostFeatures} from 'utils/constants'; -import {mapFeatureIdToTranslation} from 'utils/notify_admin_utils'; - -const MinimumPlansForFeature = { - Professional: 'Professional plan', - Enterprise: 'Enterprise plan', -}; - -type FeatureRequest = { - user_id: string; - required_feature: string; - required_plan: string; - create_at: string; - trial: string; -} - -type RequestedFeature = Record - -type CustomPostProps = { - requested_features: RequestedFeature; - trial: boolean; -} - -const style = { - display: 'flex', - gap: '10px', - padding: '12px', - borderRadius: '4px', - border: '1px solid rgba(var(--center-channel-color-rgb), 0.16)', - width: 'max-content', - margin: '10px 0', -}; - -const btnStyle = { - background: 'var(--button-bg)', - color: 'var(--button-color)', - border: 'none', - borderRadius: '4px', - padding: '8px 20px', - fontWeight: 600, -}; - -const messageStyle = { - marginBottom: '16px', -}; - -export default function OpenPricingModalPost(props: {post: Post}) { - let allProfessional = true; - - const dispatch = useDispatch(); - const userProfiles = useSelector(getUsers); - - const openPurchaseModal = useOpenCloudPurchaseModal({}); - const {formatMessage} = useIntl(); - - const openPricingModal = useOpenPricingModal(); - - const getUserIdsForUsersThatRequestedFeature = (requests: FeatureRequest[]): string[] => requests.map((request: FeatureRequest) => request.user_id); - const postProps = props.post.props as Partial; - const requestFeatures = postProps?.requested_features; - const wasTrialRequest = postProps?.trial; - - useEffect(() => { - if (requestFeatures) { - for (const featureId of Object.keys(requestFeatures)) { - dispatch(getMissingProfilesByIds(getUserIdsForUsersThatRequestedFeature(requestFeatures[featureId]))); - } - } - }, [dispatch, requestFeatures]); - - const isDowngradeNotification = (featureId: string) => featureId === MattermostFeatures.UPGRADE_DOWNGRADED_WORKSPACE; - - const customMessageBody = []; - - const getUserNamesForUsersThatRequestedFeature = (requests: FeatureRequest[]): string[] => { - const unknownName = formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.unknown', defaultMessage: '@unknown'}); - const userNames = requests.map((req: FeatureRequest) => { - const username = userProfiles[req.user_id]?.username; - return username ? '@' + username : unknownName; - }); - - return userNames; - }; - - const renderUsersThatRequestedFeature = (requests: FeatureRequest[]) => { - if (requests.length >= 5) { - return formatMessage({ - id: 'postypes.custom_open_pricing_modal_post_renderer.members', - defaultMessage: '{members} members'}, - {members: requests.length}); - } - - let renderedUsers; - - const users = getUserNamesForUsersThatRequestedFeature(requests); - - if (users.length === 1) { - renderedUsers = users[0]; - } else { - const lastUser = users.splice(-1, 1)[0]; - users.push(formatMessage({id: 'postypes.custom_open_pricing_modal_post_renderer.and', defaultMessage: 'and'}) + ' ' + lastUser); - renderedUsers = users.join(', ').replace(/,([^,]*)$/, '$1'); - } - - return renderedUsers; - }; - - const markDownOptions = { - atSumOfMembersMentions: true, - atPlanMentions: true, - markdown: false, - }; - - const mapFeatureToPlan = (feature: string) => { - switch (feature) { - case MattermostFeatures.ALL_ENTERPRISE_FEATURES: - case MattermostFeatures.CUSTOM_USER_GROUPS: - allProfessional = false; - return MinimumPlansForFeature.Enterprise; - default: - return MinimumPlansForFeature.Professional; - } - }; - - if (requestFeatures) { - for (const featureId of Object.keys(requestFeatures)) { - let title = ( -
- - - {mapFeatureIdToTranslation(featureId, formatMessage)} - - - - - -
); - let subTitle = ( -
    -
  • - -
  • -
); - - if (isDowngradeNotification(featureId)) { - title = ( -
- - - {mapFeatureIdToTranslation(featureId, formatMessage)} - - -
); - subTitle = ( -
    -
  • - -
  • -
); - } - - const featureMessage = ( -
- {title} - {subTitle} -
- ); - - customMessageBody.push(featureMessage); - } - } - - const openLearnMoreTrialModal = () => { - dispatch(openModal({ - modalId: ModalIdentifiers.LEARN_MORE_TRIAL_MODAL, - dialogType: LearnMoreTrialModal, - dialogProps: { - launchedBy: 'pricing_modal', - }, - })); - }; - - const renderButtons = () => { - if (wasTrialRequest) { - return ( - <> - - - - ); - } - - if (allProfessional) { - return ( - <> - - - - ); - } - return ( - - ); - }; - - return ( -
-
- -
- {customMessageBody} -
-
- {renderButtons()} -
-
-
- ); -} diff --git a/webapp/channels/src/components/pricing_modal/card.tsx b/webapp/channels/src/components/pricing_modal/card.tsx index 0ea19c7e8c..39695995d1 100644 --- a/webapp/channels/src/components/pricing_modal/card.tsx +++ b/webapp/channels/src/components/pricing_modal/card.tsx @@ -57,7 +57,6 @@ type CardProps = { planAddonsInfo?: PlanAddonsInfo; planTrialDisclaimer?: JSX.Element; isCloud: boolean; - cloudFreeDeprecated: boolean; } type StyledProps = { @@ -117,28 +116,20 @@ export function BlankCard() { function Card(props: CardProps) { const {formatMessage} = useIntl(); const bottomClassName = classNames('bottom', { - bottom__round: props.cloudFreeDeprecated && props.isCloud, + bottom__round: props.isCloud, }); const contactSalesCTAClassName = classNames('contact_sales_cta', { - contact_sales_cta__reduced: props.cloudFreeDeprecated && props.isCloud, + contact_sales_cta__reduced: props.isCloud, }); - const planBriefingContentClassName = classNames('plan_briefing_content', { - plan_briefing_content__reduced: props.cloudFreeDeprecated, - }); + const planBriefingContentClassName = classNames('plan_briefing_content', 'plan_briefing_content__reduced'); - const planPriceRateSectionClassName = classNames('plan_price_rate_section', { - plan_price_rate_section__expanded: props.cloudFreeDeprecated, - }); + const planPriceRateSectionClassName = classNames('plan_price_rate_section', 'plan_price_rate_section__expanded'); - const planLimitsCtaClassName = classNames('plan_limits_cta', { - plan_limits_cta__expanded: props.cloudFreeDeprecated, - }); + const planLimitsCtaClassName = classNames('plan_limits_cta', 'plan_limits_cta__expanded'); - const buildingImgClassName = classNames('building_img', { - building_img__expanded: props.cloudFreeDeprecated, - }); + const buildingImgClassName = classNames('building_img', 'building_img__expanded'); return (
{props.planLabel} - {(!props.cloudFreeDeprecated || !props.isCloud) && ( + {!props.isCloud && ( {props.plan}

{props.planSummary}

{props.price ?

{props.price}

:
} - {props.cloudFreeDeprecated ? ({props.rate}) : ({props.rate})} + {props.rate}
@@ -188,7 +179,6 @@ function Card(props: CardProps) {
- {!props.cloudFreeDeprecated &&
} {props.planTrialDisclaimer}
{props.briefing.title} diff --git a/webapp/channels/src/components/pricing_modal/content.tsx b/webapp/channels/src/components/pricing_modal/content.tsx index 75286dfb63..6817c360e7 100644 --- a/webapp/channels/src/components/pricing_modal/content.tsx +++ b/webapp/channels/src/components/pricing_modal/content.tsx @@ -4,9 +4,7 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import type {Feedback} from '@mattermost/types/cloud'; +import {useSelector} from 'react-redux'; import { getCloudSubscription as selectCloudSubscription, @@ -16,34 +14,20 @@ import { import {deprecateCloudFree} from 'mattermost-redux/selectors/entities/preferences'; import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; -import {subscribeCloudSubscription} from 'actions/cloud'; import {trackEvent} from 'actions/telemetry_actions'; -import {closeModal, openModal} from 'actions/views/modals'; -import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn'; -import ErrorModal from 'components/cloud_subscribe_result_modal/error'; -import SuccessModal from 'components/cloud_subscribe_result_modal/success'; -import useGetLimits from 'components/common/hooks/useGetLimits'; import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import useOpenDowngradeModal from 'components/common/hooks/useOpenDowngradeModal'; -import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import {useOpenCloudZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; import PlanLabel from 'components/common/plan_label'; -import ExternalLink from 'components/external_link'; -import DowngradeFeedbackModal from 'components/feedback_modal/downgrade_feedback'; import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta'; import CheckMarkSvg from 'components/widgets/icons/check_mark_icon'; -import {CloudLinks, CloudProducts, LicenseSkus, ModalIdentifiers, MattermostFeatures, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; -import {fallbackStarterLimits, asGBString, hasSomeLimits} from 'utils/limits'; +import {CloudProducts, LicenseSkus, MattermostFeatures, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; import {findOnlyYearlyProducts, findProductBySku} from 'utils/products'; import Card, {BlankCard, ButtonCustomiserClasses} from './card'; import ContactSalesCTA from './contact_sales_cta'; import StartTrialCaution from './start_trial_caution'; -import StarterDisclaimerCTA from './starter_disclaimer_cta'; import './content.scss'; @@ -57,9 +41,6 @@ type ContentProps = { function Content(props: ContentProps) { const {formatMessage, formatNumber} = useIntl(); - const dispatch = useDispatch(); - const [limits] = useGetLimits(); - const openPricingModalBackAction = useOpenPricingModal(); const isAdmin = useSelector(isCurrentUserSystemAdmin); @@ -76,35 +57,16 @@ function Content(props: ContentProps) { const yearlyProfessionalProduct = findProductBySku(yearlyProducts, CloudProducts.PROFESSIONAL); const professionalPrice = formatNumber((yearlyProfessionalProduct?.price_per_seat || 0) / 12, {maximumFractionDigits: 2}); - const starterProduct = Object.values(products || {}).find(((product) => { - return product.sku === CloudProducts.STARTER; - })); - - const isStarter = currentProduct?.sku === CloudProducts.STARTER; const isProfessional = currentProduct?.sku === CloudProducts.PROFESSIONAL; const currentSubscriptionIsMonthlyProfessional = currentSubscriptionIsMonthly && isProfessional; - const isProfessionalAnnual = isProfessional && currentProduct?.recurring_interval === RecurringIntervals.YEAR; const isPreTrial = subscription?.trial_end_at === 0; let isPostTrial = false; - if ((subscription && subscription.trial_end_at > 0) && !isEnterpriseTrial && (isStarter || isEnterprise)) { + if ((subscription && subscription.trial_end_at > 0) && !isEnterpriseTrial && isEnterprise) { isPostTrial = true; } - const [notifyAdminBtnTextProfessional, notifyAdminOnProfessionalFeatures, professionalNotifyRequestStatus] = useNotifyAdmin({ - ctaText: formatMessage({id: 'pricing_modal.noitfy_cta.request', defaultMessage: 'Request admin to upgrade'}), - successText: ( - <> - - {formatMessage({id: 'pricing_modal.noitfy_cta.request_success', defaultMessage: 'Request sent'})} - ), - }, { - required_feature: MattermostFeatures.ALL_PROFESSIONAL_FEATURES, - required_plan: LicenseSkus.Professional, - trial_notification: false, - }); - const [notifyAdminBtnTextEnterprise, notifyAdminOnEnterpriseFeatures, enterpriseNotifyRequestStatus] = useNotifyAdmin({ ctaText: formatMessage({id: 'pricing_modal.noitfy_cta.request', defaultMessage: 'Request admin to upgrade'}), successText: ( @@ -123,130 +85,24 @@ function Content(props: ContentProps) { return formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}); } - if (cloudFreeDeprecated) { - return formatMessage({id: 'pricing_modal.btn.purchase', defaultMessage: 'Purchase'}); - } - - return formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}); + return formatMessage({id: 'pricing_modal.btn.purchase', defaultMessage: 'Purchase'}); }; - const freeTierText = (!isStarter && !currentSubscriptionIsMonthly) ? formatMessage({id: 'pricing_modal.btn.contactSupport', defaultMessage: 'Contact Support'}) : formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'}); const adminProfessionalTierText = getAdminProfessionalBtnText(); const [openContactSales] = useOpenSalesLink(); - const [openContactSupport] = useOpenCloudZendeskSupportForm('Workspace downgrade', ''); - const openCloudPurchaseModal = useOpenCloudPurchaseModal({}); - const openCloudDelinquencyModal = useOpenCloudPurchaseModal({ - isDelinquencyModal: true, - }); - const openDowngradeModal = useOpenDowngradeModal(); - const openPurchaseModal = (callerInfo: string) => { - props.onHide(); - const telemetryInfo = props.callerCTA + ' > ' + callerInfo; - if (subscription?.delinquent_since) { - openCloudDelinquencyModal({trackingLocation: telemetryInfo}); - } - openCloudPurchaseModal({trackingLocation: telemetryInfo}); - }; - - const closePricingModal = () => { - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - }; - - const handleClickDowngrade = (downgradeFeedback?: Feedback) => { - downgrade('click_pricing_modal_free_card_downgrade_button', downgradeFeedback); - }; - - const downgrade = async (callerInfo: string, downgradeFeedback?: Feedback) => { - if (!starterProduct) { - return; - } - - const telemetryInfo = props.callerCTA + ' > ' + callerInfo; - openDowngradeModal({trackingLocation: telemetryInfo}); - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - - const result = await dispatch(subscribeCloudSubscription(starterProduct.id, undefined, 0, downgradeFeedback)); - - if (result.error) { - dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL)); - dispatch(closeModal(ModalIdentifiers.CLOUD_DOWNGRADE_CHOOSE_TEAM)); - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - - dispatch( - openModal({ - modalId: ModalIdentifiers.ERROR_MODAL, - dialogType: ErrorModal, - dialogProps: { - backButtonAction: openPricingModalBackAction, - }, - }), - ); - return; - } - - dispatch(closeModal(ModalIdentifiers.DOWNGRADE_MODAL)); - dispatch(closeModal(ModalIdentifiers.CLOUD_DOWNGRADE_CHOOSE_TEAM)); - dispatch( - openModal({ - modalId: ModalIdentifiers.SUCCESS_MODAL, - dialogType: SuccessModal, - }), - ); - - props.onHide(); - }; - - const hasLimits = hasSomeLimits(limits); - - const starterBriefing = [ - formatMessage({id: 'pricing_modal.briefing.free.recentMessageBoards', defaultMessage: 'Access to {messages} most recent messages'}, {messages: formatNumber(fallbackStarterLimits.messages.history)}), - formatMessage({id: 'pricing_modal.briefing.storageStarter', defaultMessage: '{storage} file storage limit'}, {storage: asGBString(fallbackStarterLimits.files.totalStorage, formatNumber)}), - formatMessage({id: 'pricing_modal.briefing.free.noLimitBoards', defaultMessage: 'Unlimited board cards'}), - formatMessage({id: 'pricing_modal.briefing.free.oneTeamPerWorkspace', defaultMessage: 'One team per workspace'}), - formatMessage({id: 'pricing_modal.briefing.free.gitLabGitHubGSuite', defaultMessage: 'GitLab, GitHub, and GSuite SSO'}), - formatMessage({id: 'pricing_modal.extra_briefing.cloud.free.calls', defaultMessage: 'Group calls of up to 8 people, 1:1 calls, and screen share'}), - ]; - - const legacyStarterBriefing = [ - formatMessage({id: 'admin.billing.subscription.planDetails.features.groupAndOneToOneMessaging', defaultMessage: 'Group and one-to-one messaging, file sharing, and search'}), - formatMessage({id: 'admin.billing.subscription.planDetails.features.incidentCollaboration', defaultMessage: 'Incident collaboration'}), - formatMessage({id: 'admin.billing.subscription.planDetails.features.unlimittedUsersAndMessagingHistory', defaultMessage: 'Unlimited users & message history'}), - formatMessage({id: 'admin.billing.subscription.planDetails.features.mfa', defaultMessage: 'Multi-Factor Authentication (MFA)'}), - ]; const professionalBtnDetails = () => { - if (isAdmin) { - return { - action: () => openPurchaseModal('click_pricing_modal_professional_card_upgrade_button'), - text: adminProfessionalTierText, - disabled: isProfessionalAnnual || (isEnterprise && !isEnterpriseTrial), - customClass: (cloudFreeDeprecated || isPostTrial) ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active, - }; - } - - let trialBtnClass = ButtonCustomiserClasses.special; - if (isPostTrial) { - trialBtnClass = ButtonCustomiserClasses.special; - } else { - trialBtnClass = ButtonCustomiserClasses.active; - } - - if (professionalNotifyRequestStatus === NotifyStatus.Success) { - trialBtnClass = ButtonCustomiserClasses.green; - } return { - action: (e: React.MouseEvent) => { - notifyAdminOnProfessionalFeatures(e, 'professional_plan_pricing_modal_card'); - }, - text: notifyAdminBtnTextProfessional, - disabled: isProfessional || (isEnterprise && !isEnterpriseTrial), - customClass: trialBtnClass, + action: () => { }, + text: adminProfessionalTierText, + disabled: true, + customClass: (isPostTrial) ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active, }; }; const enterpriseBtnDetails = () => { - if (cloudFreeDeprecated || (isPostTrial && isAdmin)) { + if (isAdmin) { return { action: () => { trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales'); @@ -257,48 +113,28 @@ function Content(props: ContentProps) { }; } - if (!isAdmin) { - let trialBtnClass = ButtonCustomiserClasses.special; - if (isPostTrial) { - trialBtnClass = ButtonCustomiserClasses.special; - } else { - trialBtnClass = ButtonCustomiserClasses.active; - } - - if (enterpriseNotifyRequestStatus === NotifyStatus.Success) { - trialBtnClass = ButtonCustomiserClasses.green; - } - return { - action: (e: React.MouseEvent) => { - notifyAdminOnEnterpriseFeatures(e, 'enterprise_plan_pricing_modal_card'); - }, - text: notifyAdminBtnTextEnterprise, - disabled: isEnterprise, - customClass: trialBtnClass, - }; + let trialBtnClass = ButtonCustomiserClasses.special; + if (isPostTrial) { + trialBtnClass = ButtonCustomiserClasses.special; + } else { + trialBtnClass = ButtonCustomiserClasses.active; } - return undefined; - }; - - const enterpriseCustomBtnDetails = () => { - if (!isPostTrial && isAdmin && !cloudFreeDeprecated) { - return ( - - ); + if (enterpriseNotifyRequestStatus === NotifyStatus.Success) { + trialBtnClass = ButtonCustomiserClasses.green; } - - return undefined; + return { + action: (e: React.MouseEvent) => { + notifyAdminOnEnterpriseFeatures(e, 'enterprise_plan_pricing_modal_card'); + }, + text: notifyAdminBtnTextEnterprise, + disabled: isEnterprise, + customClass: trialBtnClass, + }; }; const professionalPlanLabelText = () => { - if (isProfessionalAnnual || !isAdmin) { + if (isProfessional || !isAdmin) { return formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'}); } @@ -325,81 +161,11 @@ function Content(props: ContentProps) { - {!cloudFreeDeprecated && ( -
-
-
- {formatMessage({id: 'pricing_modal.lookingToSelfHost', defaultMessage: 'Looking to self-host?'})} - - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'click_looking_to_self_host', - ) - } - href={CloudLinks.DEPLOYMENT_OPTIONS} - location='pricing_modal_content' - >{formatMessage({id: 'pricing_modal.reviewDeploymentOptions', defaultMessage: 'Review deployment options'})} -
-
-
- )} -
- {!cloudFreeDeprecated && ( - } - />) : undefined} - planExtraInformation={} - buttonDetails={{ - action: () => { - if (!isStarter && !currentSubscriptionIsMonthly) { - openContactSupport(); - return; - } - - if (!starterProduct) { - return; - } - dispatch( - openModal({ - modalId: ModalIdentifiers.FEEDBACK, - dialogType: DowngradeFeedbackModal, - dialogProps: { - onSubmit: handleClickDowngrade, - }, - }), - ); - }, - text: freeTierText, - disabled: isStarter || isEnterprise || !isAdmin, - customClass: (isStarter || isEnterprise || !isAdmin) ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.secondary, - }} - briefing={{ - title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}), - items: hasLimits ? starterBriefing : legacyStarterBriefing, - }} - /> - ) - - } - + {isProfessional && , b: (chunks: React.ReactNode | React.ReactNodeArray) => ( - { - cloudFreeDeprecated ? chunks : ({chunks}) - } + {chunks} ), })} isCloud={true} - cloudFreeDeprecated={cloudFreeDeprecated} planLabel={isProfessional ? ( ) : undefined} buttonDetails={professionalBtnDetails()} briefing={{ - title: cloudFreeDeprecated ? formatMessage({id: 'pricing_modal.briefing.title_no_limit', defaultMessage: 'No limits on your team’s usage'}) : formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}), + title: formatMessage({id: 'pricing_modal.briefing.title_no_limit', defaultMessage: 'No limits on your team’s usage'}), items: [ formatMessage({id: 'pricing_modal.briefing.professional.messageBoardsIntegrationsCalls', defaultMessage: 'Unlimited access to messages and files'}), formatMessage({id: 'pricing_modal.briefing.professional.unLimitedTeams', defaultMessage: 'Unlimited teams'}), @@ -439,7 +202,7 @@ function Content(props: ContentProps) { formatMessage({id: 'pricing_modal.extra_briefing.professional.guestAccess', defaultMessage: 'Guest access with MFA enforcement'}), ], }} - /> + />} ) : undefined} buttonDetails={enterpriseBtnDetails()} - customButtonDetails={enterpriseCustomBtnDetails()} planTrialDisclaimer={(!isPostTrial && isAdmin && !cloudFreeDeprecated) ? : undefined} contactSalesCTA={(isPostTrial || !isAdmin || cloudFreeDeprecated) ? undefined : } briefing={{ @@ -486,7 +247,7 @@ function Content(props: ContentProps) { ], }} /> - {cloudFreeDeprecated && } +
diff --git a/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx b/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx index cf5e7dcd7c..cfbc752cae 100644 --- a/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx +++ b/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx @@ -10,29 +10,20 @@ import type {GlobalState} from '@mattermost/types/store'; import {getPrevTrialLicense} from 'mattermost-redux/actions/admin'; import {Client4} from 'mattermost-redux/client'; -import {getConfig} from 'mattermost-redux/selectors/entities/admin'; import {getLicense} from 'mattermost-redux/selectors/entities/general'; import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; import {trackEvent} from 'actions/telemetry_actions'; import {closeModal} from 'actions/views/modals'; -import useCanSelfHostedSignup from 'components/common/hooks/useCanSelfHostedSignup'; -import { - useControlAirGappedSelfHostedPurchaseModal, - useControlScreeningInProgressModal, -} from 'components/common/hooks/useControlModal'; -import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal'; import useFetchAdminConfig from 'components/common/hooks/useFetchAdminConfig'; -import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; import PlanLabel from 'components/common/plan_label'; import ExternalLink from 'components/external_link'; import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn'; import CheckMarkSvg from 'components/widgets/icons/check_mark_icon'; -import {CloudLinks, ModalIdentifiers, SelfHostedProducts, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; -import {findSelfHostedProductBySku} from 'utils/hosted_customer'; +import {CloudLinks, ModalIdentifiers, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; import Card, {ButtonCustomiserClasses} from './card'; import ContactSalesCTA from './contact_sales_cta'; @@ -51,13 +42,7 @@ function SelfHostedContent(props: ContentProps) { useFetchAdminConfig(); const {formatMessage} = useIntl(); const dispatch = useDispatch(); - const signupAvailable = useCanSelfHostedSignup(); - - const [products, productsLoaded] = useGetSelfHostedProducts(); - const professionalProductId = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL)?.id || ''; - - const controlSelfHostedPurchaseModal = useControlSelfHostedPurchaseModal({productId: professionalProductId}); - const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase; + const [openSalesLink] = useOpenSalesLink(); useEffect(() => { dispatch(getPrevTrialLicense()); @@ -91,8 +76,6 @@ function SelfHostedContent(props: ContentProps) { const isPostSelfHostedEnterpriseTrial = prevSelfHostedTrialLicense.IsLicensed === 'true'; const [openContactSales] = useOpenSalesLink(); - const controlScreeningInProgressModal = useControlScreeningInProgressModal(); - const controlAirgappedModal = useControlAirGappedSelfHostedPurchaseModal(); const closePricingModal = () => { dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); @@ -188,7 +171,6 @@ function SelfHostedContent(props: ContentProps) { planSummary={formatMessage({id: 'pricing_modal.planSummary.free', defaultMessage: 'Increased productivity for small teams'})} price='$0' isCloud={false} - cloudFreeDeprecated={false} planLabel={ isStarter ? ( { trackEvent('self_hosted_pricing', 'click_upgrade_button'); - - if (!isSelfHostedPurchaseEnabled) { - window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank'); - return; - } - - if (!signupAvailable.ok) { - if (signupAvailable.cwsContacted && !signupAvailable.cwsServiceOn) { - window.open(CloudLinks.SELF_HOSTED_SIGNUP, '_blank'); - return; - } - if (signupAvailable.screeningInProgress) { - controlScreeningInProgressModal.open(); - } else { - controlAirgappedModal.open(); - } - closePricingModal(); - return; - } - - const professionalProduct = findSelfHostedProductBySku(products, SelfHostedProducts.PROFESSIONAL); - if (productsLoaded && professionalProduct) { - // let the control modal close this modal - // we need to wait for its timing, - // it doesn't return a signal, - // and we can not do this in a useEffect hook - // at the top of this file easily because - // sometimes we want both modals to be open if user is in purchase - // modal and wants to compare plans - controlSelfHostedPurchaseModal.open(); - } + openSalesLink(); }, text: formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}), disabled: !isAdmin || isProfessional, @@ -285,7 +236,6 @@ function SelfHostedContent(props: ContentProps) { plan='Enterprise' planSummary={formatMessage({id: 'pricing_modal.planSummary.enterprise', defaultMessage: 'Administration, security, and compliance for large teams'})} isCloud={false} - cloudFreeDeprecated={false} planLabel={ isEnterprise ? ( { - const original = jest.requireActual('mattermost-redux/client'); - return { - __esModule: true, - ...original, - Client4: { - ...original, - bootstrapSelfHostedSignup: jest.fn(), - }, - }; -}); - -const initialState: DeepPartial = { - entities: { - preferences: { - myPreferences: { - theme: {}, - }, - }, - users: { - currentUserId: 'adminUserId', - profiles: { - adminUserId: TH.getUserMock({ - id: 'adminUserId', - username: 'UserAdmin', - roles: 'admin', - email: 'admin@example.com', - }), - otherUserId: TH.getUserMock({ - id: 'otherUserId', - username: 'UserOther', - roles: '', - email: 'other-user@example.com', - }), - }, - }, - }, -}; - -describe('PurchaseInProgressModal', () => { - it('when purchaser and user emails are different, user is instructed to wait', () => { - const stateOverride: DeepPartial = JSON.parse(JSON.stringify(initialState)); - stateOverride.entities!.users!.currentUserId = 'otherUserId'; - renderWithContext( -
- -
, stateOverride, - ); - - screen.getByText('@UserAdmin is currently attempting to purchase a paid license.'); - }); - - it('when purchaser and user emails are same, allows user to reset purchase flow', () => { - renderWithContext( -
- -
, initialState, - ); - - expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled(); - screen.getByText('Reset purchase flow').click(); - expect(Client4.bootstrapSelfHostedSignup).toHaveBeenCalled(); - }); -}); diff --git a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx b/webapp/channels/src/components/purchase_in_progress_modal/index.tsx deleted file mode 100644 index c2e7abcfd8..0000000000 --- a/webapp/channels/src/components/purchase_in_progress_modal/index.tsx +++ /dev/null @@ -1,104 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import type {GenericModalProps} from '@mattermost/components'; -import {GenericModal} from '@mattermost/components'; -import type {GlobalState} from '@mattermost/types/store'; - -import {Client4} from 'mattermost-redux/client'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; -import {getUserByEmail} from 'mattermost-redux/selectors/entities/users'; - -import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal'; -import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; - -import './index.scss'; - -interface Props { - purchaserEmail: string; - storageKey: string; -} - -export default function PurchaseInProgressModal(props: Props) { - const {close} = useControlPurchaseInProgressModal(); - const currentUser = useSelector(getCurrentUser); - const purchaserUser = useSelector((state: GlobalState) => getUserByEmail(state, props.purchaserEmail)); - const header = ( - - ); - - const sameUserAlreadyPurchasing = props.purchaserEmail === currentUser.email; - let username = '@' + purchaserUser.username; - if (purchaserUser.first_name && purchaserUser.last_name) { - username = purchaserUser.first_name + ' ' + purchaserUser.last_name; - } - let description = ( - - ); - let actionToTake; - const genericModalProps: Partial = {}; - if (sameUserAlreadyPurchasing) { - description = ( - - ); - actionToTake = ( - - ); - - genericModalProps.handleConfirm = () => { - localStorage.removeItem(props.storageKey); - Client4.bootstrapSelfHostedSignup(true); - close(); - }; - genericModalProps.confirmButtonText = ( - - ); - } - return ( - -
- -
- {description} -
- {actionToTake && -
- {actionToTake} -
- } -
-
- ); -} diff --git a/webapp/channels/src/components/purchase_modal/delinquency_card.tsx b/webapp/channels/src/components/purchase_modal/delinquency_card.tsx deleted file mode 100644 index 09a7a6a33c..0000000000 --- a/webapp/channels/src/components/purchase_modal/delinquency_card.tsx +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants'; - -import type {ButtonDetails} from './purchase_modal'; - -type DelinquencyCardProps = { - topColor: string; - price: string; - buttonDetails: ButtonDetails; - onViewBreakdownClick: () => void; - isCloudDelinquencyGreaterThan90Days: boolean; - users: number; - cost: number; -}; - -export default function DelinquencyCard(props: DelinquencyCardProps) { - const handleSeeHowBillingWorksClick = ( - e: React.MouseEvent, - ) => { - e.preventDefault(); - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_ADMIN, - 'click_see_how_billing_works', - ); - window.open(CloudLinks.DELINQUENCY_DOCS, '_blank'); - }; - - const seeHowBillingWorks = ( - - - - ); - - return ( -
-
-
-
-
-
- - {':'} -
-
{props.price}
-
- -
-
-
-
- -
-
- {Boolean(!props.isCloudDelinquencyGreaterThan90Days) && ( - - )} - {Boolean(props.isCloudDelinquencyGreaterThan90Days) && ( - - )} -
-
-
- ); -} - diff --git a/webapp/channels/src/components/purchase_modal/icon_message.scss b/webapp/channels/src/components/purchase_modal/icon_message.scss index e16c043b75..ddda9c2853 100644 --- a/webapp/channels/src/components/purchase_modal/icon_message.scss +++ b/webapp/channels/src/components/purchase_modal/icon_message.scss @@ -107,4 +107,4 @@ } } } -} +} \ No newline at end of file diff --git a/webapp/channels/src/components/purchase_modal/index.ts b/webapp/channels/src/components/purchase_modal/index.ts deleted file mode 100644 index f44b2d5b74..0000000000 --- a/webapp/channels/src/components/purchase_modal/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import {getCloudProducts, getCloudSubscription, getInvoices} from 'mattermost-redux/actions/cloud'; -import {getClientConfig} from 'mattermost-redux/actions/general'; -import {getAdminAnalytics, getConfig} from 'mattermost-redux/selectors/entities/admin'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; - -import {completeStripeAddPaymentMethod, subscribeCloudSubscription} from 'actions/cloud'; -import {closeModal, openModal} from 'actions/views/modals'; -import {getCloudDelinquentInvoices, isCloudDelinquencyGreaterThan90Days, isCwsMockMode} from 'selectors/cloud'; -import {isModalOpen} from 'selectors/views/modals'; - -import {makeAsyncComponent} from 'components/async_load'; -import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; -import {getStripePublicKey} from 'components/payment_form/stripe'; - -import {daysToExpiration} from 'utils/cloud_utils'; -import {ModalIdentifiers} from 'utils/constants'; -import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales'; -import {findOnlyYearlyProducts} from 'utils/products'; - -import type {GlobalState} from 'types/store'; - -const PurchaseModal = makeAsyncComponent('PurchaseModal', React.lazy(() => import('./purchase_modal'))); - -function mapStateToProps(state: GlobalState) { - const subscription = state.entities.cloud.subscription; - - const isDelinquencyModal = Boolean(state.entities.cloud.subscription?.delinquent_since); - const isRenewalModal = daysToExpiration(state.entities.cloud.subscription) <= 60 && state.entities.cloud.subscription?.is_free_trial === 'false' && !isDelinquencyModal && getConfig(state).FeatureFlags?.CloudAnnualRenewals; - const products = state.entities.cloud!.products; - const yearlyProducts = findOnlyYearlyProducts(products || {}); - - const customer = state.entities.cloud.customer; - const customerEmail = customer?.email || ''; - const firstName = customer?.contact_first_name || ''; - const lastName = customer?.contact_last_name || ''; - const companyName = customer?.name || ''; - const contactSalesLink = getCloudContactSalesLink(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud'); - const contactSupportLink = getCloudSupportLink(customerEmail, 'Cloud purchase', '', window.location.host); - const stripePublicKey = getStripePublicKey(state); - - return { - show: isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE), - products, - yearlyProducts, - cwsMockMode: isCwsMockMode(state), - contactSupportLink, - invoices: getCloudDelinquentInvoices(state), - isCloudDelinquencyGreaterThan90Days: isCloudDelinquencyGreaterThan90Days(state), - isFreeTrial: subscription?.is_free_trial === 'true', - isComplianceBlocked: subscription?.compliance_blocked === 'true', - contactSalesLink, - productId: subscription?.product_id, - customer, - currentTeam: getCurrentTeam(state), - theme: getTheme(state), - isDelinquencyModal, - isRenewalModal, - usersCount: Number(getAdminAnalytics(state)!.TOTAL_USERS) || 1, - stripePublicKey, - subscription, - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators( - { - closeModal: () => closeModal(ModalIdentifiers.CLOUD_PURCHASE), - openModal, - getCloudProducts, - completeStripeAddPaymentMethod, - subscribeCloudSubscription, - getClientConfig, - getInvoices, - getCloudSubscription, - }, - dispatch, - ), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(withGetCloudSubscription(PurchaseModal)); diff --git a/webapp/channels/src/components/purchase_modal/process_payment.css b/webapp/channels/src/components/purchase_modal/process_payment.css deleted file mode 100644 index 68632e43ac..0000000000 --- a/webapp/channels/src/components/purchase_modal/process_payment.css +++ /dev/null @@ -1,19 +0,0 @@ -.ProcessPayment-progress { - width: 387px; - height: 4px; - border-radius: 4px; - margin: 0 auto; - margin-top: 60px; - background: rgb(34, 64, 109, 0.12); -} - -.ProcessPayment-progress-fill { - height: 100%; - border-radius: 4px; - background: #0058cc; -} - -.ProcessPayment-body { - overflow-x: hidden; - overflow-y: hidden; -} diff --git a/webapp/channels/src/components/purchase_modal/process_payment_setup.tsx b/webapp/channels/src/components/purchase_modal/process_payment_setup.tsx deleted file mode 100644 index e0396864f4..0000000000 --- a/webapp/channels/src/components/purchase_modal/process_payment_setup.tsx +++ /dev/null @@ -1,420 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {Stripe} from '@stripe/stripe-js'; -import React from 'react'; -import {FormattedMessage, injectIntl} from 'react-intl'; -import type {IntlShape} from 'react-intl'; -import {withRouter} from 'react-router-dom'; -import type {RouteComponentProps} from 'react-router-dom'; - -import type {Address, CloudCustomerPatch, Feedback, Product} from '@mattermost/types/cloud'; -import type {Team} from '@mattermost/types/teams'; - -import type {ActionResult} from 'mattermost-redux/types/actions'; - -import {pageVisited, trackEvent} from 'actions/telemetry_actions'; - -import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg'; -import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; -import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; -import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; - -import {RecurringIntervals, TELEMETRY_CATEGORIES} from 'utils/constants'; -import {t} from 'utils/i18n'; -import {getNextBillingDate} from 'utils/utils'; - -import type {BillingDetails} from 'types/cloud/sku'; - -import IconMessage from './icon_message'; - -import './process_payment.css'; - -type ComplianceError = { - error: string; - status: number; -} - -type Props = RouteComponentProps & { - billingDetails: BillingDetails | null; - shippingAddress: Address | null; - stripe: Promise; - cwsMockMode: boolean; - contactSupportLink: string; - currentTeam: Team; - addPaymentMethod: ( - stripe: Stripe, - billingDetails: BillingDetails, - cwsMockMode: boolean - ) => Promise; - subscribeCloudSubscription: - | ((productId: string, shippingAddress: Address, seats?: number, downgradeFeedback?: Feedback, customerPatch?: CloudCustomerPatch) => Promise>) - | null; - onBack: () => void; - onClose: () => void; - selectedProduct?: Product | null | undefined; - currentProduct?: Product | null | undefined; - isProratedPayment?: boolean; - isUpgradeFromTrial: boolean; - setIsUpgradeFromTrialToFalse: () => void; - telemetryProps?: { callerInfo: string }; - onSuccess?: () => void; - intl: IntlShape; - usersCount: number; - isSwitchingToAnnual: boolean; -}; - -type State = { - progress: number; - error: boolean; - state: ProcessState; -} - -enum ProcessState { - PROCESSING = 0, - SUCCESS, - FAILED, - FAILED_COMPLIANCE_SCREEN, -} - -const MIN_PROCESSING_MILLISECONDS = 5000; -const MAX_FAKE_PROGRESS = 95; - -class ProcessPaymentSetup extends React.PureComponent { - intervalId: NodeJS.Timeout; - - public constructor(props: Props) { - super(props); - - this.intervalId = {} as NodeJS.Timeout; - - this.state = { - progress: 0, - error: false, - state: ProcessState.PROCESSING, - }; - } - - public componentDidMount() { - this.savePaymentMethod(); - - this.intervalId = setInterval(this.updateProgress, MIN_PROCESSING_MILLISECONDS / MAX_FAKE_PROGRESS); - } - - public componentWillUnmount() { - clearInterval(this.intervalId); - } - - private updateProgress = () => { - let {progress} = this.state; - - if (progress >= MAX_FAKE_PROGRESS) { - clearInterval(this.intervalId); - return; - } - - progress += 1; - this.setState({progress: progress > MAX_FAKE_PROGRESS ? MAX_FAKE_PROGRESS : progress}); - }; - - private savePaymentMethod = async () => { - const start = new Date(); - const { - stripe, - addPaymentMethod, - billingDetails, - cwsMockMode, - subscribeCloudSubscription, - } = this.props; - const success = await addPaymentMethod((await stripe)!, billingDetails!, cwsMockMode); - - if (typeof success !== 'boolean' || !success) { - trackEvent('cloud_admin', 'complete_payment_failed', { - callerInfo: this.props.telemetryProps?.callerInfo, - }); - this.setState({ - error: true, - state: ProcessState.FAILED}); - return; - } - - if (subscribeCloudSubscription) { - const customerPatch = { - name: billingDetails?.company_name, - } as CloudCustomerPatch; - const result = await subscribeCloudSubscription(this.props.selectedProduct?.id as string, this.props.shippingAddress as Address, this.props.usersCount, undefined, customerPatch); - - // the action subscribeCloudSubscription returns a true boolean when successful and an error when it fails - if (result.error) { - trackEvent('cloud_admin', 'complete_payment_failed_compliance_screen', { - callerInfo: this.props.telemetryProps?.callerInfo, - }); - if (result.error.status === 422) { - this.setState({ - error: true, - state: ProcessState.FAILED_COMPLIANCE_SCREEN, - }); - return; - } - trackEvent('cloud_admin', 'complete_payment_failed', { - callerInfo: this.props.telemetryProps?.callerInfo, - }); - this.setState({ - error: true, - state: ProcessState.FAILED}); - return; - } - } - - const end = new Date(); - const millisecondsElapsed = end.valueOf() - start.valueOf(); - if (millisecondsElapsed < MIN_PROCESSING_MILLISECONDS) { - setTimeout(this.completePayment, MIN_PROCESSING_MILLISECONDS - millisecondsElapsed); - return; - } - - this.completePayment(); - }; - - private completePayment = () => { - clearInterval(this.intervalId); - trackEvent('cloud_admin', 'complete_payment_success', { - callerInfo: this.props.telemetryProps?.callerInfo, - }); - pageVisited( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'pageview_payment_success', - ); - this.setState({state: ProcessState.SUCCESS, progress: 100}); - }; - - private handleGoBack = () => { - clearInterval(this.intervalId); - this.setState({ - progress: 0, - error: false, - state: ProcessState.PROCESSING, - }); - this.props.onBack(); - }; - - private successPage = () => { - const {error} = this.state; - const formattedBtnText = ( - - ); - if (this.props.isProratedPayment) { - const formattedTitle = ( - - ); - const formattedSubtitle = ( - - ); - return ( - <> - - } - formattedButtonText={formattedBtnText} - buttonHandler={this.props.onClose} - className={'success'} - /> - - ); - } else if (this.props.isSwitchingToAnnual) { - const formattedTitle = ( - - ); - return ( - <> - - } - formattedButtonText={formattedBtnText} - buttonHandler={this.props.onClose} - tertiaryBtnText={t('admin.billing.subscription.viewBilling')} - tertiaryButtonHandler={() => { - this.props.onClose(); - this.props.history.push('/admin_console/billing/subscription'); - }} - className={'success'} - /> - - ); - } - const productName = this.props.selectedProduct?.name; - const title = ( - - ); - - let handleClose = () => { - this.props.onClose(); - }; - - if (typeof this.props.onSuccess === 'function') { - this.props.onSuccess(); - } - - // if is the first purchase, show a different success purchasing title - if (this.props.isUpgradeFromTrial) { - handleClose = () => { - // set the property isUpgrading to false onClose since we can not use directly isFreeTrial because of component rerendering - this.props.setIsUpgradeFromTrialToFalse(); - this.props.onClose(); - }; - } - - const formattedSubtitle = this.props.selectedProduct?.recurring_interval === RecurringIntervals.YEAR ? ( - - ) : ( - - ); - return ( - - } - formattedButtonText={formattedBtnText} - buttonHandler={handleClose} - className={'success'} - tertiaryBtnText={t('admin.billing.subscription.viewBilling')} - tertiaryButtonHandler={() => { - this.props.onClose(); - this.props.history.push('/admin_console/billing/subscription'); - }} - /> - ); - }; - - public render() { - const {state, progress, error} = this.state; - - const progressBar: JSX.Element | null = ( -
-
-
- ); - - switch (state) { - case ProcessState.PROCESSING: - return ( - - } - footer={progressBar} - className={'processing'} - /> - ); - case ProcessState.SUCCESS: - return this.successPage(); - case ProcessState.FAILED_COMPLIANCE_SCREEN: - return ( - - } - error={error} - buttonText={t( - 'admin.billing.subscription.complianceScreenFailed.button', - )} - buttonHandler={() => this.props.onClose()} - linkText={t( - 'admin.billing.subscription.privateCloudCard.contactSupport', - )} - linkURL={this.props.contactSupportLink} - className={'failed'} - /> - ); - case ProcessState.FAILED: - return ( - - } - error={error} - buttonText={t('admin.billing.subscription.goBackTryAgain')} - buttonHandler={this.handleGoBack} - linkText={t('admin.billing.subscription.privateCloudCard.contactSupport')} - linkURL={this.props.contactSupportLink} - className={'failed'} - /> - ); - default: - return null; - } - } -} - -export default injectIntl(withRouter(ProcessPaymentSetup)); diff --git a/webapp/channels/src/components/purchase_modal/purchase.scss b/webapp/channels/src/components/purchase_modal/purchase.scss deleted file mode 100644 index 044bcfe3e3..0000000000 --- a/webapp/channels/src/components/purchase_modal/purchase.scss +++ /dev/null @@ -1,547 +0,0 @@ -.PurchaseModal { - overflow: hidden; - height: 100%; - - & &__purchase-body { - overflow-y: auto; - } - - >div { - &.processing { - display: none; - } - - display: flex; - overflow: hidden; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: flex-start; - justify-content: center; - padding: 40px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; - - .footer-text { - .normal-payment-text { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 14px; - } - - .prorrated-payment-text { - div { - display: inline-block; - margin-right: 4px; - font-family: "compass-icons"; - font-size: 16px; - } - } - - a { - color: var(--button-bg) !important; - } - } - - .fineprint-text { - margin-top: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; - line-height: 16px; - } - - .bold-text { - font-weight: 600; - } - - .normal-text { - font-weight: normal; - } - - .LHS { - width: 25%; - - .title { - font-weight: 600; - } - - .image { - padding: 32px 0; - } - } - - .central-panel { - width: 50%; - padding-top: 80px; - - .PaymentDetails { - padding: 10px 0 0 10%; - font-size: 14px; - font-style: normal; - font-weight: normal; - line-height: 20px; - - .PaymentInfoDisplay__paymentInfo-text { - width: 416px; - height: 318px; - box-sizing: border-box; - padding: 32px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); - border-radius: 4px; - margin: 24px 0; - background-color: #fff; - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); - - .CardImage { - max-width: 55px !important; - max-height: 37px !important; - } - - .PaymentInfoDisplay__paymentInfo-cardInfo { - margin: 10px 0; - } - } - - .title { - font-size: 16px; - font-style: normal; - font-weight: 600; - line-height: 24px; - } - - .editPaymentButton { - padding-left: 20px !important; - border: none; - background: none; - color: var(--button-bg); - font-size: 14px; - font-weight: 600; - line-height: 24px; - } - } - } - - .full-width { - border-color: rgba(var(--center-channel-color-rgb), 0.16); - } - - .RHS { - position: sticky; - display: flex; - width: 25%; - flex-direction: column; - align-items: center; - - .price-text { - display: flex; - align-items: baseline; - padding: 8px 0; - font-size: 32px; - font-weight: 600; - line-height: 1; - - .price-decimals { - align-self: end; - padding-bottom: 2px; - font-size: 16px; - - &::before { - content: '.'; - } - } - } - - .monthly-text { - align-self: center; - margin-left: 5px; - font-size: 14px; - font-weight: normal; - } - - .plan_comparison { - &.show_label { - margin-bottom: 51px; - } - - text-align: center; - - button, - a { - width: 100%; - height: 40px; - padding: 10px 24px; - border: none; - background: none; - color: var(--denim-button-bg); - font-size: 14px; - font-weight: 600; - line-height: 20px; - } - } - - .PlanCard { - position: relative; - width: 280px; - height: auto; - - .planLabel { - position: absolute; - top: -44px; - left: 52px; - display: flex; - width: 184px; - align-items: center; - justify-content: center; - padding: 14px; - border-radius: 13px 13px 0 0; - font-family: 'Open Sans'; - font-size: 12px; - font-style: normal; - font-weight: 600; - gap: 5px; - line-height: 16px; - } - - .top { - height: 18px; - border-radius: 4px 4px 0 0; - } - - .bottom { - &:not(.delinquency, .bottom-monthly-yearly) { - height: auto; - } - - padding-right: 24px; - padding-bottom: 24px; - padding-left: 24px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 0 0 4px 4px; - background-color: var(--center-channel-bg); - - .enable_annual_sub { - margin-top: 12px; - } - - .plan_action_btn { - width: 100%; - height: 40px; - padding: 10px 24px; - border: none; - border-radius: 4px; - background: none; - color: var(--denim-button-bg); - font-size: 14px; - font-weight: 700; - line-height: 14px; - - &.grayed { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.32); - } - - &.active { - border: 1px solid var(--denim-button-bg); - } - - &.special { - background: var(--denim-button-bg); - color: var(--button-color); - } - } - - .button-description { - margin-top: 8px; - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - text-align: center; - } - - .delinquency_summary_section { - width: 100%; - height: 116px; - margin-top: 24px; - margin-bottom: 24px; - font-weight: 400; - letter-spacing: -0.02em; - text-align: center; - - .summary-section { - border-radius: 4px; - background-color: rgba(var(--denim-button-bg-rgb), 0.08); - font-family: 'Metropolis'; - - .summary-title { - padding-top: 12px; - font-size: 14px; - font-weight: 400; - } - - .summary-total { - padding-top: 12px; - color: #152234; - font-size: 28px; - font-weight: 700; - line-height: 20px; - } - - .view-breakdown { - &:hover { - cursor: pointer; - } - - padding-top: 12px; - padding-bottom: 12px; - color: var(--denim-button-bg); - font-family: 'Metropolis'; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - } - - h1 { - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 52px; - font-style: normal; - font-weight: 700; - line-height: 60px; - } - - .enterprise_price { - font-size: 32px; - } - - p { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 18px; - font-style: normal; - line-height: 20px; - } - } - - .plan_price_rate_section { - width: 100%; - height: 150px; - border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - font-weight: 400; - letter-spacing: -0.02em; - text-align: center; - - h4 { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 20px; - font-style: normal; - line-height: 30px; - } - - h4.plan_name { - margin-top: 16px; - margin-bottom: 0; - } - - h1 { - margin: 0; - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 52px; - font-style: normal; - font-weight: 700; - line-height: 60px; - } - - .enterprise_price { - font-size: 32px; - } - - p { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 18px; - font-style: normal; - line-height: 20px; - } - - p.plan_text { - margin-bottom: 0; - } - } - - .plan_payment_commencement { - margin-top: 16px; - margin-bottom: 24px; - color: var(--center-channel-color-rgb); - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - - .plan_billing_cycle { - &.delinquency { - margin-bottom: 24px; - } - - margin-top: 16px; - margin-bottom: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 16px; - } - - table { - width: 100%; - border-collapse: collapse; - - .yearly_savings { - padding-top: 12px; - padding-bottom: 12px; - color: var(--denim-status-online); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 20px; - } - - .total_price { - color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 24px; - } - - .monthly_price { - color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 400; - letter-spacing: 0.02em; - line-height: 20px; - } - } - - table tr td:nth-child(2) { - text-align: right; - } - - .flex-grid { - display: flex; - margin-top: 20px; - - .user_seats_container { - width: 100px; - - .user_seats { - height: 24px; - } - - .Input_container { - fieldset:not(.Input_fieldset___error) { - margin-bottom: 24px; - } - } - } - - .icon { - flex: 1; - align-self: flex-start; - justify-content: left; - - .icon-information-outline { - margin: 0; - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - } - } - - .Input___customMessage { - width: 250px; - margin-bottom: 4px; - - .icon.error { - display: none; - } - } - } - } - - .bottom.bottom-monthly-yearly { - display: block; - overflow: auto; - border-radius: 4px; - } - - .signup-consequences { - display: flex; - flex-direction: column; - margin-top: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 16px; - - span:first-of-type { - margin-bottom: 10px; - } - } - } - } - - .logo { - position: absolute; - right: 0; - bottom: 0; - } - - button { - width: fit-content; - height: 40px; - border: 0; - border-radius: 4px; - background: var(--button-bg); - color: var(--button-color); - font-size: 14px; - font-weight: 600; - - &:disabled { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.32); - } - } - } -} - -.background-svg { - position: absolute; - z-index: -1; - top: 0; - - >div { - position: absolute; - top: 0; - left: 0; - } -} - -.FullScreenModal { - .close-x { - top: 12px; - right: 12px; - } -} diff --git a/webapp/channels/src/components/purchase_modal/purchase_modal.tsx b/webapp/channels/src/components/purchase_modal/purchase_modal.tsx deleted file mode 100644 index ce96666e7a..0000000000 --- a/webapp/channels/src/components/purchase_modal/purchase_modal.tsx +++ /dev/null @@ -1,871 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -/* eslint-disable max-lines */ - -import {Elements} from '@stripe/react-stripe-js'; -import type {Stripe, StripeCardElementChangeEvent} from '@stripe/stripe-js'; -import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects -import classnames from 'classnames'; -import isEmpty from 'lodash/isEmpty'; -import React from 'react'; -import type {ReactNode} from 'react'; -import {FormattedMessage, injectIntl} from 'react-intl'; -import type {IntlShape} from 'react-intl'; - -import type {Address, CloudCustomer, Product, Invoice, Feedback, Subscription, InvoiceLineItem} from '@mattermost/types/cloud'; -import {areShippingDetailsValid} from '@mattermost/types/cloud'; -import type {Team} from '@mattermost/types/teams'; - -import {Client4} from 'mattermost-redux/client'; -import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; -import type {ActionResult} from 'mattermost-redux/types/actions'; - -import {trackEvent, pageVisited} from 'actions/telemetry_actions'; - -import BillingHistoryModal from 'components/admin_console/billing/billing_history_modal'; -import PaymentDetails from 'components/admin_console/billing/payment_details'; -import CloudInvoicePreview from 'components/cloud_invoice_preview'; -import PlanLabel from 'components/common/plan_label'; -import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg'; -import BackgroundSvg from 'components/common/svg_images_components/background_svg'; -import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; -import ExternalLink from 'components/external_link'; -import AddressForm from 'components/payment_form/address_form'; -import PaymentForm from 'components/payment_form/payment_form'; -import {STRIPE_CSS_SRC} from 'components/payment_form/stripe'; -import PricingModal from 'components/pricing_modal'; -import RootPortal from 'components/root_portal'; -import SeatsCalculator, {errorInvalidNumber} from 'components/seats_calculator'; -import type {Seats} from 'components/seats_calculator'; -import Consequences from 'components/seats_calculator/consequences'; -import SwitchToYearlyPlanConfirmModal from 'components/switch_to_yearly_plan_confirm_modal'; -import StarMarkSvg from 'components/widgets/icons/star_mark_icon'; -import FullScreenModal from 'components/widgets/modals/full_screen_modal'; -import 'components/payment_form/payment_form.scss'; - -import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils'; -import { - TELEMETRY_CATEGORIES, - CloudLinks, - CloudProducts, - BillingSchemes, - ModalIdentifiers, - RecurringIntervals, -} from 'utils/constants'; -import {goToMattermostContactSalesForm} from 'utils/contact_support_sales'; -import {t} from 'utils/i18n'; -import {localizeMessage, getNextBillingDate, getBlankAddressWithCountry} from 'utils/utils'; - -import type {ModalData} from 'types/actions'; -import type {BillingDetails} from 'types/cloud/sku'; -import {areBillingDetailsValid} from 'types/cloud/sku'; - -import DelinquencyCard from './delinquency_card'; -import IconMessage from './icon_message'; -import ProcessPaymentSetup from './process_payment_setup'; -import RenewalCard from './renewal_card'; -import {findProductInDictionary, getSelectedProduct} from './utils'; - -import './purchase.scss'; -let stripePromise: Promise; - -export enum ButtonCustomiserClasses { - grayed = 'grayed', - active = 'active', - special = 'special', -} - -export type ButtonDetails = { - action: () => void; - text: string; - disabled?: boolean; - customClass?: ButtonCustomiserClasses; -} - -type CardProps = { - topColor?: string; - plan: string; - price?: string; - rate?: ReactNode; - buttonDetails: ButtonDetails; - planBriefing?: JSX.Element | null; // can be removed once Yearly Subscriptions are available - planLabel?: JSX.Element; - preButtonContent?: React.ReactNode; - afterButtonContent?: React.ReactNode; -} - -type Props = { - customer: CloudCustomer | undefined; - show: boolean; - cwsMockMode: boolean; - products: Record | undefined; - yearlyProducts: Record; - contactSalesLink: string; - isFreeTrial: boolean; - productId: string | undefined; - currentTeam: Team; - intl: IntlShape; - theme: Theme; - isDelinquencyModal?: boolean; - isRenewalModal?: boolean; - invoices?: Invoice[]; - isCloudDelinquencyGreaterThan90Days: boolean; - usersCount: number; - isComplianceBlocked: boolean; - contactSupportLink: string; - subscription: Subscription | undefined; - - // callerCTA is information about the cta that opened this modal. This helps us provide a telemetry path - // showing information about how the modal was opened all the way to more CTAs within the modal itself - callerCTA?: string; - - stripePublicKey: string; - actions: { - openModal:

(modalData: ModalData

) => void; - closeModal: () => void; - getCloudProducts: () => void; - completeStripeAddPaymentMethod: ( - stripe: Stripe, - billingDetails: BillingDetails, - cwsMockMode: boolean - ) => Promise; - subscribeCloudSubscription: ( - productId: string, - shippingAddress: Address, - seats?: number, - downgradeFeedback?: Feedback, - ) => Promise; - getClientConfig: () => void; - getCloudSubscription: () => void; - getInvoices: () => void; - }; -}; - -type State = { - paymentInfoIsValid: boolean; - billingDetails: BillingDetails | null; - shippingAddress: Address | null; - cardInputComplete: boolean; - billingSameAsShipping: boolean; - processing: boolean; - editPaymentInfo: boolean; - currentProduct: Product | null | undefined; - selectedProduct: Product | null | undefined; - isUpgradeFromTrial: boolean; - buttonClickedInfo: string; - selectedProductPrice: string | null; - usersCount: number; - seats: Seats; - isSwitchingToAnnual: boolean; -} - -export function Card(props: CardProps) { - const cardContent = ( -

- {props.planLabel && props.planLabel} -
-
-
-

{props.plan}

-

{`$${props.price}`}

-

{props.rate}

-
- {props.planBriefing} - {props.preButtonContent} -
- -
- {props.afterButtonContent} -
-
- ); - - return ( - cardContent - ); -} - -class PurchaseModal extends React.PureComponent { - modal = React.createRef(); - - public constructor(props: Props) { - super(props); - this.state = { - paymentInfoIsValid: false, - billingDetails: null, - billingSameAsShipping: true, - shippingAddress: null, - cardInputComplete: false, - processing: false, - editPaymentInfo: isEmpty( - props.customer?.payment_method && - props.customer?.billing_address, - ), - currentProduct: findProductInDictionary( - props.products, - props.productId, - ), - selectedProduct: getSelectedProduct( - props.products!, - ), - isUpgradeFromTrial: props.isFreeTrial, - buttonClickedInfo: '', - selectedProductPrice: getSelectedProduct(props.products!)?.price_per_seat.toString() || null, - usersCount: this.props.usersCount, - seats: { - quantity: this.props.usersCount.toString(), - error: this.props.usersCount.toString() === '0' ? errorInvalidNumber : null, - }, - isSwitchingToAnnual: false, - }; - } - - async componentDidMount() { - if (isEmpty(this.state.currentProduct || this.state.selectedProduct)) { - await this.props.actions.getCloudProducts(); - this.setState({ - currentProduct: findProductInDictionary(this.props.products, this.props.productId), - selectedProduct: getSelectedProduct(this.props.products!), - selectedProductPrice: getSelectedProduct(this.props.products!)?.price_per_seat.toString() ?? null, - }); - } - - if (this.props.isDelinquencyModal) { - pageVisited( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'pageview_delinquency_cc_update', - ); - this.props.actions.getInvoices(); - } else { - pageVisited(TELEMETRY_CATEGORIES.CLOUD_PURCHASING, 'pageview_purchase'); - } - - this.props.actions.getClientConfig(); - } - - getDelinquencyTotalString = () => { - let totalOwed = 0; - this.props.invoices?.forEach((invoice) => { - totalOwed += invoice.total; - }); - return `$${totalOwed / 100}`; - }; - - onPaymentInput = (billing: BillingDetails) => { - this.setState({billingDetails: billing}, this.isFormComplete); - }; - - isFormComplete = () => { - let paymentInfoIsValid = areBillingDetailsValid(this.state.billingDetails) && this.state.cardInputComplete; - if (!this.state.billingSameAsShipping) { - paymentInfoIsValid = paymentInfoIsValid && areShippingDetailsValid(this.state.shippingAddress); - } - this.setState({paymentInfoIsValid}); - }; - - handleShippingSameAsBillingChange(value: boolean) { - this.setState({billingSameAsShipping: value}, this.isFormComplete); - } - - onShippingInput = (address: Address) => { - this.setState({shippingAddress: {...this.state.shippingAddress, ...address}}, this.isFormComplete); - }; - - handleCardInputChange = (event: StripeCardElementChangeEvent) => { - this.setState({ - paymentInfoIsValid: - areBillingDetailsValid(this.state.billingDetails) && event.complete, - }); - this.setState({cardInputComplete: event.complete}); - }; - - handleSubmitClick = async (callerInfo: string) => { - const update = { - selectedProduct: this.state.selectedProduct, - paymentInfoIsValid: false, - buttonClickedInfo: callerInfo, - processing: true, - } as unknown as Pick; - this.setState(update); - }; - - confirmSwitchToAnnual = () => { - const {customer} = this.props; - this.props.actions.openModal({ - modalId: ModalIdentifiers.CONFIRM_SWITCH_TO_YEARLY, - dialogType: SwitchToYearlyPlanConfirmModal, - dialogProps: { - confirmSwitchToYearlyFunc: () => { - this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > confirm_switch_to_annual_modal > confirm_click'); - this.setState({isSwitchingToAnnual: true}); - }, - contactSalesFunc: () => { - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_ADMIN, - 'confirm_switch_to_annual_click_contact_sales', - ); - const customerEmail = customer?.email || ''; - const firstName = customer?.contact_first_name || ''; - const lastName = customer?.contact_last_name || ''; - const companyName = customer?.name || ''; - goToMattermostContactSalesForm(firstName, lastName, companyName, customerEmail, 'mattermost', 'in-product-cloud'); - }, - }, - }); - }; - - setIsUpgradeFromTrialToFalse = () => { - this.setState({isUpgradeFromTrial: false}); - }; - - openPricingModal = (callerInfo: string) => { - this.props.actions.openModal({ - modalId: ModalIdentifiers.PRICING_MODAL, - dialogType: PricingModal, - dialogProps: { - callerCTA: callerInfo, - }, - }); - }; - - comparePlan = ( - - ); - - contactSalesLink = (text: ReactNode) => { - return ( - { - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'click_contact_sales', - ); - }} - href={this.props.contactSalesLink} - location='purchase_modal' - > - {text} - - ); - }; - - learnMoreLink = () => { - return ( - { - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'learn_more_prorated_payment', - ); - }} - href={CloudLinks.PRORATED_PAYMENT} - location='purchase_modal' - > - - - ); - }; - - editPaymentInfoHandler = () => { - this.setState((prevState: State) => { - return { - ...prevState, - editPaymentInfo: !prevState.editPaymentInfo, - }; - }); - }; - - paymentFooterText = () => { - return ( -
- -
- ); - }; - - getPlanNameFromProductName = (productName: string): string => { - if (productName.length > 0) { - const [name] = productName.split(' ').slice(-1); - return name; - } - - return productName; - }; - - getShippingAddressForProcessing = (): Address => { - if (this.state.billingSameAsShipping) { - return { - line1: this.state.billingDetails?.address || '', - line2: this.state.billingDetails?.address2 || '', - city: this.state.billingDetails?.city || '', - state: this.state.billingDetails?.state || '', - postal_code: this.state.billingDetails?.postalCode || '', - country: this.state.billingDetails?.country || '', - }; - } - - return this.state.shippingAddress as Address; - }; - - handleViewBreakdownClick = () => { - // If there is only one invoice, we can skip the summary and go straight to the invoice PDF preview for this singular invoice. - if (this.props.invoices?.length === 1) { - this.props.actions.openModal({ - modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW, - dialogType: CloudInvoicePreview, - dialogProps: { - url: Client4.getInvoicePdfUrl(this.props.invoices[0].id), - }, - }); - } else { - this.props.actions.openModal({ - modalId: ModalIdentifiers.BILLING_HISTORY, - dialogType: BillingHistoryModal, - dialogProps: { - invoices: this.props.invoices, - }, - }); - } - }; - - purchaseScreenCard = () => { - if (this.props.isDelinquencyModal) { - return ( -
- this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click'), - text: localizeMessage( - 'cloud_delinquency.cc_modal.card.reactivate', - 'Re-active', - ), - customClass: this.state.paymentInfoIsValid ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.grayed, - disabled: !this.state.paymentInfoIsValid, - }} - onViewBreakdownClick={this.handleViewBreakdownClick} - isCloudDelinquencyGreaterThan90Days={this.props.isCloudDelinquencyGreaterThan90Days} - cost={parseInt(this.state.selectedProductPrice || '', 10) * this.props.usersCount} - users={this.props.usersCount} - /> -
- ); - } - - if (this.props.isRenewalModal) { - if (!this.props.subscription || !this.props.subscription.upcoming_invoice) { - return null; - } - const invoice = this.props.subscription?.upcoming_invoice; - const invoiceSummaryProps = buildInvoiceSummaryPropsFromLineItems(invoice?.line_items || []); - if (this.state.seats.quantity !== this.props.usersCount.toString()) { - // If the user has changed the number of seats, the stripe invoice won't yet reflect that new seat count - // We must look for the invoice item that occurs in the future (ie, the invoice item for the next billing period) - // And adjust the quantity, so the summary equates properly - invoiceSummaryProps.fullCharges = invoiceSummaryProps.fullCharges.map((lineitem: InvoiceLineItem) => { - if (new Date(lineitem.period_start * 1000) > new Date()) { - return { - ...lineitem, - quantity: parseInt(this.state.seats.quantity, 10), - total: parseInt(this.state.seats.quantity, 10) * lineitem.price_per_unit, - }; - } - - return lineitem; - }); - } - return ( - <> - this.setState({seats})} - buttonDisabled={!this.state.paymentInfoIsValid} - onButtonClick={() => this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > renew_button_click')} - {...invoiceSummaryProps} - /> - - ); - } - - const showPlanLabel = this.state.selectedProduct?.sku === CloudProducts.PROFESSIONAL; - const {formatMessage, formatNumber} = this.props.intl; - - const checkIsYearlyProfessionalProduct = (product: Product | null | undefined) => { - if (!product) { - return false; - } - return product.recurring_interval === RecurringIntervals.YEAR && product.sku === CloudProducts.PROFESSIONAL; - }; - - const yearlyProductMonthlyPrice = formatNumber(parseInt(this.state.selectedProductPrice || '0', 10) / 12, {maximumFractionDigits: 2}); - - const cardBtnText = formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}); - - return ( -
-
- {this.comparePlan} -
- (billed annually)'}, { - br:
, - b: (chunks: React.ReactNode | React.ReactNodeArray) => ( - - {chunks} - - ), - })} - planBriefing={<>} - buttonDetails={{ - action: () => { - this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click'); - }, - text: cardBtnText, - customClass: - !this.state.paymentInfoIsValid || - this.state.selectedProduct?.billing_scheme === BillingSchemes.SALES_SERVE || this.state.seats.error !== null || - (checkIsYearlyProfessionalProduct(this.state.currentProduct)) ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.special, - disabled: - !this.state.paymentInfoIsValid || - this.state.selectedProduct?.billing_scheme === BillingSchemes.SALES_SERVE || - this.state.seats.error !== null || - (checkIsYearlyProfessionalProduct(this.state.currentProduct)), - }} - planLabel={ - showPlanLabel ? ( - } - secondSvg={} - /> - ) : undefined - } - preButtonContent={( - { - this.setState({seats}); - }} - /> - )} - afterButtonContent={ - - } - /> -
- ); - }; - - purchaseScreen = () => { - const title = ( - - ); - - let initialBillingDetails; - let validBillingDetails = false; - - if (this.props.customer?.billing_address && this.props.customer?.payment_method) { - initialBillingDetails = { - address: this.props.customer?.billing_address.line1, - address2: this.props.customer?.billing_address.line2, - city: this.props.customer?.billing_address.city, - state: this.props.customer?.billing_address.state, - country: this.props.customer?.billing_address.country, - postalCode: this.props.customer?.billing_address.postal_code, - name: this.props.customer?.payment_method.name, - } as BillingDetails; - - validBillingDetails = areBillingDetailsValid(initialBillingDetails); - } - - return ( -
-
-

{title}

- -
{'Questions?'}
- {this.contactSalesLink('Contact Sales')} -
-
- {this.state.editPaymentInfo || !validBillingDetails ? ( - - ) : ( -
-
- -
- - - -
- )} -
- - this.handleShippingSameAsBillingChange( - !this.state.billingSameAsShipping, - ) - } - /> - - - -
- {!this.state.billingSameAsShipping && ( - {}} - title={{ - id: 'payment_form.shipping_address', - defaultMessage: 'Shipping Address', - }} - formId={'shippingAddress'} - - // Setup the initial country based on their billing country, or USA. - address={ - this.state.shippingAddress || - getBlankAddressWithCountry( - this.state.billingDetails?.country || 'US', - ) - } - /> - )} -
- {this.purchaseScreenCard()} -
- ); - }; - - render() { - if (this.props.isComplianceBlocked) { - return ( - - { - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'click_close_purchasing_screen', - ); - this.props.actions.getCloudSubscription(); - this.props.actions.closeModal(); - }} - ref={this.modal} - ariaLabelledBy='purchase_modal_title' - > -
- - } - buttonText={t( - 'admin.billing.subscription.complianceScreenFailed.button', - )} - buttonHandler={() => - this.props.actions.closeModal() - } - linkText={t( - 'admin.billing.subscription.privateCloudCard.contactSupport', - )} - linkURL={this.props.contactSupportLink} - className={'failed'} - /> -
-
-
- ); - } - if (!stripePromise) { - stripePromise = loadStripe(this.props.stripePublicKey); - } - - return ( - - - { - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'click_close_purchasing_screen', - ); - this.props.actions.getCloudSubscription(); - this.props.actions.closeModal(); - }} - ref={this.modal} - ariaLabelledBy='purchase_modal_title' - overrideTargetEvent={false} - > -
- {this.state.processing ? ( -
- { - this.props.actions.getCloudSubscription(); - this.props.actions.closeModal(); - }} - onBack={() => { - this.setState({ - processing: false, - }); - }} - contactSupportLink={ - this.props.contactSupportLink - } - currentTeam={this.props.currentTeam} - onSuccess={() => { - // Success only happens if all invoices have been paid. - if (this.props.isDelinquencyModal) { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'paid_arrears'); - } - }} - selectedProduct={this.state.selectedProduct} - currentProduct={this.state.currentProduct} - isProratedPayment={(!this.props.isFreeTrial && this.state.currentProduct?.billing_scheme === BillingSchemes.FLAT_FEE) && - this.state.selectedProduct?.billing_scheme === BillingSchemes.PER_SEAT} - setIsUpgradeFromTrialToFalse={this.setIsUpgradeFromTrialToFalse} - isUpgradeFromTrial={this.state.isUpgradeFromTrial} - isSwitchingToAnnual={this.state.isSwitchingToAnnual} - telemetryProps={{ - callerInfo: - this.state.buttonClickedInfo, - }} - usersCount={parseInt(this.state.seats.quantity, 10)} - /> -
- ) : null} - {this.purchaseScreen()} -
- -
-
-
-
-
- ); - } -} - -export default injectIntl(PurchaseModal); diff --git a/webapp/channels/src/components/purchase_modal/renewal_card.scss b/webapp/channels/src/components/purchase_modal/renewal_card.scss deleted file mode 100644 index 6423702235..0000000000 --- a/webapp/channels/src/components/purchase_modal/renewal_card.scss +++ /dev/null @@ -1,257 +0,0 @@ -.PurchaseModal { - .RenewalRHS { - position: sticky; - display: flex; - width: 25%; - flex-direction: column; - padding-top: 80px; - - .RenewalCard { - max-width: 353px; - padding: 28px 32px; - border: 1px solid var(--light-8-center-channel-text, rgba(61, 60, 64, 0.08)); - border-radius: 4px; - background: var(--light-center-channel-bg, #fff); - box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08); - - .SeatsCalculator { - padding: 0; - - .SeatsCalculator__seats-item.SeatsCalculator__seats-item--input { - padding: 0; - } - } - - .RenewalSummary { - width: 332px; - padding: 28px 32px; - border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); - border-radius: 4px; - margin-left: 20px; - background-color: var(--sys-center-channel-bg); - box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); - } - - .RenewalSummary__noBillingHistory { - display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - } - - .RenewalSummary__noBillingHistory-title { - margin-top: 32px; - font-size: 16px; - font-weight: 600; - line-height: 24px; - } - - .RenewalSummary__noBillingHistory-message { - margin-top: 8px; - color: var(--sys-center-channel-color); - font-size: 14px; - line-height: 20px; - } - - .RenewalSummary__noBillingHistory-link { - margin-top: 16px; - margin-bottom: 24px; - color: var(--sys-button-bg); - font-size: 12px; - font-weight: 600; - } - - .RenewalSummary__lastInvoice { - color: var(--sys-center-channel-color); - - hr { - border-color: rgba(var(--sys-center-channel-color-rgb), 0.32); - margin: 12px 0; - } - } - - .RenewalSummary__lastInvoice-header { - display: flex; - align-items: center; - } - - .RenewalSummary__lastInvoice-headerTitle { - font-size: 20px; - font-weight: 600; - line-height: 28px; - } - - .BillingSummary__lastInvoice-headerStatus { - display: flex; - align-items: center; - margin-left: auto; - font-size: 12px; - font-weight: 600; - line-height: 16px; - - &.paid { - color: var(--sys-online-indicator); - } - - &.failed { - color: var(--sys-error-text); - } - - &.pending { - color: #f58b00; - } - - svg { - width: 16px; - height: 16px; - margin-left: 4px; - font-size: 14.4px; - - &::before { - margin: 0 auto; - } - } - - span { - margin-left: 4px; - } - } - - .RenewalSummary__upcomingInvoice-due-date { - margin-top: 8px; - color: var(--light-72-center-channel-text, rgba(61, 60, 64, 0.72)); - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - - .RenewalSummary__lastInvoice-productName { - margin-top: 32px; - font-size: 16px; - font-weight: 600; - line-height: 24px; - } - - .RenewalSummary__upcomingInvoice-charge { - display: flex; - margin: 12px 0; - color: var(--center-channel-color); - font-size: 12px; - font-weight: 400; - line-height: 20px; - - &.total { - font-size: 14px; - font-weight: 600; - } - } - - .RenewalSummary__upcomingInvoice-hasMoreItems { - display: flex; - align-items: center; - color: rgba(var(--center-channel-color-rgb), 0.72); - font-size: 14px; - font-weight: 400; - - .RenewalSummary__upcomingInvoice-chargeDescription { - &:hover { - cursor: pointer; - text-decoration: underline; - } - } - } - - .RenewalSummary__upcomingInvoice-ellipses { - display: flex; - align-items: center; - align-self: stretch; - justify-content: center; - fill: #d9d9d9; - } - - .RenewalSummary__lastInvoice-chargeAmount { - margin-left: auto; - } - - .RenewalSummary__lastInvoice-partialCharges { - color: rgba(var(--sys-center-channel-color-rgb), 0.56); - font-size: 12px; - line-height: 16px; - - & + .RenewalSummary__lastInvoice-charge { - margin-top: 4px; - } - } - - .RenewalSummary__lastInvoice-download { - margin-top: 32px; - margin-bottom: 16px; - } - - .RenewalSummary__upcomingInvoice-renew-button { - display: flex; - width: 100%; - align-items: center; - justify-content: center; - padding: 11px 20px; - border-radius: 4px; - background: var(--sys-button-bg); - color: var(--sys-button-color); - - &:hover:not(:disabled) { - background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg); - color: var(--sys-button-color); - cursor: pointer; - text-decoration: none; - } - - &:disabled { - background: rgba(var(--sys-center-channel-color-rgb), 0.08); - color: rgba(var(--sys-center-channel-color-rgb), 0.32); - cursor: not-allowed; - } - - >span { - margin-left: 6px; - font-size: 14px; - font-weight: 600; - line-height: 14px; - } - } - - .RenewalSummary__upcomingInvoice-viewInvoiceLink { - display: block; - width: 100%; - height: 16px; - margin-bottom: 12px; - background: none; - color: var(--link-color, #1c58d9); - font-size: 12px; - font-weight: 600; - line-height: 9.5px; - text-align: center; - } - - .RenewalSummary__upcomingInvoice-viewInvoice { - display: block; - width: 100%; - background: none; - color: var(--link-color, #1c58d9); - font-size: 12px; - font-weight: 600; - line-height: 9.5px; - text-align: center; - } - - .RenewalSummary__disclaimer { - margin-top: 16px; - color: rgba(var(--center-channel-color-rgb), 0.72); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 16px; - } - } - } -} diff --git a/webapp/channels/src/components/purchase_modal/renewal_card.tsx b/webapp/channels/src/components/purchase_modal/renewal_card.tsx deleted file mode 100644 index 0d635f79c4..0000000000 --- a/webapp/channels/src/components/purchase_modal/renewal_card.tsx +++ /dev/null @@ -1,311 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage, FormattedNumber, FormattedDate, defineMessages} from 'react-intl'; -import {useDispatch} from 'react-redux'; - -import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud'; - -import {Client4} from 'mattermost-redux/client'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {openModal} from 'actions/views/modals'; - -import {getPaymentStatus} from 'components/admin_console/billing/billing_summary/billing_summary'; -import CloudInvoicePreview from 'components/cloud_invoice_preview'; -import type {Seats} from 'components/seats_calculator'; -import SeatsCalculator from 'components/seats_calculator'; -import EllipsisHorizontalIcon from 'components/widgets/icons/ellipsis_h_icon'; -import WithTooltip from 'components/with_tooltip'; - -import {BillingSchemes, ModalIdentifiers, TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants'; - -import './renewal_card.scss'; - -const messages = defineMessages({ - partialChargesTooltipTitle: { - id: 'admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges', - defaultMessage: 'What are partial charges?', - }, - partialChargesTooltipText: { - id: 'admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges.message', - defaultMessage: 'Users who have not been enabled for the full duration of the month are charged at a prorated monthly rate.', - }, -}); - -type RenewalCardProps = { - invoice: Invoice; - product?: Product; - hasMore?: number; - fullCharges: InvoiceLineItem[]; - partialCharges: InvoiceLineItem[]; - seats: Seats; - existingUsers: number; - onSeatChange: (seats: Seats) => void; - buttonDisabled?: boolean; - onButtonClick?: () => void; -}; - -export default function RenewalCard({invoice, product, hasMore, fullCharges, partialCharges, seats, onSeatChange, existingUsers, buttonDisabled, onButtonClick}: RenewalCardProps) { - const dispatch = useDispatch(); - - const openInvoicePreview = () => { - dispatch( - openModal({ - modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW, - dialogType: CloudInvoicePreview, - dialogProps: { - url: Client4.getInvoicePdfUrl(invoice.id), - }, - }), - ); - }; - - const seeHowBillingWorks = ( - e: React.MouseEvent, - ) => { - e.preventDefault(); - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_ADMIN, - 'click_see_how_billing_works', - ); - window.open(CloudLinks.DELINQUENCY_DOCS, '_blank'); - }; - - return ( -
-
-
-
- -
- {getPaymentStatus(invoice.status)} -
-
- - ), - }} - /> -
-
- {product?.name} -
-
- { - onSeatChange(seats); - }} - isCloud={true} - existingUsers={existingUsers} - excludeTotal={true} - /> - {fullCharges.map((charge: any) => ( -
-
- <> - - - {(' ')} - {'('} - - {')'} - - -
-
- -
-
- ))} - {Boolean(hasMore) && ( -
-
- {product?.billing_scheme === BillingSchemes.FLAT_FEE ? ( - - ) : ( - <> - - - )} -
-
- )} - {Boolean(partialCharges.length) && ( - <> -
- - - - -
- {partialCharges.map((charge: any) => ( -
-
- -
-
- -
-
- ))} - - )} - {Boolean(hasMore) && ( -
- -
- )} - {Boolean(invoice.tax) && ( -
-
- -
-
- -
-
- )} - -
-
-
- -
-
- sum + item.total, 0) / 100.0} - // eslint-disable-next-line react/style-prop-object - style='currency' - currency='USD' - /> -
-
- - -
- - - - ), - }} - /> -
- -
-
- ); -} diff --git a/webapp/channels/src/components/purchase_modal/utils.ts b/webapp/channels/src/components/purchase_modal/utils.ts deleted file mode 100644 index d38d375a32..0000000000 --- a/webapp/channels/src/components/purchase_modal/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {Product} from '@mattermost/types/cloud'; - -import {CloudProducts, RecurringIntervals} from 'utils/constants'; - -/** - * - * @param products Record | undefined - the list of current cloud products - * @param productId String - a valid product id used to find a particular product in the dictionary - * @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise - * @returns Product - */ -export function findProductInDictionary(products: Record | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null { - if (!products) { - return null; - } - const keys = Object.keys(products); - if (!keys.length) { - return null; - } - if (!productId && !productSku) { - return products[keys[0]]; - } - let currentProduct = products[keys[0]]; - if (keys.length > 1) { - // here find the product by the provided id or name, otherwise return the one with Professional in the name - keys.forEach((key) => { - if (productId && products[key].id === productId) { - currentProduct = products[key]; - } else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) { - currentProduct = products[key]; - } - }); - } - return currentProduct; -} - -export function getSelectedProduct(yearlyProducts: Record) { - return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR); -} diff --git a/webapp/channels/src/components/root/root.tsx b/webapp/channels/src/components/root/root.tsx index d96e156459..c0f872fa7f 100644 --- a/webapp/channels/src/components/root/root.tsx +++ b/webapp/channels/src/components/root/root.tsx @@ -28,7 +28,6 @@ import {makeAsyncComponent} from 'components/async_load'; import CloudEffects from 'components/cloud_effects'; import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; import OpenPluginInstallPost from 'components/custom_open_plugin_install_post_renderer'; -import OpenPricingModalPost from 'components/custom_open_pricing_modal_post_renderer'; import GlobalHeader from 'components/global_header/global_header'; import {HFRoute} from 'components/header_footer_route/header_footer_route'; import {HFTRoute, LoggedInHFTRoute} from 'components/header_footer_template_route'; @@ -412,7 +411,6 @@ export default class Root extends React.PureComponent { this.initiateMeRequests(); // See figma design on issue https://mattermost.atlassian.net/browse/MM-43649 - this.props.actions.registerCustomPostRenderer('custom_up_notification', OpenPricingModalPost, 'upgrade_post_message_renderer'); this.props.actions.registerCustomPostRenderer('custom_pl_notification', OpenPluginInstallPost, 'plugin_install_post_message_renderer'); measurePageLoadTelemetry(); diff --git a/webapp/channels/src/components/self_hosted_purchases/address.tsx b/webapp/channels/src/components/self_hosted_purchases/address.tsx deleted file mode 100644 index a595f29139..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/address.tsx +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import classNames from 'classnames'; -import React from 'react'; -import {useIntl} from 'react-intl'; - -import CountrySelector from 'components/payment_form/country_selector'; -import StateSelector from 'components/payment_form/state_selector'; -import Input from 'components/widgets/inputs/input/input'; - -interface Props { - type: 'shipping' | 'billing'; - testPrefix?: string; - - country: string; - changeCountry: (option: {value: string}) => void; - - address: string; - changeAddress: (e: React.ChangeEvent) => void; - - address2: string; - changeAddress2: (e: React.ChangeEvent) => void; - - city: string; - changeCity: (e: React.ChangeEvent) => void; - - state: string; - changeState: (postalCode: string) => void; - - postalCode: string; - changePostalCode: (e: React.ChangeEvent) => void; -} -export default function Address(props: Props) { - const testPrefix = props.testPrefix || 'selfHostedPurchase'; - const intl = useIntl(); - let countrySelectorId = `${testPrefix}CountrySelector`; - let stateSelectorId = `${testPrefix}StateSelector`; - if (props.type === 'shipping') { - countrySelectorId += '_Shipping'; - stateSelectorId += '_Shipping'; - } - return ( - <> -
- -
-
- -
-
- -
-
- -
-
-
- -
-
- -
-
- - ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/constants.ts b/webapp/channels/src/components/self_hosted_purchases/constants.ts deleted file mode 100644 index 72b16f750a..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/constants.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -export const STORAGE_KEY_PURCHASE_IN_PROGRESS = 'PURCHASE_IN_PROGRESS'; -export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS'; diff --git a/webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx b/webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx deleted file mode 100644 index d2737f2b02..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/contact_sales_link.tsx +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {useIntl} from 'react-intl'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import ExternalLink from 'components/external_link'; - -import { - TELEMETRY_CATEGORIES, -} from 'utils/constants'; - -export default function ContactSalesLink() { - const [, contactSalesLink] = useOpenSalesLink(); - const intl = useIntl(); - return ( - { - trackEvent( - TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, - 'click_contact_sales', - ); - }} - href={contactSalesLink} - location='contact_sales_link' - > - {intl.formatMessage({id: 'self_hosted_signup.contact_sales', defaultMessage: 'Contact Sales'})} - - ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss deleted file mode 100644 index 9a25362e9d..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.scss +++ /dev/null @@ -1,3 +0,0 @@ -.self_hosted_expansion_failed { - margin-top: 163px; -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx deleted file mode 100644 index 47de87d314..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/error_page.tsx +++ /dev/null @@ -1,89 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; -import IconMessage from 'components/purchase_modal/icon_message'; - -import {TELEMETRY_CATEGORIES} from 'utils/constants'; - -import './error_page.scss'; - -interface Props { - canRetry: boolean; - tryAgain: () => void; -} - -export default function SelfHostedExpansionErrorPage(props: Props) { - const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); - - const formattedTitle = ( - - ); - - let formattedButtonText = ( - - ); - - if (!props.canRetry) { - formattedButtonText = ( - - ); - } - - const formattedSubtitle = ( - - ); - - const tertiaryButtonText = ( - - ); - - const icon = ( - - ); - - return ( -
- { - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'failure_try_again_clicked'); - props.tryAgain(); - }} - formattedTertiaryButonText={tertiaryButtonText} - tertiaryButtonHandler={() => { - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'failure_contact_support_clicked'); - window.open(contactSupportLink, '_blank', 'noreferrer'); - }} - /> -
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss deleted file mode 100644 index 23382357b0..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.scss +++ /dev/null @@ -1,131 +0,0 @@ -.SelfHostedExpansionRHSCard { - display: flex; - width: 280px; - flex-direction: column; - - &__Content { - padding: 24px; - border: 1px solid; - border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); - border-radius: 4px; - } - - &__RHSCardTitle { - display: block; - margin-bottom: 12px; - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 14px; - font-weight: 600; - text-align: center; - text-transform: capitalize; - } - - .seatsInput { - width: 73px; - margin-left: auto; - font-size: 14px; - font-weight: 400; - - input[type="number"] { - text-align: right; - } - - input[type="number"]::-webkit-inner-spin-button, - input[type="number"]::-webkit-outer-spin-button { - margin: 0; - -webkit-appearance: none; - } - } - - &__PlanDetails { - display: flex; - flex-direction: column; - text-align: center; - - .planName { - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 20px; - font-weight: 400; - text-transform: capitalize; - } - - .usage { - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 12px; - font-weight: 600; - - :first-child { - text-transform: uppercase; - } - } - } - - hr { - width: 90%; - height: 2px; - background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16); - } - - &__seatInput, - &__cost_breakdown { - display: grid; - font-weight: 400; - gap: 10px; - grid-template-columns: repeat(2, 1fr); - - .costPerUser > span:first-child { - font-size: 14px; - } - - .costPerUser > span:last-child { - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 12px; - } - - .totalCostWarning { - width: 141px; - } - - .totalCostWarning > span:first-child { - color: var(--sys-denim-center-channel-text); - font-size: 14px; - font-weight: 700; - } - - .totalCostWarning > span:last-child { - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 12px; - } - - .costAmount { - width: 100%; - margin-right: 0; - margin-left: auto; - font-weight: 700; - } - } - - &__AddSeatsWarning { - display: block; - width: 100%; - height: 35px; - margin-bottom: 15px; - color: var(--dnd-indicator); - font-size: 12px; - font-weight: 600; - text-align: right; - } - - &__CompletePurchaseButton { - width: 100%; - border-radius: 4px; - margin-top: 10px; - margin-bottom: 10px; - } - - &__ChargedTodayDisclaimer { - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - font-size: 12px; - font-weight: 400; - } -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx deleted file mode 100644 index 3270548c70..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/expansion_card.tsx +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import moment from 'moment-timezone'; -import React, {useState} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {getLicense} from 'mattermost-redux/selectors/entities/general'; - -import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts'; -import ExternalLink from 'components/external_link'; -import {OutlinedInput} from 'components/outlined_input'; -import WarningIcon from 'components/widgets/icons/fa_warning_icon'; - -import {DocLinks} from 'utils/constants'; -import {findSelfHostedProductBySku} from 'utils/hosted_customer'; - -import './expansion_card.scss'; - -const MONTHS_IN_YEAR = 12; -const MAX_TRANSACTION_VALUE = 1_000_000 - 1; - -interface Props { - canSubmit: boolean; - licensedSeats: number; - minimumSeats: number; - submit: () => void; - updateSeats: (seats: number) => void; -} - -export default function SelfHostedExpansionCard(props: Props) { - const intl = useIntl(); - const license = useSelector(getLicense); - const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY'); - const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY'); - const [additionalSeats, setAdditionalSeats] = useState(props.minimumSeats); - const [overMaxSeats, setOverMaxSeats] = useState(false); - const licenseExpiry = new Date(parseInt(license.ExpiresAt, 10)); - const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats) || additionalSeats < props.minimumSeats; - const [products] = useGetSelfHostedProducts(); - const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName); - const costPerMonth = currentProduct?.price_per_seat || 0; - - const getMonthsUntilExpiry = () => { - const now = new Date(); - return (licenseExpiry.getMonth() - now.getMonth()) + (MONTHS_IN_YEAR * (licenseExpiry.getFullYear() - now.getFullYear())); - }; - - const getCostPerUser = () => { - const monthsUntilExpiry = getMonthsUntilExpiry(); - return costPerMonth * monthsUntilExpiry; - }; - - const getPaymentTotal = () => { - if (isNaN(additionalSeats)) { - return 0; - } - const monthsUntilExpiry = getMonthsUntilExpiry(); - return additionalSeats * costPerMonth * monthsUntilExpiry; - }; - - // Finds the maximum number of additional seats that is possible, taking into account - // the stripe transaction limit. The maximum number of seats will follow the formula: - // (StripeTransaction Limit - (current_seats * yearly_price_per_seat)) / yearly_price_per_seat - const getMaximumAdditionalSeats = () => { - if (currentProduct === null) { - return 0; - } - - const currentPaymentPrice = costPerMonth * props.licensedSeats * 12; - const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice; - const remainingSeats = Math.floor(remainingTransactionLimit / (costPerMonth * 12)); - return Math.max(0, remainingSeats); - }; - const maxAdditionalSeats = getMaximumAdditionalSeats(); - - const handleNewSeatsInputChange = (e: React.ChangeEvent) => { - const requestedSeats = parseInt(e.target.value, 10); - - if (!isNaN(requestedSeats) && requestedSeats <= 0) { - e.preventDefault(); - return; - } - - setOverMaxSeats(false); - - const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats; - setOverMaxSeats(overMaxAdditionalSeats); - - const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats; - setAdditionalSeats(finalSeatCount); - - props.updateSeats(finalSeatCount); - }; - - const formatCurrency = (value: number) => { - return intl.formatNumber(value, {style: 'currency', currency: 'USD'}); - }; - - return ( -
-
- -
-
-
- {license.SkuShortName} -
- -
- -
-
-
-
- - -
-
- {invalidAdditionalSeats && !overMaxSeats && isNaN(additionalSeats) && - , - }} - /> - } - {invalidAdditionalSeats && additionalSeats < props.minimumSeats && - , - minimumSeats: props.minimumSeats, - }} - /> - } - {overMaxSeats && maxAdditionalSeats > 0 && - , - }} - /> - } -
-
-
- -
- -
-
- {formatCurrency(getCostPerUser())} -
-
- -
- -
- - {formatCurrency(getPaymentTotal()) } - -
- -
- ( - <> -
- - {text} - - - ), - }} - /> -
-
-
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx deleted file mode 100644 index 5df00858b9..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.test.tsx +++ /dev/null @@ -1,505 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import moment from 'moment-timezone'; -import React from 'react'; - -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; -import type {SelfHostedSignupForm} from '@mattermost/types/hosted_customer'; -import type {DeepPartial} from '@mattermost/types/utilities'; - -import mergeObjects from 'packages/mattermost-redux/test/merge_objects'; -import { - fireEvent, - renderWithContext, - screen, - waitFor, -} from 'tests/react_testing_utils'; -import {SelfHostedProducts, ModalIdentifiers, RecurringIntervals} from 'utils/constants'; -import {TestHelper as TH} from 'utils/test_helper'; - -import type {GlobalState} from 'types/store'; - -import SelfHostedExpansionModal, {makeInitialState, canSubmit} from './'; -import type {FormState} from './'; - -interface MockCardInputProps { - onCardInputChange: (event: {complete: boolean}) => void; - forwardedRef: React.MutableRefObject; -} - -// number borrowed from stripe -const successCardNumber = '4242424242424242'; -function MockCardInput(props: MockCardInputProps) { - props.forwardedRef.current = { - getCard: () => ({}), - }; - return ( - ) => { - if (e.target.value === successCardNumber) { - props.onCardInputChange({complete: true}); - } - }} - /> - ); -} - -jest.mock('components/payment_form/card_input', () => { - const original = jest.requireActual('components/payment_form/card_input'); - return { - ...original, - __esModule: true, - default: MockCardInput, - }; -}); - -jest.mock('components/self_hosted_purchases/stripe_provider', () => { - return function(props: {children: React.ReactNode | React.ReactNodeArray}) { - return props.children; - }; -}); - -jest.mock('components/common/hooks/useLoadStripe', () => { - return function() { - return {current: { - stripe: {}, - - }}; - }; -}); - -const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT; -const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE; -const failOrg = 'failorg'; - -const existingUsers = 10; - -const mockProfessionalProduct = TH.getProductMock({ - id: 'prod_professional', - name: 'Professional', - sku: SelfHostedProducts.PROFESSIONAL, - price_per_seat: 7.5, - recurring_interval: RecurringIntervals.MONTH, -}); - -jest.mock('mattermost-redux/client', () => { - const original = jest.requireActual('mattermost-redux/client'); - return { - __esModule: true, - ...original, - Client4: { - ...original.Client4, - pageVisited: jest.fn(), - setAcceptLanguage: jest.fn(), - trackEvent: jest.fn(), - createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => { - if (form.organization === failOrg) { - throw new Error('error creating customer'); - } - return Promise.resolve({ - progress: mockCreatedIntent, - }); - }, - confirmSelfHostedExpansion: () => Promise.resolve({ - progress: mockCreatedLicense, - license: {Users: existingUsers * 2}, - }), - }, - }; -}); - -jest.mock('components/payment_form/stripe', () => { - const original = jest.requireActual('components/payment_form/stripe'); - return { - __esModule: true, - ...original, - getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}), - }; -}); - -jest.mock('utils/hosted_customer', () => { - const original = jest.requireActual('utils/hosted_customer'); - return { - __esModule: true, - ...original, - findSelfHostedProductBySku: () => { - return mockProfessionalProduct; - }, - }; -}); - -const productName = SelfHostedProducts.PROFESSIONAL; - -// Licensed expiry set as 3 months from the current date (rolls over to new years). -let licenseExpiry = moment(); -const monthsUntilLicenseExpiry = 3; -licenseExpiry = licenseExpiry.add(monthsUntilLicenseExpiry, 'months'); - -const initialState: DeepPartial = { - views: { - modals: { - modalState: { - [ModalIdentifiers.SELF_HOSTED_EXPANSION]: { - open: true, - }, - }, - }, - }, - storage: { - storage: {}, - }, - entities: { - teams: { - currentTeamId: '', - }, - preferences: { - myPreferences: { - theme: {}, - }, - }, - general: { - config: { - EnableDeveloper: 'false', - }, - license: { - SkuName: productName, - Sku: productName, - Users: '50', - ExpiresAt: licenseExpiry.valueOf().toString(), - }, - }, - cloud: { - subscription: {}, - }, - users: { - currentUserId: 'adminUserId', - profiles: { - adminUserId: TH.getUserMock({ - id: 'adminUserId', - roles: 'admin', - first_name: 'first', - last_name: 'admin', - }), - otherUserId: TH.getUserMock({ - id: 'otherUserId', - roles: '', - first_name: '', - last_name: '', - }), - }, - filteredStats: { - total_users_count: 100, - }, - }, - hostedCustomer: { - products: { - productsLoaded: true, - products: { - prod_professional: mockProfessionalProduct, - }, - }, - signupProgress: SelfHostedSignupProgress.START, - }, - }, -}; - -const valueEvent = (value: any) => ({target: {value}}); -function changeByPlaceholder(sel: string, val: any) { - fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val)); -} - -function selectDropdownValue(testId: string, value: string) { - fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); - fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any); -} - -function changeByTestId(testId: string, value: string) { - fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); -} - -interface PurchaseForm { - card: string; - org: string; - name: string; - country: string; - address: string; - city: string; - state: string; - zip: string; - seats: string; - agree: boolean; -} - -const defaultSuccessForm: PurchaseForm = { - card: successCardNumber, - org: 'My org', - name: 'The Cardholder', - country: 'United States of America', - address: '123 Main Street', - city: 'Minneapolis', - state: 'MN', - zip: '55423', - seats: '50', - agree: true, -}; - -function fillForm(form: PurchaseForm) { - changeByPlaceholder('Card number', form.card); - changeByPlaceholder('Organization Name', form.org); - changeByPlaceholder('Name on Card', form.name); - selectDropdownValue('selfHostedExpansionCountrySelector', form.country); - changeByPlaceholder('Address', form.address); - changeByPlaceholder('City', form.city); - selectDropdownValue('selfHostedExpansionStateSelector', form.state); - changeByPlaceholder('Zip/Postal Code', form.zip); - if (form.agree) { - fireEvent.click(screen.getByText('I have read and agree', {exact: false})); - } - - const completeButton = screen.getByText('Complete purchase'); - - if (form === defaultSuccessForm) { - expect(completeButton).toBeEnabled(); - } - - return completeButton; -} - -describe('SelfHostedExpansionModal Open', () => { - it('renders the form', () => { - renderWithContext(
, initialState); - - screen.getByText('Provide your payment details'); - screen.getByText('Add new seats'); - screen.getByText('Contact Sales'); - screen.getByText('Cost per user', {exact: false}); - - // screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}}); - screen.getByText('Your credit card will be charged today.'); - screen.getByText('See how billing works', {exact: false}); - }); - - it('filling the form enables expansion', () => { - renderWithContext(
, initialState); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - fillForm(defaultSuccessForm); - }); - - it('happy path submit shows success screen when confirmation succeeds', async () => { - renderWithContext(
, initialState); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - - const upgradeButton = fillForm(defaultSuccessForm); - upgradeButton.click(); - - expect(screen.findByText('The license has been automatically applied')).toBeTruthy(); - }); - - it('happy path submit shows submitting screen while requesting confirmation', async () => { - renderWithContext(
, initialState); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - - const upgradeButton = fillForm(defaultSuccessForm); - upgradeButton.click(); - - await waitFor(() => expect(document.getElementsByClassName('submitting')[0]).toBeTruthy(), {timeout: 1234}); - }); - - it('sad path submit shows error screen', async () => { - renderWithContext(
, initialState); - expect(screen.getByText('Complete purchase')).toBeDisabled(); - fillForm(defaultSuccessForm); - changeByPlaceholder('Organization Name', failOrg); - - const upgradeButton = screen.getByText('Complete purchase'); - expect(upgradeButton).toBeEnabled(); - upgradeButton.click(); - await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234}); - }); -}); - -describe('SelfHostedExpansionModal RHS Card', () => { - it('New seats input should be pre-populated with the difference from the active users and licensed seats', () => { - renderWithContext(
, initialState); - - const expectedPrePopulatedSeats = (initialState.entities?.users?.filteredStats?.total_users_count || 1) - parseInt(initialState.entities?.general?.license?.Users || '1', 10); - - const seatsField = screen.getByTestId('seatsInput').querySelector('input'); - expect(seatsField).toBeInTheDocument(); - expect(seatsField?.value).toBe(expectedPrePopulatedSeats.toString()); - }); - - it('Seat input only allows users to fill input with the licensed seats and active users difference if it is not 0', () => { - const expectedUserOverage = '50'; - - renderWithContext(
, initialState); - fillForm(defaultSuccessForm); - - // The seat input should already have the expected value. - expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage); - - // Try to set an undefined value. - fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined); - - // Expecting the seats input to now contain the difference between active users and licensed seats. - expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedUserOverage); - expect(screen.getByText('Complete purchase')).toBeEnabled(); - }); - - it('New seats input cannot be less than 1', () => { - const state = mergeObjects(initialState, { - entities: { - users: { - filteredStats: { - total_users_count: 50, - }, - }, - }, - }); - - const expectedAddNewSeats = '1'; - - renderWithContext(
, state); - fillForm(defaultSuccessForm); - - // Try to set a negative value. - fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, -10); - expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats); - - // Try to set a 0 value. - fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, 0); - expect(screen.getByTestId('seatsInput').querySelector('input')?.value).toContain(expectedAddNewSeats); - }); - - it('Cost per User should be represented as the current subscription price multiplied by the remaining months', () => { - renderWithContext(
, initialState); - - const expectedCostPerUser = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat; - - const costPerUser = document.getElementsByClassName('costPerUser')[0]; - expect(costPerUser).toBeInTheDocument(); - expect(costPerUser.innerHTML).toContain('Cost per user
$' + mockProfessionalProduct.price_per_seat.toFixed(2) + ' x ' + monthsUntilLicenseExpiry + ' months'); - - const costAmount = document.getElementsByClassName('costAmount')[0]; - expect(costAmount).toBeInTheDocument(); - expect(costAmount.innerHTML).toContain('$' + expectedCostPerUser); - }); - - it('Total cost User should be represented as the current subscription price multiplied by the remaining months multiplied by the number of users', () => { - renderWithContext(
, initialState); - const seatsInputValue = 100; - changeByTestId('seatsInput', seatsInputValue.toString()); - - const expectedTotalCost = monthsUntilLicenseExpiry * mockProfessionalProduct.price_per_seat * seatsInputValue; - - const costAmount = document.getElementsByClassName('totalCostAmount')[0]; - expect(costAmount).toBeInTheDocument(); - expect(costAmount).toHaveTextContent(Intl.NumberFormat('en-US', {style: 'currency', currency: 'USD'}).format(expectedTotalCost)); - }); -}); - -describe('SelfHostedExpansionModal Submit', () => { - function makeHappyPathState(): FormState { - return { - address: 'string', - address2: 'string', - city: 'string', - state: 'string', - country: 'string', - postalCode: '12345', - shippingAddress: 'string', - shippingAddress2: 'string', - shippingCity: 'string', - shippingState: 'string', - shippingCountry: 'string', - shippingPostalCode: '12345', - shippingSame: false, - agreedTerms: true, - cardName: 'string', - organization: 'string', - cardFilled: true, - seats: 1, - submitting: false, - succeeded: false, - progressBar: 0, - error: '', - }; - } - it('if submitting, can not submit again', () => { - const state = makeHappyPathState(); - state.submitting = true; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false); - }); - - it('if created license, can submit', () => { - const state = makeInitialState(1); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true); - }); - - it('if paid, can submit', () => { - const state = makeInitialState(1); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); - }); - - it('if created subscription, can submit', () => { - const state = makeInitialState(1); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true); - }); - - it('if all details filled and card has not been confirmed, can submit', () => { - const state = makeHappyPathState(); - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true); - }); - - it('if card name missing and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.cardName = ''; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if card number missing and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.cardFilled = false; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if address not filled and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.address = ''; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if seats not valid and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.seats = 0; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if card confirmed, card not required for submission', () => { - const state = makeHappyPathState(); - state.cardFilled = false; - state.cardName = ''; - expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true); - }); - - it('if passed unknown progress status, can not submit', () => { - const state = makeHappyPathState(); - expect(canSubmit(state, 'unknown status' as any)).toBe(false); - }); -}); diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx deleted file mode 100644 index e4129b0912..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/index.tsx +++ /dev/null @@ -1,536 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {StripeCardElementChangeEvent} from '@stripe/stripe-js'; -import classNames from 'classnames'; -import React, {useEffect, useRef, useState} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import type {SelfHostedSignupCustomerResponse} from '@mattermost/types/hosted_customer'; -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; -import type {ValueOf} from '@mattermost/types/utilities'; - -import {HostedCustomerTypes} from 'mattermost-redux/action_types'; -import {getLicenseConfig} from 'mattermost-redux/actions/general'; -import {Client4} from 'mattermost-redux/client'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users'; - -import {confirmSelfHostedExpansion} from 'actions/hosted_customer'; -import {pageVisited} from 'actions/telemetry_actions'; -import {closeModal} from 'actions/views/modals'; -import {isCwsMockMode} from 'selectors/cloud'; - -import ChooseDifferentShipping from 'components/choose_different_shipping'; -import useLoadStripe from 'components/common/hooks/useLoadStripe'; -import BackgroundSvg from 'components/common/svg_images_components/background_svg'; -import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; -import CardInput from 'components/payment_form/card_input'; -import type {CardInputType} from 'components/payment_form/card_input'; -import RootPortal from 'components/root_portal'; -import Address from 'components/self_hosted_purchases/address'; -import ContactSalesLink from 'components/self_hosted_purchases/contact_sales_link'; -import ErrorPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/error_page'; -import SuccessPage from 'components/self_hosted_purchases/self_hosted_expansion_modal/success_page'; -import Terms from 'components/self_hosted_purchases/self_hosted_purchase_modal/terms'; -import Input from 'components/widgets/inputs/input/input'; -import FullScreenModal from 'components/widgets/modals/full_screen_modal'; - -import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; -import {inferNames} from 'utils/hosted_customer'; - -import SelfHostedExpansionCard from './expansion_card'; -import Submitting from './submitting'; - -import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from '../constants'; -import StripeProvider from '../stripe_provider'; - -import './self_hosted_expansion_modal.scss'; - -export interface FormState { - cardName: string; - cardFilled: boolean; - - address: string; - address2: string; - city: string; - state: string; - country: string; - postalCode: string; - organization: string; - - seats: number; - - shippingSame: boolean; - shippingAddress: string; - shippingAddress2: string; - shippingCity: string; - shippingState: string; - shippingCountry: string; - shippingPostalCode: string; - - agreedTerms: boolean; - - submitting: boolean; - succeeded: boolean; - progressBar: number; - error: string; -} - -export function makeInitialState(seats: number): FormState { - return { - cardName: '', - cardFilled: false, - address: '', - address2: '', - city: '', - state: '', - country: '', - postalCode: '', - organization: '', - shippingSame: true, - shippingAddress: '', - shippingAddress2: '', - shippingCity: '', - shippingState: '', - shippingCountry: '', - shippingPostalCode: '', - seats, - agreedTerms: false, - submitting: false, - succeeded: false, - progressBar: 0, - error: '', - }; -} - -export function canSubmit(formState: FormState, progress: ValueOf) { - if (formState.submitting) { - return false; - } - - const validAddress = Boolean( - formState.organization && - formState.address && - formState.city && - formState.state && - formState.postalCode && - formState.country, - ); - - const validShippingAddress = Boolean( - formState.shippingSame || - (formState.shippingAddress && - formState.shippingCity && - formState.shippingState && - formState.shippingPostalCode && - formState.shippingCountry), - ); - - const agreedToTerms = formState.agreedTerms; - - const validCard = Boolean( - formState.cardName && - formState.cardFilled, - ); - const validSeats = formState.seats > 0; - - switch (progress) { - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return true; - case SelfHostedSignupProgress.CONFIRMED_INTENT: { - return Boolean( - validAddress && validShippingAddress && validSeats && agreedToTerms, - ); - } - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return Boolean( - validCard && - validAddress && - validShippingAddress && - validSeats && - agreedToTerms, - ); - default: { - return false; - } - } -} - -export default function SelfHostedExpansionModal() { - const dispatch = useDispatch(); - const intl = useIntl(); - const cardRef = useRef(null); - const theme = useSelector(getTheme); - const progress = useSelector(getSelfHostedSignupProgress); - const user = useSelector(getCurrentUser); - const cwsMockMode = useSelector(isCwsMockMode); - - const license = useSelector(getLicense); - const licensedSeats = parseInt(license.Users, 10); - const currentPlan = license.SkuName; - const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0; - const [minimumSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats); - const [requestedSeats, setRequestedSeats] = useState(minimumSeats); - - const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); - const stripeRef = useLoadStripe(stripeLoadHint); - - const initialState = makeInitialState(requestedSeats); - const [formState, setFormState] = useState(initialState); - const [show] = useState(true); - const canRetry = formState.error !== '422'; - const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !formState.submitting && !formState.error && !formState.succeeded; - - const title = intl.formatMessage({ - id: 'self_hosted_expansion.expansion_modal.title', - defaultMessage: 'Provide your payment details', - }); - - const canSubmitForm = canSubmit(formState, progress); - - const submit = async () => { - let submitProgress = progress; - let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null; - setFormState({...formState, submitting: true}); - try { - const [firstName, lastName] = inferNames(user, formState.cardName); - - signupCustomerResult = await Client4.createCustomerSelfHostedSignup({ - first_name: firstName, - last_name: lastName, - billing_address: { - city: formState.city, - country: formState.country, - line1: formState.address, - line2: formState.address2, - postal_code: formState.postalCode, - state: formState.state, - }, - shipping_address: { - city: formState.city, - country: formState.country, - line1: formState.address, - line2: formState.address2, - postal_code: formState.postalCode, - state: formState.state, - }, - organization: formState.organization, - }); - } catch { - setFormState({...formState, error: 'Failed to submit payment information'}); - return; - } - - if (signupCustomerResult === null) { - setStripeLoadHint(Math.random()); - setFormState({...formState, submitting: false}); - return; - } - - if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) { - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: signupCustomerResult.progress, - }); - submitProgress = signupCustomerResult.progress; - } - if (stripeRef.current === null) { - setStripeLoadHint(Math.random()); - setFormState({...formState, submitting: false}); - return; - } - - try { - const card = cardRef.current?.getCard(); - if (!card) { - const message = 'Failed to get card when it was expected'; - setFormState({...formState, error: message}); - return; - } - const finished = await dispatch(confirmSelfHostedExpansion( - stripeRef.current, - { - id: signupCustomerResult.setup_intent_id, - client_secret: signupCustomerResult.setup_intent_secret, - }, - cwsMockMode, - { - address: formState.address, - address2: formState.address2, - city: formState.city, - state: formState.state, - country: formState.country, - postalCode: formState.postalCode, - name: formState.cardName, - card, - }, - submitProgress, - { - seats: formState.seats, - license_id: license.Id, - }, - )); - - if (finished.data) { - setFormState({...formState, succeeded: true}); - - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: SelfHostedSignupProgress.CREATED_LICENSE, - }); - - // Reload license in background. - // Needed if this was completed while on the Edition and License page. - dispatch(getLicenseConfig()); - } else if (finished.error) { - let errorData = finished.error; - if (finished.error === 422) { - errorData = finished.error.toString(); - } - setFormState({...formState, error: errorData}); - return; - } - setFormState({...formState, submitting: false}); - } catch (e) { - setFormState({...formState, error: 'unable to complete signup'}); - } - }; - - useEffect(() => { - pageVisited( - TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, - 'pageview_self_hosted_expansion', - ); - - localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true'); - return () => { - localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS); - }; - }, []); - - const resetToken = () => { - try { - Client4.bootstrapSelfHostedSignup(true). - then((data) => { - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: data.progress, - }); - }); - } catch { - // swallow error ok here - } - }; - - return ( - - - { - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); - resetToken(); - }} - > -
-
-
-

{title}

- -
{'Questions?'}
- -
-
-
- - {intl.formatMessage({ - id: 'payment_form.credit_card', - defaultMessage: 'Credit Card', - })} - -
- { - setFormState({...formState, cardFilled: event.complete}); - }} - theme={theme} - /> -
-
- ) => { - setFormState({...formState, organization: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'self_hosted_signup.organization', - defaultMessage: 'Organization Name', - })} - required={true} - /> -
-
- ) => { - setFormState({...formState, cardName: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.name_on_card', - defaultMessage: 'Name on Card', - })} - required={true} - /> -
- - - -
{ - setFormState({...formState, country: option.value}); - }} - address={formState.address} - changeAddress={(e) => { - setFormState({...formState, address: e.target.value}); - }} - address2={formState.address2} - changeAddress2={(e) => { - setFormState({...formState, address2: e.target.value}); - }} - city={formState.city} - changeCity={(e) => { - setFormState({...formState, city: e.target.value}); - }} - state={formState.state} - changeState={(state: string) => { - setFormState({...formState, state}); - }} - postalCode={formState.postalCode} - changePostalCode={(e) => { - setFormState({...formState, postalCode: e.target.value}); - }} - /> - { - setFormState({...formState, shippingSame: val}); - }} - /> - {!formState.shippingSame && ( - <> -
- -
-
{ - setFormState({...formState, shippingCountry: option.value}); - }} - address={formState.shippingAddress} - changeAddress={(e) => { - setFormState({...formState, shippingAddress: e.target.value}); - }} - address2={formState.shippingAddress2} - changeAddress2={(e) => { - setFormState({...formState, shippingAddress2: e.target.value}); - }} - city={formState.shippingCity} - changeCity={(e) => { - setFormState({...formState, shippingCity: e.target.value}); - }} - state={formState.shippingState} - changeState={(state: string) => { - setFormState({...formState, shippingState: state}); - }} - postalCode={formState.shippingPostalCode} - changePostalCode={(e) => { - setFormState({...formState, shippingPostalCode: e.target.value}); - }} - /> - - )} - { - setFormState({...formState, agreedTerms: data}); - }} - /> -
-
-
- { - setFormState({...formState, seats}); - setRequestedSeats(seats); - }} - canSubmit={canSubmitForm} - submit={submit} - licensedSeats={licensedSeats} - minimumSeats={minimumSeats} - /> -
-
- {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE)) && !formState.error && !formState.submitting && ( - { - setFormState({...formState, submitting: false, error: '', succeeded: false}); - dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION)); - }} - /> - )} - {formState.submitting && ( - - )} - {formState.error && ( - { - setFormState({...formState, submitting: false, error: ''}); - }} - /> - )} -
- -
-
-
-
-
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss deleted file mode 100644 index 3cd20f0915..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/self_hosted_expansion_modal.scss +++ /dev/null @@ -1,183 +0,0 @@ -.SelfHostedExpansionModal { - height: 100%; - - .form-view { - display: flex; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: top; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; - overflow-x: hidden; - - .title { - font-size: 22px; - font-weight: 600; - } - - .form { - padding: 0 96px; - margin: 0 auto; - - .form-row { - display: flex; - width: 100%; - margin-bottom: 24px; - } - - .form-row-third-1 { - width: 66%; - max-width: 288px; - margin-right: 16px; - - .DropdownInput { - margin-top: 0; - } - } - - .DropdownInput { - position: relative; - height: 36px; - margin-bottom: 24px; - - .Input_fieldset { - height: 43px; - } - } - - .form-row-third-2 { - width: 34%; - max-width: 144px; - } - - .section-title { - display: block; - margin-bottom: 10px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 16px; - font-weight: 600; - text-align: left; - } - - .Input_fieldset { - height: 40px; - padding: 2px 1px; - background: var(--center-channel-bg); - - .Input { - background: inherit; - } - - .Input_wrapper { - margin: 0; - } - } - } - - &--hide { - display: none; - } - - >.lhs { - width: 25%; - } - - >.center { - width: 50%; - } - - >.rhs { - position: sticky; - display: flex; - width: 25%; - flex-direction: column; - align-items: center; - } - - .submitting, - .success, - .failed { - display: flex; - overflow: hidden; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: center; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; - - .IconMessage .content .IconMessage-link { - margin-left: 0; - } - } - - .background-svg { - position: absolute; - z-index: -1; - top: 0; - width: 100%; - height: 100%; - - >div { - position: absolute; - top: 0; - left: 0; - } - } - - .self-hosted-agreed-terms { - label { - display: flex; - align-items: flex-start; - justify-content: flex-start; - } - - input[type=checkbox] { - width: 17px; - height: 17px; - flex-shrink: 0; - margin-right: 12px; - } - - font-size: 16px; - } - } - - @media (max-width: 1020px) { - .SelfHostedExpansionModal { - .form-view { - >.lhs { - display: none; - } - - >.center { - width: 66%; - } - - >.rhs { - width: 33%; - } - } - } - } - - .FullScreenModal { - .close-x { - top: 12px; - right: 12px; - } - } -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx deleted file mode 100644 index 2c5dc5853b..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting.tsx +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect, useState} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; -import type {ValueOf} from '@mattermost/types/utilities'; - -import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; - -import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; -import IconMessage from 'components/purchase_modal/icon_message'; - -import './submitting_page.scss'; - -function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { - const intl = useIntl(); - switch (progress) { - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.submitting_payment', - defaultMessage: 'Submitting payment information', - }); - case SelfHostedSignupProgress.CONFIRMED_INTENT: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.verifying_payment', - defaultMessage: 'Verifying payment details', - }); - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.applying_license', - defaultMessage: 'Applying your {planName} license to your Mattermost instance', - }, {planName}); - default: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.submitting_payment', - defaultMessage: 'Submitting payment information', - }); - } -} - -export function convertProgressToBar(progress: ValueOf): number { - switch (progress) { - case SelfHostedSignupProgress.START: - return 15; - case SelfHostedSignupProgress.CREATED_CUSTOMER: - return 30; - case SelfHostedSignupProgress.CREATED_INTENT: - return 45; - case SelfHostedSignupProgress.CONFIRMED_INTENT: - return 60; - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return 75; - case SelfHostedSignupProgress.PAID: - return 85; - case SelfHostedSignupProgress.CREATED_LICENSE: - return 100; - default: - return 0; - } -} - -interface Props { - currentPlan: string; -} - -const maxProgressBar = 100; -const maxFakeProgressIncrement = 5; -const fakeProgressInterval = 600; - -export default function Submitting(props: Props) { - const [barProgress, setBarProgress] = useState(0); - const signupProgress = useSelector(getSelfHostedSignupProgress); - const waitingExplanation = useConvertProgressToWaitingExplanation(signupProgress, props.currentPlan); - const footer = ( -
-
-
- ); - - useEffect(() => { - const maxProgressForCurrentSignupProgress = convertProgressToBar(signupProgress); - const interval = setInterval(() => { - if (barProgress < maxProgressBar) { - setBarProgress(Math.min(maxProgressForCurrentSignupProgress, barProgress + maxFakeProgressIncrement)); - } - }, fakeProgressInterval); - - return () => clearInterval(interval); - }, [barProgress]); - - return ( - -
- - )} - formattedSubtitle={waitingExplanation} - icon={ - - } - footer={footer} - className={'processing'} - /> -
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss deleted file mode 100644 index 46179472b8..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/submitting_page.scss +++ /dev/null @@ -1,7 +0,0 @@ -.submitting { - overflow: hidden; - - .processing { - margin-top: 163px; - } -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss deleted file mode 100644 index 522384347e..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.scss +++ /dev/null @@ -1,23 +0,0 @@ -.SelfHostedPurchaseModal__success { - display: flex; - overflow: hidden; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: center; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-size: 16px; - font-weight: 600; -} - -.self_hosted_expansion_success { - overflow: hidden; - - .selfHostedExpansionModal__success { - margin-top: 163px; - } -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx deleted file mode 100644 index 6ed42304ec..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_expansion_modal/success_page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useHistory} from 'react-router-dom'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; -import IconMessage from 'components/purchase_modal/icon_message'; - -import {ConsolePages, TELEMETRY_CATEGORIES} from 'utils/constants'; - -import './success_page.scss'; - -interface Props { - onClose: () => void; -} - -export default function SelfHostedExpansionSuccessPage(props: Props) { - const history = useHistory(); - const titleText = ( - - ); - - const formattedSubtitleText = ( - Billing section of the system console.'} - values={{ - billing: (billingText: React.ReactNode) => ( - { - trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'success_screen_closed'); - history.push(ConsolePages.BILLING_HISTORY); - props.onClose(); - }} - > - {billingText} - - ), - }} - /> - ); - - const formattedButtonText = ( - - ); - - const icon = ( - - ); - - return ( -
- -
- ); -} - diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx deleted file mode 100644 index 5ab8439924..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/error.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import {useOpenSelfHostedZendeskSupportForm} from 'components/common/hooks/useOpenZendeskForm'; -import AccessDeniedHappySvg from 'components/common/svg_images_components/access_denied_happy_svg'; -import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg'; -import ExternalLink from 'components/external_link'; -import IconMessage from 'components/purchase_modal/icon_message'; - -interface Props { - nextAction: () => void; - canRetry: boolean; - errorType: 'failed_export' | 'generic'; -} - -export default function ErrorPage(props: Props) { - const [, contactSupportLink] = useOpenSelfHostedZendeskSupportForm('Purchase error'); - let formattedTitle = ( - - ); - let formattedButtonText = ( - - ); - - if (!props.canRetry) { - formattedButtonText = ( - - ); - } - - let formattedSubtitle = ( - - ); - - let icon = ( - - ); - - if (props.errorType === 'failed_export') { - formattedTitle = ( - - ); - - formattedSubtitle = ( - - ); - - icon = ( - - ); - } - - return ( -
- - - - } - /> -
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx deleted file mode 100644 index 2a56b28849..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.test.tsx +++ /dev/null @@ -1,445 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; -import type {SelfHostedSignupForm} from '@mattermost/types/hosted_customer'; -import type {DeepPartial} from '@mattermost/types/utilities'; - -import { - fireEvent, - renderWithContext, - screen, - waitFor, -} from 'tests/react_testing_utils'; -import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants'; -import {TestHelper as TH} from 'utils/test_helper'; - -import type {GlobalState} from 'types/store'; - -import SelfHostedPurchaseModal, {makeInitialState, canSubmit} from '.'; -import type {State} from '.'; - -interface MockCardInputProps { - onCardInputChange: (event: {complete: boolean}) => void; - forwardedRef: React.MutableRefObject; -} - -// number borrowed from stripe -const successCardNumber = '4242424242424242'; -function MockCardInput(props: MockCardInputProps) { - props.forwardedRef.current = { - getCard: () => ({}), - }; - return ( - ) => { - if (e.target.value === successCardNumber) { - props.onCardInputChange({complete: true}); - } - }} - /> - ); -} - -jest.mock('components/payment_form/card_input', () => { - const original = jest.requireActual('components/payment_form/card_input'); - return { - ...original, - __esModule: true, - default: MockCardInput, - }; -}); - -jest.mock('components/self_hosted_purchases/stripe_provider', () => { - return function(props: {children: React.ReactNode | React.ReactNodeArray}) { - return props.children; - }; -}); - -jest.mock('components/common/hooks/useLoadStripe', () => { - return function() { - return {current: { - stripe: {}, - - }}; - }; -}); - -const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT; -const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE; -const failOrg = 'failorg'; - -const existingUsers = 11; - -jest.mock('mattermost-redux/client', () => { - const original = jest.requireActual('mattermost-redux/client'); - return { - __esModule: true, - ...original, - Client4: { - ...original.Client4, - pageVisited: jest.fn(), - setAcceptLanguage: jest.fn(), - trackEvent: jest.fn(), - createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => { - if (form.organization === failOrg) { - throw new Error('error creating customer'); - } - return Promise.resolve({ - progress: mockCreatedIntent, - }); - }, - confirmSelfHostedSignup: () => Promise.resolve({ - progress: mockCreatedLicense, - license: {Users: existingUsers * 2}, - }), - getClientLicenseOld: () => Promise.resolve({ - data: {Sku: 'Enterprise'}, - }), - }, - }; -}); - -jest.mock('components/payment_form/stripe', () => { - const original = jest.requireActual('components/payment_form/stripe'); - return { - __esModule: true, - ...original, - getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}), - }; -}); - -const productName = 'Professional'; - -const initialState: DeepPartial = { - views: { - modals: { - modalState: { - [ModalIdentifiers.SELF_HOSTED_PURCHASE]: { - open: true, - }, - }, - }, - }, - storage: { - storage: {}, - }, - entities: { - admin: { - analytics: { - TOTAL_USERS: existingUsers, - }, - }, - teams: { - currentTeamId: '', - }, - preferences: { - myPreferences: { - theme: {}, - }, - }, - general: { - config: { - EnableDeveloper: 'false', - }, - license: { - Sku: 'Enterprise', - }, - }, - cloud: { - subscription: {}, - }, - users: { - currentUserId: 'adminUserId', - profiles: { - adminUserId: TH.getUserMock({ - id: 'adminUserId', - roles: 'admin', - first_name: 'first', - last_name: 'admin', - }), - otherUserId: TH.getUserMock({ - id: 'otherUserId', - roles: '', - first_name: '', - last_name: '', - }), - }, - }, - hostedCustomer: { - products: { - productsLoaded: true, - products: { - prod_professional: TH.getProductMock({ - id: 'prod_professional', - name: 'Professional', - sku: SelfHostedProducts.PROFESSIONAL, - price_per_seat: 7.5, - - }), - - }, - }, - signupProgress: SelfHostedSignupProgress.START, - }, - }, -}; - -const valueEvent = (value: any) => ({target: {value}}); -function changeByPlaceholder(sel: string, val: any) { - fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val)); -} - -// having issues with normal selection of texts and clicks. -function selectDropdownValue(testId: string, value: string) { - fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value)); - fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any); -} - -interface PurchaseForm { - card: string; - org: string; - name: string; - country: string; - address: string; - city: string; - state: string; - zip: string; - agree: boolean; - -} - -const defaultSuccessForm: PurchaseForm = { - card: successCardNumber, - org: 'My org', - name: 'The Cardholder', - country: 'United States of America', - address: '123 Main Street', - city: 'Minneapolis', - state: 'MN', - zip: '55423', - agree: true, -}; -function fillForm(form: PurchaseForm) { - changeByPlaceholder('Card number', form.card); - changeByPlaceholder('Organization Name', form.org); - changeByPlaceholder('Name on Card', form.name); - selectDropdownValue('selfHostedPurchaseCountrySelector', form.country); - changeByPlaceholder('Address', form.address); - changeByPlaceholder('City', form.city); - selectDropdownValue('selfHostedPurchaseStateSelector', form.state); - changeByPlaceholder('Zip/Postal Code', form.zip); - if (form.agree) { - fireEvent.click(screen.getByText('I have read and agree', {exact: false})); - } - - // not changing the license seats number, - // because it is expected to be pre-filled with the correct number of seats. - - const upgradeButton = screen.getByText('Upgrade'); - - // while this will will not if the caller passes in an object - // that has member equality but not reference equality, this is - // good enough for the limited usage this function has - if (form === defaultSuccessForm) { - expect(upgradeButton).toBeEnabled(); - } - - return upgradeButton; -} - -describe('SelfHostedPurchaseModal', () => { - it('renders the form', () => { - renderWithContext(
, initialState); - - // check title, and some of the most prominent details and secondary actions - screen.getByText('Provide your payment details'); - screen.getByText('Contact Sales'); - screen.getByText('USD per seat/month', {exact: false}); - screen.getByText('billed annually', {exact: false}); - screen.getByText(productName); - screen.getByText('You will be billed today. Your license will be applied automatically', {exact: false}); - screen.getByText('See how billing works', {exact: false}); - }); - - it('filling the form enables signup', () => { - renderWithContext(
, initialState); - expect(screen.getByText('Upgrade')).toBeDisabled(); - fillForm(defaultSuccessForm); - }); - - it('disables signup if too few seats chosen', () => { - renderWithContext(
, initialState); - fillForm(defaultSuccessForm); - - const tooFewSeats = existingUsers - 1; - fireEvent.change(screen.getByTestId('selfHostedPurchaseSeatsInput'), valueEvent(tooFewSeats.toString())); - expect(screen.getByText('Upgrade')).toBeDisabled(); - screen.getByText('Your workspace currently has 11 users', {exact: false}); - }); - - it('Minimum of 10 seats is required for sign up', () => { - renderWithContext(
, initialState); - fillForm(defaultSuccessForm); - - const tooFewSeats = 9; - fireEvent.change(screen.getByTestId('selfHostedPurchaseSeatsInput'), valueEvent(tooFewSeats.toString())); - expect(screen.getByText('Upgrade')).toBeDisabled(); - screen.getByText('Minimum of 10 seats required', {exact: false}); - }); - - it('happy path submit shows success screen', async () => { - renderWithContext(
, initialState); - expect(screen.getByText('Upgrade')).toBeDisabled(); - const upgradeButton = fillForm(defaultSuccessForm); - - upgradeButton.click(); - await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234}); - }); - - it('sad path submit shows error screen', async () => { - renderWithContext(
, initialState); - expect(screen.getByText('Upgrade')).toBeDisabled(); - fillForm(defaultSuccessForm); - changeByPlaceholder('Organization Name', failOrg); - - const upgradeButton = screen.getByText('Upgrade'); - expect(upgradeButton).toBeEnabled(); - upgradeButton.click(); - await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234}); - }); -}); - -describe('SelfHostedPurchaseModal :: canSubmit', () => { - function makeHappyPathState(): State { - return { - - address: 'string', - address2: 'string', - city: 'string', - state: 'string', - country: 'string', - postalCode: '12345', - - shippingSame: true, - shippingAddress: '', - shippingAddress2: '', - shippingCity: '', - shippingState: '', - shippingCountry: '', - shippingPostalCode: '', - - cardName: 'string', - organization: 'string', - agreedTerms: true, - cardFilled: true, - seats: { - quantity: '12', - error: null, - }, - submitting: false, - succeeded: false, - progressBar: 0, - error: '', - }; - } - it('if submitting, can not submit', () => { - const state = makeHappyPathState(); - state.submitting = true; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false); - }); - it('if created license, can submit', () => { - const state = makeInitialState(); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true); - }); - - it('if paid, can submit', () => { - const state = makeInitialState(); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true); - }); - - it('if created subscription, can submit', () => { - const state = makeInitialState(); - state.submitting = false; - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true); - }); - - it('if all details filled and card has not been confirmed, can submit', () => { - const state = makeHappyPathState(); - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true); - }); - - it('if card name missing and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.cardName = ''; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if shipping address different and is not filled, can not submit', () => { - const state = makeHappyPathState(); - state.shippingSame = false; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - - state.shippingAddress = 'more shipping info'; - state.shippingAddress2 = 'more shipping info'; - state.shippingCity = 'more shipping info'; - state.shippingState = 'more shipping info'; - state.shippingCountry = 'more shipping info'; - state.shippingPostalCode = 'more shipping info'; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true); - }); - - it('if card number missing and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.cardFilled = false; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if address not filled and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.address = ''; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if seats not valid and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.seats.error = 'some seats error'; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if did not agree to terms and card has not been confirmed, can not submit', () => { - const state = makeHappyPathState(); - state.agreedTerms = false; - expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false); - expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false); - }); - - it('if card confirmed, card not required for submission', () => { - const state = makeHappyPathState(); - state.cardFilled = false; - state.cardName = ''; - expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true); - }); - - it('if passed unknown progress status, can not submit', () => { - const state = makeHappyPathState(); - expect(canSubmit(state, 'unknown status' as any)).toBe(false); - }); -}); diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx deleted file mode 100644 index 4045decbeb..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/index.tsx +++ /dev/null @@ -1,742 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {StripeCardElementChangeEvent} from '@stripe/stripe-js'; -import classNames from 'classnames'; -import React, {useEffect, useRef, useReducer, useState} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useSelector, useDispatch} from 'react-redux'; - -import type { - SelfHostedSignupCustomerResponse} from '@mattermost/types/hosted_customer'; -import { - SelfHostedSignupProgress, -} from '@mattermost/types/hosted_customer'; -import type {ValueOf} from '@mattermost/types/utilities'; - -import {HostedCustomerTypes} from 'mattermost-redux/action_types'; -import {getLicenseConfig} from 'mattermost-redux/actions/general'; -import {Client4} from 'mattermost-redux/client'; -import {getAdminAnalytics} from 'mattermost-redux/selectors/entities/admin'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getSelfHostedProducts, getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; - -import {confirmSelfHostedSignup} from 'actions/hosted_customer'; -import {trackEvent, pageVisited} from 'actions/telemetry_actions'; -import {isCwsMockMode} from 'selectors/cloud'; -import {isModalOpen} from 'selectors/views/modals'; - -import ChooseDifferentShipping from 'components/choose_different_shipping'; -import useControlSelfHostedPurchaseModal from 'components/common/hooks/useControlSelfHostedPurchaseModal'; -import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics'; -import useLoadStripe from 'components/common/hooks/useLoadStripe'; -import BackgroundSvg from 'components/common/svg_images_components/background_svg'; -import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; -import CardInput from 'components/payment_form/card_input'; -import type {CardInputType} from 'components/payment_form/card_input'; -import RootPortal from 'components/root_portal'; -import {errorInvalidNumber} from 'components/seats_calculator'; -import type {Seats} from 'components/seats_calculator'; -import Input from 'components/widgets/inputs/input/input'; -import FullScreenModal from 'components/widgets/modals/full_screen_modal'; - -import { - ModalIdentifiers, - StatTypes, - TELEMETRY_CATEGORIES, -} from 'utils/constants'; -import {inferNames} from 'utils/hosted_customer'; - -import type {GlobalState} from 'types/store'; - -import ErrorPage from './error'; -import SelfHostedCard from './self_hosted_card'; -import Submitting, {convertProgressToBar} from './submitting'; -import SuccessPage from './success_page'; -import Terms from './terms'; -import {SetPrefix} from './types'; -import type {UnionSetActions} from './types'; -import useNoEscape from './useNoEscape'; - -import Address from '../address'; -import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from '../constants'; -import ContactSalesLink from '../contact_sales_link'; -import StripeProvider from '../stripe_provider'; - -import './self_hosted_purchase_modal.scss'; - -export interface State { - - // billing address - address: string; - address2: string; - city: string; - state: string; - country: string; - postalCode: string; - - // shipping address - shippingSame: boolean; - shippingAddress: string; - shippingAddress2: string; - shippingCity: string; - shippingState: string; - shippingCountry: string; - shippingPostalCode: string; - - cardName: string; - organization: string; - agreedTerms: boolean; - cardFilled: boolean; - seats: Seats; - submitting: boolean; - succeeded: boolean; - progressBar: number; - error: string; -} - -interface UpdateSucceeded { - type: 'succeeded'; -} - -interface UpdateProgressBarFake { - type: 'update_progress_bar_fake'; -} - -interface ClearError { - type: 'clear_error'; -} - -type SetActions = UnionSetActions; - -type Action = SetActions | UpdateProgressBarFake | UpdateSucceeded | ClearError -export function makeInitialState(): State { - return { - address: '', - address2: '', - city: '', - state: '', - country: '', - postalCode: '', - - shippingSame: true, - shippingAddress: '', - shippingAddress2: '', - shippingCity: '', - shippingState: '', - shippingCountry: '', - shippingPostalCode: '', - - cardName: '', - organization: '', - agreedTerms: false, - cardFilled: false, - seats: { - quantity: '0', - error: errorInvalidNumber, - }, - submitting: false, - succeeded: false, - progressBar: 0, - error: '', - }; -} -const initialState = makeInitialState(); - -const maxFakeProgress = 90; -const maxFakeProgressIncrement = 5; -const fakeProgressInterval = 1500; - -function getPlanNameFromProductName(productName: string): string { - if (productName.length > 0) { - const [name] = productName.split(' ').slice(-1); - return name; - } - - return productName; -} - -function isSetAction(action: Action): action is SetActions { - return Object.prototype.hasOwnProperty.call(action, 'data'); -} - -type SetKey=`${typeof SetPrefix}${Extract}`; - -function actionTypeToStateKey(actionType: SetKey): Extract { - return actionType.slice(SetPrefix.length) as Extract; -} - -function simpleSet(keys: Array>, state: State, action: Action): [State, boolean] { - if (!isSetAction(action)) { - return [state, false]; - } - const stateKey = actionTypeToStateKey(action.type); - if (!keys.includes(stateKey)) { - return [state, false]; - } - - return [{...state, [stateKey]: action.data}, true]; -} - -// properties we can set the field on directly without needing to consider or modify other properties -const simpleSetters: Array> = [ - 'address', - 'address2', - 'city', - 'country', - 'state', - 'postalCode', - - // shipping address - 'shippingSame', - 'shippingAddress', - 'shippingAddress2', - 'shippingCity', - 'shippingState', - 'shippingCountry', - 'shippingPostalCode', - - 'agreedTerms', - 'cardFilled', - 'cardName', - 'organization', - 'progressBar', - 'seats', - 'submitting', -]; -function reducer(state: State, action: Action): State { - const [newState, handled] = simpleSet(simpleSetters, state, action); - if (handled) { - return newState; - } - switch (action.type) { - case 'update_progress_bar_fake': { - const firstLongStep = SelfHostedSignupProgress.CONFIRMED_INTENT; - if (state.progressBar >= convertProgressToBar(firstLongStep) && state.progressBar <= maxFakeProgress - maxFakeProgressIncrement) { - return {...state, progressBar: state.progressBar + maxFakeProgressIncrement}; - } - return state; - } - case 'clear_error': { - return { - ...state, - error: '', - }; - } - case 'set_error': { - return { - ...state, - submitting: false, - error: action.data, - }; - } - case 'succeeded': - return {...state, submitting: false, succeeded: true}; - default: - // eslint-disable-next-line no-console - console.error(`Exhaustiveness failure for self hosted purchase modal. action: ${JSON.stringify(action)}`); - return state; - } -} - -export function canSubmit(state: State, progress: ValueOf) { - if (state.submitting) { - return false; - } - - let validAddress = Boolean( - state.organization && - state.address && - state.city && - state.state && - state.postalCode && - state.country, - ); - if (!state.shippingSame) { - validAddress = validAddress && Boolean( - state.shippingAddress && - state.shippingCity && - state.shippingState && - state.shippingPostalCode && - state.shippingCountry, - - ); - } - const validCard = Boolean( - state.cardName && - state.cardFilled, - ); - const validSeats = !state.seats.error; - switch (progress) { - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return true; - case SelfHostedSignupProgress.CONFIRMED_INTENT: { - return Boolean( - validAddress && - validSeats && - state.agreedTerms, - ); - } - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return Boolean( - validCard && - validAddress && - validSeats && - state.agreedTerms, - ); - default: { - // eslint-disable-next-line no-console - console.log(`Unexpected progress state: ${progress}`); - return false; - } - } -} - -interface Props { - productId: string; -} - -interface FakeProgress { - intervalId?: NodeJS.Timeout; -} - -export default function SelfHostedPurchaseModal(props: Props) { - useFetchStandardAnalytics(); - useNoEscape(); - const controlModal = useControlSelfHostedPurchaseModal({productId: props.productId}); - const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.SELF_HOSTED_PURCHASE)); - const progress = useSelector(getSelfHostedSignupProgress); - const user = useSelector(getCurrentUser); - const theme = useSelector(getTheme); - const analytics = useSelector(getAdminAnalytics) || {}; - const desiredProduct = useSelector(getSelfHostedProducts)[props.productId]; - const desiredProductName = desiredProduct?.name || ''; - const desiredPlanName = getPlanNameFromProductName(desiredProductName); - const currentUsers = analytics[StatTypes.TOTAL_USERS] as number; - const cwsMockMode = useSelector(isCwsMockMode); - const hasLicense = Object.keys(useSelector(getLicense) || {}).length > 0; - - const intl = useIntl(); - const fakeProgressRef = useRef({ - }); - - const [state, dispatch] = useReducer(reducer, initialState); - const reduxDispatch = useDispatch(); - - const cardRef = useRef(null); - const modalRef = useRef(); - const [stripeLoadHint, setStripeLoadHint] = useState(Math.random()); - - const stripeRef = useLoadStripe(stripeLoadHint); - const showForm = progress !== SelfHostedSignupProgress.PAID && progress !== SelfHostedSignupProgress.CREATED_LICENSE && !state.submitting && !state.error && !state.succeeded; - - useEffect(() => { - if (typeof currentUsers === 'number' && (currentUsers > parseInt(state.seats.quantity, 10) || !parseInt(state.seats.quantity, 10))) { - dispatch({type: 'set_seats', - data: { - quantity: currentUsers.toString(), - error: null, - }}); - } - }, [currentUsers]); - useEffect(() => { - pageVisited( - TELEMETRY_CATEGORIES.CLOUD_PURCHASING, - 'pageview_self_hosted_purchase', - ); - - localStorage.setItem(STORAGE_KEY_PURCHASE_IN_PROGRESS, 'true'); - return () => { - localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS); - }; - }, []); - - useEffect(() => { - const progressBar = convertProgressToBar(progress); - if (progressBar > state.progressBar) { - dispatch({type: 'set_progressBar', data: progressBar}); - } - }, [progress]); - - useEffect(() => { - if (fakeProgressRef.current && fakeProgressRef.current.intervalId) { - clearInterval(fakeProgressRef.current.intervalId); - } - if (state.submitting) { - fakeProgressRef.current.intervalId = setInterval(() => { - dispatch({type: 'update_progress_bar_fake'}); - }, fakeProgressInterval); - } - return () => { - if (fakeProgressRef.current && fakeProgressRef.current.intervalId) { - clearInterval(fakeProgressRef.current.intervalId); - } - }; - }, [state.submitting]); - - const handleCardInputChange = (event: StripeCardElementChangeEvent) => { - dispatch({type: 'set_cardFilled', data: event.complete}); - }; - - async function submit() { - let submitProgress = progress; - dispatch({type: 'set_submitting', data: true}); - let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null; - try { - const [firstName, lastName] = inferNames(user, state.cardName); - - const billingAddress = { - city: state.city, - country: state.country, - line1: state.address, - line2: state.address2, - postal_code: state.postalCode, - state: state.state, - }; - signupCustomerResult = await Client4.createCustomerSelfHostedSignup({ - first_name: firstName, - last_name: lastName, - billing_address: billingAddress, - shipping_address: state.shippingSame ? billingAddress : { - city: state.shippingCity, - country: state.shippingCountry, - line1: state.shippingAddress, - line2: state.shippingAddress2, - postal_code: state.shippingPostalCode, - state: state.shippingState, - }, - organization: state.organization, - }); - } catch { - dispatch({type: 'set_error', data: 'Failed to submit payment information'}); - return; - } - - if (signupCustomerResult === null || !signupCustomerResult.progress) { - dispatch({type: 'set_error', data: 'Failed to submit payment information'}); - return; - } - if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) { - reduxDispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: signupCustomerResult.progress, - }); - submitProgress = signupCustomerResult.progress; - } - if (stripeRef.current === null) { - setStripeLoadHint(Math.random()); - dispatch({type: 'set_submitting', data: false}); - return; - } - try { - const card = cardRef.current?.getCard(); - if (!card) { - const message = 'Failed to get card when it was expected'; - // eslint-disable-next-line no-console - console.error(message); - dispatch({type: 'set_error', data: message}); - return; - } - const finished = await reduxDispatch(confirmSelfHostedSignup( - stripeRef.current, - { - id: signupCustomerResult.setup_intent_id, - client_secret: signupCustomerResult.setup_intent_secret, - }, - cwsMockMode, - { - address: state.address, - address2: state.address2, - city: state.city, - state: state.state, - country: state.country, - postalCode: state.postalCode, - name: state.cardName, - card, - }, - submitProgress, - { - product_id: props.productId, - add_ons: [], - seats: parseInt(state.seats.quantity, 10), - }, - )); - if (finished.data) { - trackEvent( - TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, - 'purchase_success', - {seats: parseInt(finished.data?.Users, 10) || 0, users: currentUsers}, - ); - dispatch({type: 'succeeded'}); - - reduxDispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: SelfHostedSignupProgress.CREATED_LICENSE, - }); - - // Reload license in background. - // Needed if this was completed while on the Edition and License page. - reduxDispatch(getLicenseConfig()); - } else if (finished.error) { - let errorData = finished.error; - if (finished.error === 422) { - errorData = finished.error.toString(); - } - dispatch({type: 'set_error', data: errorData}); - return; - } - dispatch({type: 'set_submitting', data: false}); - } catch (e) { - // eslint-disable-next-line no-console - console.error('could not complete setup', e); - dispatch({type: 'set_error', data: 'unable to complete signup'}); - } - } - const canSubmitForm = canSubmit(state, progress); - - const title = ( - - ); - - const canRetry = state.error !== '422'; - const resetToken = () => { - try { - Client4.bootstrapSelfHostedSignup(true). - then((data) => { - reduxDispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS, - data: data.progress, - }); - }); - } catch { - // swallow error ok here - } - }; - const errorAction = () => { - if (canRetry && (progress === SelfHostedSignupProgress.CREATED_SUBSCRIPTION || progress === SelfHostedSignupProgress.PAID || progress === SelfHostedSignupProgress.CREATED_LICENSE)) { - submit(); - dispatch({type: 'clear_error'}); - return; - } - - resetToken(); - if (canRetry) { - dispatch({type: 'set_error', data: ''}); - } else { - controlModal.close(); - } - }; - - return ( - - - { - trackEvent( - TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, - 'click_close_purchasing_screen', - ); - resetToken(); - controlModal.close(); - }} - > -
- {
-
-

{title}

- -
{'Questions?'}
- -
-
-
-
- -
-
- -
-
- ) => { - dispatch({type: 'set_organization', data: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'self_hosted_signup.organization', - defaultMessage: 'Organization Name', - })} - required={true} - /> -
-
- ) => { - dispatch({type: 'set_cardName', data: e.target.value}); - }} - placeholder={intl.formatMessage({ - id: 'payment_form.name_on_card', - defaultMessage: 'Name on Card', - })} - required={true} - /> -
-
- -
-
{ - dispatch({type: 'set_country', data: option.value}); - }} - address={state.address} - changeAddress={(e) => { - dispatch({type: 'set_address', data: e.target.value}); - }} - address2={state.address2} - changeAddress2={(e) => { - dispatch({type: 'set_address2', data: e.target.value}); - }} - city={state.city} - changeCity={(e) => { - dispatch({type: 'set_city', data: e.target.value}); - }} - state={state.state} - changeState={(state: string) => { - dispatch({type: 'set_state', data: state}); - }} - postalCode={state.postalCode} - changePostalCode={(e) => { - dispatch({type: 'set_postalCode', data: e.target.value}); - }} - /> - { - dispatch({type: 'set_shippingSame', data: val}); - }} - /> - {!state.shippingSame && ( - <> -
- -
-
{ - dispatch({type: 'set_shippingCountry', data: option.value}); - }} - address={state.shippingAddress} - changeAddress={(e) => { - dispatch({type: 'set_shippingAddress', data: e.target.value}); - }} - address2={state.shippingAddress2} - changeAddress2={(e) => { - dispatch({type: 'set_shippingAddress2', data: e.target.value}); - }} - city={state.shippingCity} - changeCity={(e) => { - dispatch({type: 'set_shippingCity', data: e.target.value}); - }} - state={state.shippingState} - changeState={(state: string) => { - dispatch({type: 'set_shippingState', data: state}); - }} - postalCode={state.shippingPostalCode} - changePostalCode={(e) => { - dispatch({type: 'set_shippingPostalCode', data: e.target.value}); - }} - /> - - )} - { - dispatch({type: 'set_agreedTerms', data}); - }} - /> -
-
-
- { - dispatch({type: 'set_seats', data: seats}); - }} - canSubmit={canSubmitForm} - submit={submit} - /> -
-
} - {((state.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !state.error && !state.submitting && ( - - )} - {state.submitting && ( - - )} - {state.error && ( - - )} -
- -
-
-
-
-
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx deleted file mode 100644 index 5620ac4a21..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_card.tsx +++ /dev/null @@ -1,130 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; - -import type {Product} from '@mattermost/types/cloud'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; -import PlanLabel from 'components/common/plan_label'; -import {Card, ButtonCustomiserClasses} from 'components/purchase_modal/purchase_modal'; -import SeatsCalculator from 'components/seats_calculator'; -import type {Seats} from 'components/seats_calculator'; -import Consequences from 'components/seats_calculator/consequences'; -import StarMarkSvg from 'components/widgets/icons/star_mark_icon'; - -import { - SelfHostedProducts, -} from 'utils/constants'; - -// Card has a bunch of props needed for monthly/yearly payments that -// do not apply to self-hosted. -const dummyCardProps = { - isCloud: false, - usersCount: 0, - yearlyPrice: 0, - monthlyPrice: 0, - isInitialPlanMonthly: false, - updateIsMonthly: () => {}, - updateInputUserCount: () => {}, - setUserCountError: () => {}, - isCurrentPlanMonthlyProfessional: false, -}; - -interface Props { - desiredPlanName: string; - desiredProduct: Product; - seats: Seats; - currentUsers: number; - updateSeats: (seats: Seats) => void; - canSubmit: boolean; - submit: () => void; -} - -export default function SelfHostedCard(props: Props) { - const intl = useIntl(); - const openPricingModal = useOpenPricingModal(); - - const showPlanLabel = props.desiredProduct.sku === SelfHostedProducts.PROFESSIONAL; - - const comparePlan = ( - - ); - const comparePlanWrapper = ( -
- {comparePlan} -
- ); - - return ( - <> - {comparePlanWrapper} - (billed annually)'}, { - br:
, - b: (chunks: React.ReactNode | React.ReactNodeArray) => ( - - {chunks} - - ), - })} - planBriefing={<>} - preButtonContent={( - - )} - afterButtonContent={ - - } - buttonDetails={{ - action: props.submit, - disabled: !props.canSubmit, - text: intl.formatMessage({id: 'self_hosted_signup.cta', defaultMessage: 'Upgrade'}), - customClass: props.canSubmit ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.grayed, - }} - planLabel={ - showPlanLabel ? ( - } - secondSvg={} - /> - ) : undefined - } - /> - - ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss deleted file mode 100644 index a9cdb64d0f..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/self_hosted_purchase_modal.scss +++ /dev/null @@ -1,552 +0,0 @@ -.SelfHostedPurchaseModal { - overflow: hidden; - height: 100%; - - .form-view { - display: flex; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-items: flex-start; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; - overflow-x: hidden; - overflow-y: auto; - - .title { - font-size: 22px; - font-weight: 600; - } - - .form { - padding: 0 96px; - margin: 0 auto; - - .form-row { - display: flex; - width: 100%; - margin-bottom: 24px; - } - - .form-row-third-1 { - width: 66%; - max-width: 288px; - margin-right: 16px; - - .DropdownInput { - margin-top: 0; - } - } - - .DropdownInput { - position: relative; - height: 36px; - margin-bottom: 24px; - - .Input_fieldset { - height: 43px; - } - } - - .form-row-third-2 { - width: 34%; - max-width: 144px; - } - - .section-title { - margin-bottom: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 16px; - font-weight: 600; - text-align: left; - } - - .Input_fieldset { - height: 40px; - padding: 2px 1px; - background: var(--center-channel-bg); - - .Input { - height: 32px; - background: inherit; - } - - .Input_wrapper { - margin: 0; - } - } - } - - &--hide { - display: none; - } - - > .lhs { - width: 25%; - } - - > .center { - width: 50%; - } - - > .rhs { - position: sticky; - display: flex; - width: 25%; - flex-direction: column; - align-items: center; - - .price-text { - display: flex; - align-items: baseline; - padding: 8px 0; - font-size: 32px; - font-weight: 600; - line-height: 1; - - .price-decimals { - align-self: end; - padding-bottom: 2px; - font-size: 16px; - - &::before { - content: '.'; - } - } - } - - .monthly-text { - align-self: center; - margin-left: 5px; - font-size: 14px; - font-weight: normal; - } - - .plan_comparison { - &.show_label { - margin-bottom: 51px; - } - - text-align: center; - - button, - a { - width: 100%; - height: 40px; - padding: 10px 24px; - border: none; - background: none; - color: var(--denim-button-bg); - font-size: 14px; - font-weight: 600; - line-height: 20px; - } - } - - .PlanCard { - position: relative; - width: 280px; - height: auto; - - .planLabel { - position: absolute; - top: -44px; - left: 52px; - display: flex; - width: 184px; - align-items: center; - justify-content: center; - padding: 14px; - border-radius: 13px 13px 0 0; - font-family: 'Open Sans'; - font-size: 12px; - font-style: normal; - font-weight: 600; - gap: 5px; - line-height: 16px; - } - - .top { - height: 18px; - border-radius: 4px 4px 0 0; - } - - .bottom { - &:not(.delinquency, .bottom-monthly-yearly) { - height: auto; - } - - padding-right: 24px; - padding-bottom: 24px; - padding-left: 24px; - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 0 0 4px 4px; - background-color: var(--center-channel-bg); - - .enable_annual_sub { - margin-top: 12px; - } - - .plan_action_btn { - width: 100%; - height: 40px; - padding: 10px 24px; - border: none; - border-radius: 4px; - background: none; - color: var(--denim-button-bg); - font-size: 14px; - font-weight: 700; - line-height: 14px; - - &.grayed { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.32); - } - - &.active { - border: 1px solid var(--denim-button-bg); - } - - &.special { - background: var(--denim-button-bg); - color: var(--button-color); - } - } - - .button-description { - margin-top: 8px; - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - text-align: center; - } - - .delinquency_summary_section { - width: 100%; - height: 116px; - margin-top: 24px; - margin-bottom: 24px; - font-weight: 400; - letter-spacing: -0.02em; - text-align: center; - - .summary-section { - border-radius: 4px; - background-color: rgba(var(--denim-button-bg-rgb), 0.08); - font-family: 'Metropolis'; - - .summary-title { - padding-top: 12px; - font-size: 14px; - font-weight: 400; - } - - .summary-total { - padding-top: 12px; - color: #152234; - font-size: 28px; - font-weight: 700; - line-height: 20px; - } - - .view-breakdown { - &:hover { - cursor: pointer; - } - - padding-top: 12px; - padding-bottom: 12px; - color: var(--denim-button-bg); - font-family: 'Metropolis'; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - } - - h1 { - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 52px; - font-style: normal; - font-weight: 700; - line-height: 60px; - } - - .enterprise_price { - font-size: 32px; - } - - p { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 18px; - font-style: normal; - line-height: 20px; - } - } - - .plan_price_rate_section { - width: 100%; - height: 150px; - border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - font-weight: 400; - letter-spacing: -0.02em; - text-align: center; - - h4 { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 20px; - font-style: normal; - line-height: 30px; - } - - h4.plan_name { - margin-top: 16px; - margin-bottom: 0; - } - - h1 { - margin: 0; - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 52px; - font-style: normal; - font-weight: 700; - line-height: 60px; - } - - .enterprise_price { - font-size: 32px; - } - - p { - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 18px; - font-style: normal; - line-height: 20px; - } - - p.plan_text { - margin-bottom: 0; - } - } - - .plan_payment_commencement { - margin-top: 16px; - margin-bottom: 24px; - color: var(--center-channel-color-rgb); - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - - .plan_billing_cycle { - &.delinquency { - margin-bottom: 24px; - } - - margin-top: 16px; - margin-bottom: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 16px; - } - - table { - width: 100%; - border-collapse: collapse; - - .yearly_savings { - padding-top: 12px; - padding-bottom: 12px; - color: var(--denim-status-online); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 20px; - } - - .total_price { - color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 24px; - } - - .monthly_price { - color: var(--sys-denim-center-channel-text); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 400; - letter-spacing: 0.02em; - line-height: 20px; - } - } - - table tr td:nth-child(2) { - text-align: right; - } - - .flex-grid { - display: flex; - margin-top: 20px; - - .user_seats_container { - width: 100px; - - .user_seats { - height: 24px; - } - - .Input_container { - fieldset:not(.Input_fieldset___error) { - margin-bottom: 24px; - } - } - } - - .icon { - flex: 1; - align-self: flex-start; - justify-content: left; - - .icon-information-outline { - margin: 0; - color: rgba(var(--sys-denim-center-channel-text-rgb), 0.75); - } - } - - .Input___customMessage { - width: 250px; - margin-bottom: 4px; - - .icon.error { - display: none; - } - } - } - } - - .bottom.bottom-monthly-yearly { - display: block; - overflow: auto; - border-radius: 4px; - } - } - } - } - - .signup-consequences { - display: flex; - flex-direction: column; - margin-top: 24px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 16px; - - span:first-of-type { - margin-bottom: 10px; - } - } - - .submitting, - .success, - .failed { - display: flex; - overflow: hidden; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: center; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; - - .IconMessage .content .IconMessage-link { - margin-left: 0; - } - } - - .background-svg { - position: absolute; - z-index: -1; - top: 0; - width: 100%; - height: 100%; - - >div { - position: absolute; - top: 0; - left: 0; - } - } - - .self-hosted-agreed-terms { - label { - display: flex; - align-items: flex-start; - justify-content: flex-start; - } - - input[type=checkbox] { - width: 17px; - height: 17px; - flex-shrink: 0; - margin-right: 12px; - } - - font-size: 16px; - } -} - -@media (max-width: 1020px) { - .SelfHostedPurchaseModal { - .form-view { - > .lhs { - display: none; - } - - > .center { - width: 66%; - } - - > .rhs { - width: 33%; - } - } - } -} - -.FullScreenModal { - .close-x { - top: 12px; - right: 12px; - } -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx deleted file mode 100644 index 4cfde43f99..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/submitting.tsx +++ /dev/null @@ -1,106 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer'; -import type {ValueOf} from '@mattermost/types/utilities'; - -import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer'; - -import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg'; -import IconMessage from 'components/purchase_modal/icon_message'; - -function useConvertProgressToWaitingExplanation(progress: ValueOf, planName: string): React.ReactNode { - const intl = useIntl(); - switch (progress) { - case SelfHostedSignupProgress.START: - case SelfHostedSignupProgress.CREATED_CUSTOMER: - case SelfHostedSignupProgress.CREATED_INTENT: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.submitting_payment', - defaultMessage: 'Submitting payment information', - }); - case SelfHostedSignupProgress.CONFIRMED_INTENT: - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.verifying_payment', - defaultMessage: 'Verifying payment details', - }); - case SelfHostedSignupProgress.PAID: - case SelfHostedSignupProgress.CREATED_LICENSE: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.applying_license', - defaultMessage: 'Applying your {planName} license to your Mattermost instance', - }, {planName}); - default: - return intl.formatMessage({ - id: 'self_hosted_signup.progress_step.submitting_payment', - defaultMessage: 'Submitting payment information', - }); - } -} - -export function convertProgressToBar(progress: ValueOf): number { - switch (progress) { - case SelfHostedSignupProgress.START: - return 0; - case SelfHostedSignupProgress.CREATED_CUSTOMER: - return 10; - case SelfHostedSignupProgress.CREATED_INTENT: - return 20; - case SelfHostedSignupProgress.CONFIRMED_INTENT: - return 30; - case SelfHostedSignupProgress.CREATED_SUBSCRIPTION: - return 50; - case SelfHostedSignupProgress.PAID: - return 90; - case SelfHostedSignupProgress.CREATED_LICENSE: - return 100; - default: - return 0; - } -} - -interface Props { - desiredPlanName: string; - progressBar: number; -} - -export default function Submitting(props: Props) { - const progress = useSelector(getSelfHostedSignupProgress); - const waitingExplanation = useConvertProgressToWaitingExplanation(progress, props.desiredPlanName); - const footer = ( -
-
-
- ); - - return ( - -
- - )} - formattedSubtitle={waitingExplanation} - icon={ - - } - footer={footer} - className={'processing'} - /> -
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss deleted file mode 100644 index 401f99006f..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.scss +++ /dev/null @@ -1,16 +0,0 @@ -.SelfHostedPurchaseModal__success { - display: flex; - overflow: hidden; - width: 100%; - height: 100%; - flex-direction: row; - flex-grow: 1; - flex-wrap: wrap; - align-content: center; - justify-content: center; - padding: 77px 107px; - color: var(--center-channel-color); - font-family: "Open Sans"; - font-size: 16px; - font-weight: 600; -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx deleted file mode 100644 index cfc088256f..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/success_page.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg'; -import IconMessage from 'components/purchase_modal/icon_message'; - -import './success_page.scss'; - -interface Props { - planName: string; - onClose: () => void; - -} - -export default function SuccessPage(props: Props) { - const title = ( - - ); - const formattedSubtitle = ( - - ); - const formattedBtnText = ( - - ); - return ( -
- - } - formattedButtonText={formattedBtnText} - buttonHandler={props.onClose} - /> -
- ); -} - diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx deleted file mode 100644 index 87319dff01..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/terms.tsx +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import ExternalLink from 'components/external_link'; - -interface Props { - agreed: boolean; - setAgreed: (agreed: boolean) => void; -} - -import {HostedCustomerLinks} from 'utils/constants'; - -export default function Terms(props: Props) { - return ( -
-
- -
-
- ); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts deleted file mode 100644 index d77ab3649b..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/types.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -export const SetPrefix = 'set_' as const; - -export type SetAction = { - type: `${P}${K}`; - data: V; -} - -export type UnionSetActions = { - [Key in Extract]: SetAction -}[Extract] - diff --git a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts b/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts deleted file mode 100644 index b3b8d3541e..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/self_hosted_purchase_modal/useNoEscape.ts +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useEffect} from 'react'; - -// hack to disallow closing payment modal with escape. -// Wrapping the function in this way enables the -// somewhat common use case accidental modal open followed -// by immediate closing by pressing escape -function makeDisallowEscape() { - let hitOtherKey = false; - return function disallowEscape(e: KeyboardEvent) { - if (e.key === 'Escape' && hitOtherKey) { - e.preventDefault(); - e.stopPropagation(); - } - hitOtherKey = true; - }; -} - -export default function useNoEscape() { - useEffect(() => { - const disallowEscape = makeDisallowEscape(); - document.addEventListener('keydown', disallowEscape, true); - return () => { - document.removeEventListener('keydown', disallowEscape, true); - }; - }, []); -} diff --git a/webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx b/webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx deleted file mode 100644 index d4d1969034..0000000000 --- a/webapp/channels/src/components/self_hosted_purchases/stripe_provider.tsx +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {Elements} from '@stripe/react-stripe-js'; -import type {Stripe} from '@stripe/stripe-js'; -import React from 'react'; - -import {STRIPE_CSS_SRC} from 'components/payment_form/stripe'; - -interface Props { - children: React.ReactNode | React.ReactNodeArray; - stripeRef: React.MutableRefObject; -} -export default function StripeElementsProvider(props: Props) { - return ( - - {props.children} - - ); -} diff --git a/webapp/channels/src/components/widgets/links/upgrade_link.test.tsx b/webapp/channels/src/components/widgets/links/upgrade_link.test.tsx index bd84127f21..86e203fcaa 100644 --- a/webapp/channels/src/components/widgets/links/upgrade_link.test.tsx +++ b/webapp/channels/src/components/widgets/links/upgrade_link.test.tsx @@ -37,7 +37,19 @@ describe('components/widgets/links/UpgradeLink', () => { }); test('should trigger telemetry call when button clicked', (done) => { - const store = mockStore({}); + const mockWindowOpen = jest.fn(); + global.window.open = mockWindowOpen; + const store = mockStore({ + entities: { + general: {}, + cloud: { + customer: {}, + }, + users: { + profiles: {}, + }, + }, + }); const wrapper = mountWithIntl( , ); @@ -49,5 +61,6 @@ describe('components/widgets/links/UpgradeLink', () => { done(); }); expect(wrapper).toMatchSnapshot(); + expect(mockWindowOpen).toHaveBeenCalled(); }); }); diff --git a/webapp/channels/src/components/widgets/links/upgrade_link.tsx b/webapp/channels/src/components/widgets/links/upgrade_link.tsx index b139e0ec50..039a58c335 100644 --- a/webapp/channels/src/components/widgets/links/upgrade_link.tsx +++ b/webapp/channels/src/components/widgets/links/upgrade_link.tsx @@ -3,16 +3,11 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; -import {useDispatch} from 'react-redux'; import {trackEvent} from 'actions/telemetry_actions'; -import {openModal} from 'actions/views/modals'; - -import PurchaseModal from 'components/purchase_modal'; - -import {ModalIdentifiers} from 'utils/constants'; import './link.scss'; +import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; export interface UpgradeLinkProps { telemetryInfo?: string; @@ -22,26 +17,17 @@ export interface UpgradeLinkProps { } const UpgradeLink = (props: UpgradeLinkProps) => { - const dispatch = useDispatch(); const styleButton = props.styleButton ? ' style-button' : ''; const styleLink = props.styleLink ? ' style-link' : ''; + const [openSalesLink] = useOpenSalesLink(); + const handleLinkClick = async (e: React.MouseEvent) => { e.preventDefault(); if (props.telemetryInfo) { trackEvent('upgrade_mm_cloud', props.telemetryInfo); } - try { - dispatch(openModal({ - modalId: ModalIdentifiers.CLOUD_PURCHASE, - dialogType: PurchaseModal, - dialogProps: { - callerCTA: props.telemetryInfo, - }, - })); - } catch (error) { - // do nothing - } + openSalesLink(); }; const buttonText = ( {legalText}", "admin.billing.subscription.cancelSubscriptionSection.contactUs": "Contact Us", "admin.billing.subscription.cancelSubscriptionSection.description": "At this time, deleting a workspace can only be done with the help of a customer support representative.", @@ -344,12 +327,9 @@ "admin.billing.subscription.cloudTrial.daysLeftOnTrial": "There are {daysLeftOnTrial} days left on your free trial", "admin.billing.subscription.cloudTrial.lastDay": "This is the last day of your free trial. Your access will expire on {userEndTrialDate} at {userEndTrialHour}.", "admin.billing.subscription.cloudTrial.moreThan3Days": "Your trial has started! There are {daysLeftOnTrial} days left", - "admin.billing.subscription.cloudTrial.purchaseButton": "Purchase Now", "admin.billing.subscription.cloudTrial.subscribeButton": "Upgrade Now", "admin.billing.subscription.cloudTrialBadge.daysLeftOnTrial": "{daysLeftOnTrial} trial days left", "admin.billing.subscription.cloudYearlyBadge": "Annual", - "admin.billing.subscription.complianceScreenFailed.button": "OK", - "admin.billing.subscription.complianceScreenFailed.title": "Your transaction is being reviewed", "admin.billing.subscription.complianceScreenShippingSameAsBilling": "My shipping address is the same as my billing address", "admin.billing.subscription.creditCardExpired": "Your credit card has expired. Update your payment information to avoid disruption.", "admin.billing.subscription.creditCardHasExpired": "Your credit card has expired", @@ -365,20 +345,13 @@ "admin.billing.subscription.deleteWorkspaceSection.description": "Deleting {workspaceLink} is final and cannot be reversed.", "admin.billing.subscription.deleteWorkspaceSection.title": "Delete your workspace", "admin.billing.subscription.downgrading": "Downgrading your workspace", - "admin.billing.subscription.featuresAvailable": "{productName} features are now available and ready to use.", "admin.billing.subscription.freeTrial.description": "Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.", "admin.billing.subscription.freeTrial.lastDay.description": "Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.", "admin.billing.subscription.freeTrial.lastDay.title": "Your free trial ends today", "admin.billing.subscription.freeTrial.lessThan3Days.description": "Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.", "admin.billing.subscription.freeTrial.title": "You're currently on a free trial", - "admin.billing.subscription.goBackTryAgain": "Go back and try again", - "admin.billing.subscription.howItWorks": "See how billing works", "admin.billing.subscription.invoice.next": "Next Invoice", - "admin.billing.subscription.LearnMore": "Learn more", "admin.billing.subscription.mostRecentPaymentFailed": "Your most recent payment failed", - "admin.billing.subscription.nextBillingDate": "Starting from {date}, you will be billed for the {productName} plan. You can change your plan whenever you like and we will pro-rate the charges.", - "admin.billing.subscription.paymentFailed": "Payment failed. Please try again or contact support.", - "admin.billing.subscription.paymentVerificationFailed": "Sorry, the payment verification failed", "admin.billing.subscription.planDetails.currentPlan": "Current Plan", "admin.billing.subscription.planDetails.features.advanceTeamPermission": "Advanced team permissions", "admin.billing.subscription.planDetails.features.autoComplianceExports": "Automated compliance exports", @@ -424,28 +397,17 @@ "admin.billing.subscription.privateCloudCard.freeTrial.description": "We love to work with our customers and their needs. Contact sales for subscription, billing or trial-specific questions.", "admin.billing.subscription.privateCloudCard.freeTrial.title": "Questions about your trial?", "admin.billing.subscription.privateCloudCard.upgradeNow": "Upgrade Now", - "admin.billing.subscription.proratedPayment.substitle": "Thank you for upgrading to {selectedProductName}. Check your workspace in a few minutes to access all the plan's features. You'll be charged a prorated amount for your {currentProductName} plan and {selectedProductName} plan based on the number of days left in the billing cycle and number of users you have.", "admin.billing.subscription.proratedPayment.title": "You are now subscribed to {selectedProductName}", - "admin.billing.subscription.providePaymentDetails": "Provide your payment details", - "admin.billing.subscription.returnToTeam": "Return to {team}", "admin.billing.subscription.stateprovince": "State/Province", - "admin.billing.subscription.subscribedSuccess": "You're now subscribed to {productName}", - "admin.billing.subscription.switchedToAnnual.title": "You're now switched to {selectedProductName} annual", "admin.billing.subscription.title": "Subscription", "admin.billing.subscription.updatePaymentInfo": "Update Payment Information", - "admin.billing.subscription.upgradedSuccess": "You're now upgraded to {productName}", "admin.billing.subscription.userCount.tooltipText": "You must purchase at least the current number of active users.", "admin.billing.subscription.userCount.tooltipTitle": "Current User Count", - "admin.billing.subscription.verifyPaymentInformation": "Verifying your payment information", - "admin.billing.subscription.viewBilling": "View Billing", "admin.billing.subscriptions.billing_summary.explore_enterprise": "Explore Enterprise features", "admin.billing.subscriptions.billing_summary.explore_enterprise.cta": "View all features", - "admin.billing.subscriptions.billing_summary.lastInvoice.approved": "Approved", - "admin.billing.subscriptions.billing_summary.lastInvoice.failed": "Failed", "admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee": "Monthly Flat Fee", "admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Paid", "admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges": "Partial charges", - "admin.billing.subscriptions.billing_summary.lastInvoice.pending": "Pending", "admin.billing.subscriptions.billing_summary.lastInvoice.seatCount": " x {seats} seats", "admin.billing.subscriptions.billing_summary.lastInvoice.seatCountPartial": "{seats} seats", "admin.billing.subscriptions.billing_summary.lastInvoice.seeBillingHistory": "See Billing History", @@ -461,8 +423,6 @@ "admin.billing.subscriptions.billing_summary.try_enterprise": "Try Enterprise features for free", "admin.billing.subscriptions.billing_summary.try_enterprise.cta": "Try free for {trialLength} days", "admin.billing.subscriptions.billing_summary.upcomingInvoice.has_more_line_items": "And {count} more items", - "admin.billing.subscriptions.billing_summary.upgrade_professional": "Upgrade to the Professional Plan", - "admin.billing.subscriptions.billing_summary.upgrade_professional.cta": "Upgrade", "admin.billing.trueUpReview.button_download": "Download Data", "admin.billing.trueUpReview.button_share": "Share to Mattermost", "admin.billing.trueUpReview.docsLinkCTA": "Learn more about true-up.", @@ -1302,7 +1262,6 @@ "admin.jobTable.statusPending": "Pending", "admin.jobTable.statusSuccess": "Success", "admin.jobTable.statusWarning": "Warning", - "admin.ldap_feature_discovery_cloud.call_to_action.primary": "Upgrade now", "admin.ldap_feature_discovery_cloud.call_to_action.primary_sales": "Contact sales", "admin.ldap_feature_discovery.call_to_action.primary": "Start trial", "admin.ldap_feature_discovery.call_to_action.primary.cloudFree": "Try free for {trialLength} days", @@ -1457,7 +1416,6 @@ "admin.license.trialCard.description": "Your free trial will expire in **{daysCount} {daysCount, plural, one {day} other {days}}**. Visit our customer portal to purchase a license now to continue using Mattermost Professional and Enterprise features after trial ends.", "admin.license.trialCard.description.expiringToday": "Your free trial expires **Today at {time}**. Visit our customer portal to purchase a license now to continue using Mattermost Professional and Enterprise features after trial ends", "admin.license.trialCard.licenseExpiring": "You’re currently on a free trial of our Mattermost Enterprise license.", - "admin.license.trialCard.purchase": "Purchase", "admin.license.trialCard.purchase_license": "Purchase a license", "admin.license.trialUpgradeAndRequest.submit": "Upgrade Server And Start trial", "admin.license.upgrade-and-trial-request.accept-terms-final-part": "Also, I agree to the terms of the Mattermost {eeModalTerms}. Upgrading will download the binary and update your Team Edition instance.", @@ -2403,7 +2361,6 @@ "admin.sidebar.oauth": "OAuth 2.0", "admin.sidebar.openid": "OpenID Connect", "admin.sidebar.password": "Password", - "admin.sidebar.payment_info": "Payment Information", "admin.sidebar.permissions": "Permissions", "admin.sidebar.plugins": "Plugins", "admin.sidebar.posts": "Posts", @@ -3007,8 +2964,6 @@ "backstage_sidebar.integrations.oauthApps": "OAuth 2.0 Applications", "backstage_sidebar.integrations.outgoing_webhooks": "Outgoing Webhooks", "backstage_sidebar.integrations.outgoingOauthConnections": "Outgoing OAuth 2.0 Connections", - "billing_subscriptions.cloud_annual_renewal_alert_banner_title": "Your annual subscription expires in {days} days. Please renew now to avoid any disruption", - "billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired": "Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption", "billing.subscription.info.mostRecentPaymentFailed": "Your most recent payment failed", "billing.subscription.info.mostRecentPaymentFailed.description.mostRecentPaymentFailed": "It looks your most recent payment failed because the credit card on your account has expired. Please update your payment information to avoid any disruption.", "bot.add.description": "Description", @@ -3271,37 +3226,15 @@ "claim.oauth_to_email.pwdNotMatch": "Passwords do not match.", "claim.oauth_to_email.switchTo": "Switch {type} to Email and Password", "claim.oauth_to_email.title": "Switch {type} Account to Email", - "cloud_annual_renewal_delinquency.banner.end_user.message": "Your annual subscription has expired. Please contact your System Admin to keep this workspace", - "cloud_annual_renewal_delinquency.banner.message": "Your annual subscription has expired. Please renew now to keep this workspace", - "cloud_annual_renewal.banner.buttonText.contactSales": "Contact Sales", - "cloud_annual_renewal.banner.buttonText.renew": "Renew", - "cloud_annual_renewal.banner.message.30": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.", - "cloud_annual_renewal.banner.message.60": "Your annual subscription expires in {days} days. Please renew to avoid any disruption.", - "cloud_annual_renewal.banner.message.7": "Your annual subscription expires in {days} days. Failure to renew will result in your workspace being deleted.", "cloud_archived.error.access": "Permalink belongs to a message that has been archived because of {planName} limits. Upgrade to access message again.", "cloud_archived.error.title": "Message Archived", "cloud_billing_history_modal.title": "Invoice(s)", - "cloud_billing.nudge_to_paid.contact_sales": "Contact sales", - "cloud_billing.nudge_to_paid.description": "Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.", - "cloud_billing.nudge_to_paid.learn_more": "Upgrade", - "cloud_billing.nudge_to_paid.title": "Upgrade to paid plan to keep your workspace", "cloud_billing.nudge_to_paid.view_plans": "View plans", - "cloud_delinquency.banner.buttonText": "Update billing now", - "cloud_delinquency.cc_modal.card.reactivate": "Reactivate", - "cloud_delinquency.cc_modal.card.totalOwed": "Total owed", - "cloud_delinquency.cc_modal.card.viewBreakdown": "View breakdown", - "cloud_delinquency.cc_modal.disclaimer": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}", - "cloud_delinquency.cc_modal.disclaimer_with_upgrade_info": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. You'll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}", "cloud_signup.signup_consequences": "Your credit card will be charged today. See how billing works.", - "cloud_subscribe.contact_support": "Compare plans", "cloud_upgrade.error_min_seats": "Minimum of 10 seats required", "cloud.fetch_error": "Error fetching billing data. Please try again later.", "cloud.fetch_error.retry": "Retry", "cloud.invoice_pdf_preview.download": "Download this page for your records", - "cloud.renewal.andMoreItems": "+ {count} more items", - "cloud.renewal.renew": "Renew", - "cloud.renewal.tobepaid": "To be paid on {date}", - "cloud.renewal.viewInvoice": "View Invoice", "cloud.startTrial.modal.btn": "Start trial", "collapsed_reply_threads_modal.confirm": "Got it", "collapsed_reply_threads_modal.description": "Threads have been revamped to help you create organized conversation around specific messages. Now, channels will appear less cluttered as replies are collapsed under the original message, and all the conversations you're following are available in your **Threads** view. Take the tour to see what's new.", @@ -4498,7 +4431,6 @@ "payment_form.no_billing_address": "No billing address added", "payment_form.no_credit_card": "No credit card added", "payment_form.saved_payment_method": "Saved Payment Method", - "payment_form.shipping_address": "Shipping Address", "payment_form.zipcode": "Zip/Postal Code", "payment.card_number": "Card Number", "payment.field_required": "This field is required", @@ -4639,15 +4571,9 @@ "postypes.custom_open_plugin_install_post_rendered.plugins_installed": "{pluginName} is now installed.", "postypes.custom_open_plugin_install_post_rendered.plugins_instructions": "Install the apps or visit the Marketplace to view all plugins.", "postypes.custom_open_pricing_modal_post_renderer.and": "and", - "postypes.custom_open_pricing_modal_post_renderer.availableOn": " - available on the {feature}", - "postypes.custom_open_pricing_modal_post_renderer.downgradeNotfication": "{userRequests} requested to revert the workspace to a paid plan", - "postypes.custom_open_pricing_modal_post_renderer.learn_trial": "Learn more about trial", "postypes.custom_open_pricing_modal_post_renderer.members": "{members} members", "postypes.custom_open_pricing_modal_post_renderer.membersThatRequested": "Members that requested ", "postypes.custom_open_pricing_modal_post_renderer.unknown": "@unknown", - "postypes.custom_open_pricing_modal_post_renderer.upgrade_professional": "Upgrade to Professional", - "postypes.custom_open_pricing_modal_post_renderer.userRequests": "{userRequests} requested access to this feature", - "postypes.custom_open_pricing_modal_post_renderer.view_options": "View upgrade options", "pricing_modal.addons.dedicatedDB": "Dedicated database", "pricing_modal.addons.dedicatedDeployment": "Dedicated virtual secure cloud deployment (Cloud)", "pricing_modal.addons.dedicatedEncryption": "Dedicated encryption keys", @@ -4662,16 +4588,11 @@ "pricing_modal.briefing.enterprise.groupSync": "AD/LDAP group sync", "pricing_modal.briefing.enterprise.mobileSecurity": "Advanced mobile security via ID-only push notifications", "pricing_modal.briefing.enterprise.rolesAndPermissions": "Advanced roles and permissions", - "pricing_modal.briefing.free.gitLabGitHubGSuite": "GitLab, GitHub, and GSuite SSO", - "pricing_modal.briefing.free.noLimitBoards": "Unlimited board cards", - "pricing_modal.briefing.free.oneTeamPerWorkspace": "One team per workspace", - "pricing_modal.briefing.free.recentMessageBoards": "Access to {messages} most recent messages", "pricing_modal.briefing.fullMessageAndHistory": "Full message and file history", "pricing_modal.briefing.professional.advancedPlaybook": "Advanced Playbook workflows with retrospectives", "pricing_modal.briefing.professional.messageBoardsIntegrationsCalls": "Unlimited access to messages and files", "pricing_modal.briefing.professional.unLimitedTeams": "Unlimited teams", "pricing_modal.briefing.ssoWithGitLab": "SSO with Gitlab", - "pricing_modal.briefing.storageStarter": "{storage} file storage limit", "pricing_modal.briefing.title": "Top features", "pricing_modal.briefing.title_large_scale": "Large scale collaboration", "pricing_modal.briefing.title_no_limit": "No limits on your team’s usage", @@ -4679,7 +4600,6 @@ "pricing_modal.briefing.unlimitedWorkspaceTeams": "Unlimited workspace teams", "pricing_modal.btn.contactSales": "Contact Sales", "pricing_modal.btn.contactSalesForQuote": "Contact Sales", - "pricing_modal.btn.contactSupport": "Contact Support", "pricing_modal.btn.downgrade": "Downgrade", "pricing_modal.btn.purchase": "Purchase", "pricing_modal.btn.switch_to_annual": "Switch to annual billing", @@ -4688,7 +4608,6 @@ "pricing_modal.btn.upgrade": "Upgrade", "pricing_modal.btn.viewPlans": "View plans", "pricing_modal.contact_us": "Contact us", - "pricing_modal.extra_briefing.cloud.free.calls": "Group calls of up to 8 people, 1:1 calls, and screen share", "pricing_modal.extra_briefing.enterprise.playBookAnalytics": "Playbook analytics dashboard", "pricing_modal.extra_briefing.free.calls": "Voice calls and screen share", "pricing_modal.extra_briefing.professional.guestAccess": "Guest access with MFA enforcement", @@ -4697,7 +4616,6 @@ "pricing_modal.interested_self_hosting": "Interested in self-hosting?", "pricing_modal.learn_more": "Learn more", "pricing_modal.lookingForCloudOption": "Looking for a cloud option?", - "pricing_modal.lookingToSelfHost": "Looking to self-host?", "pricing_modal.noitfy_cta.request": "Request admin to upgrade", "pricing_modal.noitfy_cta.request_success": "Request sent", "pricing_modal.or": "or", @@ -4705,7 +4623,6 @@ "pricing_modal.planDisclaimer.free": "This plan has data restrictions.", "pricing_modal.planLabel.currentPlan": "CURRENT PLAN", "pricing_modal.planLabel.currentPlanMonthly": "CURRENTLY ON MONTHLY BILLING", - "pricing_modal.planLabel.mostPopular": "MOST POPULAR", "pricing_modal.planSummary.enterprise": "Administration, security, and compliance for large teams", "pricing_modal.planSummary.free": "Increased productivity for small teams", "pricing_modal.planSummary.professional": "Scalable solutions {br} for growing teams", @@ -4854,49 +4771,13 @@ "select_team.icon": "Select Team Icon", "select_team.join.icon": "Join Team Icon", "select_team.private.icon": "Private Team", - "self_hosted_expansion_rhs_card_add_new_seats": "Add new seats", - "self_hosted_expansion_rhs_card_cost_per_user_breakdown": "{costPerUser} x {monthsUntilExpiry} months", - "self_hosted_expansion_rhs_card_cost_per_user_title": "Cost per user", - "self_hosted_expansion_rhs_card_license_date": "{startsAt} - {endsAt}", - "self_hosted_expansion_rhs_card_licensed_seats": "{licensedSeats} LICENSES SEATS", - "self_hosted_expansion_rhs_card_maximum_seats_warning": "{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats", - "self_hosted_expansion_rhs_card_must_add_seats_warning": "{warningIcon} You must add a seat to continue", - "self_hosted_expansion_rhs_card_must_purchase_enough_seats": "{warningIcon} You must purchase at least {minimumSeats} seats to be compliant with your license", - "self_hosted_expansion_rhs_card_total_prorated_warning": "The total will be prorated", - "self_hosted_expansion_rhs_card_total_title": "Total", - "self_hosted_expansion_rhs_complete_button": "Complete purchase", - "self_hosted_expansion_rhs_credit_card_charge_today_warning": "Your credit card will be charged today.See how billing works.", - "self_hosted_expansion_rhs_license_summary_title": "License Summary", - "self_hosted_expansion.close": "Close", - "self_hosted_expansion.contact_support": "Contact Support", - "self_hosted_expansion.expand_success": "You've successfully updated your license seat count", - "self_hosted_expansion.expansion_modal.title": "Provide your payment details", - "self_hosted_expansion.license_applied": "The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the Billing section of the system console.", - "self_hosted_expansion.paymentFailed": "Payment failed. Please try again or contact support.", - "self_hosted_expansion.try_again": "Try again", "self_hosted_signup.air_gapped_content": "It appears that your instance is air-gapped, or it may not be connected to the internet. To purchase a license, please visit", "self_hosted_signup.air_gapped_title": "Purchase through the customer portal", "self_hosted_signup.close": "Close", - "self_hosted_signup.contact_sales": "Contact Sales", - "self_hosted_signup.cta": "Upgrade", - "self_hosted_signup.disclaimer": "I have read and agree to the Enterprise Edition Subscription Terms", "self_hosted_signup.error_invalid_number": "Enter a valid number of seats", "self_hosted_signup.error_max_seats": " license purchase only supports purchases up to {num} seats", "self_hosted_signup.error_min_seats": "Your workspace currently has {num} users", - "self_hosted_signup.failed_export.subtitle": "We will check things on our side and get back to you within 3 days once your license is approved. In the meantime, please feel free to continue using the free version of our product.", - "self_hosted_signup.failed_export.title": "Your transaction is being reviewed", - "self_hosted_signup.license_applied": "Your {planName} license has now been applied. {planName} features are now available and ready to use.", "self_hosted_signup.line_item_subtotal": "{num} seats × 12 mo.", - "self_hosted_signup.organization": "Organization Name", - "self_hosted_signup.progress_step.applying_license": "Applying your {planName} license to your Mattermost instance", - "self_hosted_signup.progress_step.submitting_payment": "Submitting payment information", - "self_hosted_signup.progress_step.verifying_payment": "Verifying payment details", - "self_hosted_signup.purchase_in_progress.by_other": "{username} is currently attempting to purchase a paid license.", - "self_hosted_signup.purchase_in_progress.by_self": "You are currently attempting to purchase in another browser window. Complete your purchase or close the other window(s).", - "self_hosted_signup.purchase_in_progress.by_self_restart": "If you believe this to be a mistake, restart your purchase.", - "self_hosted_signup.purchase_in_progress.reset": "Restart purchase", - "self_hosted_signup.purchase_in_progress.title": "Purchase in progress", - "self_hosted_signup.retry": "Try again", "self_hosted_signup.screening_description": "We will check things on our side and get back to you within 3 days once your license is approved. In the meantime, please feel free to continue using the free version of our product.", "self_hosted_signup.screening_title": "Your transaction is being reviewed", "self_hosted_signup.seats": "Seats", @@ -5896,8 +5777,6 @@ "workspace_limits.archived_file.archived_compact": "(archived)", "workspace_limits.archived_file.tooltip_description": "Your workspace has hit the file storage limit of {storageLimit}. To view this again, upgrade to a paid plan", "workspace_limits.archived_file.tooltip_title": "Unarchive this file by upgrading", - "workspace_limits.banner_upgrade_reason.free": "Your workspace has exceeded {planName} plan data limits. Upgrade to a paid plan for additional capacity.", - "workspace_limits.banner_upgrade.free": "Upgrade to one of our paid plans to avoid {planName} plan data limits", "workspace_limits.file_storage": "File storage", "workspace_limits.file_storage.short": "Files", "workspace_limits.file_storage.short.usage": "{actual} / {limit}", diff --git a/webapp/channels/src/plugins/export.js b/webapp/channels/src/plugins/export.js index 7f610cc077..a1b0545b47 100644 --- a/webapp/channels/src/plugins/export.js +++ b/webapp/channels/src/plugins/export.js @@ -11,7 +11,6 @@ import ChannelMembersModal from 'components/channel_members_modal'; import {openPricingModal} from 'components/global_header/right_controls/plan_upgrade_button'; import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta'; import PostMessagePreview from 'components/post_view/post_message_preview'; -import PurchaseModal from 'components/purchase_modal'; import StartTrialFormModal from 'components/start_trial_form_modal'; import ThreadViewer from 'components/threading/thread_viewer'; import Timestamp from 'components/timestamp'; @@ -82,7 +81,6 @@ window.openPricingModal = () => openPricingModal; // guarantee better compatibility. window.Components = { Textbox, - PurchaseModal, Timestamp, ChannelInviteModal, ChannelMembersModal,