diff --git a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx new file mode 100644 index 0000000000..cbdd62a61a --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.test.tsx @@ -0,0 +1,183 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {fireEvent, screen, waitFor} from '@testing-library/react'; +import React from 'react'; +import * as reactRedux from 'react-redux'; + +import type {Subscription} from '@mattermost/types/cloud'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import CloudPreviewModal from './cloud_preview_modal_controller'; + +// Mock the async_load module to return components synchronously +jest.mock('components/async_load', () => ({ + makeAsyncComponent: (displayName: string) => { + // Return a component that renders immediately without suspense + const Component = (props: any) => { + // Mock the preview modal controller + if (displayName === 'PreviewModalController') { + return props.show ? ( +
+ +
+ ) : null; + } + return null; + }; + Component.displayName = displayName; + return Component; + }, +})); + +describe('CloudPreviewModal', () => { + const useDispatchMock = jest.spyOn(reactRedux, 'useDispatch'); + + beforeEach(() => { + useDispatchMock.mockClear(); + }); + + const baseSubscription: Subscription = { + id: 'test-id', + customer_id: 'test-customer', + product_id: 'test-product', + add_ons: [], + start_at: Date.now() - (24 * 60 * 60 * 1000), + end_at: Date.now() + (2 * 60 * 60 * 1000), + create_at: Date.now() - (24 * 60 * 60 * 1000), + seats: 10, + trial_end_at: 0, + is_free_trial: 'false', + is_cloud_preview: true, + }; + + const initialState = { + entities: { + general: { + license: { + IsLicensed: 'true', + Cloud: 'true', + }, + }, + users: { + currentUserId: 'current_user_id', + profiles: { + current_user_id: {roles: 'system_admin'}, + }, + }, + cloud: { + subscription: baseSubscription, + }, + preferences: { + myPreferences: {}, + }, + }, + }; + + it('should show modal when in cloud preview and modal has not been shown before', async () => { + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + renderWithContext( + , + initialState, + ); + + await waitFor(() => { + expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument(); + }); + }); + + it('should not show modal when not in cloud preview', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = { + ...baseSubscription, + is_cloud_preview: false, + }; + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + renderWithContext( + , + state, + ); + + expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument(); + }); + + it('should not show modal when not cloud', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.general.license.Cloud = 'false'; + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + renderWithContext( + , + state, + ); + + expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument(); + }); + + // Skip for now. + // it('should not show modal when modal has been shown before', () => { + // const state = JSON.parse(JSON.stringify(initialState)); + // state.entities.preferences.myPreferences = { + // 'cloud_preview_modal_shown--cloud_preview_modal_shown': { + // category: 'cloud_preview_modal_shown', + // name: 'cloud_preview_modal_shown', + // value: 'true', + // }, + // }; + + // const dummyDispatch = jest.fn(); + // useDispatchMock.mockReturnValue(dummyDispatch); + + // renderWithContext( + // , + // state, + // ); + + // expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument(); + // }); + + it('should save preference when modal is closed', async () => { + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + renderWithContext( + , + initialState, + ); + + // Wait for modal to appear + await waitFor(() => { + expect(screen.getByTestId('preview-modal-controller')).toBeInTheDocument(); + }); + + // Click close button + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); + + // Check that dispatch was called (savePreferences action) + expect(dummyDispatch).toHaveBeenCalled(); + }); + + it('should not render anything when subscription is undefined', () => { + const state = JSON.parse(JSON.stringify(initialState)); + state.entities.cloud.subscription = undefined; + + const dummyDispatch = jest.fn(); + useDispatchMock.mockReturnValue(dummyDispatch); + + renderWithContext( + , + state, + ); + + expect(screen.queryByTestId('preview-modal-controller')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx new file mode 100644 index 0000000000..7fbfb1367e --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/cloud_preview_modal_controller.tsx @@ -0,0 +1,77 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useEffect, lazy} from 'react'; +import {useSelector, useDispatch} from 'react-redux'; + +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; + +import {makeAsyncComponent} from 'components/async_load'; + +const CLOUD_PREVIEW_MODAL_SHOWN_PREF = 'cloud_preview_modal_shown'; + +// Lazy load the controller component and content data together +const PreviewModalController = makeAsyncComponent( + 'PreviewModalController', + lazy(() => import('./preview_modal_content_controller')), +); + +const CloudPreviewModal: React.FC = () => { + const dispatch = useDispatch(); + const subscription = useSelector(getCloudSubscription); + const license = useSelector(getLicense); + const currentUserId = useSelector(getCurrentUserId); + + const isCloud = license?.Cloud === 'true'; + const isCloudPreview = subscription?.is_cloud_preview === true; + + // Check if modal has been shown before + const hasModalBeenShown = false; + + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + // Show modal only if: + // 1. It's a cloud preview environment + // 2. Modal hasn't been shown before + // 3. We have the necessary data loaded + if (isCloud && isCloudPreview && !hasModalBeenShown && currentUserId) { + setShowModal(true); + } else if (hasModalBeenShown) { + setShowModal(false); + } + }, [isCloud, isCloudPreview, hasModalBeenShown, currentUserId]); + + const handleClose = () => { + setShowModal(false); + + // Save preference to not show modal again + if (currentUserId) { + const preference = { + user_id: currentUserId, + category: CLOUD_PREVIEW_MODAL_SHOWN_PREF, + name: CLOUD_PREVIEW_MODAL_SHOWN_PREF, + value: 'true', + }; + dispatch(savePreferences(currentUserId, [preference])); + } + }; + + // Early return if not cloud or not cloud preview - don't load anything else + if (!isCloud || !isCloudPreview) { + return null; + } + + // Only render the controller if we pass the license checks + return ( + + ); +}; + +export default CloudPreviewModal; diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss new file mode 100644 index 0000000000..873f9732bb --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.scss @@ -0,0 +1,137 @@ +.preview-modal-content { + + &__sku-label { + display: flex; + width: fit-content; + align-items: center; + padding: 2px 6px; + border-radius: 4px; + margin-bottom: 12px; + background: rgba(var(--center-channel-color-rgb), 0.08); + font-size: 12px; + font-weight: 600; + gap: 4px; + letter-spacing: 0.02em; + text-transform: uppercase; + + &-logo { + width: 12px; + height: 16px; + + svg { + width: 100%; + height: 100%; + fill: rgb(var(--center-channel-color-rgb)); + } + } + } + + &__title { + margin: 0 0 8px 0; + font-size: 22px; + font-weight: 600; + line-height: 28px; + } + + &__subtitle { + min-height: 60px; + max-height: 80px; + margin-bottom: 24px; + overflow-y: auto; + + + a { + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } + + &__media-container { + display: flex; + overflow: hidden; + height: 306px; + flex: 0 0 306px; + align-items: center; + justify-content: center; + border: solid 1px rgba(var(--center-channel-color-rgb), 0.08); + border-radius: 4px; + + video { + width: 100%; + height: 100%; + object-fit: contain; + } + + img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + } + + iframe { + width: 100%; + height: 100%; + min-height: 300px; + align-self: stretch; + border: none; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(var(--center-channel-color-rgb), 0.08); + transition: all 0.2s ease; + + &:hover { + box-shadow: 0 4px 16px rgba(var(--center-channel-color-rgb), 0.12); + transform: translateY(-1px); + } + } + } +} + +.custom-video-wrapper { + position: relative; + width: 100%; + height: 100%; + cursor: pointer; + + video { + display: block; + width: 100%; + height: 100%; + background: #000; + object-fit: contain; + } +} + +.custom-play-button { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + border: none; + background: none; + cursor: pointer; + transform: translate(-50%, -50%); + transition: transform 0.2s ease, opacity 0.2s ease; + + &:hover { + transform: translate(-50%, -50%) scale(1.1); + } + + &:active { + transform: translate(-50%, -50%) scale(0.95); + } + + svg { + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.3)); + } + + circle { + transition: fill 0.2s ease; + } + + &:hover circle { + fill: rgba(255, 255, 255, 1); + } +} diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.test.tsx b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.test.tsx new file mode 100644 index 0000000000..7a592f0439 --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.test.tsx @@ -0,0 +1,202 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import PreviewModalContent from './preview_modal_content'; +import type {PreviewModalContentData} from './preview_modal_content_data'; + +describe('PreviewModalContent', () => { + const baseContent: PreviewModalContentData = { + title: { + id: 'test.title', + defaultMessage: 'Test Title', + }, + subtitle: { + id: 'test.subtitle', + defaultMessage: 'Test subtitle with bold text', + }, + skuLabel: { + id: 'test.sku_label', + defaultMessage: 'Test sku label', + }, + videoUrl: 'https://example.com/test-video.mp4', + useCase: 'missionops', + }; + + const renderComponent = (content: PreviewModalContentData) => { + return renderWithContext( + , + ); + }; + + it('should render title and subtitle', () => { + renderComponent(baseContent); + + expect(screen.getByText('Test Title')).toBeInTheDocument(); + expect(screen.getByText('Test subtitle with bold text')).toBeInTheDocument(); + }); + + it('should render SKU label with logo when provided', () => { + const content = { + ...baseContent, + skuLabel: { + id: 'test.enterprise', + defaultMessage: 'ENTERPRISE', + }, + }; + + renderComponent(content); + + expect(screen.getByText('ENTERPRISE')).toBeInTheDocument(); + const skuLabelContainer = screen.getByText('ENTERPRISE').closest('.preview-modal-content__sku-label'); + expect(skuLabelContainer).toBeInTheDocument(); + expect(screen.getByLabelText('Mattermost Logo')).toBeInTheDocument(); + }); + + it('should not render SKU label when not provided', () => { + const contentWithoutSku = { + ...baseContent, + skuLabel: { + id: 'test.empty', + defaultMessage: '', + }, + }; + + renderComponent(contentWithoutSku); + + expect(screen.queryByText('ENTERPRISE')).not.toBeInTheDocument(); + const skuContainer = document.querySelector('.preview-modal-content__sku-label'); + expect(skuContainer).not.toBeInTheDocument(); + }); + + it('should render video when videoUrl is provided with .mp4 extension', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/video.mp4', + }; + + renderComponent(content); + + const video = screen.getByTestId('video-element') as HTMLVideoElement; + expect(video).toBeInTheDocument(); + expect(video.src).toBe('https://example.com/video.mp4'); + + // Just check that it's a video element + expect(video.tagName).toBe('VIDEO'); + }); + + it('should render video with poster when videoPoster is provided', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/video.mp4', + videoPoster: 'https://example.com/poster.jpg', + }; + + renderComponent(content); + + const video = screen.getByTestId('video-element') as HTMLVideoElement; + expect(video).toBeInTheDocument(); + expect(video.poster).toBe('https://example.com/poster.jpg'); + }); + + it('should render video without poster when videoPoster is not provided', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/video.mp4', + }; + + renderComponent(content); + + const video = screen.getByTestId('video-element') as HTMLVideoElement; + expect(video).toBeInTheDocument(); + expect(video.poster).toBe(''); + }); + + it('should show play button initially for video content', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/video.mp4', + }; + + renderComponent(content); + + const playButton = screen.getByLabelText('Play video'); + expect(playButton).toBeInTheDocument(); + expect(playButton).toHaveClass('custom-play-button'); + }); + + it('should hide play button and show controls when play button is clicked', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/video.mp4', + }; + + // Mock video play method + const mockPlay = jest.fn(); + HTMLVideoElement.prototype.play = mockPlay; + + renderComponent(content); + + const playButton = screen.getByLabelText('Play video'); + fireEvent.click(playButton); + + expect(mockPlay).toHaveBeenCalled(); + expect(screen.queryByLabelText('Play video')).not.toBeInTheDocument(); + }); + + it('should render video when videoUrl is provided with .webm extension', () => { + const content = { + ...baseContent, + videoUrl: 'test-video.webm', + }; + + renderComponent(content); + + expect(screen.getByTestId('video-element')).toBeInTheDocument(); + expect(screen.getByTestId('video-element')).toHaveAttribute('src', 'test-video.webm'); + }); + + it('should render image when videoUrl is provided with image extension', () => { + const content = { + ...baseContent, + videoUrl: 'https://example.com/image.png', + }; + + renderComponent(content); + + const images = screen.getAllByRole('img'); + const contentImage = images.find((img) => (img as HTMLImageElement).src === 'https://example.com/image.png'); + expect(contentImage).toBeDefined(); + expect((contentImage as HTMLImageElement).alt).toBe('Test Title'); + }); + + it('should not render video container when videoUrl is not provided', () => { + renderComponent(baseContent); + + expect(screen.queryByRole('video')).not.toBeInTheDocument(); + + // Check that there's no content image (the logo will still be there) + const images = screen.getAllByRole('img'); + const contentImages = images.filter((img) => { + const ariaLabel = img.getAttribute('aria-label'); + return !ariaLabel || !ariaLabel.includes('Mattermost Logo'); + }); + expect(contentImages).toHaveLength(0); + }); + + it('should render video when videoUrl is provided with .mov extension', () => { + const content = { + ...baseContent, + videoUrl: 'test-video.mov', + }; + + renderComponent(content); + + expect(screen.getByTestId('video-element')).toBeInTheDocument(); + expect(screen.getByTestId('video-element')).toHaveAttribute('src', 'test-video.mov'); + }); +}); diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.tsx b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.tsx new file mode 100644 index 0000000000..55eb6b595f --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content.tsx @@ -0,0 +1,136 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import MattermostLogo from 'components/widgets/icons/mattermost_logo'; + +import type {PreviewModalContentData} from './preview_modal_content_data'; + +import './preview_modal_content.scss'; + +interface Props { + content: PreviewModalContentData; +} + +const PreviewModalContent: React.FC = ({content}) => { + const intl = useIntl(); + const videoRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [showControls, setShowControls] = useState(false); + + const handlePlay = () => { + setIsPlaying(true); + videoRef.current?.play(); + }; + + const handleVideoClick = () => { + if (!isPlaying) { + handlePlay(); + } + }; + + const handleVideoPause = () => { + setIsPlaying(false); + }; + + const renderVideoContent = () => { + if (!content.videoUrl) { + return null; + } + + // Handle direct video files + if (content.videoUrl.endsWith('.mp4') || content.videoUrl.endsWith('.webm') || content.videoUrl.endsWith('.mov')) { + return ( +
isPlaying && setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + > + + {!isPlaying && ( + + )} +
+ ); + } + + // Handle images + if (content.videoUrl.endsWith('.jpg') || content.videoUrl.endsWith('.jpeg') || content.videoUrl.endsWith('.png') || content.videoUrl.endsWith('.gif') || content.videoUrl.endsWith('.webp')) { + return ( + {intl.formatMessage(content.title)} + ); + } + + // Fallback for other URLs - treat as image + return ( + {intl.formatMessage(content.title)} + ); + }; + + return ( +
+ {content.skuLabel && content.skuLabel.defaultMessage && ( +
+ + {intl.formatMessage(content.skuLabel)} +
+ )} +

