diff --git a/server/channels/api4/cloud.go b/server/channels/api4/cloud.go index 061c05935e..02449b965c 100644 --- a/server/channels/api4/cloud.go +++ b/server/channels/api4/cloud.go @@ -22,8 +22,6 @@ func (api *API) InitCloud() { // GET /api/v4/cloud/limits api.BaseRoutes.Cloud.Handle("/limits", api.APISessionRequired(getCloudLimits)).Methods(http.MethodGet) - api.BaseRoutes.Cloud.Handle("/products/selfhosted", api.APISessionRequired(getSelfHostedProducts)).Methods(http.MethodGet) - // GET /api/v4/cloud/customer // PUT /api/v4/cloud/customer // PUT /api/v4/cloud/customer/address @@ -212,45 +210,6 @@ func validateWorkspaceBusinessEmail(c *Context, w http.ResponseWriter, r *http.R } } -func getSelfHostedProducts(c *Context, w http.ResponseWriter, r *http.Request) { - ensured := ensureCloudInterface(c, "Api4.getSelfHostedProducts") - if !ensured { - return - } - - products, err := c.App.Cloud().GetSelfHostedProducts(c.AppContext.Session().UserId) - if err != nil { - c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.request_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - byteProductsData, err := json.Marshal(products) - if err != nil { - c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadBilling) { - sanitizedProducts := []model.UserFacingProduct{} - err = json.Unmarshal(byteProductsData, &sanitizedProducts) - if err != nil || sanitizedProducts == nil { - c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - byteSanitizedProductsData, err := json.Marshal(sanitizedProducts) - if err != nil { - c.Err = model.NewAppError("Api4.getSelfHostedProducts", "api.cloud.app_error", nil, "", http.StatusInternalServerError).Wrap(err) - return - } - - w.Write(byteSanitizedProductsData) - return - } - - w.Write(byteProductsData) -} - func getCloudProducts(c *Context, w http.ResponseWriter, r *http.Request) { ensured := ensureCloudInterface(c, "Api4.getCloudProducts") if !ensured { diff --git a/server/channels/api4/cloud_test.go b/server/channels/api4/cloud_test.go index 1481bf8278..1c436a94ee 100644 --- a/server/channels/api4/cloud_test.go +++ b/server/channels/api4/cloud_test.go @@ -430,113 +430,3 @@ func TestGetCloudProducts(t *testing.T) { require.Equal(t, returnedProducts[2].CrossSellsTo, "prod_test2") }) } - -func TestGetSelfHostedProducts(t *testing.T) { - mainHelper.Parallel(t) - products := []*model.Product{ - { - ID: "prod_test", - Name: "Self-Hosted Professional", - Description: "Ideal for small companies and departments with data security requirements", - PricePerSeat: 10, - SKU: "professional", - PriceID: "price_1JPXbNI67GP2qpb4VuFdFbwQ", - Family: "on-prem", - RecurringInterval: model.RecurringIntervalYearly, - }, - { - ID: "prod_test2", - Name: "Self-Hosted Enterprise", - Description: "Built to scale for high-trust organizations and companies in regulated industries.", - PricePerSeat: 30, - SKU: "enterprise", - PriceID: "price_1JPXaVI67GP2qpb4l40bXyRu", - Family: "on-prem", - RecurringInterval: model.RecurringIntervalYearly, - }, - } - - sanitizedProducts := []*model.Product{ - { - ID: "prod_test", - Name: "Self-Hosted Professional", - PricePerSeat: 10, - SKU: "professional", - RecurringInterval: model.RecurringIntervalYearly, - }, - { - ID: "prod_test2", - Name: "Self-Hosted Enterprise", - PricePerSeat: 30, - SKU: "enterprise", - RecurringInterval: model.RecurringIntervalYearly, - }, - } - - t.Run("get products for admins", func(t *testing.T) { - mainHelper.Parallel(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("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - returnedProducts, r, err := th.Client.GetSelfHostedProducts(context.Background()) - require.NoError(t, err) - require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") - require.Equal(t, returnedProducts, products) - }) - - t.Run("get products for non admins", func(t *testing.T) { - mainHelper.Parallel(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("GetSelfHostedProducts", mock.Anything, mock.Anything).Return(products, nil) - - cloudImpl := th.App.Srv().Cloud - defer func() { - th.App.Srv().Cloud = cloudImpl - }() - th.App.Srv().Cloud = &cloud - - returnedProducts, r, err := th.Client.GetSelfHostedProducts(context.Background()) - require.NoError(t, err) - require.Equal(t, http.StatusOK, r.StatusCode, "Status OK") - require.Equal(t, returnedProducts, sanitizedProducts) - - // make a more explicit check - require.Equal(t, returnedProducts[0].ID, "prod_test") - require.Equal(t, returnedProducts[0].Name, "Self-Hosted Professional") - require.Equal(t, returnedProducts[0].SKU, "professional") - require.Equal(t, returnedProducts[0].PricePerSeat, float64(10)) - require.Equal(t, returnedProducts[0].Description, "") - require.Equal(t, returnedProducts[0].PriceID, "") - require.Equal(t, returnedProducts[0].Family, model.SubscriptionFamily("")) - require.Equal(t, returnedProducts[0].RecurringInterval, model.RecurringInterval("year")) - require.Equal(t, returnedProducts[0].BillingScheme, model.BillingScheme("")) - require.Equal(t, returnedProducts[0].CrossSellsTo, "") - - require.Equal(t, returnedProducts[1].ID, "prod_test2") - require.Equal(t, returnedProducts[1].Name, "Self-Hosted Enterprise") - require.Equal(t, returnedProducts[1].SKU, "enterprise") - require.Equal(t, returnedProducts[1].PricePerSeat, float64(30)) - require.Equal(t, returnedProducts[1].Description, "") - require.Equal(t, returnedProducts[1].PriceID, "") - require.Equal(t, returnedProducts[1].Family, model.SubscriptionFamily("")) - require.Equal(t, returnedProducts[1].RecurringInterval, model.RecurringInterval("year")) - require.Equal(t, returnedProducts[1].BillingScheme, model.BillingScheme("")) - require.Equal(t, returnedProducts[1].CrossSellsTo, "") - }) -} diff --git a/webapp/channels/src/actions/hosted_customer.tsx b/webapp/channels/src/actions/hosted_customer.tsx deleted file mode 100644 index 4eb8a9e83c..0000000000 --- a/webapp/channels/src/actions/hosted_customer.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {ServerError} from '@mattermost/types/errors'; - -import {HostedCustomerTypes} from 'mattermost-redux/action_types'; -import {Client4} from 'mattermost-redux/client'; - -import type {ThunkActionFunc} from 'types/store'; - -export function getSelfHostedProducts(): ThunkActionFunc> { - return async (dispatch) => { - try { - dispatch({ - type: HostedCustomerTypes.SELF_HOSTED_PRODUCTS_REQUEST, - }); - const result = await Client4.getSelfHostedProducts(); - if (result) { - dispatch({ - type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_PRODUCTS, - data: result, - }); - } - } catch (error) { - dispatch({ - type: HostedCustomerTypes.SELF_HOSTED_PRODUCTS_FAILED, - }); - return error; - } - return true; - }; -} - 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 486c4a99fd..5df57e8ae3 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 @@ -55,7 +55,7 @@ const BillingSubscriptions = () => { const product = useSelector(getSubscriptionProduct); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); let isFreeTrial = false; let daysLeftOnTrial = 0; @@ -75,7 +75,7 @@ const BillingSubscriptions = () => { pageVisited('cloud_admin', 'pageview_billing_subscription'); - if (actionQueryParam === 'show_pricing_modal') { + if (actionQueryParam === 'show_pricing_modal' && !isAirGapped) { openPricingModal({trackingLocation: 'billing_subscriptions_external_direct_link'}); } }, []); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limits.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limits.tsx index 9fde2adedc..dffa3d5ec5 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limits.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/limits.tsx @@ -31,7 +31,7 @@ const Limits = (): JSX.Element | null => { const [cloudLimits, limitsLoaded] = useGetLimits(); const usage = useGetUsage(); const [openSalesLink] = useOpenSalesLink(); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); if (!subscriptionProduct || !limitsLoaded || !hasSomeLimits(cloudLimits)) { return null; @@ -133,15 +133,17 @@ const Limits = (): JSX.Element | null => {
{subscriptionProduct.sku === CloudProducts.STARTER && ( <> - + {!isAirGapped && ( + + )}
{button()} - {restoreDisabled && + {restoreDisabled && !isAirGapped && + {!this.props.hideConfirm && ( + + )}
diff --git a/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx b/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx index c92d7bbc0b..0fbc504713 100644 --- a/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx +++ b/webapp/channels/src/components/feature_restricted_modal/feature_restricted_modal.tsx @@ -66,7 +66,7 @@ const FeatureRestrictedModal = ({ const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.FEATURE_RESTRICTED_MODAL)); const license = useSelector(getLicense); const isCloud = license?.Cloud === 'true'; - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const [notifyAdminBtnText, notifyAdmin, notifyRequestStatus] = useNotifyAdmin({ ctaText: formatMessage({ @@ -88,10 +88,10 @@ const FeatureRestrictedModal = ({ }; const handleViewPlansClick = (e: React.MouseEvent) => { - if (isSystemAdmin) { + if (isSystemAdmin && !isAirGapped) { openPricingModal({trackingLocation: 'feature_restricted_modal'}); dismissAction(); - } else { + } else if (!isSystemAdmin) { notifyAdmin(e, 'feature_restricted_modal'); } }; @@ -125,6 +125,9 @@ const FeatureRestrictedModal = ({ secondaryBtnAction = customSecondaryButton.action; } + // Hide view plans button for admins if air-gapped + const showSecondaryButton = !isAirGapped || !isSystemAdmin || customSecondaryButton; + const trialBtn = ( )}
- + {showSecondaryButton && ( + + )} {showStartTrial && ( trialBtn )} diff --git a/webapp/channels/src/components/file_limit_sticky_banner/index.tsx b/webapp/channels/src/components/file_limit_sticky_banner/index.tsx index 599b561e25..990efd620b 100644 --- a/webapp/channels/src/components/file_limit_sticky_banner/index.tsx +++ b/webapp/channels/src/components/file_limit_sticky_banner/index.tsx @@ -42,7 +42,7 @@ function FileLimitStickyBanner() { const usage = useGetUsage(); const [cloudLimits] = useGetLimits(); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const user = useSelector(getCurrentUser); const isAdmin = useSelector(isCurrentUserSystemAdmin); @@ -104,7 +104,13 @@ function FileLimitStickyBanner() { /> ); - const adminMessage = + const adminMessage = isAirGapped ? + ( + + ) : ( ); - const nonAdminMessage = + const nonAdminMessage = isAirGapped ? + ( + + ) : ( void; - const PlanUpgradeButton = (): JSX.Element | null => { const dispatch = useDispatch(); const {formatMessage} = useIntl(); - openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const isCloud = useSelector(isCurrentLicenseCloud); useEffect(() => { @@ -65,6 +62,11 @@ const PlanUpgradeButton = (): JSX.Element | null => { return null; } + // Don't show the button if air-gapped + if (isAirGapped) { + return null; + } + return ( { }; export default PlanUpgradeButton; -export {openPricingModal}; diff --git a/webapp/channels/src/components/pricing_modal/building.svg.tsx b/webapp/channels/src/components/pricing_modal/building.svg.tsx deleted file mode 100644 index 06f2e34d95..0000000000 --- a/webapp/channels/src/components/pricing_modal/building.svg.tsx +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -function BuildingSvg() { - return ( - - - - - - - - - - - - - - - - ); -} - -export default BuildingSvg; diff --git a/webapp/channels/src/components/pricing_modal/card.tsx b/webapp/channels/src/components/pricing_modal/card.tsx deleted file mode 100644 index d2a69e8d12..0000000000 --- a/webapp/channels/src/components/pricing_modal/card.tsx +++ /dev/null @@ -1,233 +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 type {ReactNode} from 'react'; -import {useIntl} from 'react-intl'; -import styled from 'styled-components'; - -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import ChatIllustration from 'components/common/svg_images_components/chat_illustration'; -import ExternalLink from 'components/external_link'; - -import {HostedCustomerLinks} from 'utils/constants'; - -import BuildingSvg from './building.svg'; -import TadaSvg from './tada.svg'; - -export enum ButtonCustomiserClasses { - grayed = 'grayed', - active = 'active', - special = 'special', - secondary = 'secondary', - green = 'green', -} - -type PlanBriefing = { - title: string; - items?: string[]; -} - -type PlanAddonsInfo = { - title: string; - items: PlanBriefing[]; -} - -type ButtonDetails = { - action: (e: React.MouseEvent) => void; - text: ReactNode; - disabled?: boolean; - customClass?: ButtonCustomiserClasses; -} - -type CardProps = { - id: string; - topColor: string; - planLabel?: JSX.Element; - plan: string; - planSummary?: ReactNode; - price?: string; - rate?: ReactNode; - planExtraInformation?: JSX.Element; - buttonDetails?: ButtonDetails; - customButtonDetails?: JSX.Element; - contactSalesCTA?: JSX.Element; - briefing: PlanBriefing; - planAddonsInfo?: PlanAddonsInfo; - planTrialDisclaimer?: JSX.Element; - isCloud: boolean; -} - -type StyledProps = { - bgColor?: string; -} - -const StyledDiv = styled.div` -background-color: ${(props) => props.bgColor}; -`; - -export function BlankCard() { - const {formatMessage} = useIntl(); - const [, contactSalesLink] = useOpenSalesLink(); - - return ( -
-
- {ChatIllustration} -
- -
-
- - {formatMessage({id: 'pricing_modal.questions', defaultMessage: 'Questions?'})} - - - - {formatMessage({id: 'pricing_modal.contact_us', defaultMessage: 'Contact us'})} - - -
-
- {formatMessage({id: 'pricing_modal.reach_out', defaultMessage: 'Reach out to us and we’ll help you decide which plan is right for you and your organization.'})} -
-
-
-
- - {formatMessage({id: 'pricing_modal.interested_self_hosting', defaultMessage: 'Interested in self-hosting?'})} - - - - {formatMessage({id: 'pricing_modal.learn_more', defaultMessage: 'Learn more'})} - - -
-
- ); -} - -function Card(props: CardProps) { - const {formatMessage} = useIntl(); - const bottomClassName = classNames('bottom', { - bottom__round: props.isCloud, - }); - - const contactSalesCTAClassName = classNames('contact_sales_cta', { - contact_sales_cta__reduced: props.isCloud, - }); - - const planBriefingContentClassName = classNames('plan_briefing_content', 'plan_briefing_content__reduced'); - - const planPriceRateSectionClassName = classNames('plan_price_rate_section', 'plan_price_rate_section__expanded'); - - const planLimitsCtaClassName = classNames('plan_limits_cta', 'plan_limits_cta__expanded'); - - const buildingImgClassName = classNames('building_img', 'building_img__expanded'); - - return ( -
- {props.planLabel} - {!props.isCloud && ( - - )} - -
-
-
-

{props.plan}

-

{props.planSummary}

- {props.price ?

{props.price}

:
} - {props.rate} -
- -
- {props.planExtraInformation} -
- -
- {props.customButtonDetails || ( - - )} -
- -
- {props.contactSalesCTA && ( -
-

{formatMessage({id: 'pricing_modal.or', defaultMessage: 'or'})}

- {props.contactSalesCTA} -
)} -
- -
- {props.planTrialDisclaimer} -
- {props.briefing.title} - {props.briefing.items?.map((i) => { - return ( -
-

{i}

-
- ); - })} -
-
-
- - {props.planAddonsInfo && ( -
-
-

{props.planAddonsInfo.title}

- {props.planAddonsInfo.items.map((i) => { - return ( -
-

{i.title}

- {i.items?.map((sub) => { - return ( -
-

{sub}

-
- - ); - })} -
- ); - })} - -
- )} - -
-
- ); -} - -export default Card; diff --git a/webapp/channels/src/components/pricing_modal/contact_sales_cta.tsx b/webapp/channels/src/components/pricing_modal/contact_sales_cta.tsx deleted file mode 100644 index c754f262d5..0000000000 --- a/webapp/channels/src/components/pricing_modal/contact_sales_cta.tsx +++ /dev/null @@ -1,52 +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 styled from 'styled-components'; - -import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; - -import {TELEMETRY_CATEGORIES} from 'utils/constants'; - -const StyledA = styled.a` -color: var(--button-bg); -font-family: 'Open Sans'; -font-size: 12px; -font-style: normal; -font-weight: 600; -line-height: 16px; -cursor: pointer; -text-align: center; -`; - -function ContactSalesCTA() { - const {formatMessage} = useIntl(); - const [openSalesLink] = useOpenSalesLink(); - - const isCloud = useSelector(isCurrentLicenseCloud); - - return ( - ) => { - e.preventDefault(); - if (isCloud) { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales'); - } else { - trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales'); - } - openSalesLink(); - }} - > - {formatMessage({id: 'pricing_modal.btn.contactSalesForQuote', defaultMessage: 'Contact Sales'})} - - ); -} - -export default ContactSalesCTA; diff --git a/webapp/channels/src/components/pricing_modal/content.scss b/webapp/channels/src/components/pricing_modal/content.scss deleted file mode 100644 index 0cef4dd886..0000000000 --- a/webapp/channels/src/components/pricing_modal/content.scss +++ /dev/null @@ -1,35 +0,0 @@ -.Content { - position: relative; - display: flex; - flex-direction: column; - justify-content: center; - - &.Content--self-hosted { - .alert-option { - padding: 40px 0; - } - } -} - -.pricing-options-container { - display: flex; - min-height: 120px; - align-items: center; - - .alert-option-container { - flex: 1; - } - - .save-text { - flex: 1; - margin: 0; - margin-right: 12px; - color: var(--online-indicator); - font-family: 'Metropolis'; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 18px; - text-align: right; - } -} diff --git a/webapp/channels/src/components/pricing_modal/content.tsx b/webapp/channels/src/components/pricing_modal/content.tsx deleted file mode 100644 index 72a3a49fad..0000000000 --- a/webapp/channels/src/components/pricing_modal/content.tsx +++ /dev/null @@ -1,252 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {useIntl} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import { - getCloudSubscription as selectCloudSubscription, - getSubscriptionProduct as selectSubscriptionProduct, - getCloudProducts as selectCloudProducts, -} from 'mattermost-redux/selectors/entities/cloud'; -import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {NotifyStatus} from 'components/common/hooks/useGetNotifyAdmin'; -import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink'; -import PlanLabel from 'components/common/plan_label'; -import {useNotifyAdmin} from 'components/notify_admin_cta/notify_admin_cta'; -import CheckMarkSvg from 'components/widgets/icons/check_mark_icon'; - -import {CloudProducts, LicenseSkus, MattermostFeatures, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; -import {findOnlyYearlyProducts, findProductBySku} from 'utils/products'; - -import Card, {BlankCard, ButtonCustomiserClasses} from './card'; - -import './content.scss'; - -type ContentProps = { - onHide: () => void; - - // 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; -} - -function Content(props: ContentProps) { - const {formatMessage, formatNumber} = useIntl(); - - const isAdmin = useSelector(isCurrentUserSystemAdmin); - - const subscription = useSelector(selectCloudSubscription); - const currentProduct = useSelector(selectSubscriptionProduct); - const products = useSelector(selectCloudProducts); - - const yearlyProducts = findOnlyYearlyProducts(products || {}); // pricing modal should now only show yearly products - - const currentSubscriptionIsMonthly = currentProduct?.recurring_interval === RecurringIntervals.MONTH; - const isEnterprise = currentProduct?.sku === CloudProducts.ENTERPRISE; - const isEnterpriseTrial = subscription?.is_free_trial === 'true'; - const yearlyProfessionalProduct = findProductBySku(yearlyProducts, CloudProducts.PROFESSIONAL); - const professionalPrice = formatNumber((yearlyProfessionalProduct?.price_per_seat || 0) / 12, {maximumFractionDigits: 2}); - - const isProfessional = currentProduct?.sku === CloudProducts.PROFESSIONAL; - const currentSubscriptionIsMonthlyProfessional = currentSubscriptionIsMonthly && isProfessional; - - const isPreTrial = subscription?.trial_end_at === 0; - - let isPostTrial = false; - if ((subscription && subscription.trial_end_at > 0) && !isEnterpriseTrial && isEnterprise) { - isPostTrial = true; - } - - const [notifyAdminBtnTextEnterprise, notifyAdminOnEnterpriseFeatures, enterpriseNotifyRequestStatus] = 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_ENTERPRISE_FEATURES, - required_plan: LicenseSkus.Enterprise, - trial_notification: isPreTrial, - }); - - const getAdminProfessionalBtnText = () => { - if (currentSubscriptionIsMonthlyProfessional) { - return formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}); - } - - return formatMessage({id: 'pricing_modal.btn.purchase', defaultMessage: 'Purchase'}); - }; - - const adminProfessionalTierText = getAdminProfessionalBtnText(); - - const [openContactSales] = useOpenSalesLink(); - - const professionalBtnDetails = () => { - return { - action: () => { }, - text: adminProfessionalTierText, - disabled: true, - customClass: (isPostTrial) ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active, - }; - }; - - const enterpriseBtnDetails = () => { - if (isAdmin) { - return { - action: () => { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_PRICING, 'click_enterprise_contact_sales'); - openContactSales(); - }, - text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}), - customClass: ButtonCustomiserClasses.special, - }; - } - - 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, - }; - }; - - const professionalPlanLabelText = () => { - if (isProfessional || !isAdmin) { - return formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'}); - } - - return formatMessage({id: 'pricing_modal.planLabel.currentPlanMonthly', defaultMessage: 'CURRENTLY ON MONTHLY BILLING'}); - }; - - return ( -
- -
-

- {formatMessage({id: 'pricing_modal.title', defaultMessage: 'Select a plan'})} -

-
{formatMessage({id: 'pricing_modal.subtitle', defaultMessage: 'Choose a plan to get started'})}
-
- -
- -
- {isProfessional && - , - })} - price={`$${professionalPrice}`} - rate={formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}(billed annually)'}, { - br:
, - b: (chunks: React.ReactNode | React.ReactNodeArray) => ( - - {chunks} - - ), - })} - isCloud={true} - planLabel={isProfessional ? ( - } - />) : undefined} - buttonDetails={professionalBtnDetails()} - briefing={{ - 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'}), - formatMessage({id: 'pricing_modal.briefing.professional.advancedPlaybook', defaultMessage: 'Advanced Playbook workflows with retrospectives'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.ssoSaml', defaultMessage: 'SSO with SAML 2.0, including Okta, OneLogin, and ADFS'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.ssoadLdap', defaultMessage: 'SSO support with AD/LDAP, Google, O365, OpenID'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.guestAccess', defaultMessage: 'Guest access with MFA enforcement'}), - ], - }} - />} - - } - renderLastDaysOnTrial={true} - />) : undefined} - buttonDetails={enterpriseBtnDetails()} - planTrialDisclaimer={undefined} - briefing={{ - title: formatMessage({id: 'pricing_modal.briefing.title_large_scale', defaultMessage: 'Large scale collaboration'}), - items: [ - formatMessage({id: 'pricing_modal.briefing.enterprise.groupSync', defaultMessage: 'AD/LDAP group sync'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.rolesAndPermissions', defaultMessage: 'Advanced roles and permissions'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.advancedComplianceManagement', defaultMessage: 'Advanced compliance management'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.mobileSecurity', defaultMessage: 'Advanced mobile security via ID-only push notifications'}), - formatMessage({id: 'pricing_modal.extra_briefing.enterprise.playBookAnalytics', defaultMessage: 'Playbook analytics dashboard'}), - ], - }} - planAddonsInfo={{ - title: formatMessage({id: 'pricing_modal.addons.title', defaultMessage: 'Available Add-ons'}), - items: [ - {title: formatMessage({id: 'pricing_modal.addons.premiumSupport', defaultMessage: 'Premium support'})}, - {title: formatMessage({id: 'pricing_modal.addons.missionCritical', defaultMessage: 'Mission-critical 24x7'})}, - {title: '1hr-L1, 2hr-L2'}, - {title: formatMessage({id: 'pricing_modal.addons.USSupport', defaultMessage: 'U.S.- only based support'})}, - {title: formatMessage({id: 'pricing_modal.addons.dedicatedDeployment', defaultMessage: 'Dedicated virtual secure cloud deployment (Cloud)'})}, - {title: formatMessage({id: 'pricing_modal.addons.dedicatedK8sCluster', defaultMessage: 'Dedicated Kubernetes cluster'})}, - {title: formatMessage({id: 'pricing_modal.addons.dedicatedDB', defaultMessage: 'Dedicated database'})}, - {title: formatMessage({id: 'pricing_modal.addons.dedicatedEncryption', defaultMessage: 'Dedicated encryption keys'})}, - {title: formatMessage({id: 'pricing_modal.addons.uptimeGuarantee', defaultMessage: '99% uptime guarantee'})}, - ], - }} - /> - -
-
-
- ); -} - -export default Content; diff --git a/webapp/channels/src/components/pricing_modal/index.tsx b/webapp/channels/src/components/pricing_modal/index.tsx deleted file mode 100644 index 5bd1871908..0000000000 --- a/webapp/channels/src/components/pricing_modal/index.tsx +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useState} from 'react'; -import {Modal} from 'react-bootstrap'; -import {useDispatch, useSelector} from 'react-redux'; - -import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud'; - -import {closeModal} from 'actions/views/modals'; -import {isModalOpen} from 'selectors/views/modals'; - -import {ModalIdentifiers} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import Content from './content'; -import SelfHostedContent from './self_hosted_content'; - -import './pricing_modal.scss'; - -type Props = { - - // 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; -} - -function PricingModal(props: Props) { - const [showModal, setShowModal] = useState(true); - const dispatch = useDispatch(); - const isCloud = useSelector(isCurrentLicenseCloud); - const isCloudPurchaseModalOpen = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE)); - - const onHide = () => { - // this fixes problem when both pricing modal and purchase modal are open and subsequently, when a user closes the pricing modal, - // the purchase modal becomes unresponsive for sometime because the pricing modal is still in the DOM. - if (isCloudPurchaseModalOpen) { - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - } else { - setShowModal(false); - } - }; - - const content = isCloud ? ( - - ) : ( - - ); - - return ( - { - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - }} - data-testid='pricingModal' - dialogClassName='a11y__modal' - onHide={onHide} - role='none' - aria-modal='true' - aria-labelledby='pricing_modal_title' - > - {content} - - - ); -} - -export default PricingModal; diff --git a/webapp/channels/src/components/pricing_modal/pricing_modal.scss b/webapp/channels/src/components/pricing_modal/pricing_modal.scss deleted file mode 100644 index b6449b7ddb..0000000000 --- a/webapp/channels/src/components/pricing_modal/pricing_modal.scss +++ /dev/null @@ -1,444 +0,0 @@ -.PricingModal { - .modal-dialog { - position: absolute; - top: 50%; - left: 50%; - width: 1440px; - margin: auto; - transform: translate(-50%, -50%) !important; - } - - .modal-header { - height: 77px; - min-height: 77px; - } - - .PricingModal__header { - display: flex; - align-items: center; - justify-content: space-between; - border: none; - border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); - font-size: 22px; - font-weight: 600; - - .header_lhs { - display: flex; - align-items: center; - - h1 { - margin: 0; - color: var(--center-channel-color); - font-family: 'Metropolis', sans-serif; - font-size: 22px; - font-style: normal; - font-weight: 600; - line-height: 28px; - } - - div { - padding-left: 8px; - border-left: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - margin-left: 8px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Open Sans'; - font-size: 12px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - } - - &::before, - &::after { - display: none; - } - } - - .modal-body { - display: flex; - height: 623px; - min-height: 623px; - flex-direction: column; - padding: 0 71px; - overflow-x: visible; - overflow-y: scroll; - - .alert-option { - display: flex; - width: 100%; - flex-direction: column; - text-align: right; - - span { - color: var(--center-channel-color); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 20px; - text-align: right; - } - - a { - color: var(--button-bg); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 20px; - } - } - } - - .PricingModal__body { - position: relative; - display: flex; - align-self: center; - margin-bottom: 111px; - gap: 48px; - - .BlankCard { - position: relative; - width: 280px; - - .image { - margin-top: 8px; - } - - hr { - border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - } - - .description { - border-bottom: 1px solid rgba(var(--center-channel-color-rgb) 0.16); - text-align: left; - - .title { - margin-top: 24px; - margin-bottom: 8px; - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 20px; - - .questions { - margin-right: 2px; - color: var(--center-channel-color); - } - - .contact { - color: var(--button-bg); - } - } - - .content { - margin-bottom: 25px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - } - } - - .self-hosted-interest { - padding: 8px; - border-radius: 8px; - margin-top: 24px; - background: rgba(var(--button-bg-rgb), 0.08); - font-family: 'Open Sans'; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 20px; - - .interested { - margin-right: 2px; - color: var(--center-channel-color); - } - - .learn { - color: var(--button-bg); - } - } - } - - .PlanCard { - position: relative; - width: 280px; - - .planLabel { - position: absolute; - top: -36px; - right: 0; - left: 0; - display: flex; - width: auto; - height: 36px; - align-items: center; - justify-content: center; - padding: 14px; - border-radius: 13px 13px 0 0; - margin-right: auto; - margin-left: auto; - font-size: 12px; - font-style: normal; - font-weight: 600; - gap: 5px; - letter-spacing: 0.02em; - line-height: 16px; - text-align: center; - text-transform: uppercase; - } - - .top { - height: 18px; - border-radius: 4px 4px 0 0; - } - - .bottom { - height: auto; - border: 1px solid rgba(var(--center-channel-text-rgb), 0.08); - border-radius: 0 0 4px 4px; - border-top: 1px solid var(--center-channel-bg); - background-color: var(--center-channel-bg); - box-shadow: var(--elevation-6); - - .bottom_container { - padding-right: 24px; - padding-left: 24px; - margin-bottom: 24px; - - .plan_price_rate_section { - width: 100%; - height: 221px; - text-align: center; - - h3 { - margin-top: 32px; - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 24px; - font-style: normal; - font-weight: 700; - line-height: 31px; - text-align: center; - } - - h1 { - margin: 0; - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 52px; - font-style: normal; - font-weight: 700; - letter-spacing: -0.02em; - line-height: 60px; - } - - span, - p { - margin-bottom: 32px; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 16px; - font-style: normal; - font-weight: 400; - line-height: 24px; - } - - .plan_rate { - margin-bottom: 0; - } - - .building_img { - padding-top: 0; - } - - .building_img__expanded { - padding-top: 14px; - } - - .billed_annually { - font-family: 'Open sans', sans-serif; - font-size: 16px; - } - } - - .plan_price_rate_section__expanded { - height: 244px; - - span, - p { - margin-bottom: 45px; - } - } - - .contact_sales_cta { - height: 49px; - margin-top: 16px; - margin-bottom: 16px; - text-align: center; - } - - .contact_sales_cta__reduced { - height: 32px; - margin-top: 0; - } - - .plan_limits_cta { - height: 21px; - } - - .plan_limits_cta__expanded { - height: 32px; - } - - .plan_buttons { - height: auto; - - .plan_action_btn { - width: 100%; - height: 40px; - padding: 10px 24px; - border: none; - border-radius: 4px; - background: none; - color: var(--button-bg); - font-weight: 600; - - &.grayed { - background: rgba(var(--center-channel-color-rgb), 0.08); - color: rgba(var(--center-channel-color-rgb), 0.32); - cursor: not-allowed; - } - - &.secondary { - background: rgba(var(--button-bg-rgb), 0.08); - color: var(--button-bg); - } - - &.active { - border: 1px solid var(--button-bg); - } - - &.special { - background: var(--button-bg); - color: var(--button-color); - } - - &.green { - background: var(--online-indicator); - color: var(--button-color); - } - } - } - - .plan_briefing { - height: auto; - - hr { - border-top: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - } - - .plan_briefing_content { - margin-top: 16px; - - .title { - color: var(--center-channel-color); - font-family: 'Metropolis'; - font-size: 13px; - font-style: normal; - font-weight: 600; - letter-spacing: -0.02em; - line-height: 24px; - white-space: nowrap; - } - - .item { - display: flex; - max-height: 72px; - - .bullet { - display: inline-block; - margin: 8px 8px 0 0; - color: var(--button-bg); - font-size: 10px; - } - - p { - margin: 0; - color: rgba(var(--center-channel-color-rgb), 0.75); - font-family: 'Metropolis'; - font-size: 14px; - font-style: normal; - font-weight: 400; - letter-spacing: -0.02em; - line-height: 24px; - } - } - } - - .plan_briefing_content__reduced { - margin-top: 0; - } - } - } - - .plan_add_ons { - position: relative; - height: auto; - padding: 37px 24px 24px 24px; - border-radius: 0 0 4px 4px; - background-color: rgba(var(--center-channel-text-rgb), 0.75); - color: var(--sidebar-text); - - .illustration { - position: absolute; - top: 24px; - right: 36px; - } - - .title { - font-family: 'Metropolis'; - font-size: 16px; - font-style: normal; - font-weight: 700; - line-height: 24px; - } - - .item, - .subitem { - font-weight: 400; - - &_title { - display: flex; - - .bullet { - display: inline-block; - margin: 8px 8px 0 0; - font-size: 5px; - } - } - } - - .item { - margin-left: 5px; - } - - .subitem { - margin-left: 20px; - } - } - } - - .bottom__round { - border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); - border-radius: 8px; - } - } - } -} diff --git a/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx b/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx deleted file mode 100644 index fe0e1e3de3..0000000000 --- a/webapp/channels/src/components/pricing_modal/self_hosted_content.tsx +++ /dev/null @@ -1,271 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect, useState} from 'react'; -import {Modal} from 'react-bootstrap'; -import {useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import type {GlobalState} from '@mattermost/types/store'; - -import {getPrevTrialLicense} from 'mattermost-redux/actions/admin'; -import {Client4} from 'mattermost-redux/client'; -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 useFetchAdminConfig from 'components/common/hooks/useFetchAdminConfig'; -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, LicenseSkus, TELEMETRY_CATEGORIES, RecurringIntervals} from 'utils/constants'; - -import Card, {ButtonCustomiserClasses} from './card'; -import ContactSalesCTA from './contact_sales_cta'; -import StartTrialCaution from './start_trial_caution'; - -import './content.scss'; - -type ContentProps = { - onHide: () => void; -} - -const FALL_BACK_PROFESSIONAL_PRICE = '10'; - -function SelfHostedContent(props: ContentProps) { - const [professionalPrice, setProfessionalPrice] = useState(' '); - useFetchAdminConfig(); - const {formatMessage} = useIntl(); - const dispatch = useDispatch(); - const [openSalesLink] = useOpenSalesLink(); - - useEffect(() => { - dispatch(getPrevTrialLicense()); - }, []); - - useEffect(() => { - async function fetchSelfHostedProducts() { - try { - const products = await Client4.getSelfHostedProducts(); - const professionalProduct = products.find((prod) => prod.sku === LicenseSkus.Professional && prod.recurring_interval === RecurringIntervals.YEAR); - const price = professionalProduct ? professionalProduct.price_per_seat.toString() : FALL_BACK_PROFESSIONAL_PRICE; - setProfessionalPrice(`$${price}`); - } catch (error) { - setProfessionalPrice(`$${FALL_BACK_PROFESSIONAL_PRICE}`); - } - } - - fetchSelfHostedProducts(); - }, []); - - const isAdmin = useSelector(isCurrentUserSystemAdmin); - - const license = useSelector(getLicense); - const prevSelfHostedTrialLicense = useSelector((state: GlobalState) => state.entities.admin.prevTrialLicense); - - const isSelfHostedEnterpriseTrial = license.IsTrial === 'true'; - - const isStarter = license.IsLicensed === 'false'; - const isProfessional = license.SkuShortName === LicenseSkus.Professional; - const isEnterprise = license.SkuShortName === LicenseSkus.Enterprise; - const isPostSelfHostedEnterpriseTrial = prevSelfHostedTrialLicense.IsLicensed === 'true'; - - const [openContactSales] = useOpenSalesLink(); - - const closePricingModal = () => { - dispatch(closeModal(ModalIdentifiers.PRICING_MODAL)); - }; - - const starterBriefing = [ - formatMessage({id: 'pricing_modal.briefing.unlimitedWorkspaceTeams', defaultMessage: 'Unlimited workspace teams'}), - formatMessage({id: 'pricing_modal.briefing.unlimitedPlaybookRuns', defaultMessage: 'Unlimited playbooks and runs'}), - formatMessage({id: 'pricing_modal.extra_briefing.free.calls', defaultMessage: 'Voice calls and screen share'}), - formatMessage({id: 'pricing_modal.briefing.fullMessageAndHistory', defaultMessage: 'Full message and file history'}), - formatMessage({id: 'pricing_modal.briefing.ssoWithGitLab', defaultMessage: 'SSO with Gitlab'}), - ]; - - const professionalBriefing = [ - formatMessage({id: 'pricing_modal.briefing.customUserGroups', defaultMessage: 'Custom user groups'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.ssoSaml', defaultMessage: 'SSO with SAML 2.0, including Okta, OneLogin, and ADFS'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.ssoadLdap', defaultMessage: 'SSO support with AD/LDAP, Google, O365, OpenID'}), - formatMessage({id: 'pricing_modal.extra_briefing.professional.guestAccess', defaultMessage: 'Guest access with MFA enforcement'}), - - ]; - - const enterpriseBriefing = [ - formatMessage({id: 'pricing_modal.briefing.enterprise.groupSync', defaultMessage: 'AD/LDAP group sync'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.mobileSecurity', defaultMessage: 'Advanced mobile security via ID-only push notifications'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.rolesAndPermissions', defaultMessage: 'Advanced roles and permissions'}), - formatMessage({id: 'pricing_modal.briefing.enterprise.advancedComplianceManagement', defaultMessage: 'Advanced compliance management'}), - formatMessage({id: 'pricing_modal.extra_briefing.enterprise.playBookAnalytics', defaultMessage: 'Playbook analytics dashboard'}), - ]; - - const renderAlert = () => { - return ( -
- - {formatMessage({id: 'pricing_modal.lookingForCloudOption', defaultMessage: 'Looking for a cloud option?'})} - - { - trackEvent( - TELEMETRY_CATEGORIES.SELF_HOSTED_PURCHASING, - 'click_looking_for_a_cloud_option', - ); - } - } - href={CloudLinks.CLOUD_SIGNUP_PAGE} - location='pricing_modal_self_hosted_content' - >{formatMessage({id: 'pricing_modal.reviewDeploymentOptions', defaultMessage: 'Review deployment options'})} -
- ); - }; - - const trialButton = () => { - return ( - - ); - }; - - return ( -
- -
-

- {formatMessage({id: 'pricing_modal.title', defaultMessage: 'Select a plan'})} -

-
{formatMessage({id: 'pricing_modal.subtitle', defaultMessage: 'Choose a plan to get started'})}
-
- -
- - {renderAlert()} -
- } - />) : undefined} - buttonDetails={{ - action: () => {}, - text: formatMessage({id: 'pricing_modal.btn.downgrade', defaultMessage: 'Downgrade'}), - disabled: true, - customClass: ButtonCustomiserClasses.active, - }} - briefing={{ - title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}), - items: starterBriefing, - }} - /> - , - })} - price={professionalPrice} - rate={formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}(billed annually)'}, { - br:
, - b: (chunks: React.ReactNode | React.ReactNodeArray) => ( - - {chunks} - - ), - })} - isCloud={false} - planLabel={ - isProfessional ? ( - } - />) : undefined} - buttonDetails={{ - action: () => { - trackEvent('self_hosted_pricing', 'click_upgrade_button'); - openSalesLink(); - }, - text: formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}), - disabled: !isAdmin || isProfessional, - customClass: isPostSelfHostedEnterpriseTrial ? ButtonCustomiserClasses.special : ButtonCustomiserClasses.active, - }} - - briefing={{ - title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}), - items: professionalBriefing, - }} - /> - } - renderLastDaysOnTrial={true} - />) : undefined} - buttonDetails={(isPostSelfHostedEnterpriseTrial || !isAdmin) ? { - action: () => { - trackEvent('self_hosted_pricing', 'click_enterprise_contact_sales'); - openContactSales(); - }, - text: formatMessage({id: 'pricing_modal.btn.contactSales', defaultMessage: 'Contact Sales'}), - customClass: ButtonCustomiserClasses.special, - } : undefined} - customButtonDetails={(!isPostSelfHostedEnterpriseTrial && isAdmin) ? ( - trialButton() - ) : undefined} - planTrialDisclaimer={(!isPostSelfHostedEnterpriseTrial && isAdmin) ? : undefined} - contactSalesCTA={(isPostSelfHostedEnterpriseTrial || !isAdmin) ? undefined : } - briefing={{ - title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}), - items: enterpriseBriefing, - }} - /> -
-
-
- ); -} - -export default SelfHostedContent; diff --git a/webapp/channels/src/components/pricing_modal/start_trial_caution.tsx b/webapp/channels/src/components/pricing_modal/start_trial_caution.tsx deleted file mode 100644 index 4ee95a6bed..0000000000 --- a/webapp/channels/src/components/pricing_modal/start_trial_caution.tsx +++ /dev/null @@ -1,54 +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 styled from 'styled-components'; - -import ExternalLink from 'components/external_link'; - -import {AboutLinks, LicenseLinks} from 'utils/constants'; - -const ContainerSpan = styled.span` -font-style: normal; -display: inline-block; -font-weight: 400; -font-size: 10px; -line-height: 14px; -letter-spacing: 0.02em; -color: rgba(var(--center-channel-color-rgb), 0.75); -`; - -const Span = styled.span` -font-weight: 600; -`; - -function StartTrialCaution() { - const {formatMessage} = useIntl(); - - const message = formatMessage({ - id: 'pricing_modal.start_trial.disclaimer', - defaultMessage: 'By selecting Try free for 30 days, I agree to the Mattermost Software and Services License Agreement, Privacy Policy, and receiving product emails.', - }, { - span: (chunks: React.ReactNode | React.ReactNodeArray) => ({chunks}), - linkAgreement: (msg: React.ReactNode) => ( - - {msg} - - ), - linkPrivacy: (msg: React.ReactNode) => ( - - {msg} - - ), - }); - return ({message}); -} - -export default StartTrialCaution; diff --git a/webapp/channels/src/components/pricing_modal/starter_disclaimer_cta.tsx b/webapp/channels/src/components/pricing_modal/starter_disclaimer_cta.tsx deleted file mode 100644 index d5b5cfbcb6..0000000000 --- a/webapp/channels/src/components/pricing_modal/starter_disclaimer_cta.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {defineMessage, useIntl} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; -import styled from 'styled-components'; - -import type {Product} from '@mattermost/types/cloud'; - -import {getCloudProducts} from 'mattermost-redux/selectors/entities/cloud'; - -import {openModal, closeModal} from 'actions/views/modals'; - -import CloudUsageModal from 'components/cloud_usage_modal'; -import useGetLimits from 'components/common/hooks/useGetLimits'; - -import {CloudProducts, ModalIdentifiers} from 'utils/constants'; -import {fallbackStarterLimits, asGBString, hasSomeLimits} from 'utils/limits'; - -const Disclaimer = styled.div` -margin-bottom: 8px; -color: var(--error-text); -font-family: 'Open Sans'; -font-size: 12px; -font-style: normal; -font-weight: 600; -line-height: 16px; -cursor: pointer; -`; - -function StarterDisclaimerCTA() { - const intl = useIntl(); - const dispatch = useDispatch(); - const [limits] = useGetLimits(); - const products = useSelector(getCloudProducts); - const starterProductName = Object.values(products || {})?.find((product: Product) => product?.sku === CloudProducts.STARTER)?.name || 'Cloud Free'; - - if (!hasSomeLimits(limits)) { - return null; - } - - const openLimitsMiniModal = () => { - dispatch(openModal({ - modalId: ModalIdentifiers.CLOUD_LIMITS, - dialogType: CloudUsageModal, - dialogProps: { - backdropClassName: 'cloud-usage-backdrop', - title: defineMessage({ - id: 'workspace_limits.modals.informational.title', - defaultMessage: '{planName} limits', - values: { - planName: starterProductName, - }, - }), - description: defineMessage({ - id: 'workspace_limits.modals.informational.description.freeLimits', - defaultMessage: '{planName} is restricted to {messages} message history and {storage} file storage.', - values: { - planName: starterProductName, - messages: intl.formatNumber(fallbackStarterLimits.messages.history), - storage: asGBString(fallbackStarterLimits.files.totalStorage, intl.formatNumber), - }, - }), - secondaryAction: { - message: defineMessage({ - id: 'workspace_limits.modals.close', - defaultMessage: 'Close', - }), - onClick: () => { - dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS)); - }, - }, - onClose: () => { - dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS)); - }, - ownLimits: { - messages: { - history: fallbackStarterLimits.messages.history, - }, - files: { - total_storage: fallbackStarterLimits.files.totalStorage, - }, - }, - needsTheme: true, - }, - })); - }; - return ( - - - {intl.formatMessage({id: 'pricing_modal.planDisclaimer.free', defaultMessage: 'This plan has data restrictions.'})} - - ); -} - -export default StarterDisclaimerCTA; diff --git a/webapp/channels/src/components/pricing_modal/tada.svg.tsx b/webapp/channels/src/components/pricing_modal/tada.svg.tsx deleted file mode 100644 index 567a6c9420..0000000000 --- a/webapp/channels/src/components/pricing_modal/tada.svg.tsx +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -function TadaSvg() { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} - -export default TadaSvg; diff --git a/webapp/channels/src/components/search_results/search_limits_banner.tsx b/webapp/channels/src/components/search_results/search_limits_banner.tsx index 41a2366d40..647e2af878 100644 --- a/webapp/channels/src/components/search_results/search_limits_banner.tsx +++ b/webapp/channels/src/components/search_results/search_limits_banner.tsx @@ -46,7 +46,7 @@ type Props = { function SearchLimitsBanner(props: Props) { const {formatMessage, formatNumber} = useIntl(); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const usage = useGetUsage(); const [cloudLimits] = useGetLimits(); const isAdminUser = isAdmin(useSelector(getCurrentUser).roles); @@ -83,43 +83,61 @@ function SearchLimitsBanner(props: Props) { }; switch (props.searchType) { - case DataSearchTypes.FILES_SEARCH_TYPE: + case DataSearchTypes.FILES_SEARCH_TYPE: { if ((fileStorageLimit === undefined) || !(currentFileStorageUsage > fileStorageLimit)) { return null; } - return renderBanner(formatMessage({ - id: 'workspace_limits.search_files_limit.banner_text', - defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}. {ctaAction}', - }, { - ctaAction, - storage: asGBString(fileStorageLimit, formatNumber), - a: (chunks: React.ReactNode | React.ReactNodeArray) => ( - openPricingModal({trackingLocation: 'file_search_limits_banner'})} - > - {chunks} - - ), - }), `${DataSearchTypes.FILES_SEARCH_TYPE}_search_limits_banner`); + const filesBannerMessage = isAirGapped ? + formatMessage({ + id: 'workspace_limits.search_files_limit.banner_text_airgapped', + defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}.', + }, { + storage: asGBString(fileStorageLimit, formatNumber), + }) : + formatMessage({ + id: 'workspace_limits.search_files_limit.banner_text', + defaultMessage: 'Some older files may not be shown because your workspace has met its file storage limit of {storage}. {ctaAction}', + }, { + ctaAction, + storage: asGBString(fileStorageLimit, formatNumber), + a: (chunks: React.ReactNode | React.ReactNodeArray) => ( + openPricingModal({trackingLocation: 'file_search_limits_banner'})} + > + {chunks} + + ), + }); + return renderBanner(filesBannerMessage, `${DataSearchTypes.FILES_SEARCH_TYPE}_search_limits_banner`); + } - case DataSearchTypes.MESSAGES_SEARCH_TYPE: + case DataSearchTypes.MESSAGES_SEARCH_TYPE: { if ((messagesLimit === undefined) || !(currentMessagesUsage > messagesLimit)) { return null; } - return renderBanner(formatMessage({ - id: 'workspace_limits.search_message_limit.banner_text', - defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages. {ctaAction}', - }, { - ctaAction, - messages: formatNumber(messagesLimit), - a: (chunks: React.ReactNode | React.ReactNodeArray) => ( - openPricingModal({trackingLocation: 'messages_search_limits_banner'})} - > - {chunks} - - ), - }), `${DataSearchTypes.MESSAGES_SEARCH_TYPE}_search_limits_banner`); + const messagesBannerMessage = isAirGapped ? + formatMessage({ + id: 'workspace_limits.search_message_limit.banner_text_airgapped', + defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages.', + }, { + messages: formatNumber(messagesLimit), + }) : + formatMessage({ + id: 'workspace_limits.search_message_limit.banner_text', + defaultMessage: 'Some older messages may not be shown because your workspace has over {messages} messages. {ctaAction}', + }, { + ctaAction, + messages: formatNumber(messagesLimit), + a: (chunks: React.ReactNode | React.ReactNodeArray) => ( + openPricingModal({trackingLocation: 'messages_search_limits_banner'})} + > + {chunks} + + ), + }); + return renderBanner(messagesBannerMessage, `${DataSearchTypes.MESSAGES_SEARCH_TYPE}_search_limits_banner`); + } default: return null; } diff --git a/webapp/channels/src/components/three_days_left_trial_modal/three_days_left_trial_modal.tsx b/webapp/channels/src/components/three_days_left_trial_modal/three_days_left_trial_modal.tsx index 28048f9881..b905ac5f22 100644 --- a/webapp/channels/src/components/three_days_left_trial_modal/three_days_left_trial_modal.tsx +++ b/webapp/channels/src/components/three_days_left_trial_modal/three_days_left_trial_modal.tsx @@ -35,7 +35,7 @@ type Props = { function ThreeDaysLeftTrialModal(props: Props): JSX.Element | null { const dispatch = useDispatch(); const {formatMessage} = useIntl(); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.THREE_DAYS_LEFT_TRIAL_MODAL)); const usage = useGetUsage(); const [limits] = useGetLimits(); @@ -151,14 +151,16 @@ function ThreeDaysLeftTrialModal(props: Props): JSX.Element | null { {content}
-
- -
+ {!isAirGapped && ( +
+ +
+ )} ); } diff --git a/webapp/channels/src/components/widgets/menu/menu_items/menu_cloud_trial.tsx b/webapp/channels/src/components/widgets/menu/menu_items/menu_cloud_trial.tsx index 65471398e2..1d840e88f7 100644 --- a/webapp/channels/src/components/widgets/menu/menu_items/menu_cloud_trial.tsx +++ b/webapp/channels/src/components/widgets/menu/menu_items/menu_cloud_trial.tsx @@ -35,7 +35,7 @@ const MenuCloudTrial = ({id}: Props): JSX.Element | null => { const isFreeTrial = subscription?.is_free_trial === 'true'; const freeTrialEndDay = moment(subscription?.trial_end_at).format('MMMM DD'); const isAdmin = useSelector(isCurrentUserSystemAdmin); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); const openTrialBenefitsModal = async () => { await dispatch(openModal({ @@ -61,6 +61,11 @@ const MenuCloudTrial = ({id}: Props): JSX.Element | null => { return null; } + // Don't show if air-gapped + if (isAirGapped) { + return null; + } + const freeTrialContent = (
diff --git a/webapp/channels/src/components/widgets/menu/menu_items/useWords.tsx b/webapp/channels/src/components/widgets/menu/menu_items/useWords.tsx index 2a4efb2430..0059c15488 100644 --- a/webapp/channels/src/components/widgets/menu/menu_items/useWords.tsx +++ b/webapp/channels/src/components/widgets/menu/menu_items/useWords.tsx @@ -21,7 +21,7 @@ interface Words { export default function useWords(highestLimit: LimitSummary | false, isAdminUser: boolean, callerInfo: string): Words | false { const intl = useIntl(); - const openPricingModal = useOpenPricingModal(); + const {openPricingModal, isAirGapped} = useOpenPricingModal(); if (!highestLimit) { return false; } @@ -41,13 +41,20 @@ export default function useWords(highestLimit: LimitSummary | false, isAdminUser const values: Record | ((chunks: React.ReactNode | React.ReactNodeArray) => JSX.Element)> = { callToAction, - a: (chunks: React.ReactNode | React.ReactNodeArray) => ( - openPricingModal({trackingLocation: callerInfo})} - > - {chunks} - ), + a: (chunks: React.ReactNode | React.ReactNodeArray) => { + if (isAirGapped) { + // Return plain text if air-gapped + return <>{chunks}; + } + return ( + openPricingModal({trackingLocation: callerInfo})} + > + {chunks} + + ); + }, }; @@ -83,14 +90,14 @@ export default function useWords(highestLimit: LimitSummary | false, isAdminUser case LimitTypes.messageHistory: { let description = defineMessage({ id: 'workspace_limits.menu_limit.warn.messages_history', - defaultMessage: 'You’re getting closer to the free {limit} message limit. {callToAction}', + defaultMessage: 'You\'re getting closer to the free {limit} message limit. {callToAction}', }); values.limit = intl.formatNumber(highestLimit.limit); if (usageRatio >= limitThresholds.danger) { if (isAdminUser) { description = defineMessage({ id: 'workspace_limits.menu_limit.critical.messages_history', - defaultMessage: 'You’re close to hitting the free {limit} message history limit {callToAction}', + defaultMessage: 'You\'re close to hitting the free {limit} message history limit {callToAction}', }); } else { description = defineMessage({ @@ -103,13 +110,13 @@ export default function useWords(highestLimit: LimitSummary | false, isAdminUser if (isAdminUser) { description = defineMessage({ id: 'workspace_limits.menu_limit.reached.messages_history', - defaultMessage: 'You’ve reached the free message history limit. You can only view up to the last {limit} messages in your history. {callToAction}', + defaultMessage: 'You\'ve reached the free message history limit. You can only view up to the last {limit} messages in your history. {callToAction}', }); values.limit = inK(highestLimit.limit); } else { description = defineMessage({ id: 'workspace_limits.menu_limit.reached.messages_history_non_admin', - defaultMessage: 'You’ve reached your message limit. Your admin can upgrade your plan for unlimited messages. {callToAction}', + defaultMessage: 'You\'ve reached your message limit. Your admin can upgrade your plan for unlimited messages. {callToAction}', }); } } @@ -117,7 +124,7 @@ export default function useWords(highestLimit: LimitSummary | false, isAdminUser if (isAdminUser) { description = defineMessage({ id: 'workspace_limits.menu_limit.over.messages_history', - defaultMessage: 'You’re over the free message history limit. You can only view up to the last {limit} messages in your history. {callToAction}', + defaultMessage: 'You\'re over the free message history limit. You can only view up to the last {limit} messages in your history. {callToAction}', }); values.limit = inK(highestLimit.limit); } else { @@ -142,25 +149,25 @@ export default function useWords(highestLimit: LimitSummary | false, isAdminUser case LimitTypes.fileStorage: { let description = defineMessage({ id: 'workspace_limits.menu_limit.warn.files_storage', - defaultMessage: 'You’re getting closer to the {limit} file storage limit. {callToAction}', + defaultMessage: 'You\'re getting closer to the {limit} file storage limit. {callToAction}', }); values.limit = asGBString(highestLimit.limit, intl.formatNumber); if (usageRatio >= limitThresholds.danger) { description = defineMessage({ id: 'workspace_limits.menu_limit.critical.files_storage', - defaultMessage: 'You’re getting closer to the {limit} file storage limit. {callToAction}', + defaultMessage: 'You\'re getting closer to the {limit} file storage limit. {callToAction}', }); } if (usageRatio >= limitThresholds.reached) { description = defineMessage({ id: 'workspace_limits.menu_limit.reached.files_storage', - defaultMessage: 'You’ve reached the {limit} file storage limit. You can only access the most recent {limit} worth of files. {callToAction}', + defaultMessage: 'You\'ve reached the {limit} file storage limit. You can only access the most recent {limit} worth of files. {callToAction}', }); } if (usageRatio >= limitThresholds.exceeded) { description = defineMessage({ id: 'workspace_limits.menu_limit.over.files_storage', - defaultMessage: 'You’re over the {limit} file storage limit. You can only access the most recent {limit} worth of files. {callToAction}', + defaultMessage: 'You\'re over the {limit} file storage limit. You can only access the most recent {limit} worth of files. {callToAction}', }); } diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index ee6d2050ab..13bce3d0ad 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3675,7 +3675,6 @@ "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.view_plans": "View plans", "cloud_signup.signup_consequences": "Your credit card will be charged today. See how billing works.", "cloud_upgrade.error_min_seats": "Minimum of 10 seats required", "cloud.fetch_error": "Error fetching billing data. Please try again later.", @@ -3767,8 +3766,10 @@ "create_post.dm_or_gm_remote": "Direct Messages and Group Messages with remote users are not supported.", "create_post.error_message": "Your message is too long. Character count: {length}/{limit}", "create_post.file_limit_sticky_banner.admin_message": "New uploads will automatically archive older files. To view them again, you can delete older files or upgrade to a paid plan.", + "create_post.file_limit_sticky_banner.admin_message_airgapped": "New uploads will automatically archive older files. To view them again, you can delete older files.", "create_post.file_limit_sticky_banner.messageTitle": "Your free plan is limited to {storageGB} of files.", "create_post.file_limit_sticky_banner.non_admin_message": "New uploads will automatically archive older files. To view them again, notify your admin to upgrade to a paid plan.", + "create_post.file_limit_sticky_banner.non_admin_message_airgapped": "New uploads will automatically archive older files. To view them again, contact your admin.", "create_post.file_limit_sticky_banner.snooze_tooltip": "Snooze for {snoozeDays} days", "create_post.fileProcessing": "Processing...", "create_post.prewritten.custom": "Custom message...", @@ -5019,64 +5020,9 @@ "posts_view.loadMore": "Load More messages", "posts_view.newMsg": "New Messages", "postypes.custom_open_pricing_modal_post_renderer.membersThatRequested": "Members that requested ", - "pricing_modal.addons.dedicatedDB": "Dedicated database", - "pricing_modal.addons.dedicatedDeployment": "Dedicated virtual secure cloud deployment (Cloud)", - "pricing_modal.addons.dedicatedEncryption": "Dedicated encryption keys", - "pricing_modal.addons.dedicatedK8sCluster": "Dedicated Kubernetes cluster", - "pricing_modal.addons.missionCritical": "Mission-critical 24x7", - "pricing_modal.addons.premiumSupport": "Premium support", - "pricing_modal.addons.title": "Available Add-ons", - "pricing_modal.addons.uptimeGuarantee": "99% uptime guarantee", - "pricing_modal.addons.USSupport": "U.S.- only based support", - "pricing_modal.briefing.customUserGroups": "Custom user groups", - "pricing_modal.briefing.enterprise.advancedComplianceManagement": "Advanced compliance management", - "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.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.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", - "pricing_modal.briefing.unlimitedPlaybookRuns": "Unlimited playbooks and runs", - "pricing_modal.briefing.unlimitedWorkspaceTeams": "Unlimited workspace teams", - "pricing_modal.btn.contactSales": "Contact Sales", - "pricing_modal.btn.contactSalesForQuote": "Contact Sales", - "pricing_modal.btn.downgrade": "Downgrade", - "pricing_modal.btn.purchase": "Purchase", - "pricing_modal.btn.switch_to_annual": "Switch to annual billing", "pricing_modal.btn.tooltip": "Only visible to system admins", - "pricing_modal.btn.upgrade": "Upgrade", "pricing_modal.btn.viewPlans": "View plans", - "pricing_modal.contact_us": "Contact us", - "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", - "pricing_modal.extra_briefing.professional.ssoadLdap": "SSO support with AD/LDAP, Google, O365, OpenID", - "pricing_modal.extra_briefing.professional.ssoSaml": "SSO with SAML 2.0, including Okta, OneLogin, and ADFS", - "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.noitfy_cta.request": "Request admin to upgrade", - "pricing_modal.noitfy_cta.request_success": "Request sent", - "pricing_modal.or": "or", "pricing_modal.plan_label_trialDays": "{days} DAYS LEFT ON TRIAL", - "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.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", - "pricing_modal.questions": "Questions?", - "pricing_modal.rate.seatPerMonth": "USD per seat/month {br}(billed annually)", - "pricing_modal.reach_out": "Reach out to us and we’ll help you decide which plan is right for you and your organization.", - "pricing_modal.reviewDeploymentOptions": "Review deployment options", - "pricing_modal.start_trial.disclaimer": "By selecting Try free for 30 days, I agree to the Mattermost Software and Services License Agreement, Privacy Policy, and receiving product emails.", - "pricing_modal.subtitle": "Choose a plan to get started", - "pricing_modal.title": "Select a plan", "pricing_modal.wantToTry": "Want to try? ", "pricing_modal.wantToUpgrade": "Want to upgrade? ", "profile_popover.aria_label.with_username": "{userName}'s profile popover", @@ -6327,6 +6273,7 @@ "workspace_limits.message_history.locked.cta.admin": "Upgrade now", "workspace_limits.message_history.locked.cta.end_user": "Notify Admin", "workspace_limits.message_history.locked.description.admin": "To view and search all of the messages in your workspace’s history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. Review our plan options and pricing.", + "workspace_limits.message_history.locked.description.admin.airgapped": "To view and search all of the messages in your workspace's history, rather than just the most recent {limit} messages, upgrade to one of our paid plans.", "workspace_limits.message_history.locked.description.end_user": "Some older messages may not be shown because your workspace has over {limit} messages. Select Notify Admin to send an automatic request to your System Admins to upgrade.", "workspace_limits.message_history.locked.title.admin": "Unlock messages prior to {date} in {team}", "workspace_limits.message_history.locked.title.end_user": "Notify your admin to unlock messages prior to {date} in {team}", @@ -6342,9 +6289,11 @@ "workspace_limits.modals.view_plan_options": "View plan options", "workspace_limits.modals.view_plans": "View plans", "workspace_limits.search_files_limit.banner_text": "Some older files may not be shown because your workspace has met its file storage limit of {storage}. {ctaAction}", + "workspace_limits.search_files_limit.banner_text_airgapped": "Some older files may not be shown because your workspace has met its file storage limit of {storage}.", "workspace_limits.search_limit.upgrade_now": "Upgrade now", "workspace_limits.search_limit.view_plans": "View plans", "workspace_limits.search_message_limit.banner_text": "Some older messages may not be shown because your workspace has over {messages} messages. {ctaAction}", + "workspace_limits.search_message_limit.banner_text_airgapped": "Some older messages may not be shown because your workspace has over {messages} messages.", "workspace_limits.teams_limit_reached.tool_tip": "You've reached the team limit for your current plan. Consider upgrading to unarchive this team or archive your other teams", "workspace_limits.teams_limit_reached.upgrade_to_unarchive": "Upgrade to Unarchive", "workspace_limits.teams_limit_reached.view_upgrade_options": "View upgrade options", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts index 452a08f403..4c3615d500 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/general.ts @@ -33,4 +33,8 @@ export default keyMirror({ FIRST_ADMIN_VISIT_MARKETPLACE_STATUS_RECEIVED: null, FIRST_ADMIN_COMPLETE_SETUP_RECEIVED: null, SHOW_LAUNCHING_WORKSPACE: null, + + CWS_AVAILABILITY_CHECK_REQUEST: null, + CWS_AVAILABILITY_CHECK_SUCCESS: null, + CWS_AVAILABILITY_CHECK_FAILURE: null, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts index ac3d5730bf..3aa2650753 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/general.ts @@ -107,6 +107,30 @@ export function getFirstAdminSetupComplete(): ActionFuncAsync { }; } +export function checkCWSAvailability(): ActionFuncAsync { + return async (dispatch, getState) => { + const state = getState(); + const config = state.entities.general.config; + const isEnterpriseReady = config.BuildEnterpriseReady === 'true'; + + if (!isEnterpriseReady) { + dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_SUCCESS, data: 'not_applicable'}); + return {data: 'not_applicable'}; + } + + dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_REQUEST}); + + try { + await Client4.cwsAvailabilityCheck(); + dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_SUCCESS, data: 'available'}); + return {data: 'available'}; + } catch (error) { + dispatch({type: GeneralTypes.CWS_AVAILABILITY_CHECK_FAILURE}); + return {data: 'unavailable'}; + } + }; +} + export default { getClientConfig, getLicenseConfig, diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts index 5e553c4c84..203559a170 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/general.ts @@ -100,6 +100,23 @@ function firstAdminCompleteSetup(state = false, action: MMReduxAction) { } } +export type CWSAvailabilityState = 'pending' | 'available' | 'unavailable' | 'not_applicable'; + +function cwsAvailability(state: CWSAvailabilityState = 'pending', action: MMReduxAction): CWSAvailabilityState { + switch (action.type) { + case GeneralTypes.CWS_AVAILABILITY_CHECK_REQUEST: + return 'pending'; + case GeneralTypes.CWS_AVAILABILITY_CHECK_SUCCESS: + return action.data; + case GeneralTypes.CWS_AVAILABILITY_CHECK_FAILURE: + return 'unavailable'; + case UserTypes.LOGOUT_SUCCESS: + return 'pending'; + default: + return state; + } +} + export default combineReducers({ config, license, @@ -107,4 +124,5 @@ export default combineReducers({ serverVersion, firstAdminVisitMarketplaceStatus, firstAdminCompleteSetup, + cwsAvailability, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts index 0f4fd2217a..db3518523a 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/general.ts @@ -9,6 +9,8 @@ import {General} from 'mattermost-redux/constants'; import {createSelector} from 'mattermost-redux/selectors/create_selector'; import {isMinimumServerVersion} from 'mattermost-redux/utils/helpers'; +import type {CWSAvailabilityState} from '../../reducers/entities/general'; + export function getConfig(state: GlobalState): Partial { return state.entities.general.config; } @@ -163,3 +165,7 @@ export const getCustomProfileAttributes: (state: GlobalState) => UserPropertyFie export function getIsCrossTeamSearchEnabled(state: GlobalState): boolean { return state.entities.general.config.EnableCrossTeamSearch === 'true'; } + +export function getCWSAvailability(state: GlobalState): CWSAvailabilityState { + return state.entities.general.cwsAvailability; +} diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/hosted_customer.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/hosted_customer.ts deleted file mode 100644 index 31472707d6..0000000000 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/hosted_customer.ts +++ /dev/null @@ -1,13 +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 type {GlobalState} from '@mattermost/types/store'; - -export function getSelfHostedProducts(state: GlobalState): Record { - return state.entities.hostedCustomer.products.products; -} - -export function getSelfHostedProductsLoaded(state: GlobalState): boolean { - return state.entities.hostedCustomer.products.productsLoaded; -} diff --git a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts index b2e2e68587..e5438eeafe 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/store/initial_state.ts @@ -14,6 +14,7 @@ const state: GlobalState = { firstAdminVisitMarketplaceStatus: false, firstAdminCompleteSetup: false, customProfileAttributes: {}, + cwsAvailability: 'pending', }, users: { currentUserId: '', diff --git a/webapp/channels/src/plugins/export.ts b/webapp/channels/src/plugins/export.ts index 5c81379ffe..82cb339411 100644 --- a/webapp/channels/src/plugins/export.ts +++ b/webapp/channels/src/plugins/export.ts @@ -9,7 +9,6 @@ import {getSelectedPostId, getIsRhsOpen} from 'selectors/rhs'; import AdvancedTextEditor from 'components/advanced_text_editor/advanced_text_editor'; import ChannelInviteModal from 'components/channel_invite_modal'; 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 StartTrialFormModal from 'components/start_trial_form_modal'; @@ -31,6 +30,12 @@ import {imageURLForUser} from 'utils/utils'; import {openInteractiveDialog} from './interactive_dialog'; // This import has intentional side effects. Do not remove without research. import Textbox from './textbox'; +// Note: We can't directly use the hook here, but we can create a function that opens the external pricing page +// For plugins, we'll always try to open the external page and let the browser handle if it's blocked +const openPricingModalForPlugins = () => { + (window as any).open('https://mattermost.com/pricing', '_blank', 'noopener,noreferrer'); +}; + interface WindowWithLibraries { React: typeof import('react'); ReactDOM: typeof import('react-dom'); @@ -61,7 +66,7 @@ interface WindowWithLibraries { openUserSettings: (dialogProps: any) => void; browserHistory: ReturnType; }; - openPricingModal: () => typeof openPricingModal; + openPricingModal: () => void; Components: { Textbox: typeof Textbox; Timestamp: typeof Timestamp; @@ -133,11 +138,9 @@ window.WebappUtils = { }), }; -// This need to be a function because `openPricingModal` -// is initialized when `UpgradeCloudButton` is loaded. -// So if we export `openPricingModal` directly, it will be locked -// to the initial value of undefined. -window.openPricingModal = () => openPricingModal; +// For plugins, we provide a simple function that always tries to open the external pricing page +// This won't respect air-gapped status, but plugins shouldn't be calling this in air-gapped environments +window.openPricingModal = openPricingModalForPlugins; // Components exposed on window FOR INTERNAL PLUGIN USE ONLY. These components may have breaking changes in the future // outside of major releases. They will be replaced by common components once that project is more mature and able to diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index d4876a5d33..0d0f6d1358 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -4004,12 +4004,6 @@ export default class Client4 { ); }; - getSelfHostedProducts = () => { - return this.doFetch( - `${this.getCloudRoute()}/products/selfhosted`, {method: 'get'}, - ); - }; - subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => { return this.doFetch( `${this.getHostedCustomerRoute()}/subscribe-newsletter`, diff --git a/webapp/platform/types/src/general.ts b/webapp/platform/types/src/general.ts index 45914a6c6e..9d8fa1f07d 100644 --- a/webapp/platform/types/src/general.ts +++ b/webapp/platform/types/src/general.ts @@ -12,6 +12,7 @@ export type GeneralState = { license: ClientLicense; serverVersion: string; customProfileAttributes: IDMappedObjects; + cwsAvailability: 'pending' | 'available' | 'unavailable' | 'not_applicable'; }; export type SystemSetting = {