Remove in-product pricing modal (#31187)

* Remove pricing modal. Adjust everywhere to instead open mattermost.com/pricing. When air gapped, don't show buttons to view plans.

* Fix lint

* Further clean up of unused code. Fixes for linter

* fixes for PR feedback

* useExternalLink for opening pricing modal with enriched params

* Fix i17n

* Fix style, tests
Этот коммит содержится в:
Nick Misasi
2025-06-02 14:08:57 -04:00
коммит произвёл GitHub
родитель 6484ae25b7
Коммит 0cacee570a
54 изменённых файлов: 434 добавлений и 2603 удалений

Просмотреть файл

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

Просмотреть файл

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

Просмотреть файл

@@ -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<Promise<boolean | ServerError>> {
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;
};
}

Просмотреть файл

@@ -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'});
}
}, []);

Просмотреть файл

@@ -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 => {
<div className={actionsClassname}>
{subscriptionProduct.sku === CloudProducts.STARTER && (
<>
<button
onClick={() => openPricingModal({trackingLocation: 'billing_subscriptions_limits_dashboard'})}
className='btn btn-primary'
>
{intl.formatMessage({
id: 'workspace_limits.modals.view_plan_options',
defaultMessage: 'View plan options',
})}
</button>
{!isAirGapped && (
<button
onClick={() => openPricingModal({trackingLocation: 'billing_subscriptions_limits_dashboard'})}
className='btn btn-primary'
>
{intl.formatMessage({
id: 'workspace_limits.modals.view_plan_options',
defaultMessage: 'View plan options',
})}
</button>
)}
<button
onClick={openSalesLink}
className='btn btn-secondary'

Просмотреть файл

@@ -1,28 +0,0 @@
@use 'utils/mixins';
.ToPaidNudgeBanner {
&__actions {
padding-top: 12px;
}
&__primary {
font-size: 12px;
@include mixins.primary-button;
&:hover {
color: var(--button-color);
}
}
&__secondary {
margin-left: 4px;
font-size: 12px;
@include mixins.tertiary-button;
&:hover {
color: var(--button-bg);
}
}
}

Просмотреть файл

@@ -1,172 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('ToPaidPlanBannerDismissable', () => {
test('should only show for admins on cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
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(<ToPaidPlanBannerDismissable/>, state, {useMockedStore: true});
screen.getByTestId('cloud-free-deprecation-announcement-bar');
});
test('should NOT show for NON admins', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_user'},
};
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(<ToPaidPlanBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud pro', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
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(<ToPaidPlanBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_enterprise',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_enterprise: {
id: 'prod_enterprise',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithContext(<ToPaidPlanBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins when banner was dismissed in preferences', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.preferences = {
myPreferences: {
'to_paid_plan_nudge--nudge_to_paid_plan_snoozed': {
category: 'to_paid_plan_nudge',
name: 'nudge_to_paid_plan_snoozed',
value: '{"range": 0, "show": false}',
},
},
};
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(<ToPaidPlanBannerDismissable/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
});

Просмотреть файл

@@ -1,173 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import moment from 'moment';
import React, {useEffect} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import './to_paid_plan_nudge_banner.scss';
enum DismissShowRange {
GreaterThanEqual90 = '>=90',
BetweenNinetyAnd60 = '89-61',
SixtyTo31 = '60-31',
ThirtyTo11 = '30-11',
TenTo1 = '10-1',
Zero = '0'
}
const cloudFreeCloseMoment = '20230727';
interface ToPaidPlanDismissPreference {
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
range: DismissShowRange;
show: boolean;
}
export const ToPaidPlanBannerDismissable = () => {
const dispatch = useDispatch();
const openPricingModal = useOpenPricingModal();
const currentUser = useSelector(getCurrentUser);
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const product = useSelector(selectSubscriptionProduct);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_PAID_PLAN_NUDGE, CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED, '{"range": 0, "show": true}'));
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToPaidPlanDismissPreference;
const show = snoozeInfo.show;
const snoozedForRange = (range: DismissShowRange) => {
return snoozeInfo.range === range;
};
useEffect(() => {
if (!snoozeInfo.show) {
if (daysToCloudFreeEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
showBanner(true);
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 10) {
showBanner(true);
}
}
}, []);
const showBanner = (show = false) => {
let dRange = DismissShowRange.Zero;
if (daysToCloudFreeEnd >= 90) {
dRange = DismissShowRange.GreaterThanEqual90;
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60) {
dRange = DismissShowRange.BetweenNinetyAnd60;
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30) {
dRange = DismissShowRange.SixtyTo31;
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10) {
dRange = DismissShowRange.ThirtyTo11;
}
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
if (daysToCloudFreeEnd <= 10 && daysToCloudFreeEnd > 0) {
dRange = DismissShowRange.TenTo1;
}
const snoozeInfo: ToPaidPlanDismissPreference = {
range: dRange,
show,
};
dispatch(savePreferences(currentUser.id, [{
category: Preferences.TO_PAID_PLAN_NUDGE,
name: CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED,
user_id: currentUser.id,
value: JSON.stringify(snoozeInfo),
}]));
};
if (!show) {
return null;
}
if (!isAdmin) {
return null;
}
if (!currentProductStarter) {
return null;
}
let message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar',
defaultMessage: 'Cloud Free will be deprecated on {date}. To keep your workspace, upgrade to a paid plan',
values: {
date: moment(cloudFreeCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY'),
},
};
if (daysToCloudFreeEnd < 0) {
message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar_deprecated',
defaultMessage: 'Cloud Free was deprecated. To keep your workspace, upgrade to a paid plan',
} as any;
}
const announcementType = (daysToCloudFreeEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
return (
<AnnouncementBar
id='cloud-free-deprecation-announcement-bar'
type={announcementType}
showCloseButton={daysToCloudFreeEnd > 10}
onButtonClick={openPricingModal}
modalButtonText={messages.viewPlans}
message={<FormattedMessage {...message}/>}
showLinkAsButton={true}
handleClose={showBanner}
/>
);
};
const messages = defineMessages({
viewPlans: {
id: 'cloud_billing.nudge_to_paid.view_plans',
defaultMessage: 'View plans',
},
});

Просмотреть файл

@@ -27,7 +27,7 @@ export const PlanDetailsTopElements = ({
isYearly,
}: Props) => {
let productName;
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const {formatMessage} = useIntl();
const userCountDisplay = (
@@ -123,7 +123,7 @@ export const PlanDetailsTopElements = ({
return monthlyBadge;
};
const viewPlansButton = (
const viewPlansButton = isAirGapped ? null : (
<button
onClick={() => openPricingModal({trackingLocation: 'billing_plan_details_view_plans'})}
className='btn btn-secondary PlanDetails__viewPlansButton'

Просмотреть файл

@@ -55,7 +55,7 @@ const EnterpriseEditionLeftPanel = ({
}: EnterpriseEditionProps) => {
const {formatMessage} = useIntl();
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const [openContactSales] = useOpenSalesLink();
useEffect(() => {
@@ -73,7 +73,7 @@ const EnterpriseEditionLeftPanel = ({
const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true');
const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10));
const viewPlansButton = (
const viewPlansButton = isAirGapped ? null : (
<button
id='enterprise_edition_view_plans'
onClick={() => openPricingModal({trackingLocation: 'license_settings_view_plans'})}
@@ -163,7 +163,7 @@ const EnterpriseEditionLeftPanel = ({
>
{'here'}
</a>
{' for Enterprise Edition License for details. '}
{' for "Enterprise Edition License" for details. '}
{'See NOTICE.txt for information about open source software used in the system.'}
</p>
</> : <p>

Просмотреть файл

@@ -29,10 +29,10 @@ const StarterLeftPanel: React.FC<StarterEditionProps> = ({
fileInputRef,
handleChange,
}: StarterEditionProps) => {
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const intl = useIntl();
const viewPlansButton = (
const viewPlansButton = isAirGapped ? null : (
<button
id='starter_edition_view_plans'
onClick={() => openPricingModal({trackingLocation: 'license_settings_view_plans'})}
@@ -81,7 +81,7 @@ const StarterLeftPanel: React.FC<StarterEditionProps> = ({
>
{'here'}
</a>
{' for Enterprise Edition License for details. '}
{' for "Enterprise Edition License" for details. '}
{'See NOTICE.txt for information about open source software used in the system.'}
</p>
</> : <p>

Просмотреть файл

@@ -15,7 +15,7 @@ type Props = {
}
export function UpgradeExportDataModal({onExited}: Props) {
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const confirm = () => {
openPricingModal();
@@ -57,6 +57,7 @@ export function UpgradeExportDataModal({onExited}: Props) {
confirmButtonText={viewPlansButton}
onConfirm={confirm}
onExited={onExited}
hideConfirm={isAirGapped}
/>
);
}

Просмотреть файл

@@ -5,22 +5,19 @@ import classNames from 'classnames';
import noop from 'lodash/noop';
import React, {useEffect, useState} from 'react';
import {FormattedMessage, defineMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useSelector} from 'react-redux';
import type {Team} from '@mattermost/types/teams';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {openModal} from 'actions/views/modals';
import useGetUsage from 'components/common/hooks/useGetUsage';
import useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas';
import PricingModal from 'components/pricing_modal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import AdminPanel from 'components/widgets/admin_console/admin_panel';
import TeamIcon from 'components/widgets/team_icon/team_icon';
import WithTooltip from 'components/with_tooltip';
import {ModalIdentifiers} from 'utils/constants';
import {imageURLForTeam} from 'utils/utils';
import './team_profile.scss';
@@ -36,10 +33,10 @@ type Props = {
export function TeamProfile({team, isArchived, onToggleArchive, isDisabled, saveNeeded}: Props) {
const teamIconUrl = imageURLForTeam(team);
const usageDeltas = useGetUsageDeltas();
const dispatch = useDispatch();
const usage = useGetUsage();
const license = useSelector(getLicense);
const intl = useIntl();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const [overrideRestoreDisabled, setOverrideRestoreDisabled] = useState(false);
const [restoreDisabled, setRestoreDisabled] = useState(usageDeltas.teams.teamsLoaded && usageDeltas.teams.active >= 0 && isArchived);
@@ -170,13 +167,10 @@ export function TeamProfile({team, isArchived, onToggleArchive, isDisabled, save
</div>
<div className='AdminChannelDetails_archiveContainer'>
{button()}
{restoreDisabled &&
{restoreDisabled && !isAirGapped &&
<button
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.PRICING_MODAL,
dialogType: PricingModal,
}));
openPricingModal({trackingLocation: 'team_profile_view_upgrade_options'});
}}
type='button'
className={

Просмотреть файл

@@ -5,7 +5,6 @@ import React from 'react';
import type {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/types/config';
import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner';
import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription';
import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar';
@@ -69,7 +68,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let cloudTrialEndAnnouncementBar = null;
const notifyAdminDowngradeDelinquencyBar = null;
const toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
if (this.props.license?.Cloud === 'true') {
paymentAnnouncementBar = (
<PaymentAnnouncementBar/>
@@ -80,8 +78,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
cloudTrialEndAnnouncementBar = (
<CloudTrialEndAnnouncementBar/>
);
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
}
let autoStartTrialModal = null;
@@ -115,7 +111,6 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{cloudTrialEndAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{toPaidPlanNudgeBannerDismissable}
{this.props.license?.Cloud !== 'true' && <OverageUsersBanner/>}
{autoStartTrialModal}
<ShowThreeDaysLeftTrialModal/>

Просмотреть файл

@@ -12,13 +12,12 @@ import type {UserProfile} from '@mattermost/types/users';
import {trackEvent} from 'actions/telemetry_actions';
import PricingModal from 'components/pricing_modal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import {
Preferences,
CloudBanners,
AnnouncementBarTypes,
ModalIdentifiers,
TELEMETRY_CATEGORIES,
TrialPeriodDays,
} from 'utils/constants';
@@ -43,9 +42,14 @@ type Props = {
};
};
type PropsWithPricingModal = Props & {
openPricingModal: (telemetryProps?: {trackingLocation: string}) => void;
isAirGapped: boolean;
};
const MAX_DAYS_BANNER = 'max_days_banner';
const THREE_DAYS_BANNER = '3_days_banner';
class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
class CloudTrialAnnouncementBarInternal extends React.PureComponent<PropsWithPricingModal> {
async componentDidMount() {
if (!isEmpty(this.props.subscription) && this.shouldShowBanner()) {
const {daysLeftOnTrial} = this.props;
@@ -84,8 +88,8 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
};
shouldShowBanner = () => {
const {isFreeTrial, userIsAdmin, isCloud} = this.props;
return isFreeTrial && userIsAdmin && isCloud;
const {isFreeTrial, userIsAdmin, isCloud, isAirGapped} = this.props;
return isFreeTrial && userIsAdmin && isCloud && !isAirGapped;
};
isDismissable = () => {
@@ -111,10 +115,7 @@ class CloudTrialAnnouncementBar extends React.PureComponent<Props> {
'click_subscribe_from_banner_trial_ended',
);
}
this.props.actions.openModal({
modalId: ModalIdentifiers.PRICING_MODAL,
dialogType: PricingModal,
});
this.props.openPricingModal({trackingLocation: 'cloud_trial_announcement_bar'});
};
render() {
@@ -200,4 +201,17 @@ const messages = defineMessages({
},
});
// Wrapper component to use the hook
const CloudTrialAnnouncementBar: React.FC<Props> = (props) => {
const {openPricingModal, isAirGapped} = useOpenPricingModal();
return (
<CloudTrialAnnouncementBarInternal
{...props}
openPricingModal={openPricingModal}
isAirGapped={isAirGapped}
/>
);
};
export default CloudTrialAnnouncementBar;

Просмотреть файл

@@ -42,7 +42,7 @@ const CloudTrialEndAnnouncementBar: React.FC = () => {
);
const subscriptionProduct = useSelector((state: GlobalState) => getSubscriptionProduct(state));
const openPricingModal = useOpenPricingModal();
const {openPricingModal} = useOpenPricingModal();
const shouldShowBanner = () => {
if (!subscription || !subscriptionProduct) {

Просмотреть файл

@@ -11,7 +11,10 @@ import AtPlanMention from './index';
describe('components/AtPlanMention', () => {
it('should open pricing modal when plan mentioned is trial', () => {
const openPricingModal = jest.fn();
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => openPricingModal);
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => ({
openPricingModal,
isAirGapped: false,
}));
const wrapper = shallow(<AtPlanMention plan='Enterprise trial'/>);
wrapper.find('a').simulate('click', {
@@ -24,7 +27,10 @@ describe('components/AtPlanMention', () => {
it('should open pricing modal when plan mentioned is Enterprise', () => {
const openPricingModal = jest.fn();
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => openPricingModal);
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => ({
openPricingModal,
isAirGapped: false,
}));
const wrapper = shallow(<AtPlanMention plan='Enterprise plan'/>);
wrapper.find('a').simulate('click', {
@@ -37,7 +43,10 @@ describe('components/AtPlanMention', () => {
it('should open purchase modal when plan mentioned is professional', () => {
const openPricingModal = jest.fn();
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => openPricingModal);
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => ({
openPricingModal,
isAirGapped: false,
}));
const wrapper = shallow(<AtPlanMention plan='Professional plan'/>);
wrapper.find('a').simulate('click', {
@@ -47,4 +56,17 @@ describe('components/AtPlanMention', () => {
expect(openPricingModal).toHaveBeenCalledTimes(1);
});
it('should render as span when air-gapped', () => {
const openPricingModal = jest.fn();
jest.spyOn(useOpenPricingModal, 'default').mockImplementation(() => ({
openPricingModal,
isAirGapped: true,
}));
const wrapper = shallow(<AtPlanMention plan='Enterprise plan'/>);
expect(wrapper.find('span').exists()).toBe(true);
expect(wrapper.find('a').exists()).toBe(false);
expect(wrapper.find('span').text()).toBe('Enterprise plan');
});
});

Просмотреть файл

@@ -10,12 +10,17 @@ type Props = {
}
function AtPlanMention(props: Props) {
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const handleClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
e.preventDefault();
openPricingModal({trackingLocation: 'notify_admin_message_view'});
};
if (isAirGapped) {
return <span id='at_plan_mention'>{props.plan}</span>;
}
return (
<a
id='at_plan_mention'

Просмотреть файл

@@ -43,7 +43,7 @@ function getNextDay(timestamp?: number): number {
export default function CenterMessageLock(props: Props) {
const intl = useIntl();
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const isAdminUser = isAdmin(useSelector(getCurrentUser).roles);
const [cloudLimits, limitsLoaded] = useGetLimits();
const currentTeam = useSelector(getCurrentTeam);
@@ -96,7 +96,7 @@ export default function CenterMessageLock(props: Props) {
},
);
let cta = (
let cta: React.ReactNode = (
<button
className='btn btn-primary'
onClick={(e) => notifyAdmin(e, 'center_channel_posts_over_limit_banner')}
@@ -111,28 +111,40 @@ export default function CenterMessageLock(props: Props) {
defaultMessage: 'Unlock messages prior to {date} in {team}',
}, titleValues);
description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.admin',
defaultMessage: 'To view and search all of the messages in your workspaces history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>',
},
{
limit,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<a
href='#'
onClick={(e: React.MouseEvent) => {
e.preventDefault();
openPricingModal({trackingLocation: 'center_channel_posts_over_limit_banner'});
}}
>
{chunks}
</a>
),
},
);
if (isAirGapped) {
description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.admin.airgapped',
defaultMessage: '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.',
},
{
limit,
},
);
} else {
description = intl.formatMessage(
{
id: 'workspace_limits.message_history.locked.description.admin',
defaultMessage: '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. <a>Review our plan options and pricing.</a>',
},
{
limit,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<a
href='#'
onClick={(e: React.MouseEvent) => {
e.preventDefault();
openPricingModal({trackingLocation: 'center_channel_posts_over_limit_banner'});
}}
>
{chunks}
</a>
),
},
);
}
cta = (
cta = isAirGapped ? null : (
<button
className='btn is-admin'
onClick={() => openPricingModal({trackingLocation: 'center_channel_posts_over_limit_banner'})}

Просмотреть файл

@@ -25,11 +25,11 @@ export default function LHSNearingLimitsModal() {
const product = useSelector(getSubscriptionProduct);
const usage = useGetUsage();
const intl = useIntl();
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
const [limits] = useGetLimits();
const primaryAction = {
const primaryAction = isAirGapped ? undefined : {
message: defineMessage({
id: 'workspace_limits.modals.view_plans',
defaultMessage: 'View plans',

Просмотреть файл

@@ -1,41 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useState} from 'react';
import {useSelector} from 'react-redux';
import {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {Client4} from 'mattermost-redux/client';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {checkCWSAvailability} from 'mattermost-redux/actions/general';
import {getCWSAvailability} from 'mattermost-redux/selectors/entities/general';
export enum CSWAvailabilityCheckTypes {
Available = 'available',
Unavailable = 'unavailable',
Pending = 'pending',
NotApplicable = 'notApplicable',
NotApplicable = 'not_applicable',
}
export default function useCWSAvailabilityCheck(): CSWAvailabilityCheckTypes {
const [cswAvailability, setCSWAvailability] = useState<CSWAvailabilityCheckTypes>(CSWAvailabilityCheckTypes.Pending);
const config = useSelector(getConfig);
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
const dispatch = useDispatch();
const cwsAvailability = useSelector(getCWSAvailability);
useEffect(() => {
async function cwsAvailabilityCheck() {
try {
await Client4.cwsAvailabilityCheck();
setCSWAvailability(CSWAvailabilityCheckTypes.Available);
} catch (error) {
setCSWAvailability(CSWAvailabilityCheckTypes.Unavailable);
}
// Only check if we haven't checked yet (pending state)
if (cwsAvailability === 'pending') {
dispatch(checkCWSAvailability());
}
}, [dispatch, cwsAvailability]);
if (isEnterpriseReady) {
cwsAvailabilityCheck();
} else {
setCSWAvailability(CSWAvailabilityCheckTypes.NotApplicable);
}
}, [isEnterpriseReady]);
return cswAvailability;
// Convert the string to the enum value
switch (cwsAvailability) {
case 'available':
return CSWAvailabilityCheckTypes.Available;
case 'unavailable':
return CSWAvailabilityCheckTypes.Unavailable;
case 'not_applicable':
return CSWAvailabilityCheckTypes.NotApplicable;
case 'pending':
default:
return CSWAvailabilityCheckTypes.Pending;
}
}

Просмотреть файл

@@ -1,35 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useEffect, useMemo, useRef} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {Product} from '@mattermost/types/cloud';
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {getSelfHostedProducts, getSelfHostedProductsLoaded} from 'mattermost-redux/selectors/entities/hosted_customer';
import {getSelfHostedProducts as getSelfHostedProductsAction} from 'actions/hosted_customer';
import {useIsLoggedIn} from 'components/global_header/hooks';
export default function useGetSelfHostedProducts(): [Record<string, Product>, boolean] {
const isCloud = useSelector(isCurrentLicenseCloud);
const isLoggedIn = useIsLoggedIn();
const products = useSelector(getSelfHostedProducts);
const productsReceived = useSelector(getSelfHostedProductsLoaded);
const dispatch = useDispatch();
const requested = useRef(false);
useEffect(() => {
if (isLoggedIn && !isCloud && !requested.current && !productsReceived) {
dispatch(getSelfHostedProductsAction());
requested.current = true;
}
}, [isLoggedIn, isCloud, productsReceived]);
const result: [Record<string, Product>, boolean] = useMemo(() => {
return [products, productsReceived];
}, [products, productsReceived]);
return result;
}

Просмотреть файл

@@ -2,24 +2,35 @@
// See LICENSE.txt for license information.
import {useCallback} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useSelector} from 'react-redux';
import {isCurrentLicenseCloud} from 'mattermost-redux/selectors/entities/cloud';
import {trackEvent} from 'actions/telemetry_actions';
import {openModal} from 'actions/views/modals';
import PricingModal from 'components/pricing_modal';
import {TELEMETRY_CATEGORIES} from 'utils/constants';
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
import {useExternalLink} from './use_external_link';
import useCWSAvailabilityCheck, {CSWAvailabilityCheckTypes} from './useCWSAvailabilityCheck';
export type TelemetryProps = {
trackingLocation: string;
}
export default function useOpenPricingModal() {
const dispatch = useDispatch();
export type UseOpenPricingModalReturn = {
openPricingModal: (telemetryProps?: TelemetryProps) => void;
isAirGapped: boolean;
}
export default function useOpenPricingModal(): UseOpenPricingModalReturn {
const isCloud = useSelector(isCurrentLicenseCloud);
const cwsAvailability = useCWSAvailabilityCheck();
const [externalLink] = useExternalLink('https://mattermost.com/pricing');
const isAirGapped = cwsAvailability === CSWAvailabilityCheckTypes.Unavailable;
const canAccessExternalPricing = cwsAvailability === CSWAvailabilityCheckTypes.Available ||
cwsAvailability === CSWAvailabilityCheckTypes.NotApplicable;
const openPricingModal = useCallback((telemetryProps?: TelemetryProps) => {
let category;
@@ -31,14 +42,17 @@ export default function useOpenPricingModal() {
trackEvent(category, 'click_open_pricing_modal', {
callerInfo: telemetryProps?.trackingLocation,
});
dispatch(openModal({
modalId: ModalIdentifiers.PRICING_MODAL,
dialogType: PricingModal,
dialogProps: {
callerCTA: telemetryProps?.trackingLocation,
},
}));
}, [dispatch, isCloud]);
return openPricingModal;
if (canAccessExternalPricing) {
// Redirect to external pricing page
window.open(externalLink, '_blank', 'noopener,noreferrer');
}
// For air-gapped instances, we don't open anything since the pricing modal has been removed
}, [isCloud, canAccessExternalPricing]);
return {
openPricingModal,
isAirGapped,
};
}

Просмотреть файл

@@ -30,7 +30,7 @@ export default function useShowAdminLimitReached() {
Preferences.CATEGORY_CLOUD_LIMITS,
Preferences.SHOWN_LIMITS_REACHED_ON_LOGIN,
);
const openPricingModal = useOpenPricingModal();
const {openPricingModal, isAirGapped} = useOpenPricingModal();
if (!limitsLoaded || !usage.messages.historyLoaded || messageLimit === undefined || !needsLoggedInLimitReachedCheck || shownLimitsReachedOnLogin === 'true') {
return;
@@ -38,48 +38,55 @@ export default function useShowAdminLimitReached() {
if (usage.messages.history > messageLimit) {
setShownLimitsReachedOnLogin('true');
const modalProps: any = {
title: defineMessage({
id: 'workspace_limits.modals.limits_reached.title',
defaultMessage: '{limitName} limit reached',
values: {
limitName: intl.formatMessage({
id: 'workspace_limits.modals.limits_reached.title.message_history',
defaultMessage: 'Message history',
}),
},
}),
description: defineMessage({
id: 'workspace_limits.modals.limits_reached.description.message_history',
defaultMessage: 'Your sent message history is no longer available but you can still send messages. Upgrade to a paid plan and get unlimited access to your message history.',
}),
secondaryAction: {
message: defineMessage({
id: 'workspace_limits.modals.close',
defaultMessage: 'Close',
}),
onClick: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
},
},
onClose: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
},
needsTheme: true,
};
// Only show primary action if not air-gapped
if (!isAirGapped) {
modalProps.primaryAction = {
message: defineMessage({
id: 'workspace_limits.modals.view_plan_options',
defaultMessage: 'View plan options',
}),
onClick: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
openPricingModal({trackingLocation: 'admin_login_limit_reached_dashboard'});
},
};
}
dispatch(openModal({
modalId: ModalIdentifiers.CLOUD_LIMITS,
dialogType: CloudUsageModal,
dialogProps: {
title: defineMessage({
id: 'workspace_limits.modals.limits_reached.title',
defaultMessage: '{limitName} limit reached',
values: {
limitName: intl.formatMessage({
id: 'workspace_limits.modals.limits_reached.title.message_history',
defaultMessage: 'Message history',
}),
},
}),
description: defineMessage({
id: 'workspace_limits.modals.limits_reached.description.message_history',
defaultMessage: 'Your sent message history is no longer available but you can still send messages. Upgrade to a paid plan and get unlimited access to your message history.',
}),
secondaryAction: {
message: defineMessage({
id: 'workspace_limits.modals.close',
defaultMessage: 'Close',
}),
onClick: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
},
},
primaryAction: {
message: defineMessage({
id: 'workspace_limits.modals.view_plan_options',
defaultMessage: 'View plan options',
}),
onClick: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
openPricingModal({trackingLocation: 'admin_login_limit_reached_dashboard'});
},
},
onClose: () => {
dispatch(closeModal(ModalIdentifiers.CLOUD_LIMITS));
},
needsTheme: true,
},
dialogProps: modalProps,
}));
}
dispatch(setNeedsLoggedInLimitReachedCheck(false));

Просмотреть файл

@@ -19,8 +19,8 @@ export type ExternalLinkQueryParams = {
export function useExternalLink(href: string, location: string = '', overwriteQueryParams: ExternalLinkQueryParams = {}): [string, Record<string, string>] {
const userId = useSelector(getCurrentUserId);
const telemetryId = useSelector((state: GlobalState) => getConfig(state).TelemetryId || '');
const isCloud = useSelector((state: GlobalState) => getLicense(state).Cloud === 'true');
const telemetryId = useSelector((state: GlobalState) => getConfig(state)?.TelemetryId || '');
const isCloud = useSelector((state: GlobalState) => getLicense(state)?.Cloud === 'true');
return useMemo(() => {
if (!href?.includes('mattermost.com') || href?.startsWith('mailto:')) {

Просмотреть файл

@@ -83,6 +83,11 @@ type Props = {
*/
hideCancel?: boolean;
/*
* Set to hide the confirm button
*/
hideConfirm?: boolean;
/*
* The element that triggered the modal
*/
@@ -205,15 +210,17 @@ export default class ConfirmModal extends React.Component<Props, State> {
<div className='ConfirmModal__footer'>
{this.props.checkboxInFooter && checkbox}
{cancelButton}
<button
type='button'
className={this.props.confirmButtonClass}
onClick={this.handleConfirm}
id='confirmModalButton'
autoFocus={true}
>
{this.props.confirmButtonText}
</button>
{!this.props.hideConfirm && (
<button
type='button'
className={this.props.confirmButtonClass}
onClick={this.handleConfirm}
id='confirmModalButton'
autoFocus={true}
>
{this.props.confirmButtonText}
</button>
)}
</div>
</div>
</GenericModal>

Просмотреть файл

@@ -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<HTMLButtonElement, 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 = (
<StartTrialBtn
onClick={dismissAction}
@@ -177,14 +180,16 @@ const FeatureRestrictedModal = ({
</p>
)}
<div className={classNames('FeatureRestrictedModal__buttons', {single: !showStartTrial})}>
<button
id='button-plans'
className='button-plans'
onClick={secondaryBtnAction}
disabled={notifyRequestStatus === NotifyStatus.AlreadyComplete}
>
{secondaryBtnMsg}
</button>
{showSecondaryButton && (
<button
id='button-plans'
className='button-plans'
onClick={secondaryBtnAction}
disabled={notifyRequestStatus === NotifyStatus.AlreadyComplete}
>
{secondaryBtnMsg}
</button>
)}
{showStartTrial && (
trialBtn
)}

Просмотреть файл

@@ -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 ?
(
<FormattedMessage
id={'create_post.file_limit_sticky_banner.admin_message_airgapped'}
defaultMessage={'New uploads will automatically archive older files. To view them again, you can delete older files.'}
/>
) :
(
<FormattedMessage
id={'create_post.file_limit_sticky_banner.admin_message'}
@@ -126,7 +132,13 @@ function FileLimitStickyBanner() {
/>
);
const nonAdminMessage =
const nonAdminMessage = isAirGapped ?
(
<FormattedMessage
id={'create_post.file_limit_sticky_banner.non_admin_message_airgapped'}
defaultMessage={'New uploads will automatically archive older files. To view them again, contact your admin.'}
/>
) :
(
<FormattedMessage
id={'create_post.file_limit_sticky_banner.non_admin_message'}

Просмотреть файл

@@ -11,18 +11,15 @@ import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general
import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import type {TelemetryProps} from 'components/common/hooks/useOpenPricingModal';
import WithTooltip from 'components/with_tooltip';
import {CloudProducts} from 'utils/constants';
let openPricingModal: (telemetryProps?: TelemetryProps) => 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 (
<WithTooltip
title={formatMessage({id: 'pricing_modal.btn.tooltip', defaultMessage: 'Only visible to system admins'})}
@@ -81,4 +83,3 @@ const PlanUpgradeButton = (): JSX.Element | null => {
};
export default PlanUpgradeButton;
export {openPricingModal};

Просмотреть файл

@@ -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 (
<svg
width='98'
height='80'
viewBox='0 0 98 80'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<g clipPath='url(#clip0_249_14083)'>
<path
d='M97.2396 28.5905C97.7492 29.0983 98.0081 29.735 98.0081 30.5008V77.324C98.0081 78.0897 97.7492 78.7265 97.2396 79.2343C96.73 79.7421 96.091 80 95.3225 80H2.87974C2.11127 80 1.43987 79.7421 0.865539 79.2343C0.291209 78.7265 0 78.0897 0 77.324V30.5008C0 29.735 0.291209 29.0983 0.865539 28.5905C1.43987 28.0826 2.11127 27.8247 2.87974 27.8247H95.3144C96.0829 27.8247 96.7219 28.0826 97.2315 28.5905H97.2396Z'
fill='#818698'
/>
<path
d='M8.63085 48.4675H5.37092C4.47303 48.3385 3.96341 47.8952 3.83398 47.1295V38.1501C3.96341 37.2635 4.47303 36.8121 5.37092 36.8121H8.63085C9.52065 36.8121 10.0384 37.2635 10.1678 38.1501V47.1295C10.0384 47.8952 9.52874 48.3385 8.63085 48.4675ZM8.63085 59.1718H5.37092C4.47303 59.0428 3.96341 58.5995 3.83398 57.8337V54.7788C3.96341 53.8922 4.47303 53.4408 5.37092 53.4408H8.63085C9.52065 53.4408 10.0384 53.8922 10.1678 54.7788V57.8337C10.0384 58.5995 9.52874 59.0428 8.63085 59.1718ZM8.63085 69.4892H5.37092C4.47303 69.3602 3.96341 68.9169 3.83398 68.1511V65.0962C3.96341 64.3305 4.47303 63.8871 5.37092 63.7582H8.63085C9.52065 63.8871 10.0384 64.3305 10.1678 65.0962V68.1511C10.0384 68.9169 9.52874 69.3602 8.63085 69.4892ZM17.6422 48.4675H14.1881C13.2902 48.3385 12.8453 47.8952 12.8453 47.1295V38.1501C12.8453 37.2635 13.2902 36.8121 14.1881 36.8121H17.6422C18.4106 36.8121 18.8555 37.2635 18.985 38.1501V47.1295C18.8555 47.8952 18.4106 48.3385 17.6422 48.4675ZM17.6422 59.1718H14.1881C13.2902 59.0428 12.8453 58.5995 12.8453 57.8337V54.7788C12.8453 53.8922 13.2902 53.4408 14.1881 53.4408H17.6422C18.4106 53.4408 18.8555 53.8922 18.985 54.7788V57.8337C18.8555 58.5995 18.4106 59.0428 17.6422 59.1718ZM17.6422 69.4892H14.1881C13.2902 69.3602 12.8453 68.9169 12.8453 68.1511V65.0962C12.8453 64.3305 13.2902 63.8871 14.1881 63.7582H17.6422C18.4106 63.8871 18.8555 64.3305 18.985 65.0962V68.1511C18.8555 68.9169 18.4106 69.3602 17.6422 69.4892ZM85.9145 47.1295V38.1501C85.7851 37.2635 85.2755 36.8121 84.3776 36.8121H81.1177C80.2198 36.8121 79.7102 37.2635 79.5807 38.1501V47.1295C79.7102 47.8952 80.2198 48.3385 81.1177 48.4675H84.3776C85.2674 48.3385 85.7851 47.8952 85.9145 47.1295ZM84.3776 59.1718H81.1177C80.2198 59.0428 79.7102 58.5995 79.5807 57.8337V54.7788C79.7102 53.8922 80.2198 53.4408 81.1177 53.4408H84.3776C85.2674 53.4408 85.7851 53.8922 85.9145 54.7788V57.8337C85.7851 58.5995 85.2755 59.0428 84.3776 59.1718ZM84.3776 69.4892H81.1177C80.2198 69.3602 79.7102 68.9169 79.5807 68.1511V65.0962C79.7102 64.3305 80.2198 63.8871 81.1177 63.7582H84.3776C85.2674 63.8871 85.7851 64.3305 85.9145 65.0962V68.1511C85.7851 68.9169 85.2755 69.3602 84.3776 69.4892ZM93.3889 48.4675H89.9349C89.037 48.3385 88.592 47.8952 88.592 47.1295V38.1501C88.592 37.2635 89.037 36.8121 89.9349 36.8121H93.3889C94.1574 36.8121 94.6023 37.2635 94.7317 38.1501V47.1295C94.6023 47.8952 94.1574 48.3385 93.3889 48.4675ZM93.3889 59.1718H89.9349C89.037 59.0428 88.592 58.5995 88.592 57.8337V54.7788C88.592 53.8922 89.037 53.4408 89.9349 53.4408H93.3889C94.1574 53.4408 94.6023 53.8922 94.7317 54.7788V57.8337C94.6023 58.5995 94.1574 59.0428 93.3889 59.1718ZM93.3889 69.4892H89.9349C89.037 69.3602 88.592 68.9169 88.592 68.1511V65.0962C88.592 64.3305 89.037 63.8871 89.9349 63.7582H93.3889C94.1574 63.8871 94.6023 64.3305 94.7317 65.0962V68.1511C94.6023 68.9169 94.1574 69.3602 93.3889 69.4892Z'
fill='#EBEBEF'
/>
<path
d='M75.7548 3.44179V77.8962C75.6254 79.1697 75.1805 79.871 74.412 80H25.1248C24.2269 79.871 23.7173 79.1697 23.5879 77.8962V3.44179C23.7173 2.16824 24.2269 1.46698 25.1248 1.33801H74.412C75.1805 1.46698 75.6254 2.16824 75.7548 3.44179Z'
fill='#BABEC9'
/>
<path
d='M34.7109 41.2051C34.5814 42.0998 34.1365 42.5431 33.3681 42.5431H29.1455C28.2476 42.5431 27.8027 42.0998 27.8027 41.2051V30.6942C27.8027 29.8076 28.2476 29.3562 29.1455 29.3562H33.3681C34.1365 29.3562 34.5814 29.8076 34.7109 30.6942V41.2051ZM33.3681 53.8197C34.1365 53.8197 34.5814 53.3763 34.7109 52.4816V49.4267C34.5814 48.661 34.1365 48.2176 33.3681 48.0887H29.1455C28.2476 48.2176 27.8027 48.661 27.8027 49.4267V52.4816C27.8027 53.3763 28.2476 53.8197 29.1455 53.8197H33.3681ZM33.3681 64.9028C34.1365 64.9028 34.5814 64.4595 34.7109 63.5647V60.5098C34.5814 59.7441 34.1365 59.3008 33.3681 59.1718H29.1455C28.2476 59.3008 27.8027 59.7441 27.8027 60.5098V63.5647C27.8027 64.4595 28.2476 64.9028 29.1455 64.9028H33.3681ZM33.3681 75.0348C34.1365 74.9058 34.5814 74.4625 34.7109 73.6967V70.6418C34.5814 69.8761 34.1365 69.4328 33.3681 69.3038H29.1455C28.2476 69.4328 27.8027 69.8761 27.8027 70.6418V73.6967C27.8027 74.4625 28.2476 74.9058 29.1455 75.0348H33.3681ZM43.342 42.5431C44.2318 42.5431 44.7495 42.0998 44.8789 41.2051V30.6942C44.7495 29.8076 44.2399 29.3562 43.342 29.3562H39.1195C38.351 29.3562 37.9061 29.8076 37.7767 30.6942V41.2051C37.9061 42.0998 38.351 42.5431 39.1195 42.5431H43.342ZM43.342 53.8197C44.2318 53.8197 44.7495 53.3763 44.8789 52.4816V49.4267C44.7495 48.661 44.2399 48.2176 43.342 48.0887H39.1195C38.351 48.2176 37.9061 48.661 37.7767 49.4267V52.4816C37.9061 53.3763 38.351 53.8197 39.1195 53.8197H43.342ZM60.0299 42.5431C60.7984 42.5431 61.2433 42.0998 61.3727 41.2051V30.6942C61.2433 29.8076 60.7984 29.3562 60.0299 29.3562H55.8074C54.9095 29.3562 54.3998 29.8076 54.2704 30.6942V41.2051C54.3998 42.0998 54.9095 42.5431 55.8074 42.5431H60.0299ZM60.0299 53.8197C60.7984 53.8197 61.2433 53.3763 61.3727 52.4816V49.4267C61.2433 48.661 60.7984 48.2176 60.0299 48.0887H55.8074C54.9095 48.2176 54.3998 48.661 54.2704 49.4267V52.4816C54.3998 53.3763 54.9095 53.8197 55.8074 53.8197H60.0299ZM70.0038 42.5431C70.8936 42.5431 71.3466 42.0998 71.3466 41.2051V30.6942C71.3466 29.8076 70.8936 29.3562 70.0038 29.3562H65.7813C65.0128 29.3562 64.5679 29.8076 64.4385 30.6942V41.2051C64.5679 42.0998 65.0128 42.5431 65.7813 42.5431H70.0038ZM34.7109 22.1421C34.5814 23.0368 34.1365 23.4801 33.3681 23.4801H29.1455C28.2476 23.4801 27.8027 23.0368 27.8027 22.1421V11.6312C27.8027 10.7446 28.2476 10.2932 29.1455 10.2932H33.3681C34.1365 10.2932 34.5814 10.7446 34.7109 11.6312V22.1421ZM43.342 23.4801C44.2318 23.4801 44.7495 23.0368 44.8789 22.1421V11.6312C44.7495 10.7446 44.2399 10.2932 43.342 10.2932H39.1195C38.351 10.2932 37.9061 10.7446 37.7767 11.6312V22.1421C37.9061 23.0368 38.351 23.4801 39.1195 23.4801H43.342ZM60.0299 23.4801C60.7984 23.4801 61.2433 23.0368 61.3727 22.1421V11.6312C61.2433 10.7446 60.7984 10.2932 60.0299 10.2932H55.8074C54.9095 10.2932 54.3998 10.7446 54.2704 11.6312V22.1421C54.3998 23.0368 54.9095 23.4801 55.8074 23.4801H60.0299ZM70.0038 23.4801C70.8936 23.4801 71.3466 23.0368 71.3466 22.1421V11.6312C71.3466 10.7446 70.8936 10.2932 70.0038 10.2932H65.7813C65.0128 10.2932 64.5679 10.7446 64.4385 11.6312V22.1421C64.5679 23.0368 65.0128 23.4801 65.7813 23.4801H70.0038ZM70.0038 53.8197C70.8936 53.8197 71.3466 53.3763 71.3466 52.4816V49.4267C71.3466 48.661 70.8936 48.2176 70.0038 48.0887H65.7813C65.0128 48.2176 64.5679 48.661 64.4385 49.4267V52.4816C64.5679 53.3763 65.0128 53.8197 65.7813 53.8197H70.0038ZM70.0038 64.9028C70.8936 64.9028 71.3466 64.4595 71.3466 63.5647V60.5098C71.3466 59.7441 70.8936 59.3008 70.0038 59.1718H65.7813C65.0128 59.3008 64.5679 59.7441 64.4385 60.5098V63.5647C64.5679 64.4595 65.0128 64.9028 65.7813 64.9028H70.0038ZM70.0038 75.0348C70.8936 74.9058 71.3466 74.4625 71.3466 73.6967V70.6418C71.3466 69.8761 70.8936 69.4328 70.0038 69.3038H65.7813C65.0128 69.4328 64.5679 69.8761 64.4385 70.6418V73.6967C64.5679 74.4625 65.0128 74.9058 65.7813 75.0348H70.0038Z'
fill='#818698'
/>
<path
d='M59.649 59.5506H39.7012V80H59.649V59.5506Z'
fill='#E0E9EF'
/>
<path
d='M75.7548 5.15869H23.7821C22.8842 5.02972 22.3745 4.5864 22.2451 3.82065V1.33804C22.3745 0.451385 22.8842 0 23.7821 0H75.7548C76.5233 0 76.9682 0.451385 77.0976 1.33804V3.82065C76.9682 4.5864 76.5233 5.02972 75.7548 5.15869ZM59.8354 57.068H39.5074C38.6095 57.197 38.1646 57.6403 38.1646 58.406V60.7033C38.1646 61.598 38.6095 62.0413 39.5074 62.0413H49.093V80.0081H50.2416V62.0413H59.8273C60.7171 62.0413 61.2348 61.598 61.3642 60.7033V58.406C61.2348 57.6403 60.7252 57.197 59.8273 57.068H59.8354Z'
fill='#5A6072'
/>
</g>
<defs>
<clipPath id='clip0_249_14083'>
<rect
width='98'
height='80'
fill='white'
/>
</clipPath>
</defs>
</svg>
);
}
export default BuildingSvg;

Просмотреть файл

@@ -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<HTMLButtonElement, 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<StyledProps>`
background-color: ${(props) => props.bgColor};
`;
export function BlankCard() {
const {formatMessage} = useIntl();
const [, contactSalesLink] = useOpenSalesLink();
return (
<div className='BlankCard'>
<div className='image'>
{ChatIllustration}
</div>
<div className='description'>
<div className='title'>
<span className='questions'>
{formatMessage({id: 'pricing_modal.questions', defaultMessage: 'Questions?'})}
</span>
<span className='contact'>
<ExternalLink
location='cloud_pricing_modal'
href={contactSalesLink}
>
{formatMessage({id: 'pricing_modal.contact_us', defaultMessage: 'Contact us'})}
</ExternalLink>
</span>
</div>
<div className='content'>
{formatMessage({id: 'pricing_modal.reach_out', defaultMessage: 'Reach out to us and well help you decide which plan is right for you and your organization.'})}
</div>
</div>
<hr/>
<div className='self-hosted-interest'>
<span className='interested'>
{formatMessage({id: 'pricing_modal.interested_self_hosting', defaultMessage: 'Interested in self-hosting?'})}
</span>
<span className='learn'>
<ExternalLink
location='cloud_pricing_modal'
href={HostedCustomerLinks.DOWNLOAD}
>
{formatMessage({id: 'pricing_modal.learn_more', defaultMessage: 'Learn more'})}
</ExternalLink>
</span>
</div>
</div>
);
}
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 (
<div
id={props.id}
className='PlanCard'
>
{props.planLabel}
{!props.isCloud && (
<StyledDiv
className='top'
bgColor={props.topColor}
/>
)}
<div className={bottomClassName}>
<div className='bottom_container'>
<div className={planPriceRateSectionClassName}>
<h3>{props.plan}</h3>
<p>{props.planSummary}</p>
{props.price ? <h1>{props.price}</h1> : <div className={buildingImgClassName}><BuildingSvg/></div>}
<span className='plan_rate'>{props.rate}</span>
</div>
<div className={planLimitsCtaClassName}>
{props.planExtraInformation}
</div>
<div className='plan_buttons'>
{props.customButtonDetails || (
<button
id={props.id + '_action'}
className={`plan_action_btn ${props.buttonDetails?.disabled ? ButtonCustomiserClasses.grayed : props.buttonDetails?.customClass}`}
disabled={props.buttonDetails?.disabled}
onClick={props.buttonDetails?.action}
>
{props.buttonDetails?.text}
</button>
)}
</div>
<div className={contactSalesCTAClassName}>
{props.contactSalesCTA && (
<div>
<p>{formatMessage({id: 'pricing_modal.or', defaultMessage: 'or'})}</p>
{props.contactSalesCTA}
</div>)}
</div>
<div className='plan_briefing'>
{props.planTrialDisclaimer}
<div className={planBriefingContentClassName}>
<span className='title'>{props.briefing.title}</span>
{props.briefing.items?.map((i) => {
return (
<div
className='item'
key={i}
>
<i className='fa fa-circle bullet'/><p>{i}</p>
</div>
);
})}
</div>
</div>
</div>
{props.planAddonsInfo && (
<div className='plan_add_ons'>
<div className='illustration'><TadaSvg/></div>
<h4 className='title'>{props.planAddonsInfo.title}</h4>
{props.planAddonsInfo.items.map((i) => {
return (
<div
className='item'
key={i.title}
>
<div className='item_title'><i className='fa fa-circle bullet fa-xs'/><p>{i.title}</p></div>
{i.items?.map((sub) => {
return (
<div
className='subitem'
key={sub}
>
<div className='subitem_title'><i className='fa fa-circle bullet fa-xs'/><p>{sub}</p></div>
</div>
);
})}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
export default Card;

Просмотреть файл

@@ -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 (
<StyledA
id='contact_sales_quote'
onClick={(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
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'})}
</StyledA>
);
}
export default ContactSalesCTA;

Просмотреть файл

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

Просмотреть файл

@@ -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: (
<>
<i className='icon icon-check'/>
{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<HTMLButtonElement, 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 (
<div className='Content'>
<Modal.Header className='PricingModal__header'>
<div className='header_lhs'>
<h1 className='title'>
{formatMessage({id: 'pricing_modal.title', defaultMessage: 'Select a plan'})}
</h1>
<div>{formatMessage({id: 'pricing_modal.subtitle', defaultMessage: 'Choose a plan to get started'})}</div>
</div>
<button
id='closeIcon'
className='close'
aria-label='Close'
title='Close'
onClick={props.onHide}
>
<span aria-hidden='true'>{'×'}</span>
</button>
</Modal.Header>
<Modal.Body>
<div
className='PricingModal__body'
style={{marginTop: '74px'}}
>
{isProfessional &&
<Card
id='professional'
topColor='var(--button-bg)'
plan='Professional'
planSummary={formatMessage({id: 'pricing_modal.planSummary.professional', defaultMessage: 'Scalable solutions {br} for growing teams'}, {
br: <br/>,
})}
price={`$${professionalPrice}`}
rate={formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}<b>(billed annually)</b>'}, {
br: <br/>,
b: (chunks: React.ReactNode | React.ReactNodeArray) => (
<span className='billed_annually'>
{chunks}
</span>
),
})}
isCloud={true}
planLabel={isProfessional ? (
<PlanLabel
text={professionalPlanLabelText()}
color='var(--online-indicator)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
/>) : undefined}
buttonDetails={professionalBtnDetails()}
briefing={{
title: formatMessage({id: 'pricing_modal.briefing.title_no_limit', defaultMessage: 'No limits on your teams 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'}),
],
}}
/>}
<Card
id='enterprise'
topColor='#E07315'
plan='Enterprise'
planSummary={formatMessage({id: 'pricing_modal.planSummary.enterprise', defaultMessage: 'Administration, security, and compliance for large teams'})}
isCloud={true}
planLabel={
isEnterprise ? (
<PlanLabel
text={formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'})}
color='var(--online-indicator)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
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'})},
],
}}
/>
<BlankCard/>
</div>
</Modal.Body>
</div>
);
}
export default Content;

Просмотреть файл

@@ -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 ? (
<Content
onHide={onHide}
callerCTA={props.callerCTA}
/>
) : (
<SelfHostedContent
onHide={onHide}
/>
);
return (
<Modal
className='PricingModal'
show={showModal}
id='pricingModal'
onExited={() => {
dispatch(closeModal(ModalIdentifiers.PRICING_MODAL));
}}
data-testid='pricingModal'
dialogClassName='a11y__modal'
onHide={onHide}
role='none'
aria-modal='true'
aria-labelledby='pricing_modal_title'
>
{content}
</Modal>
);
}
export default PricingModal;

Просмотреть файл

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

Просмотреть файл

@@ -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 (
<div className='alert-option'>
<span>
{formatMessage({id: 'pricing_modal.lookingForCloudOption', defaultMessage: 'Looking for a cloud option?'})}
</span>
<ExternalLink
onClick={() => {
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'})}</ExternalLink>
</div>
);
};
const trialButton = () => {
return (
<StartTrialBtn
telemetryId='start_trial_from_self_hosted_pricing_modal'
renderAsButton={true}
disabled={isSelfHostedEnterpriseTrial}
btnClass={`plan_action_btn ${isSelfHostedEnterpriseTrial ? ButtonCustomiserClasses.grayed : ButtonCustomiserClasses.special}`}
onClick={closePricingModal}
/>
);
};
return (
<div className='Content Content--self-hosted'>
<Modal.Header className='PricingModal__header'>
<div className='header_lhs'>
<h1 className='title'>
{formatMessage({id: 'pricing_modal.title', defaultMessage: 'Select a plan'})}
</h1>
<div>{formatMessage({id: 'pricing_modal.subtitle', defaultMessage: 'Choose a plan to get started'})}</div>
</div>
<button
id='closeIcon'
className='close'
aria-label='Close'
title='Close'
onClick={() => {
trackEvent('self_hosted_pricing', 'close_pricing_modal');
props.onHide();
}}
>
<span aria-hidden='true'>{'×'}</span>
</button>
</Modal.Header>
<Modal.Body>
{renderAlert()}
<div className='PricingModal__body'>
<Card
id='free'
topColor='#339970'
plan='Free'
planSummary={formatMessage({id: 'pricing_modal.planSummary.free', defaultMessage: 'Increased productivity for small teams'})}
price='$0'
isCloud={false}
planLabel={
isStarter ? (
<PlanLabel
text={formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'})}
color='var(--online-indicator)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
/>) : 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,
}}
/>
<Card
id='professional'
topColor='var(--button-bg)'
plan='Professional'
planSummary={formatMessage({id: 'pricing_modal.planSummary.professional', defaultMessage: 'Scalable solutions {br} for growing teams'}, {
br: <br/>,
})}
price={professionalPrice}
rate={formatMessage({id: 'pricing_modal.rate.seatPerMonth', defaultMessage: 'USD per seat/month {br}<b>(billed annually)</b>'}, {
br: <br/>,
b: (chunks: React.ReactNode | React.ReactNodeArray) => (
<span className='billed_annually'>
<b>{chunks}</b>
</span>
),
})}
isCloud={false}
planLabel={
isProfessional ? (
<PlanLabel
text={formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'})}
color='var(--online-indicator)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
/>) : 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,
}}
/>
<Card
id='enterprise'
topColor='#E07315'
plan='Enterprise'
planSummary={formatMessage({id: 'pricing_modal.planSummary.enterprise', defaultMessage: 'Administration, security, and compliance for large teams'})}
isCloud={false}
planLabel={
isEnterprise ? (
<PlanLabel
text={formatMessage({id: 'pricing_modal.planLabel.currentPlan', defaultMessage: 'CURRENT PLAN'})}
color='var(--online-indicator)'
bgColor='var(--center-channel-bg)'
firstSvg={<CheckMarkSvg/>}
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) ? <StartTrialCaution/> : undefined}
contactSalesCTA={(isPostSelfHostedEnterpriseTrial || !isAdmin) ? undefined : <ContactSalesCTA/>}
briefing={{
title: formatMessage({id: 'pricing_modal.briefing.title', defaultMessage: 'Top features'}),
items: enterpriseBriefing,
}}
/>
</div>
</Modal.Body>
</div>
);
}
export default SelfHostedContent;

Просмотреть файл

@@ -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 <span>Try free for 30 days,</span> I agree to the <linkAgreement>Mattermost Software and Services License Agreement</linkAgreement>, <linkPrivacy>Privacy Policy</linkPrivacy>, and receiving product emails.',
}, {
span: (chunks: React.ReactNode | React.ReactNodeArray) => (<Span>{chunks}</Span>),
linkAgreement: (msg: React.ReactNode) => (
<ExternalLink
href={LicenseLinks.SOFTWARE_SERVICES_LICENSE_AGREEMENT}
location='start_trial_caution'
>
{msg}
</ExternalLink>
),
linkPrivacy: (msg: React.ReactNode) => (
<ExternalLink
href={AboutLinks.PRIVACY_POLICY}
location='start_trial_caution'
>
{msg}
</ExternalLink>
),
});
return (<ContainerSpan>{message}</ContainerSpan>);
}
export default StartTrialCaution;

Просмотреть файл

@@ -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 (
<Disclaimer
id='free_plan_data_restrictions_cta'
onClick={openLimitsMiniModal}
>
<i className='icon-alert-outline'/>
{intl.formatMessage({id: 'pricing_modal.planDisclaimer.free', defaultMessage: 'This plan has data restrictions.'})}
</Disclaimer>
);
}
export default StarterDisclaimerCTA;

Просмотреть файл

@@ -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 (
<svg
width='46'
height='46'
viewBox='0 0 46 46'
fill='none'
xmlns='http://www.w3.org/2000/svg'
>
<circle
cx='23'
cy='23'
r='23'
fill='white'
/>
<path
d='M9 36L13.004 34.6039L9.98 33.2079L9 36Z'
fill='#CC8F00'
/>
<path
d='M20.648 24.5522L14.964 18.8563L14.04 21.4809L20.648 24.5522Z'
fill='#FFBC1F'
/>
<path
d='M14.0412 21.4809L13.0332 24.4126L25.5492 30.2203L26.1092 30.0248L20.6492 24.5522L14.0412 21.4809Z'
fill='#CC8F00'
/>
<path
d='M12.0234 27.3444L21.3474 31.6722L25.5474 30.2203L13.0314 24.4126L12.0234 27.3444Z'
fill='#FFBC1F'
/>
<path
d='M10.9873 30.2761L17.1753 33.152L21.3473 31.6722L12.0233 27.3444L10.9873 30.2761Z'
fill='#CC8F00'
/>
<path
d='M10.9885 30.2761L9.98047 33.2078L13.0045 34.6039L17.1765 33.152L10.9885 30.2761Z'
fill='#FFBC1F'
/>
<path
d='M22.9448 22.123C26.0808 25.2223 27.7048 28.6287 26.5848 29.7456C25.4368 30.8625 21.9648 29.2709 18.8008 26.1717C15.6648 23.0724 14.0408 19.666 15.1608 18.5491C16.3088 17.4322 19.8088 19.0238 22.9448 22.123Z'
fill='#66320A'
/>
<path
d='M19.0225 14.333C18.2665 13.272 19.0785 12.7973 20.0865 12.9928C19.1345 11.8201 19.7225 10.9824 21.2345 11.3175C21.7105 11.4292 21.0385 12.211 20.6185 12.183C21.9065 13.1324 21.2065 14.1375 19.8345 13.9142C21.0385 15.5615 18.9665 15.1427 18.0705 15.2265C17.8465 16.455 19.2465 17.8511 18.7705 17.8511C17.7625 17.8511 16.1105 13.97 19.0225 14.333Z'
fill='#32A4EC'
/>
<path
d='M28.8515 16.5946C28.1515 16.9018 26.1915 13.8304 28.5995 13.8025C27.1995 12.546 27.3955 11.9317 29.2435 11.9038C27.1155 9.78179 30.5035 8.99999 30.8675 10.1448C30.9795 10.4798 29.8315 9.83763 29.4675 10.4519C29.0475 11.1499 32.0995 12.9369 28.9355 12.8531C30.0835 14.0258 30.1675 14.5843 28.3195 14.7797C28.5435 15.0869 29.2995 16.3992 28.8515 16.5946Z'
fill='#E07315'
/>
<path
d='M29.6367 23.8542L30.3367 23.2399C30.3367 23.2399 31.0087 24.2172 31.4847 24.5801C31.8487 22.9328 31.7367 21.9276 33.6967 23.0724C32.6047 20.1965 34.3967 21.2854 36.1047 22.0672C35.9927 21.3413 36.1327 21.425 36.8607 21.2017C37.5047 23.6587 35.7687 22.9328 34.3127 22.151C35.1247 24.3568 34.2847 24.273 32.4647 23.5191C32.4367 24.4685 32.1287 25.5295 31.5687 25.6132C30.9247 25.697 29.6367 23.8542 29.6367 23.8542Z'
fill='#C43133'
/>
<path
d='M24.399 16.9297C23.531 18.0465 22.215 18.6608 21.235 19.6381C20.199 20.6432 19.583 23.4633 19.583 23.4633C19.583 23.4633 19.947 20.5315 20.955 19.4147C21.851 18.4095 23.139 17.6556 23.839 16.4829C25.071 14.3609 23.979 11.5408 22.383 9.92141C22.719 9.61427 23.167 9.25129 23.419 9C24.903 10.8987 26.219 14.5843 24.399 16.9297Z'
fill='#6167BD'
/>
<path
d='M25.8289 19.3309C24.5969 20.2244 23.7569 21.5088 22.8889 22.7094C22.1329 23.7984 19.7529 25.1107 19.7529 25.1107C19.7529 25.1107 21.9929 23.6029 22.6649 22.4581C23.4769 21.0621 24.3449 19.6101 25.6329 18.605C28.2369 16.5946 32.0169 16.818 34.9849 17.879C34.8169 18.2978 34.4809 19.1913 34.4809 19.1913C34.4809 19.1913 28.3209 17.544 25.8289 19.3309Z'
fill='#E07315'
/>
<path
d='M31.0367 19.1076C30.2527 20.1406 29.8607 21.3971 29.2447 22.5419C28.6847 23.6029 27.9567 24.636 26.8647 25.1944C25.6607 25.8087 22.9727 25.5853 22.9727 25.5853C22.9727 25.5853 25.6607 25.5574 26.7527 24.8035C27.8727 24.0496 28.4607 22.7653 28.9087 21.5367C29.7207 19.2192 30.7567 16.7063 33.1927 15.6732C33.3607 16.12 33.6687 16.9855 33.6687 16.9855C33.6687 16.9855 32.3527 17.3764 31.0367 19.1076Z'
fill='#32A4EC'
/>
<path
d='M10.8849 12.9145L9.55859 14.2375L10.8853 15.5601L12.2116 14.2371L10.8849 12.9145Z'
fill='#32A4EC'
/>
<path
d='M12.7326 17.0697L11.4062 18.3927L12.733 19.7153L14.0593 18.3923L12.7326 17.0697Z'
fill='#E07315'
/>
<path
d='M16.0949 10.8513L14.7686 12.1742L16.0953 13.4969L17.4216 12.1739L16.0949 10.8513Z'
fill='#C43133'
/>
<path
d='M30.4818 26.5819L29.1553 27.9047L30.4818 29.2275L31.8083 27.9047L30.4818 26.5819Z'
fill='#6167BD'
/>
<path
d='M27.5285 31.1187L26.2021 32.4417L27.5289 33.7643L28.8552 32.4413L27.5285 31.1187Z'
fill='#C43133'
/>
<path
d='M33.3187 32.1512L31.9922 33.474L33.3187 34.7968L34.6452 33.474L33.3187 32.1512Z'
fill='#E07315'
/>
<path
d='M34.6293 26.153L33.3027 27.4758L34.6293 28.7986L35.9558 27.4758L34.6293 26.153Z'
fill='#32A4EC'
/>
<path
d='M32.44 12.2356L31.1133 13.5582L32.4396 14.8812L33.7663 13.5586L32.44 12.2356Z'
fill='#32A4EC'
/>
<path
d='M18.2882 20.164L16.9619 21.487L18.2886 22.8096L19.615 21.4866L18.2882 20.164Z'
fill='#E74C5C'
/>
</svg>
);
}
export default TadaSvg;

Просмотреть файл

@@ -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}. <a>{ctaAction}</a>',
}, {
ctaAction,
storage: asGBString(fileStorageLimit, formatNumber),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal({trackingLocation: 'file_search_limits_banner'})}
>
{chunks}
</StyledA>
),
}), `${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}. <a>{ctaAction}</a>',
}, {
ctaAction,
storage: asGBString(fileStorageLimit, formatNumber),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal({trackingLocation: 'file_search_limits_banner'})}
>
{chunks}
</StyledA>
),
});
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. <a>{ctaAction}</a>',
}, {
ctaAction,
messages: formatNumber(messagesLimit),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal({trackingLocation: 'messages_search_limits_banner'})}
>
{chunks}
</StyledA>
),
}), `${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. <a>{ctaAction}</a>',
}, {
ctaAction,
messages: formatNumber(messagesLimit),
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<StyledA
onClick={() => openPricingModal({trackingLocation: 'messages_search_limits_banner'})}
>
{chunks}
</StyledA>
),
});
return renderBanner(messagesBannerMessage, `${DataSearchTypes.MESSAGES_SEARCH_TYPE}_search_limits_banner`);
}
default:
return null;
}

Просмотреть файл

@@ -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}
</div>
<div className='divisory-line'/>
<div className='footer-content'>
<button
onClick={handleOpenPricingModal}
className='open-view-plans-modal-btn'
>
{formatMessage({id: 'three_days_left_trial.modal.viewPlans', defaultMessage: 'View plan options'})}
</button>
</div>
{!isAirGapped && (
<div className='footer-content'>
<button
onClick={handleOpenPricingModal}
className='open-view-plans-modal-btn'
>
{formatMessage({id: 'three_days_left_trial.modal.viewPlans', defaultMessage: 'View plan options'})}
</button>
</div>
)}
</GenericModal>
);
}

Просмотреть файл

@@ -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 = (
<div className='MenuCloudTrial__free-trial'>
<h5 className='MenuCloudTrial__free-trial__content-title'>

Просмотреть файл

@@ -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<string, PrimitiveType | FormatXMLElementFn<string, string> | ((chunks: React.ReactNode | React.ReactNodeArray) => JSX.Element)> = {
callToAction,
a: (chunks: React.ReactNode | React.ReactNodeArray) => (
<a
id='view_plans_cta'
onClick={() => openPricingModal({trackingLocation: callerInfo})}
>
{chunks}
</a>),
a: (chunks: React.ReactNode | React.ReactNodeArray) => {
if (isAirGapped) {
// Return plain text if air-gapped
return <>{chunks}</>;
}
return (
<a
id='view_plans_cta'
onClick={() => openPricingModal({trackingLocation: callerInfo})}
>
{chunks}
</a>
);
},
};
@@ -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: 'Youre getting closer to the free {limit} message limit. <a>{callToAction}</a>',
defaultMessage: 'You\'re getting closer to the free {limit} message limit. <a>{callToAction}</a>',
});
values.limit = intl.formatNumber(highestLimit.limit);
if (usageRatio >= limitThresholds.danger) {
if (isAdminUser) {
description = defineMessage({
id: 'workspace_limits.menu_limit.critical.messages_history',
defaultMessage: 'Youre close to hitting the free {limit} message history limit <a>{callToAction}</a>',
defaultMessage: 'You\'re close to hitting the free {limit} message history limit <a>{callToAction}</a>',
});
} 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: 'Youve reached the free message history limit. You can only view up to the last {limit} messages in your history. <a>{callToAction}</a>',
defaultMessage: 'You\'ve reached the free message history limit. You can only view up to the last {limit} messages in your history. <a>{callToAction}</a>',
});
values.limit = inK(highestLimit.limit);
} else {
description = defineMessage({
id: 'workspace_limits.menu_limit.reached.messages_history_non_admin',
defaultMessage: 'Youve reached your message limit. Your admin can upgrade your plan for unlimited messages. <a>{callToAction}</a>',
defaultMessage: 'You\'ve reached your message limit. Your admin can upgrade your plan for unlimited messages. <a>{callToAction}</a>',
});
}
}
@@ -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: 'Youre over the free message history limit. You can only view up to the last {limit} messages in your history. <a>{callToAction}</a>',
defaultMessage: 'You\'re over the free message history limit. You can only view up to the last {limit} messages in your history. <a>{callToAction}</a>',
});
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: 'Youre getting closer to the {limit} file storage limit. <a>{callToAction}</a>',
defaultMessage: 'You\'re getting closer to the {limit} file storage limit. <a>{callToAction}</a>',
});
values.limit = asGBString(highestLimit.limit, intl.formatNumber);
if (usageRatio >= limitThresholds.danger) {
description = defineMessage({
id: 'workspace_limits.menu_limit.critical.files_storage',
defaultMessage: 'Youre getting closer to the {limit} file storage limit. <a>{callToAction}</a>',
defaultMessage: 'You\'re getting closer to the {limit} file storage limit. <a>{callToAction}</a>',
});
}
if (usageRatio >= limitThresholds.reached) {
description = defineMessage({
id: 'workspace_limits.menu_limit.reached.files_storage',
defaultMessage: 'Youve reached the {limit} file storage limit. You can only access the most recent {limit} worth of files. <a>{callToAction}</a>',
defaultMessage: 'You\'ve reached the {limit} file storage limit. You can only access the most recent {limit} worth of files. <a>{callToAction}</a>',
});
}
if (usageRatio >= limitThresholds.exceeded) {
description = defineMessage({
id: 'workspace_limits.menu_limit.over.files_storage',
defaultMessage: 'Youre over the {limit} file storage limit. You can only access the most recent {limit} worth of files. <a>{callToAction}</a>',
defaultMessage: 'You\'re over the {limit} file storage limit. You can only access the most recent {limit} worth of files. <a>{callToAction}</a>',
});
}

Просмотреть файл

@@ -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. <a>See how billing works.</a>",
"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 <a>upgrade to a paid plan.</a>",
"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, <a>notify your admin to upgrade to a paid plan.</a>",
"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 teams 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}<b>(billed annually)</b>",
"pricing_modal.reach_out": "Reach out to us and well 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 <span>Try free for 30 days,</span> I agree to the <linkAgreement>Mattermost Software and Services License Agreement</linkAgreement>, <linkPrivacy>Privacy Policy</linkPrivacy>, 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 workspaces history, rather than just the most recent {limit} messages, upgrade to one of our paid plans. <a>Review our plan options and pricing.</a>",
"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}. <a>{ctaAction}</a>",
"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. <a>{ctaAction}</a>",
"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",

Просмотреть файл

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

Просмотреть файл

@@ -107,6 +107,30 @@ export function getFirstAdminSetupComplete(): ActionFuncAsync<SystemSetting> {
};
}
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,

Просмотреть файл

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

Просмотреть файл

@@ -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<ClientConfig> {
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;
}

Просмотреть файл

@@ -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<string, Product> {
return state.entities.hostedCustomer.products.products;
}
export function getSelfHostedProductsLoaded(state: GlobalState): boolean {
return state.entities.hostedCustomer.products.productsLoaded;
}

Просмотреть файл

@@ -14,6 +14,7 @@ const state: GlobalState = {
firstAdminVisitMarketplaceStatus: false,
firstAdminCompleteSetup: false,
customProfileAttributes: {},
cwsAvailability: 'pending',
},
users: {
currentUserId: '',

Просмотреть файл

@@ -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<typeof getHistory>;
};
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

Просмотреть файл

@@ -4004,12 +4004,6 @@ export default class Client4 {
);
};
getSelfHostedProducts = () => {
return this.doFetch<Product[]>(
`${this.getCloudRoute()}/products/selfhosted`, {method: 'get'},
);
};
subscribeToNewsletter = (newletterRequestBody: NewsletterRequestBody) => {
return this.doFetch<StatusOK>(
`${this.getHostedCustomerRoute()}/subscribe-newsletter`,

Просмотреть файл

@@ -12,6 +12,7 @@ export type GeneralState = {
license: ClientLicense;
serverVersion: string;
customProfileAttributes: IDMappedObjects<UserPropertyField>;
cwsAvailability: 'pending' | 'available' | 'unavailable' | 'not_applicable';
};
export type SystemSetting = {