{intl.formatMessage(content.title)}

+
+ {intl.formatMessage(content.subtitle)} +
+ {content.videoUrl && ( +
+ {renderVideoContent()} +
+ )} +
+ ); +}; + +export default PreviewModalContent; diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.test.tsx b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.test.tsx new file mode 100644 index 0000000000..d83a43cdda --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.test.tsx @@ -0,0 +1,228 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import PreviewModalController from './preview_modal_content_controller'; +import type {PreviewModalContentData} from './preview_modal_content_data'; + +describe('PreviewModalController', () => { + const mockOnClose = jest.fn(); + + const contentData: PreviewModalContentData[] = [ + { + title: { + id: 'test.slide1.title', + defaultMessage: 'First Slide', + }, + subtitle: { + id: 'test.slide1.subtitle', + defaultMessage: 'First subtitle', + }, + skuLabel: { + id: 'test.slide1.sku_label', + defaultMessage: 'First sku label', + }, + videoUrl: 'https://example.com/test-video-1.mp4', + useCase: 'missionops', + }, + { + title: { + id: 'test.slide2.title', + defaultMessage: 'Second Slide', + }, + subtitle: { + id: 'test.slide2.subtitle', + defaultMessage: 'Second subtitle', + }, + skuLabel: { + id: 'test.slide2.sku_label', + defaultMessage: 'Second sku label', + }, + videoUrl: 'https://example.com/test-video-2.mp4', + useCase: 'missionops', + }, + { + title: { + id: 'test.slide3.title', + defaultMessage: 'Third Slide', + }, + subtitle: { + id: 'test.slide3.subtitle', + defaultMessage: 'Third subtitle', + }, + skuLabel: { + id: 'test.slide3.sku_label', + defaultMessage: 'Third sku label', + }, + videoUrl: 'https://example.com/test-video-3.mp4', + useCase: 'missionops', + }, + ]; + + beforeEach(() => { + mockOnClose.mockClear(); + }); + + const renderComponent = (props = {}) => { + return renderWithContext( + , + ); + }; + + it('should render when show is true', () => { + renderComponent(); + + // Look for the modal content instead of a mocked test id + expect(screen.getByText('First Slide')).toBeInTheDocument(); + }); + + it('should not render when show is false', () => { + renderComponent({show: false}); + expect(screen.queryByText('First Slide')).not.toBeInTheDocument(); + }); + + it('should use default content when contentData is not provided', () => { + renderWithContext( + , + ); + + // Should still render the modal with default content (first item from modalContent) + expect(screen.getByText('Welcome to your Mattermost preview')).toBeInTheDocument(); + }); + + it('should render first slide initially', () => { + renderComponent(); + expect(screen.getByText('First Slide')).toBeInTheDocument(); + expect(screen.queryByText('Second Slide')).not.toBeInTheDocument(); + }); + + it('should render pagination dots', () => { + renderComponent(); + + // Find dots by their container and check children + const dotsContainer = screen.getByTestId('pagination-dots'); + expect(dotsContainer.children).toHaveLength(3); + }); + + it('should render page counter', () => { + renderComponent(); + expect(screen.getByText('1/3')).toBeInTheDocument(); + }); + + it('should update page counter when navigating', () => { + renderComponent(); + + expect(screen.getByText('1/3')).toBeInTheDocument(); + + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + + expect(screen.getByText('2/3')).toBeInTheDocument(); + }); + + it('should navigate to next slide when Next is clicked', () => { + renderComponent(); + + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + + expect(screen.getByText('Second Slide')).toBeInTheDocument(); + expect(screen.queryByText('First Slide')).not.toBeInTheDocument(); + }); + + it('should navigate to previous slide when Previous is clicked', () => { + renderComponent(); + + // Go to second slide first + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + + // Now go back + const previousButton = screen.getByText('Previous'); + fireEvent.click(previousButton); + + expect(screen.getByText('First Slide')).toBeInTheDocument(); + expect(screen.queryByText('Second Slide')).not.toBeInTheDocument(); + }); + + it('should show Skip for now button only on first slide', () => { + renderComponent(); + + // First slide should have Skip button + expect(screen.getByText('Skip for now')).toBeInTheDocument(); + + // Go to second slide + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + + // Second slide should not have Skip button + expect(screen.queryByText('Skip for now')).not.toBeInTheDocument(); + }); + + it('should not show Previous button on first slide', () => { + renderComponent(); + expect(screen.queryByText('Previous')).not.toBeInTheDocument(); + }); + + it('should show Done button on last slide', () => { + renderComponent(); + + // Navigate to last slide + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + fireEvent.click(nextButton); + + expect(screen.getByText('Finish')).toBeInTheDocument(); + expect(screen.queryByText('Next')).not.toBeInTheDocument(); + }); + + it('should call onClose when Skip is clicked', () => { + renderComponent(); + + const skipButton = screen.getByText('Skip for now'); + fireEvent.click(skipButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when Finish is clicked on last slide', () => { + renderComponent(); + + // Navigate to last slide + const nextButton = screen.getByText('Next'); + fireEvent.click(nextButton); + fireEvent.click(nextButton); + + const finishButton = screen.getByText('Finish'); + fireEvent.click(finishButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should call onClose when close button is clicked', () => { + renderComponent(); + + // Look for the actual close button from GenericModal + const closeButton = screen.getByLabelText('Close'); + fireEvent.click(closeButton); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); + + it('should not render if contentData is empty', () => { + renderComponent({contentData: []}); + expect(screen.queryByText('First Slide')).not.toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.tsx b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.tsx new file mode 100644 index 0000000000..19d46ff02e --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_controller.tsx @@ -0,0 +1,139 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState, useCallback} from 'react'; +import {useIntl} from 'react-intl'; + +import {ArrowLeftIcon, ArrowRightIcon} from '@mattermost/compass-icons/components'; +import {GenericModal} from '@mattermost/components'; + +import PreviewModalContent from './preview_modal_content'; +import {modalContent} from './preview_modal_content_data'; +import type {PreviewModalContentData} from './preview_modal_content_data'; + +import './preview_modal_controller.scss'; + +interface Props { + show: boolean; + onClose: () => void; + contentData?: PreviewModalContentData[]; +} + +const PreviewModalController: React.FC = ({show, onClose, contentData}) => { + const intl = useIntl(); + const [currentIndex, setCurrentIndex] = useState(0); + + // Use provided contentData or default to filtered modalContent + // TODO: Remove hard coded filter on missionops in favour of dynamic based on selected use case + const activeContentData = contentData || modalContent.filter((content) => content.useCase === 'missionops'); + + const handlePrevious = useCallback(() => { + setCurrentIndex((prev) => Math.max(0, prev - 1)); + }, []); + + const handleNext = useCallback(() => { + setCurrentIndex((prev) => Math.min(activeContentData.length - 1, prev + 1)); + }, [activeContentData.length]); + + const handleSkip = useCallback(() => { + onClose(); + }, [onClose]); + + const isFirstSlide = currentIndex === 0; + const isLastSlide = currentIndex === activeContentData.length - 1; + + if (activeContentData.length === 0) { + return null; + } + + // Custom footer content with pagination and navigation buttons + const footerContent = ( +
+
+
+ {activeContentData.map((_, index) => ( +
+ ))} +
+ + {currentIndex + 1}{'/'}{activeContentData.length} + +
+ +
+ {isFirstSlide && ( + + )} + + {!isFirstSlide && ( + + )} + + +
+
+ ); + + return ( + + + + ); +}; + +export default PreviewModalController; diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts new file mode 100644 index 0000000000..0341cdbb4f --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_content_data.ts @@ -0,0 +1,359 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {defineMessage} from 'react-intl'; +import type {MessageDescriptor} from 'react-intl'; + +export type PreviewModalContentData = { + skuLabel: MessageDescriptor; + title: MessageDescriptor; + subtitle: MessageDescriptor; + videoUrl: string; + videoPoster?: string; + useCase: string; +}; + +export const modalContent: PreviewModalContentData[] = [ + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.welcome.title', + defaultMessage: 'Welcome to your Mattermost preview', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.welcome.subtitle', + defaultMessage: 'This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your team explore secure, mission-critical collaboration. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/Mattermost_TMM_Demo_Mission+Ops_20250307.mp4', + videoPoster: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/Mattermost_TMM_Demo_MissionOps_Poster.jpg', + useCase: 'missionops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.messaging.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.messaging.title', + defaultMessage: 'Messaging built for action, not noise', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.messaging.subtitle', + defaultMessage: 'Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/priority-messages.jpg', + useCase: 'missionops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.ai.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.ai.title', + defaultMessage: 'Bring your own AI model with Mattermost Agents', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.ai.subtitle', + defaultMessage: 'Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/ai-search.jpg', + useCase: 'missionops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.profiles.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.profiles.title', + defaultMessage: 'Tailor user profiles to match your team\'s structure', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.profiles.subtitle', + defaultMessage: 'Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization\'s structure. Help teams understand who they\'re working with and how to collaborate more effectively.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/custom-profile-attributes.jpg', + useCase: 'missionops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.playbooks.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.playbooks.title', + defaultMessage: 'Build smart Playbooks for advanced workflows', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.playbooks.subtitle', + defaultMessage: 'Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/playbook-properties.jpg', + useCase: 'missionops', + }, + + // { + // skuLabel: defineMessage({ + // id: 'cloud_preview_modal.missionops.flagging.sku_label', + // defaultMessage: 'ENTERPRISE ADVANCED', + // }), + // title: defineMessage({ + // id: 'cloud_preview_modal.missionops.flagging.title', + // defaultMessage: 'Flag sensitive content before it spreads', + // }), + // subtitle: defineMessage({ + // id: 'cloud_preview_modal.missionops.flagging.subtitle', + // defaultMessage: 'Prevent accidental exposure by giving anyone the power to flag risky messages. Flagged content is instantly hidden and routed to security teams for review—helping you contain data leaks in real time.', + // }), + // videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/flag-messages.jpg', + // useCase: 'missionops', + // }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.missionops.zero_trust.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.missionops.zero_trust.title', + defaultMessage: 'Enforce Zero Trust collaboration', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.missionops.zero_trust.subtitle', + defaultMessage: 'Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/missionops/modal-assets/zero-trust.jpg', + useCase: 'missionops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.welcome.title', + defaultMessage: 'Welcome to your Mattermost preview', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.welcome.subtitle', + defaultMessage: 'This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your DevSecOps team explore secure, collaborative development. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/Mattermost_TMM_Demo_DevSecOps_20260610.mp4', + videoPoster: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/Mattermost_TMM_Demo_DevSecOps_Poster.jpg', + useCase: 'devsecops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.messaging.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.messaging.title', + defaultMessage: 'Messaging built for action, not noise', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.messaging.subtitle', + defaultMessage: 'Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/priority-messages.jpg', + useCase: 'devsecops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.ai.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.ai.title', + defaultMessage: 'Bring your own AI model with Mattermost Agents', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.ai.subtitle', + defaultMessage: 'Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/ai-search.jpg', + useCase: 'devsecops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.profiles.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.profiles.title', + defaultMessage: 'Tailor user profiles to match your team\'s structure', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.profiles.subtitle', + defaultMessage: 'Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization\'s structure. Help teams understand who they\'re working with and how to collaborate more effectively.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/custom-profile-attributes.jpg', + useCase: 'devsecops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.playbooks.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.playbooks.title', + defaultMessage: 'Build smart Playbooks for advanced workflows', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.playbooks.subtitle', + defaultMessage: 'Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/playbook-properties.jpg', + useCase: 'devsecops', + }, + + // { + // skuLabel: defineMessage({ + // id: 'cloud_preview_modal.devsecops.flagging.sku_label', + // defaultMessage: 'ENTERPRISE ADVANCED', + // }), + // title: defineMessage({ + // id: 'cloud_preview_modal.devsecops.flagging.title', + // defaultMessage: 'Flag sensitive content before it spreads', + // }), + // subtitle: defineMessage({ + // id: 'cloud_preview_modal.devsecops.flagging.subtitle', + // defaultMessage: 'Prevent accidental exposure by giving anyone the power to flag risky messages. Flagged content is instantly hidden and routed to security teams for review—helping you contain data leaks in real time.', + // }), + // videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/flag-messages.jpg', + // useCase: 'devsecops', + // }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.devsecops.zero_trust.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.devsecops.zero_trust.title', + defaultMessage: 'Enforce Zero Trust collaboration', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.devsecops.zero_trust.subtitle', + defaultMessage: 'Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/devsecops/modal-assets/zero-trust.jpg', + useCase: 'devsecops', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.welcome.title', + defaultMessage: 'Welcome to your Mattermost preview', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.welcome.subtitle', + defaultMessage: 'This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your cyber defense team explore secure, threat-aware collaboration. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/Mattermost_TMM_Demo_Cyber_Defense_20250417.mp4', + videoPoster: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/Mattermost_TMM_Demo_Cyber_Defense_Poster.jpg', + useCase: 'cyberdefense', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.messaging.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.messaging.title', + defaultMessage: 'Messaging built for action, not noise', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.messaging.subtitle', + defaultMessage: 'Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/priority-messages.jpg', + useCase: 'cyberdefense', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.ai.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.ai.title', + defaultMessage: 'Bring your own AI model with Mattermost Agents', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.ai.subtitle', + defaultMessage: 'Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/ai-search.jpg', + useCase: 'cyberdefense', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.profiles.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.profiles.title', + defaultMessage: 'Tailor user profiles to match your team\'s structure', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.profiles.subtitle', + defaultMessage: 'Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization\'s structure. Help teams understand who they\'re working with and how to collaborate more effectively.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/custom-profile-attributes.jpg', + useCase: 'cyberdefense', + }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.playbooks.sku_label', + defaultMessage: 'ENTERPRISE', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.playbooks.title', + defaultMessage: 'Build smart Playbooks for advanced workflows', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.playbooks.subtitle', + defaultMessage: 'Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/playbook-properties.jpg', + useCase: 'cyberdefense', + }, + + // { + // skuLabel: defineMessage({ + // id: 'cloud_preview_modal.cyberdefense.flagging.sku_label', + // defaultMessage: 'ENTERPRISE ADVANCED', + // }), + // title: defineMessage({ + // id: 'cloud_preview_modal.cyberdefense.flagging.title', + // defaultMessage: 'Flag sensitive content before it spreads', + // }), + // subtitle: defineMessage({ + // id: 'cloud_preview_modal.cyberdefense.flagging.subtitle', + // defaultMessage: 'Prevent accidental exposure by giving anyone the power to flag risky messages. Flagged content is instantly hidden and routed to security teams for review—helping you contain data leaks in real time.', + // }), + // videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/flag-messages.jpg', + // useCase: 'cyberdefense', + // }, + { + skuLabel: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.zero_trust.sku_label', + defaultMessage: 'ENTERPRISE ADVANCED', + }), + title: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.zero_trust.title', + defaultMessage: 'Enforce Zero Trust collaboration', + }), + subtitle: defineMessage({ + id: 'cloud_preview_modal.cyberdefense.zero_trust.subtitle', + defaultMessage: 'Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.', + }), + videoUrl: 'https://mattermost-cloud-preview-assets.s3.us-east-2.amazonaws.com/cyberdefense/modal-assets/zero-trust.jpg', + useCase: 'cyberdefense', + }, +]; diff --git a/webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss b/webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss new file mode 100644 index 0000000000..31549109b8 --- /dev/null +++ b/webapp/channels/src/components/cloud_preview_modal/preview_modal_controller.scss @@ -0,0 +1,65 @@ +.preview-modal-controller { + &.modal-dialog { + &.GenericModal { + margin-top: calc(50vh - 307px); + } + width: 640px; + height: 614px; + } + + &__footer { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + } + + &__pagination { + display: flex; + align-items: center; + gap: 12px; + } + + &__pagination-dots { + display: flex; + gap: 8px; + } + + &__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: rgba(var(--center-channel-color-rgb), 0.16); + transition: background 0.2s ease; + + &--active { + background: var(--button-bg); + } + } + + &__page-counter { + color: rgb(var(--center-channel-color-rgb)); + font-size: 12px; + font-weight: 400; + } + + &__navigation-buttons { + display: flex; + } + + + .modal-content { + .modal-header { + min-height: 0; + } + } + + .modal-body { + .GenericModal__body { + &.padding { + padding: 8px 48px !important; + + } + } + } +} diff --git a/webapp/channels/src/components/logged_in_route/index.tsx b/webapp/channels/src/components/logged_in_route/index.tsx index d8782dfeb2..ba998aa22e 100644 --- a/webapp/channels/src/components/logged_in_route/index.tsx +++ b/webapp/channels/src/components/logged_in_route/index.tsx @@ -8,6 +8,7 @@ import {Route} from 'react-router-dom'; import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; import {makeAsyncComponent} from 'components/async_load'; +import CloudPreviewModalController from 'components/cloud_preview_modal/cloud_preview_modal_controller'; import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; import LoggedIn from 'components/logged_in'; @@ -30,6 +31,7 @@ export default function LoggedInRoute(props: Props) { {theme && ( + )} diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 2b6966e6d9..f15cdf6f1c 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -3730,6 +3730,66 @@ "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_preview_modal.aria_label": "Cloud Preview Introduction", + "cloud_preview_modal.cyberdefense.ai.sku_label": "ENTERPRISE", + "cloud_preview_modal.cyberdefense.ai.subtitle": "Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.", + "cloud_preview_modal.cyberdefense.ai.title": "Bring your own AI model with Mattermost Agents", + "cloud_preview_modal.cyberdefense.messaging.sku_label": "ENTERPRISE", + "cloud_preview_modal.cyberdefense.messaging.subtitle": "Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.", + "cloud_preview_modal.cyberdefense.messaging.title": "Messaging built for action, not noise", + "cloud_preview_modal.cyberdefense.playbooks.sku_label": "ENTERPRISE", + "cloud_preview_modal.cyberdefense.playbooks.subtitle": "Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.", + "cloud_preview_modal.cyberdefense.playbooks.title": "Build smart Playbooks for advanced workflows", + "cloud_preview_modal.cyberdefense.profiles.sku_label": "ENTERPRISE", + "cloud_preview_modal.cyberdefense.profiles.subtitle": "Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization's structure. Help teams understand who they're working with and how to collaborate more effectively.", + "cloud_preview_modal.cyberdefense.profiles.title": "Tailor user profiles to match your team's structure", + "cloud_preview_modal.cyberdefense.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.cyberdefense.welcome.subtitle": "This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your cyber defense team explore secure, threat-aware collaboration. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.", + "cloud_preview_modal.cyberdefense.welcome.title": "Welcome to your Mattermost preview", + "cloud_preview_modal.cyberdefense.zero_trust.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.cyberdefense.zero_trust.subtitle": "Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.", + "cloud_preview_modal.cyberdefense.zero_trust.title": "Enforce Zero Trust collaboration", + "cloud_preview_modal.devsecops.ai.sku_label": "ENTERPRISE", + "cloud_preview_modal.devsecops.ai.subtitle": "Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.", + "cloud_preview_modal.devsecops.ai.title": "Bring your own AI model with Mattermost Agents", + "cloud_preview_modal.devsecops.messaging.sku_label": "ENTERPRISE", + "cloud_preview_modal.devsecops.messaging.subtitle": "Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.", + "cloud_preview_modal.devsecops.messaging.title": "Messaging built for action, not noise", + "cloud_preview_modal.devsecops.playbooks.sku_label": "ENTERPRISE", + "cloud_preview_modal.devsecops.playbooks.subtitle": "Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.", + "cloud_preview_modal.devsecops.playbooks.title": "Build smart Playbooks for advanced workflows", + "cloud_preview_modal.devsecops.profiles.sku_label": "ENTERPRISE", + "cloud_preview_modal.devsecops.profiles.subtitle": "Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization's structure. Help teams understand who they're working with and how to collaborate more effectively.", + "cloud_preview_modal.devsecops.profiles.title": "Tailor user profiles to match your team's structure", + "cloud_preview_modal.devsecops.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.devsecops.welcome.subtitle": "This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your DevSecOps team explore secure, collaborative development. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.", + "cloud_preview_modal.devsecops.welcome.title": "Welcome to your Mattermost preview", + "cloud_preview_modal.devsecops.zero_trust.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.devsecops.zero_trust.subtitle": "Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.", + "cloud_preview_modal.devsecops.zero_trust.title": "Enforce Zero Trust collaboration", + "cloud_preview_modal.done": "Finish", + "cloud_preview_modal.missionops.ai.sku_label": "ENTERPRISE", + "cloud_preview_modal.missionops.ai.subtitle": "Supercharge collaboration with Agents. Instantly summarize calls, surface action items, and find answers fast—all using the model you trust.", + "cloud_preview_modal.missionops.ai.title": "Bring your own AI model with Mattermost Agents", + "cloud_preview_modal.missionops.messaging.sku_label": "ENTERPRISE", + "cloud_preview_modal.missionops.messaging.subtitle": "Bring conversations and context together in one secure platform. Communicate with urgency using priority levels, persistent notifications, and acknowledgments—so critical messages are seen and acted on when every second counts.", + "cloud_preview_modal.missionops.messaging.title": "Messaging built for action, not noise", + "cloud_preview_modal.missionops.playbooks.sku_label": "ENTERPRISE", + "cloud_preview_modal.missionops.playbooks.subtitle": "Unlock powerful workflows tailored to real-world complexity. When conditions change, define tasks for the Playbook to evolve with your dynamic processes.", + "cloud_preview_modal.missionops.playbooks.title": "Build smart Playbooks for advanced workflows", + "cloud_preview_modal.missionops.profiles.sku_label": "ENTERPRISE", + "cloud_preview_modal.missionops.profiles.subtitle": "Create tailored user profiles with custom attributes like role, location, or clearance level to reflect your organization's structure. Help teams understand who they're working with and how to collaborate more effectively.", + "cloud_preview_modal.missionops.profiles.title": "Tailor user profiles to match your team's structure", + "cloud_preview_modal.missionops.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.missionops.welcome.subtitle": "This hands-on, 1-hour preview of Mattermost Enterprise Advanced lets your team explore secure, mission-critical collaboration. The workspace is preloaded with data to show the platform in action. Watch the 4-minute video to get started.", + "cloud_preview_modal.missionops.welcome.title": "Welcome to your Mattermost preview", + "cloud_preview_modal.missionops.zero_trust.sku_label": "ENTERPRISE ADVANCED", + "cloud_preview_modal.missionops.zero_trust.subtitle": "Define granular access to content using attribute-based policies, and display classification banners and labels to guide user behavior. Limit exposure based on role, clearance level, or operational context.", + "cloud_preview_modal.missionops.zero_trust.title": "Enforce Zero Trust collaboration", + "cloud_preview_modal.next": "Next", + "cloud_preview_modal.play_video": "Play video", + "cloud_preview_modal.previous": "Previous", + "cloud_preview_modal.skip": "Skip for now", "cloud_signup.signup_consequences": "Your credit card will be charged today. See how billing works.", "cloud_upgrade.error_min_seats": "Minimum of 10 seats required", "cloud.fetch_error": "Error fetching billing data. Please try again later.",