mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Frontend for additional environments (#8378)
This commit is contained in:
		
							parent
							
								
									99021f373f
								
							
						
					
					
						commit
						48eee2043f
					
				| @ -26,7 +26,7 @@ import { relative } from 'themes/themeStyles'; | ||||
| 
 | ||||
| interface ICreateProps { | ||||
|     title?: ReactNode; | ||||
|     description: string; | ||||
|     description: ReactNode; | ||||
|     documentationLink?: string; | ||||
|     documentationIcon?: ReactNode; | ||||
|     documentationLinkLabel?: string; | ||||
| @ -210,7 +210,7 @@ const StyledDescriptionCard = styled('article')(({ theme }) => ({ | ||||
|     marginBlockEnd: theme.spacing(3), | ||||
| })); | ||||
| 
 | ||||
| const StyledDescription = styled('p')(({ theme }) => ({ | ||||
| const StyledDescription = styled('div')(() => ({ | ||||
|     width: '100%', | ||||
| })); | ||||
| 
 | ||||
| @ -370,7 +370,7 @@ const FormTemplate: React.FC<ICreateProps> = ({ | ||||
| }; | ||||
| 
 | ||||
| interface IMobileGuidance { | ||||
|     description: string; | ||||
|     description: ReactNode; | ||||
|     documentationLink?: string; | ||||
|     documentationIcon?: ReactNode; | ||||
|     documentationLinkLabel?: string; | ||||
| @ -410,7 +410,7 @@ const MobileGuidance = ({ | ||||
| }; | ||||
| 
 | ||||
| interface IGuidanceProps { | ||||
|     description: string; | ||||
|     description: ReactNode; | ||||
|     documentationIcon?: ReactNode; | ||||
|     documentationLink?: string; | ||||
|     documentationLinkLabel?: string; | ||||
|  | ||||
| @ -8,7 +8,7 @@ import { | ||||
|     Table, | ||||
|     TablePlaceholder, | ||||
| } from 'component/common/Table'; | ||||
| import { useCallback } from 'react'; | ||||
| import { useCallback, useMemo, useState } from 'react'; | ||||
| import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext'; | ||||
| import { Alert, styled, TableBody } from '@mui/material'; | ||||
| import type { MoveListItem } from 'hooks/useDragItem'; | ||||
| @ -27,7 +27,10 @@ import { HighlightCell } from 'component/common/Table/cells/HighlightCell/Highli | ||||
| import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; | ||||
| import type { IEnvironment } from 'interfaces/environments'; | ||||
| import { useUiFlag } from 'hooks/useUiFlag'; | ||||
| import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; | ||||
| import { PremiumFeature } from 'component/common/PremiumFeature/PremiumFeature'; | ||||
| import { PurchasableFeature } from './PurchasableFeature/PurchasableFeature'; | ||||
| import { OrderEnvironmentsDialog } from './OrderEnvironmentsDialog/OrderEnvironmentsDialog'; | ||||
| 
 | ||||
| const StyledAlert = styled(Alert)(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(4), | ||||
| @ -37,7 +40,12 @@ export const EnvironmentTable = () => { | ||||
|     const { changeSortOrder } = useEnvironmentApi(); | ||||
|     const { setToastApiError } = useToast(); | ||||
|     const { environments, mutateEnvironments } = useEnvironments(); | ||||
|     const [purchaseDialogOpen, setPurchaseDialogOpen] = useState(false); | ||||
|     const isFeatureEnabled = useUiFlag('EEA'); | ||||
|     const isPurchaseAdditionalEnvronmentsEnabled = useUiFlag( | ||||
|         'purchaseAdditionalEnvironments', | ||||
|     ); | ||||
|     const { isPro } = useUiConfig(); | ||||
| 
 | ||||
|     const moveListItem: MoveListItem = useCallback( | ||||
|         async (dragIndex: number, dropIndex: number, save = false) => { | ||||
| @ -58,6 +66,28 @@ export const EnvironmentTable = () => { | ||||
|         [changeSortOrder, environments, mutateEnvironments, setToastApiError], | ||||
|     ); | ||||
| 
 | ||||
|     const columnsWithActions = useMemo(() => { | ||||
|         if (isFeatureEnabled) { | ||||
|             return [ | ||||
|                 ...COLUMNS, | ||||
|                 { | ||||
|                     Header: 'Actions', | ||||
|                     id: 'Actions', | ||||
|                     align: 'center', | ||||
|                     width: '1%', | ||||
|                     Cell: ({ | ||||
|                         row: { original }, | ||||
|                     }: { row: { original: IEnvironment } }) => ( | ||||
|                         <EnvironmentActionCell environment={original} /> | ||||
|                     ), | ||||
|                     disableGlobalFilter: true, | ||||
|                 }, | ||||
|             ]; | ||||
|         } | ||||
| 
 | ||||
|         return COLUMNS; | ||||
|     }, [isFeatureEnabled]); | ||||
| 
 | ||||
|     const { | ||||
|         getTableProps, | ||||
|         getTableBodyProps, | ||||
| @ -68,7 +98,7 @@ export const EnvironmentTable = () => { | ||||
|         setGlobalFilter, | ||||
|     } = useTable( | ||||
|         { | ||||
|             columns: COLUMNS as any, | ||||
|             columns: columnsWithActions as any, | ||||
|             data: environments, | ||||
|             disableSortBy: true, | ||||
|         }, | ||||
| @ -91,7 +121,7 @@ export const EnvironmentTable = () => { | ||||
|         <PageHeader title={`Environments (${count})`} actions={headerActions} /> | ||||
|     ); | ||||
| 
 | ||||
|     if (!isFeatureEnabled) { | ||||
|     if (!isFeatureEnabled && !isPurchaseAdditionalEnvronmentsEnabled) { | ||||
|         return ( | ||||
|             <PageContent header={header}> | ||||
|                 <PremiumFeature feature='environments' /> | ||||
| @ -101,6 +131,20 @@ export const EnvironmentTable = () => { | ||||
| 
 | ||||
|     return ( | ||||
|         <PageContent header={header}> | ||||
|             {isPro() && isPurchaseAdditionalEnvronmentsEnabled ? ( | ||||
|                 <> | ||||
|                     <PurchasableFeature | ||||
|                         title='Purchase additional environments' | ||||
|                         description='With our Pro plan, you now have the flexibility to expand your workspace by adding up to three additional environments.' | ||||
|                         onClick={() => setPurchaseDialogOpen(true)} | ||||
|                     /> | ||||
|                     <OrderEnvironmentsDialog | ||||
|                         open={purchaseDialogOpen} | ||||
|                         onClose={() => setPurchaseDialogOpen(false)} | ||||
|                         onSubmit={() => {}} // TODO: API call
 | ||||
|                     /> | ||||
|                 </> | ||||
|             ) : null} | ||||
|             <StyledAlert severity='info'> | ||||
|                 This is the order of environments that you have today in each | ||||
|                 feature flag. Rearranging them here will change also the order | ||||
| @ -185,14 +229,4 @@ const COLUMNS = [ | ||||
|             row.apiTokenCount === 1 ? '1 token' : `${row.apiTokenCount} tokens`, | ||||
|         Cell: TextCell, | ||||
|     }, | ||||
|     { | ||||
|         Header: 'Actions', | ||||
|         id: 'Actions', | ||||
|         align: 'center', | ||||
|         width: '1%', | ||||
|         Cell: ({ row: { original } }: { row: { original: IEnvironment } }) => ( | ||||
|             <EnvironmentActionCell environment={original} /> | ||||
|         ), | ||||
|         disableGlobalFilter: true, | ||||
|     }, | ||||
| ]; | ||||
|  | ||||
| @ -0,0 +1,151 @@ | ||||
| import { vi } from 'vitest'; | ||||
| import { render, screen, fireEvent } from '@testing-library/react'; | ||||
| import { OrderEnvironmentsDialog } from './OrderEnvironmentsDialog'; | ||||
| 
 | ||||
| describe('OrderEnvironmentsDialog Component', () => { | ||||
|     const renderComponent = (props = {}) => | ||||
|         render( | ||||
|             <OrderEnvironmentsDialog | ||||
|                 open={true} | ||||
|                 onClose={() => {}} | ||||
|                 onSubmit={() => {}} | ||||
|                 {...props} | ||||
|             />, | ||||
|         ); | ||||
| 
 | ||||
|     test('should disable "Order" button until the checkbox is checked', () => { | ||||
|         renderComponent(); | ||||
| 
 | ||||
|         const orderButton = screen.getByRole('button', { name: /order/i }); | ||||
|         const checkbox = screen.getByRole('checkbox', { | ||||
|             name: /i understand adding environments leads to extra costs/i, | ||||
|         }); | ||||
| 
 | ||||
|         expect(orderButton).toBeDisabled(); | ||||
| 
 | ||||
|         fireEvent.click(checkbox); | ||||
| 
 | ||||
|         expect(orderButton).toBeEnabled(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should render correct number of environment name inputs based on selected environments', () => { | ||||
|         renderComponent(); | ||||
| 
 | ||||
|         let environmentInputs = | ||||
|             screen.getAllByLabelText(/environment \d+ name/i); | ||||
|         expect(environmentInputs).toHaveLength(1); | ||||
| 
 | ||||
|         const selectButton = screen.getByRole('combobox'); | ||||
|         fireEvent.mouseDown(selectButton); | ||||
| 
 | ||||
|         const option2 = screen.getByRole('option', { name: '2 environments' }); | ||||
|         fireEvent.click(option2); | ||||
| 
 | ||||
|         environmentInputs = screen.getAllByLabelText(/environment \d+ name/i); | ||||
|         expect(environmentInputs).toHaveLength(2); | ||||
| 
 | ||||
|         fireEvent.mouseDown(selectButton); | ||||
|         const option3 = screen.getByRole('option', { name: '3 environments' }); | ||||
|         fireEvent.click(option3); | ||||
| 
 | ||||
|         environmentInputs = screen.getAllByLabelText(/environment \d+ name/i); | ||||
|         expect(environmentInputs).toHaveLength(3); | ||||
|     }); | ||||
| 
 | ||||
|     test('should enable "Order" button only when checkbox is checked', () => { | ||||
|         renderComponent(); | ||||
| 
 | ||||
|         const orderButton = screen.getByRole('button', { name: /order/i }); | ||||
|         const checkbox = screen.getByRole('checkbox', { | ||||
|             name: /i understand adding environments leads to extra costs/i, | ||||
|         }); | ||||
| 
 | ||||
|         expect(orderButton).toBeDisabled(); | ||||
| 
 | ||||
|         fireEvent.click(checkbox); | ||||
| 
 | ||||
|         expect(orderButton).toBeEnabled(); | ||||
| 
 | ||||
|         fireEvent.click(checkbox); | ||||
| 
 | ||||
|         expect(orderButton).toBeDisabled(); | ||||
|     }); | ||||
| 
 | ||||
|     test('should output environment names', () => { | ||||
|         const onSubmitMock = vi.fn(); | ||||
|         renderComponent({ onSubmit: onSubmitMock }); | ||||
| 
 | ||||
|         const selectButton = screen.getByRole('combobox'); | ||||
|         fireEvent.mouseDown(selectButton); | ||||
| 
 | ||||
|         const option2 = screen.getByRole('option', { name: '2 environments' }); | ||||
|         fireEvent.click(option2); | ||||
| 
 | ||||
|         const environmentInputs = | ||||
|             screen.getAllByLabelText(/environment \d+ name/i); | ||||
| 
 | ||||
|         fireEvent.change(environmentInputs[0], { target: { value: 'Dev' } }); | ||||
|         fireEvent.change(environmentInputs[1], { | ||||
|             target: { value: 'Staging' }, | ||||
|         }); | ||||
|         const checkbox = screen.getByRole('checkbox', { | ||||
|             name: /i understand adding environments leads to extra costs/i, | ||||
|         }); | ||||
|         fireEvent.click(checkbox); | ||||
| 
 | ||||
|         const submitButton = screen.getByRole('button', { name: /order/i }); | ||||
|         fireEvent.click(submitButton); | ||||
| 
 | ||||
|         expect(onSubmitMock).toHaveBeenCalledTimes(1); | ||||
|         expect(onSubmitMock).toHaveBeenCalledWith(['Dev', 'Staging']); | ||||
|     }); | ||||
| 
 | ||||
|     test('should call onClose when "Cancel" button is clicked', () => { | ||||
|         const onCloseMock = vi.fn(); | ||||
|         renderComponent({ onClose: onCloseMock }); | ||||
| 
 | ||||
|         const cancelButton = screen.getByRole('button', { name: /cancel/i }); | ||||
|         fireEvent.click(cancelButton); | ||||
| 
 | ||||
|         expect(onCloseMock).toHaveBeenCalledTimes(1); | ||||
|     }); | ||||
| 
 | ||||
|     test('should adjust environment name inputs when decreasing environments', () => { | ||||
|         const onSubmitMock = vi.fn(); | ||||
|         renderComponent({ onSubmit: onSubmitMock }); | ||||
| 
 | ||||
|         const selectButton = screen.getByRole('combobox'); | ||||
|         fireEvent.mouseDown(selectButton); | ||||
| 
 | ||||
|         const option3 = screen.getByRole('option', { name: '3 environments' }); | ||||
|         fireEvent.click(option3); | ||||
| 
 | ||||
|         let environmentInputs = | ||||
|             screen.getAllByLabelText(/environment \d+ name/i); | ||||
|         expect(environmentInputs).toHaveLength(3); | ||||
| 
 | ||||
|         fireEvent.change(environmentInputs[0], { target: { value: 'Dev' } }); | ||||
|         fireEvent.change(environmentInputs[1], { | ||||
|             target: { value: 'Staging' }, | ||||
|         }); | ||||
|         fireEvent.change(environmentInputs[2], { target: { value: 'Prod' } }); | ||||
| 
 | ||||
|         fireEvent.mouseDown(selectButton); | ||||
|         const option2 = screen.getByRole('option', { name: '2 environments' }); | ||||
|         fireEvent.click(option2); | ||||
| 
 | ||||
|         environmentInputs = screen.getAllByLabelText(/environment \d+ name/i); | ||||
|         expect(environmentInputs).toHaveLength(2); | ||||
| 
 | ||||
|         const checkbox = screen.getByRole('checkbox', { | ||||
|             name: /i understand adding environments leads to extra costs/i, | ||||
|         }); | ||||
|         fireEvent.click(checkbox); | ||||
| 
 | ||||
|         const submitButton = screen.getByRole('button', { name: /order/i }); | ||||
|         fireEvent.click(submitButton); | ||||
| 
 | ||||
|         expect(onSubmitMock).toHaveBeenCalledTimes(1); | ||||
|         expect(onSubmitMock).toHaveBeenCalledWith(['Dev', 'Staging']); | ||||
|     }); | ||||
| }); | ||||
| @ -0,0 +1,179 @@ | ||||
| import { useState, type FC } from 'react'; | ||||
| import { | ||||
|     Box, | ||||
|     Button, | ||||
|     Checkbox, | ||||
|     Dialog, | ||||
|     styled, | ||||
|     Typography, | ||||
| } from '@mui/material'; | ||||
| import FormTemplate from 'component/common/FormTemplate/FormTemplate'; | ||||
| import { OrderEnvironmentsDialogPricing } from './OrderEnvironmentsDialogPricing/OrderEnvironmentsDialogPricing'; | ||||
| import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect'; | ||||
| import Input from 'component/common/Input/Input'; | ||||
| 
 | ||||
| type OrderEnvironmentsDialogProps = { | ||||
|     open: boolean; | ||||
|     onClose: () => void; | ||||
|     onSubmit: (environments: string[]) => void; | ||||
| }; | ||||
| 
 | ||||
| const StyledDialog = styled(Dialog)(({ theme }) => ({ | ||||
|     maxWidth: '940px', | ||||
|     margin: 'auto', | ||||
|     '& .MuiDialog-paper': { | ||||
|         borderRadius: theme.shape.borderRadiusExtraLarge, | ||||
|         maxWidth: theme.spacing(170), | ||||
|         width: '100%', | ||||
|         backgroundColor: 'transparent', | ||||
|     }, | ||||
|     padding: 0, | ||||
|     '& .MuiPaper-root > section': { | ||||
|         overflowX: 'hidden', | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledTitle = styled('div')(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(3), | ||||
| })); | ||||
| 
 | ||||
| const StyledFooter = styled('div')(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     justifyContent: 'flex-end', | ||||
|     gap: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({ | ||||
|     margin: theme.spacing(1, 0), | ||||
| })); | ||||
| 
 | ||||
| const StyledFields = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(2), | ||||
|     paddingTop: theme.spacing(3), | ||||
| })); | ||||
| 
 | ||||
| const StyledEnvironmentNameInputs = styled('fieldset')(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     border: 'none', | ||||
|     padding: 0, | ||||
|     margin: 0, | ||||
|     gap: theme.spacing(1.5), | ||||
| })); | ||||
| 
 | ||||
| const StyledCheckbox = styled(Checkbox)(({ theme }) => ({ | ||||
|     marginBottom: theme.spacing(0.4), | ||||
| })); | ||||
| 
 | ||||
| const PRICE = 10; | ||||
| const OPTIONS = [1, 2, 3]; | ||||
| 
 | ||||
| export const OrderEnvironmentsDialog: FC<OrderEnvironmentsDialogProps> = ({ | ||||
|     open, | ||||
|     onClose, | ||||
|     onSubmit, | ||||
| }) => { | ||||
|     const [selectedOption, setSelectedOption] = useState(OPTIONS[0]); | ||||
|     const [costCheckboxChecked, setCostCheckboxChecked] = useState(false); | ||||
|     const [environmentNames, setEnvironmentNames] = useState<string[]>([]); | ||||
| 
 | ||||
|     return ( | ||||
|         <StyledDialog open={open} title=''> | ||||
|             <FormTemplate | ||||
|                 compact | ||||
|                 description={ | ||||
|                     <OrderEnvironmentsDialogPricing | ||||
|                         pricingOptions={OPTIONS.map((option) => ({ | ||||
|                             environments: option, | ||||
|                             price: option * PRICE, | ||||
|                         }))} | ||||
|                     /> | ||||
|                 } | ||||
|                 footer={ | ||||
|                     <StyledFooter> | ||||
|                         <Button onClick={onClose}>Cancel</Button> | ||||
|                         <Button | ||||
|                             variant='contained' | ||||
|                             disabled={!costCheckboxChecked} | ||||
|                             onClick={() => onSubmit(environmentNames)} | ||||
|                         > | ||||
|                             Order | ||||
|                         </Button> | ||||
|                     </StyledFooter> | ||||
|                 } | ||||
|             > | ||||
|                 <StyledTitle> | ||||
|                     <Typography variant='h3' component='div'> | ||||
|                         Order additional environments | ||||
|                     </Typography> | ||||
|                 </StyledTitle> | ||||
|                 <Typography variant='body2' color='text.secondary'> | ||||
|                     With our PRO plan, you have the flexibility to expand your | ||||
|                     workspace by adding environments at ${PRICE} per user per | ||||
|                     month. | ||||
|                 </Typography> | ||||
|                 <StyledFields> | ||||
|                     <Box> | ||||
|                         <Typography | ||||
|                             component='label' | ||||
|                             htmlFor='numberOfEnvironments' | ||||
|                         > | ||||
|                             Select the number of additional environments | ||||
|                         </Typography> | ||||
|                         <StyledGeneralSelect | ||||
|                             id='numberOfEnvironments' | ||||
|                             value={`${selectedOption}`} | ||||
|                             options={OPTIONS.map((option) => ({ | ||||
|                                 key: `${option}`, | ||||
|                                 label: `${option} environment${option > 1 ? 's' : ''}`, | ||||
|                             }))} | ||||
|                             onChange={(option) => { | ||||
|                                 const value = Number.parseInt(option, 10); | ||||
|                                 setSelectedOption(value); | ||||
|                                 setEnvironmentNames((names) => | ||||
|                                     names.slice(0, value), | ||||
|                                 ); | ||||
|                             }} | ||||
|                         /> | ||||
|                     </Box> | ||||
|                     <StyledEnvironmentNameInputs> | ||||
|                         <Typography> | ||||
|                             How would you like the environment | ||||
|                             {selectedOption > 1 ? 's' : ''} to be named? | ||||
|                         </Typography> | ||||
|                         {[...Array(selectedOption)].map((_, i) => ( | ||||
|                             <Input | ||||
|                                 key={i} | ||||
|                                 label={`Environment ${i + 1} name`} | ||||
|                                 value={environmentNames[i]} | ||||
|                                 onChange={(event) => { | ||||
|                                     setEnvironmentNames((names) => { | ||||
|                                         const newValues = [...names]; | ||||
|                                         newValues[i] = event.target.value; | ||||
|                                         return newValues; | ||||
|                                     }); | ||||
|                                 }} | ||||
|                             /> | ||||
|                         ))} | ||||
|                     </StyledEnvironmentNameInputs> | ||||
|                     <Box> | ||||
|                         <StyledCheckbox | ||||
|                             edge='start' | ||||
|                             id='costsCheckbox' | ||||
|                             checked={costCheckboxChecked} | ||||
|                             onChange={() => | ||||
|                                 setCostCheckboxChecked((state) => !state) | ||||
|                             } | ||||
|                         /> | ||||
|                         <Typography component='label' htmlFor='costsCheckbox'> | ||||
|                             I understand adding environments leads to extra | ||||
|                             costs | ||||
|                         </Typography> | ||||
|                     </Box> | ||||
|                 </StyledFields> | ||||
|             </FormTemplate> | ||||
|         </StyledDialog> | ||||
|     ); | ||||
| }; | ||||
| @ -0,0 +1,59 @@ | ||||
| import type { FC } from 'react'; | ||||
| import { Box, Card, styled, Typography } from '@mui/material'; | ||||
| import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon'; | ||||
| 
 | ||||
| type OrderEnvironmentsDialogPricingProps = { | ||||
|     pricingOptions: Array<{ environments: number; price: number }>; | ||||
| }; | ||||
| 
 | ||||
| const StyledContainer = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     flexDirection: 'column', | ||||
|     gap: theme.spacing(1), | ||||
|     justifyContent: 'center', | ||||
|     height: '100%', | ||||
|     [theme.breakpoints.up('lg')]: { | ||||
|         marginTop: theme.spacing(7.5), | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledCard = styled(Card)(({ theme }) => ({ | ||||
|     borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     boxShadow: 'none', | ||||
| })); | ||||
| 
 | ||||
| const StyledCardContent = styled(Box)(({ theme }) => ({ | ||||
|     display: 'flex', | ||||
|     alignItems: 'center', | ||||
|     justifyContent: 'space-between', | ||||
|     flexDirection: 'row', | ||||
|     padding: theme.spacing(2), | ||||
| })); | ||||
| 
 | ||||
| export const OrderEnvironmentsDialogPricing: FC< | ||||
|     OrderEnvironmentsDialogPricingProps | ||||
| > = ({ pricingOptions }) => ( | ||||
|     <StyledContainer> | ||||
|         <Typography variant='h3' component='div' color='white' gutterBottom> | ||||
|             Pricing | ||||
|         </Typography> | ||||
|         {pricingOptions.map((option) => ( | ||||
|             <StyledCard key={option.environments}> | ||||
|                 <StyledCardContent> | ||||
|                     <EnvironmentIcon enabled /> | ||||
|                     <Box> | ||||
|                         <Box> | ||||
|                             <Typography variant='body2' fontWeight='bold'> | ||||
|                                 {option.environments} additional environment | ||||
|                                 {option.environments > 1 ? 's' : ''} | ||||
|                             </Typography> | ||||
|                         </Box> | ||||
|                         <Typography variant='body2'> | ||||
|                             ${option.price} per user per month | ||||
|                         </Typography> | ||||
|                     </Box> | ||||
|                 </StyledCardContent> | ||||
|             </StyledCard> | ||||
|         ))} | ||||
|     </StyledContainer> | ||||
| ); | ||||
| @ -0,0 +1,71 @@ | ||||
| import type { FC, ReactNode } from 'react'; | ||||
| import { Box, Button, styled, Typography } from '@mui/material'; | ||||
| import { ThemeMode } from 'component/common/ThemeMode/ThemeMode'; | ||||
| import { ReactComponent as ProPlanIcon } from 'assets/icons/pro-enterprise-feature-badge.svg'; | ||||
| import { ReactComponent as ProPlanIconLight } from 'assets/icons/pro-enterprise-feature-badge-light.svg'; | ||||
| 
 | ||||
| type PurchasableFeatureProps = { | ||||
|     title: ReactNode; | ||||
|     description: ReactNode; | ||||
|     onClick: () => void; | ||||
| }; | ||||
| 
 | ||||
| const Icon = () => ( | ||||
|     <ThemeMode darkmode={<ProPlanIconLight />} lightmode={<ProPlanIcon />} /> | ||||
| ); | ||||
| 
 | ||||
| const StyledContainer = styled(Box)(({ theme }) => ({ | ||||
|     padding: theme.spacing(2, 3), | ||||
|     marginBottom: theme.spacing(3), | ||||
|     background: theme.palette.background.elevation2, | ||||
|     borderRadius: `${theme.shape.borderRadiusMedium}px`, | ||||
|     display: 'flex', | ||||
|     flexDirection: 'row', | ||||
|     gap: theme.spacing(3), | ||||
|     [theme.breakpoints.down('md')]: { | ||||
|         flexDirection: 'column', | ||||
|         gap: theme.spacing(2), | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| const StyledIconContainer = styled(Box)(() => ({ | ||||
|     width: '36px', | ||||
|     flexShrink: 0, | ||||
| })); | ||||
| 
 | ||||
| const StyledMessage = styled(Box)(() => ({ | ||||
|     flexGrow: 1, | ||||
|     display: 'flex', | ||||
| })); | ||||
| 
 | ||||
| const StyledButtonContainer = styled(Box)(() => ({ | ||||
|     display: 'flex', | ||||
|     justifyContent: 'center', | ||||
|     alignItems: 'center', | ||||
|     whiteSpace: 'nowrap', | ||||
| })); | ||||
| 
 | ||||
| export const PurchasableFeature: FC<PurchasableFeatureProps> = ({ | ||||
|     title, | ||||
|     description, | ||||
|     onClick, | ||||
| }) => { | ||||
|     return ( | ||||
|         <StyledContainer> | ||||
|             <StyledMessage> | ||||
|                 <StyledIconContainer> | ||||
|                     <Icon /> | ||||
|                 </StyledIconContainer> | ||||
|                 <Box> | ||||
|                     <Typography variant='h3'>{title}</Typography> | ||||
|                     <Typography>{description}</Typography> | ||||
|                 </Box> | ||||
|             </StyledMessage> | ||||
|             <StyledButtonContainer> | ||||
|                 <Button variant='contained' onClick={onClick}> | ||||
|                     View pricing | ||||
|                 </Button> | ||||
|             </StyledButtonContainer> | ||||
|         </StyledContainer> | ||||
|     ); | ||||
| }; | ||||
| @ -252,6 +252,18 @@ exports[`returns all baseRoutes 1`] = ` | ||||
|       "advanced": true, | ||||
|       "mobile": true, | ||||
|     }, | ||||
|     "notFlag": "purchaseAdditionalEnvironments", | ||||
|     "path": "/environments", | ||||
|     "title": "Environments", | ||||
|     "type": "protected", | ||||
|   }, | ||||
|   { | ||||
|     "component": [Function], | ||||
|     "flag": "purchaseAdditionalEnvironments", | ||||
|     "menu": { | ||||
|       "advanced": true, | ||||
|       "mobile": true, | ||||
|     }, | ||||
|     "path": "/environments", | ||||
|     "title": "Environments", | ||||
|     "type": "protected", | ||||
|  | ||||
| @ -267,6 +267,15 @@ export const routes: IRoute[] = [ | ||||
|         component: EnvironmentTable, | ||||
|         type: 'protected', | ||||
|         flag: EEA, | ||||
|         notFlag: 'purchaseAdditionalEnvironments', | ||||
|         menu: { mobile: true, advanced: true }, | ||||
|     }, | ||||
|     { | ||||
|         path: '/environments', | ||||
|         title: 'Environments', | ||||
|         component: EnvironmentTable, | ||||
|         type: 'protected', | ||||
|         flag: 'purchaseAdditionalEnvironments', | ||||
|         menu: { mobile: true, advanced: true }, | ||||
|     }, | ||||
|     { | ||||
|  | ||||
| @ -89,6 +89,7 @@ export type UiFlags = { | ||||
|     onboardingUI?: boolean; | ||||
|     eventTimeline?: boolean; | ||||
|     personalDashboardUI?: boolean; | ||||
|     purchaseAdditionalEnvironments?: boolean; | ||||
| }; | ||||
| 
 | ||||
| export interface IVersionInfo { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user