1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-06-04 01:18:20 +02:00

feat: add billing page to admin (#993)

* feat: add billing page to admin

* some adjustments to billing page

* add BillingHistory, remove invoices, misc improvements

* refactor based on instanceStatus logic, add dialog

* fix: cleanup

* some refactoring and alignment with figma

* add extend, isBilling, refactoring and misc improvements

* fix: update tests

* refactor: reorganize billing into smaller components, misc improvements

* add STRIPE flag, some refactoring and adapting to comments and discussion

* adapt BillingHistory slightly, refactor TextCell

* Update src/hooks/api/getters/useInstanceStatus/useInstanceStatus.ts

Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>

* refactor: address PR comments

* fix: adjust divider color

* fix: update snaps

* refactor: address PR comments

* fix: update snaps

* fix: amountFormatted typo

Co-authored-by: Tymoteusz Czech <2625371+Tymek@users.noreply.github.com>
Co-authored-by: Fredrik Strand Oseberg <fredrik.no@gmail.com>
This commit is contained in:
Nuno Góis 2022-05-25 22:26:05 +01:00 committed by GitHub
parent 616cc8de24
commit 7093b49962
39 changed files with 1088 additions and 270 deletions

View File

@ -0,0 +1,48 @@
import AdminMenu from '../menu/AdminMenu';
import { PageContent } from 'component/common/PageContent/PageContent';
import { useContext } from 'react';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import AccessContext from 'contexts/AccessContext';
import { AdminAlert } from 'component/common/AdminAlert/AdminAlert';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import { Alert } from '@mui/material';
import { BillingDashboard } from './BillingDashboard/BillingDashboard';
import { BillingHistory } from './BillingHistory/BillingHistory';
import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
export const Billing = () => {
const { instanceStatus, isBilling } = useInstanceStatus();
const { invoices } = useInvoices();
const { hasAccess } = useContext(AccessContext);
return (
<div>
<AdminMenu />
<PageContent header="Billing">
<ConditionallyRender
condition={isBilling}
show={
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={() => (
<>
<BillingDashboard
instanceStatus={instanceStatus!}
/>
<BillingHistory data={invoices} />
</>
)}
elseShow={() => <AdminAlert />}
/>
}
elseShow={
<Alert severity="error">
Billing is not enabled for this instance.
</Alert>
}
/>
</PageContent>
</div>
);
};

View File

@ -0,0 +1,20 @@
import { Grid } from '@mui/material';
import { IInstanceStatus } from 'interfaces/instance';
import { VFC } from 'react';
import { BillingInformation } from './BillingInformation/BillingInformation';
import { BillingPlan } from './BillingPlan/BillingPlan';
interface IBillingDashboardProps {
instanceStatus: IInstanceStatus;
}
export const BillingDashboard: VFC<IBillingDashboardProps> = ({
instanceStatus,
}) => {
return (
<Grid container spacing={4}>
<BillingInformation instanceStatus={instanceStatus} />
<BillingPlan instanceStatus={instanceStatus} />
</Grid>
);
};

View File

@ -0,0 +1,70 @@
import { FC } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { BillingInformationButton } from './BillingInformationButton/BillingInformationButton';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
interface IBillingInformationProps {
instanceStatus: IInstanceStatus;
}
export const BillingInformation: FC<IBillingInformationProps> = ({
instanceStatus,
}) => {
const inactive = instanceStatus.state !== InstanceState.ACTIVE;
return (
<Grid item xs={12} md={5}>
<StyledInfoBox>
<StyledTitle variant="body1">Billing Information</StyledTitle>
<ConditionallyRender
condition={inactive}
show={
<StyledAlert severity="warning">
In order to <strong>Upgrade trial</strong> you need
to provide us your billing information.
</StyledAlert>
}
/>
<BillingInformationButton update={!inactive} />
<StyledInfoLabel>
{inactive
? 'Once we have received your billing information we will upgrade your trial within 1 business day'
: 'These changes may take up to 1 business day and they will be visible on your next invoice'}
</StyledInfoLabel>
<StyledDivider />
<StyledInfoLabel>
<a href="mailto:elise@getunleash.ai?subject=PRO plan clarifications">
Get in touch with us
</a>{' '}
for any clarification
</StyledInfoLabel>
</StyledInfoBox>
</Grid>
);
};
const StyledInfoBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(4),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
backgroundColor: theme.palette.secondaryContainer,
}));
const StyledTitle = styled(Typography)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(2.5)} 0`,
borderColor: theme.palette.dividerAlternative,
}));

View File

@ -0,0 +1,36 @@
import { Button, styled } from '@mui/material';
import { VFC } from 'react';
const href = `mailto:elise@getunleash.ai?subject=Continue with Unleash&body=Hi Unleash,%0D%0A%0D%0A
I would like to continue with Unleash.%0D%0A%0D%0A%0D%0A%0D%0A
Billing information:%0D%0A%0D%0A
1. Company name (legal name): [add your information here]%0D%0A%0D%0A
2. Email address (where we will send the invoice): [add your information here]%0D%0A%0D%0A
3. Address: [add your information here]%0D%0A%0D%0A
4. Country: [add your information here]%0D%0A%0D%0A
5. VAT ID (optional - only European countries): [add your information here]%0D%0A%0D%0A%0D%0A%0D%0A%0D%0A
-- Thank you for signing up. We will upgrade your trial as quick as possible and we will grant you access to the application again. --`;
interface IBillingInformationButtonProps {
update?: boolean;
}
export const BillingInformationButton: VFC<IBillingInformationButtonProps> = ({
update,
}) => {
return (
<StyledButton href={href} variant={update ? 'outlined' : 'contained'}>
{update ? 'Update billing information' : 'Add billing information'}
</StyledButton>
);
};
const StyledButton = styled(Button)(({ theme }) => ({
width: '100%',
marginBottom: theme.spacing(1.5),
}));

View File

@ -0,0 +1,256 @@
import { FC } from 'react';
import { Alert, Divider, Grid, styled, Typography } from '@mui/material';
import { Link } from 'react-router-dom';
import CheckIcon from '@mui/icons-material/Check';
import useUsers from 'hooks/api/getters/useUsers/useUsers';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import {
IInstanceStatus,
InstanceState,
InstancePlan,
} from 'interfaces/instance';
import { calculateTrialDaysRemaining } from 'utils/billing';
import { GridRow } from 'component/common/GridRow/GridRow';
import { GridCol } from 'component/common/GridCol/GridCol';
import { GridColLink } from './GridColLink/GridColLink';
import { STRIPE } from 'component/admin/billing/flags';
interface IBillingPlanProps {
instanceStatus: IInstanceStatus;
}
export const BillingPlan: FC<IBillingPlanProps> = ({ instanceStatus }) => {
const { users } = useUsers();
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
const price = {
[InstancePlan.PRO]: 80,
[InstancePlan.COMPANY]: 0,
[InstancePlan.TEAM]: 0,
[InstancePlan.UNKNOWN]: 0,
user: 15,
};
const statusExpired =
instanceStatus.state === InstanceState.TRIAL &&
typeof trialDaysRemaining === 'number' &&
trialDaysRemaining <= 0;
const planPrice = price[instanceStatus.plan];
const seats = instanceStatus.seats ?? 5;
const freeAssigned = Math.min(users.length, seats);
const paidAssigned = users.length - freeAssigned;
const paidAssignedPrice = price.user * paidAssigned;
const finalPrice = planPrice + paidAssignedPrice;
const inactive = instanceStatus.state !== InstanceState.ACTIVE;
return (
<Grid item xs={12} md={7}>
<StyledPlanBox>
<ConditionallyRender
condition={inactive}
show={
<StyledAlert severity="info">
After you have sent your billing information, your
instance will be upgraded - you don't have to do
anything.{' '}
<a href="mailto:elise@getunleash.ai?subject=PRO plan clarifications">
Get in touch with us
</a>{' '}
for any clarification
</StyledAlert>
}
/>
<StyledPlanBadge>Current plan</StyledPlanBadge>
<Grid container>
<GridRow sx={theme => ({ marginBottom: theme.spacing(3) })}>
<GridCol>
<StyledPlanSpan>
{instanceStatus.plan}
</StyledPlanSpan>
<ConditionallyRender
condition={
instanceStatus.state === InstanceState.TRIAL
}
show={
<StyledTrialSpan
sx={theme => ({
color: statusExpired
? theme.palette.error.dark
: theme.palette.warning.dark,
})}
>
{statusExpired
? 'Trial expired'
: instanceStatus.trialExtended
? 'Extended Trial'
: 'Trial'}
</StyledTrialSpan>
}
/>
</GridCol>
<GridCol>
<ConditionallyRender
condition={planPrice > 0}
show={
<StyledPriceSpan>
${planPrice.toFixed(2)}
</StyledPriceSpan>
}
/>
</GridCol>
</GridRow>
</Grid>
<ConditionallyRender
condition={
STRIPE && instanceStatus.plan === InstancePlan.PRO
}
show={
<>
<Grid container>
<GridRow
sx={theme => ({
marginBottom: theme.spacing(1.5),
})}
>
<GridCol>
<Typography>
<strong>{seats}</strong> team
members
<GridColLink>
<Link to="/admin/users">
{freeAssigned} assigned
</Link>
</GridColLink>
</Typography>
</GridCol>
<GridCol>
<StyledCheckIcon />
<Typography variant="body2">
included
</Typography>
</GridCol>
</GridRow>
<GridRow>
<GridCol vertical>
<Typography>
Paid members
<GridColLink>
<Link to="/admin/users">
{paidAssigned} assigned
</Link>
</GridColLink>
</Typography>
<StyledInfoLabel>
Add up to 15 extra paid members - $
{price.user}
/month per member
</StyledInfoLabel>
</GridCol>
<GridCol>
<Typography
sx={theme => ({
fontSize:
theme.fontSizes.mainHeader,
})}
>
${paidAssignedPrice.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
<StyledDivider />
<Grid container>
<GridRow>
<GridCol>
<Typography
sx={theme => ({
fontWeight:
theme.fontWeight.bold,
fontSize:
theme.fontSizes.mainHeader,
})}
>
Total per month
</Typography>
</GridCol>
<GridCol>
<Typography
sx={theme => ({
fontWeight:
theme.fontWeight.bold,
fontSize: '2rem',
})}
>
${finalPrice.toFixed(2)}
</Typography>
</GridCol>
</GridRow>
</Grid>
</>
}
/>
</StyledPlanBox>
</Grid>
);
};
const StyledPlanBox = styled('aside')(({ theme }) => ({
padding: theme.spacing(2.5),
height: '100%',
borderRadius: theme.shape.borderRadiusLarge,
boxShadow: theme.boxShadows.elevated,
[theme.breakpoints.up('md')]: {
padding: theme.spacing(6.5),
},
}));
const StyledInfoLabel = styled(Typography)(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
color: theme.palette.text.secondary,
}));
const StyledPlanBadge = styled('span')(({ theme }) => ({
padding: `${theme.spacing(0.5)} ${theme.spacing(1)}`,
borderRadius: theme.shape.borderRadiusLarge,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.statusBadge.success,
color: theme.palette.success.dark,
fontWeight: theme.fontWeight.bold,
}));
const StyledPlanSpan = styled('span')(({ theme }) => ({
fontSize: '3.25rem',
lineHeight: 1,
color: theme.palette.primary.main,
fontWeight: 800,
}));
const StyledTrialSpan = styled('span')(({ theme }) => ({
marginLeft: theme.spacing(1.5),
fontWeight: theme.fontWeight.bold,
}));
const StyledPriceSpan = styled('span')(({ theme }) => ({
color: theme.palette.primary.main,
fontSize: theme.fontSizes.mainHeader,
fontWeight: theme.fontWeight.bold,
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
fontSize: theme.fontSizes.smallerBody,
marginBottom: theme.spacing(3),
marginTop: theme.spacing(-1.5),
[theme.breakpoints.up('md')]: {
marginTop: theme.spacing(-4.5),
},
}));
const StyledCheckIcon = styled(CheckIcon)(({ theme }) => ({
fontSize: '1rem',
marginRight: theme.spacing(1),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: `${theme.spacing(3)} 0`,
}));

View File

@ -0,0 +1,11 @@
import { styled } from '@mui/material';
import { FC } from 'react';
export const GridColLink: FC = ({ children }) => {
return <StyledSpan>({children})</StyledSpan>;
};
const StyledSpan = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
marginLeft: theme.spacing(1),
}));

View File

@ -0,0 +1,118 @@
import {
Table,
SortableTableHeader,
TableBody,
TableCell,
TableRow,
TablePlaceholder,
} from 'component/common/Table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { DateCell } from 'component/common/Table/cells/DateCell/DateCell';
import { useMemo, VFC } from 'react';
import { useGlobalFilter, useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Box, IconButton, styled, Typography } from '@mui/material';
import FileDownload from '@mui/icons-material/FileDownload';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
interface IBillingHistoryProps {
data: Record<string, any>[];
isLoading?: boolean;
}
const columns = [
{
Header: 'Amount',
accessor: 'amountFormatted',
},
{
Header: 'Status',
accessor: 'status',
},
{
Header: 'Due date',
accessor: 'dueDate',
Cell: DateCell,
sortType: 'date',
},
{
Header: 'Download',
accessor: 'invoicePDF',
align: 'center',
disableSortBy: true,
Cell: ({ value }: { value: string }) => (
<Box
sx={{ display: 'flex', justifyContent: 'center' }}
data-loading
>
<IconButton href={value}>
<FileDownload />
</IconButton>
</Box>
),
width: 100,
},
];
export const BillingHistory: VFC<IBillingHistoryProps> = ({
data,
isLoading = false,
}) => {
const initialState = useMemo(
() => ({
sortBy: [{ id: 'createdAt', desc: false }],
}),
[]
);
const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } =
useTable(
{
columns,
data,
initialState,
sortTypes,
autoResetGlobalFilter: false,
disableSortRemove: true,
defaultColumn: {
Cell: TextCell,
},
},
useGlobalFilter,
useSortBy
);
return (
<PageContent isLoading={isLoading} disablePadding>
<StyledTitle>Payment history</StyledTitle>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow hover {...row.getRowProps()}>
{row.cells.map(cell => (
<TableCell {...cell.getCellProps()}>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={<TablePlaceholder>No invoices to show.</TablePlaceholder>}
/>
</PageContent>
);
};
const StyledTitle = styled(Typography)(({ theme }) => ({
marginTop: theme.spacing(6),
marginBottom: theme.spacing(2.5),
fontSize: theme.fontSizes.mainHeader,
}));

View File

@ -0,0 +1,7 @@
import { Navigate } from 'react-router-dom';
const RedirectAdminInvoices = () => {
return <Navigate to="/admin/billing" replace />;
};
export default RedirectAdminInvoices;

View File

@ -0,0 +1 @@
export const STRIPE = false;

View File

@ -1,25 +0,0 @@
import { useContext } from 'react';
import InvoiceList from './InvoiceList';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Alert } from '@mui/material';
const InvoiceAdminPage = () => {
const { hasAccess } = useContext(AccessContext);
return (
<div>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<InvoiceList />}
elseShow={
<Alert severity="error">
You need to be instance admin to access this section.
</Alert>
}
/>
</div>
);
};
export default InvoiceAdminPage;

View File

@ -1,122 +0,0 @@
import { useEffect, useState } from 'react';
import {
Table,
TableHead,
TableBody,
TableRow,
TableCell,
Button,
} from '@mui/material';
import OpenInNew from '@mui/icons-material/OpenInNew';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { formatApiPath } from 'utils/formatPath';
import useInvoices from 'hooks/api/getters/useInvoices/useInvoices';
import { IInvoice } from 'interfaces/invoice';
import { useLocationSettings } from 'hooks/useLocationSettings';
import { formatDateYMD } from 'utils/formatDate';
const PORTAL_URL = formatApiPath('api/admin/invoices/portal');
const InvoiceList = () => {
const { refetchInvoices, invoices } = useInvoices();
const [isLoaded, setLoaded] = useState(false);
const { locationSettings } = useLocationSettings();
useEffect(() => {
refetchInvoices();
setLoaded(true);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<ConditionallyRender
condition={invoices.length > 0}
show={
<PageContent
header={
<PageHeader
title="Invoices"
actions={
<Button
href={PORTAL_URL}
rel="noreferrer"
target="_blank"
endIcon={<OpenInNew />}
>
Billing portal
</Button>
}
/>
}
>
<div>
<Table>
<TableHead>
<TableRow>
<TableCell>Amount</TableCell>
<TableCell>Status</TableCell>
<TableCell>Due date</TableCell>
<TableCell>PDF</TableCell>
<TableCell>Link</TableCell>
</TableRow>
</TableHead>
<TableBody>
{invoices.map((item: IInvoice) => (
<TableRow
key={item.invoiceURL}
style={{
backgroundColor:
item.status === 'past-due'
? '#ff9194'
: 'inherit',
}}
>
<TableCell
style={{ textAlign: 'left' }}
>
{item.amountFomratted}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
{item.status}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
{item.dueDate &&
formatDateYMD(
item.dueDate,
locationSettings.locale
)}
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
<a href={item.invoicePDF}>PDF</a>
</TableCell>
<TableCell
style={{ textAlign: 'left' }}
>
<a
href={item.invoiceURL}
target="_blank"
rel="noreferrer"
>
Payment link
</a>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</PageContent>
}
elseShow={<div>{isLoaded && 'No invoices to show.'}</div>}
/>
);
};
export default InvoiceList;

View File

@ -2,6 +2,7 @@ import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { Paper, Tab, Tabs } from '@mui/material';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
const navLinkStyle = {
display: 'flex',
@ -30,6 +31,7 @@ const createNavLinkStyle = (props: {
function AdminMenu() {
const { uiConfig } = useUiConfig();
const { pathname } = useLocation();
const { isBilling } = useInstanceStatus();
const { flags } = uiConfig;
return (
@ -79,6 +81,19 @@ function AdminMenu() {
</NavLink>
}
/>
{isBilling && (
<Tab
value="/admin/billing"
label={
<NavLink
to="/admin/billing"
style={createNavLinkStyle}
>
Billing
</NavLink>
}
/>
)}
</Tabs>
</Paper>
);

View File

@ -7,7 +7,7 @@ import {
Typography,
} from '@mui/material';
import classnames from 'classnames';
import { Delete, Edit, Lock } from '@mui/icons-material';
import { Delete, Edit, Lock, MonetizationOn } from '@mui/icons-material';
import { SyntheticEvent, useContext } from 'react';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
@ -27,6 +27,7 @@ interface IUserListItemProps {
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
locationSettings: ILocationSettings;
search: string;
isBillingUsers?: boolean;
}
const UserListItem = ({
@ -36,6 +37,7 @@ const UserListItem = ({
openPwDialog,
locationSettings,
search,
isBillingUsers,
}: IUserListItemProps) => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
@ -43,7 +45,7 @@ const UserListItem = ({
const renderTimeAgo = (date: string) => (
<Tooltip
title={`Last seen on: ${formatDateYMD(
title={`Last login: ${formatDateYMD(
date,
locationSettings.locale
)}`}
@ -57,6 +59,27 @@ const UserListItem = ({
return (
<TableRow key={user.id} className={styles.tableRow}>
<ConditionallyRender
condition={Boolean(isBillingUsers)}
show={
<TableCell align="center" className={styles.hideSM}>
<ConditionallyRender
condition={Boolean(user.paid)}
show={
<Tooltip title="Paid user" arrow>
<MonetizationOn
sx={theme => ({
color: theme.palette.primary.light,
fontSize: '1.75rem',
})}
/>
</Tooltip>
}
elseShow={<span data-loading>Free</span>}
/>
</TableCell>
}
/>
<TableCell className={styles.hideSM}>
<span data-loading>
{formatDateYMD(user.createdAt, locationSettings.locale)}

View File

@ -30,6 +30,7 @@ import { useUsersFilter } from 'hooks/useUsersFilter';
import { useUsersSort } from 'hooks/useUsersSort';
import { TableCellSortable } from 'component/common/Table/TableCellSortable/TableCellSortable';
import { useStyles } from './UserListItem/UserListItem.styles';
import { useUsersPlan } from 'hooks/useUsersPlan';
interface IUsersListProps {
search: string;
@ -52,7 +53,8 @@ const UsersList = ({ search }: IUsersListProps) => {
const [inviteLink, setInviteLink] = useState('');
const [delUser, setDelUser] = useState<IUser>();
const ref = useLoading(loading);
const { filtered, setFilter } = useUsersFilter(users);
const { planUsers, isBillingUsers } = useUsersPlan(users);
const { filtered, setFilter } = useUsersFilter(planUsers);
const { sorted, sort, setSort } = useUsersSort(filtered);
const filterUsersByQueryPage = (user: IUser) => {
@ -144,6 +146,7 @@ const UsersList = ({ search }: IUsersListProps) => {
locationSettings={locationSettings}
renderRole={renderRole}
search={search}
isBillingUsers={isBillingUsers}
/>
);
});
@ -156,6 +159,17 @@ const UsersList = ({ search }: IUsersListProps) => {
<Table>
<TableHead>
<TableRow className={styles.tableCellHeader}>
<ConditionallyRender
condition={isBillingUsers}
show={
<TableCell
align="center"
className={classnames(styles.hideSM)}
>
Type
</TableCell>
}
/>
<TableCellSortable
className={classnames(
styles.hideSM,

View File

@ -16,7 +16,7 @@ interface IDialogue {
secondaryButtonText?: string;
open: boolean;
onClick?: (e: React.SyntheticEvent) => void;
onClose?: (e: React.SyntheticEvent) => void;
onClose?: (e: React.SyntheticEvent, reason?: string) => void;
style?: object;
title: string;
fullWidth?: boolean;

View File

@ -0,0 +1,19 @@
import { Grid } from '@mui/material';
import { FC } from 'react';
export const GridCol: FC<{ vertical?: boolean }> = ({
children,
vertical = false,
}) => {
return (
<Grid
container={vertical}
item
display="flex"
alignItems={vertical ? 'start' : 'center'}
direction={vertical ? 'column' : undefined}
>
{children}
</Grid>
);
};

View File

@ -0,0 +1,21 @@
import { Grid, styled, SxProps, Theme } from '@mui/material';
import { FC } from 'react';
export const GridRow: FC<{ sx?: SxProps<Theme> }> = ({ sx, children }) => {
return (
<StyledGrid
container
item
justifyContent="space-between"
alignItems="center"
sx={sx}
>
{children}
</StyledGrid>
);
};
const StyledGrid = styled(Grid)(({ theme }) => ({
flexWrap: 'nowrap',
gap: theme.spacing(1),
}));

View File

@ -1,17 +1,130 @@
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import React, { FC } from 'react';
import React, { FC, VFC, useEffect, useState, useContext } from 'react';
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Dialogue } from 'component/common/Dialogue/Dialogue';
import { Typography } from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import AccessContext from 'contexts/AccessContext';
import useInstanceStatusApi from 'hooks/api/actions/useInstanceStatusApi/useInstanceStatusApi';
import { calculateTrialDaysRemaining } from 'utils/billing';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
export const InstanceStatus: FC = ({ children }) => {
const { instanceStatus } = useInstanceStatus();
interface ITrialDialogProps {
instanceStatus: IInstanceStatus;
onExtendTrial: () => Promise<void>;
}
const TrialDialog: VFC<ITrialDialogProps> = ({
instanceStatus,
onExtendTrial,
}) => {
const { hasAccess } = useContext(AccessContext);
const navigate = useNavigate();
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
const statusExpired =
instanceStatus.state === InstanceState.TRIAL &&
typeof trialDaysRemaining === 'number' &&
trialDaysRemaining <= 0;
const [dialogOpen, setDialogOpen] = useState(statusExpired);
useEffect(() => {
setDialogOpen(statusExpired);
const interval = setInterval(() => {
setDialogOpen(statusExpired);
}, 60000);
return () => clearInterval(interval);
}, [statusExpired]);
if (hasAccess(ADMIN)) {
return (
<Dialogue
open={dialogOpen}
primaryButtonText="Upgrade trial"
secondaryButtonText={
instanceStatus?.trialExtended
? 'Remind me later'
: 'Extend trial (5 days)'
}
onClick={() => {
navigate('/admin/billing');
setDialogOpen(false);
}}
onClose={(_: any, reason?: string) => {
if (
reason !== 'backdropClick' &&
reason !== 'escapeKeyDown'
) {
onExtendTrial();
setDialogOpen(false);
}
}}
title={`Your free ${instanceStatus.plan} trial has expired!`}
>
<Typography>
<strong>Upgrade trial</strong> otherwise your{' '}
<strong>account will be deleted.</strong>
</Typography>
</Dialogue>
);
}
return (
<div hidden={!instanceStatus} style={{ height: '100%' }}>
<Dialogue
open={dialogOpen}
secondaryButtonText="Remind me later"
onClose={() => {
setDialogOpen(false);
}}
title={`Your free ${instanceStatus.plan} trial has expired!`}
>
<Typography>
Please inform your admin to <strong>Upgrade trial</strong> or
your <strong>account will be deleted.</strong>
</Typography>
</Dialogue>
);
};
export const InstanceStatus: FC = ({ children }) => {
const { instanceStatus, refetchInstanceStatus, isBilling } =
useInstanceStatus();
const { extendTrial } = useInstanceStatusApi();
const { setToastApiError } = useToast();
const onExtendTrial = async () => {
if (
instanceStatus?.state === InstanceState.TRIAL &&
!instanceStatus?.trialExtended
) {
try {
await extendTrial();
await refetchInstanceStatus();
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
}
};
return (
<div style={{ height: '100%' }}>
<ConditionallyRender
condition={Boolean(instanceStatus)}
condition={isBilling && Boolean(instanceStatus)}
show={() => (
<InstanceStatusBarMemo instanceStatus={instanceStatus!} />
<>
<InstanceStatusBarMemo
instanceStatus={instanceStatus!}
/>
<TrialDialog
instanceStatus={instanceStatus!}
onExtendTrial={onExtendTrial}
/>
</>
)}
/>
{children}

View File

@ -1,5 +1,5 @@
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
import { InstanceState } from 'interfaces/instance';
import { InstancePlan, InstanceState } from 'interfaces/instance';
import { render } from 'utils/testRenderer';
import { screen } from '@testing-library/react';
import { addDays } from 'date-fns';
@ -18,7 +18,7 @@ test('InstanceStatusBar should be hidden when the trial is far from expired', as
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
plan: InstancePlan.PRO,
state: InstanceState.TRIAL,
trialExpiry: addDays(new Date(), 15).toISOString(),
}}
@ -34,7 +34,7 @@ test('InstanceStatusBar should warn when the trial is about to expire', async ()
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
plan: InstancePlan.PRO,
state: InstanceState.TRIAL,
trialExpiry: addDays(new Date(), 5).toISOString(),
}}
@ -49,7 +49,7 @@ test('InstanceStatusBar should warn when the trial has expired', async () => {
render(
<InstanceStatusBar
instanceStatus={{
plan: 'pro',
plan: InstancePlan.PRO,
state: InstanceState.TRIAL,
trialExpiry: new Date().toISOString(),
}}

View File

@ -1,9 +1,13 @@
import { styled, Button } from '@mui/material';
import { colors } from 'themes/colors';
import { styled, Button, Typography } from '@mui/material';
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
import { differenceInDays, parseISO } from 'date-fns';
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
import { Info } from '@mui/icons-material';
import { InfoOutlined, WarningAmber } from '@mui/icons-material';
import { useNavigate } from 'react-router-dom';
import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { ADMIN } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { calculateTrialDaysRemaining } from 'utils/billing';
interface IInstanceStatusBarProps {
instanceStatus: IInstanceStatus;
@ -12,6 +16,8 @@ interface IInstanceStatusBarProps {
export const InstanceStatusBar = ({
instanceStatus,
}: IInstanceStatusBarProps) => {
const { hasAccess } = useContext(AccessContext);
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
if (
@ -20,14 +26,22 @@ export const InstanceStatusBar = ({
trialDaysRemaining <= 0
) {
return (
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<span>
<strong>Heads up!</strong> Your free trial of the{' '}
{instanceStatus.plan.toUpperCase()} version has expired.
</span>
<ContactButton />
</StyledBar>
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledWarningIcon />
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Warning!</strong> Your free {instanceStatus.plan}{' '}
trial has expired. <strong>Upgrade trial</strong> otherwise
your <strong>account will be deleted.</strong>
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledWarningBar>
);
}
@ -37,54 +51,67 @@ export const InstanceStatusBar = ({
trialDaysRemaining <= 10
) {
return (
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
<StyledInfoIcon />
<span>
<Typography
sx={theme => ({
fontSize: theme.fontSizes.smallBody,
})}
>
<strong>Heads up!</strong> You have{' '}
<strong>{trialDaysRemaining} days</strong> remaining of your
free trial of the {instanceStatus.plan.toUpperCase()}{' '}
version.
</span>
<ContactButton />
</StyledBar>
<strong>{trialDaysRemaining} days</strong> left of your free{' '}
{instanceStatus.plan} trial.
</Typography>
<ConditionallyRender
condition={hasAccess(ADMIN)}
show={<UpgradeButton />}
/>
</StyledInfoBar>
);
}
return null;
};
const ContactButton = () => {
const UpgradeButton = () => {
const navigate = useNavigate();
return (
<StyledButton
href="mailto:support@getunleash.zendesk.com"
onClick={() => navigate('/admin/billing')}
variant="outlined"
>
Contact us
Upgrade trial
</StyledButton>
);
};
const calculateTrialDaysRemaining = (
instanceStatus: IInstanceStatus
): number | undefined => {
return instanceStatus.trialExpiry
? differenceInDays(parseISO(instanceStatus.trialExpiry), new Date())
: undefined;
};
// TODO - Cleanup to use theme instead of colors
const StyledBar = styled('aside')(({ theme }) => ({
const StyledWarningBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(2),
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: colors.blue[200],
background: colors.blue[50],
color: colors.blue[700],
borderColor: theme.palette.warning.border,
background: theme.palette.warning.light,
color: theme.palette.warning.dark,
}));
const StyledInfoBar = styled('aside')(({ theme }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette.info.border,
background: theme.palette.info.light,
color: theme.palette.info.dark,
}));
const StyledButton = styled(Button)(({ theme }) => ({
@ -93,7 +120,10 @@ const StyledButton = styled(Button)(({ theme }) => ({
marginLeft: theme.spacing(2),
}));
// TODO - Cleanup to use theme instead of colors
const StyledInfoIcon = styled(Info)(({ theme }) => ({
color: colors.blue[500],
const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({
color: theme.palette.warning.main,
}));
const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
color: theme.palette.info.main,
}));

View File

@ -2,59 +2,63 @@
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
<aside
class="mui-1rw10cs"
class="mui-jmsogz"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoIcon"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
data-testid="WarningAmberIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
d="M12 5.99 19.53 19H4.47L12 5.99M12 2 1 21h22L12 2z"
/>
<path
d="M13 16h-2v2h2zm0-6h-2v5h2z"
/>
</svg>
<span>
<strong>
Heads up!
</strong>
Your free trial of the
PRO
version has expired.
</span>
<a
class="MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButtonBase-root mui-l66s6r-MuiButtonBase-root-MuiButton-root"
href="mailto:support@getunleash.zendesk.com"
tabindex="0"
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
Contact us
<span
class="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
/>
</a>
<strong>
Warning!
</strong>
Your free
Pro
trial has expired.
<strong>
Upgrade trial
</strong>
otherwise your
<strong>
account will be deleted.
</strong>
</p>
</aside>
`;
exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
<aside
class="mui-1rw10cs"
class="mui-yx2rkt"
data-testid="INSTANCE_STATUS_BAR_ID"
>
<svg
aria-hidden="true"
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
data-testid="InfoIcon"
data-testid="InfoOutlinedIcon"
focusable="false"
viewBox="0 0 24 24"
>
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"
d="M11 7h2v2h-2zm0 4h2v6h-2zm1-9C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"
/>
</svg>
<span>
<p
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
>
<strong>
Heads up!
</strong>
@ -64,20 +68,10 @@ exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
4
days
</strong>
remaining of your free trial of the
PRO
left of your free
version.
</span>
<a
class="MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButtonBase-root mui-l66s6r-MuiButtonBase-root-MuiButton-root"
href="mailto:support@getunleash.zendesk.com"
tabindex="0"
>
Contact us
<span
class="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
/>
</a>
Pro
trial.
</p>
</aside>
`;

View File

@ -1,19 +1,20 @@
import { VFC, ReactNode } from 'react';
import { FC } from 'react';
import { Box } from '@mui/material';
interface IDateCellProps {
children?: ReactNode;
interface ITextCellProps {
value?: string | null;
}
export const TextCell: VFC<IDateCellProps> = ({ children }) => {
if (!children) {
export const TextCell: FC<ITextCellProps> = ({ value, children }) => {
const text = children ?? value;
if (!text) {
return <Box sx={{ py: 1.5, px: 2 }} />;
}
return (
<Box sx={{ py: 1.5, px: 2 }}>
<span data-loading role="tooltip">
{children}
{text}
</span>
</Box>
);

View File

@ -23,6 +23,8 @@ import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions
import { useStyles } from './Header.styles';
import classNames from 'classnames';
import { useId } from 'hooks/useId';
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
import { IRoute } from 'interfaces/route';
const Header: VFC = () => {
const theme = useTheme();
@ -55,12 +57,15 @@ const Header: VFC = () => {
}
}, [permissions]);
const { isBilling } = useInstanceStatus();
const routes = getRoutes();
const filteredMainRoutes = {
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
mobileRoutes: routes.mobileRoutes.filter(filterByFlags(flags)),
adminRoutes: routes.adminRoutes.filter(filterByFlags(flags)),
adminRoutes: routes.adminRoutes.filter(
filterByFlags(flags) && filterByBilling(isBilling)
),
};
if (smallScreen) {
@ -191,4 +196,7 @@ const Header: VFC = () => {
);
};
export const filterByBilling = (isBilling?: boolean) => (route: IRoute) =>
!route.menu.isBilling || isBilling;
export default Header;

View File

@ -407,6 +407,25 @@ exports[`returns all baseRoutes 1`] = `
"title": "Single sign-on",
"type": "protected",
},
{
"component": [Function],
"menu": {
"adminSettings": true,
"isBilling": true,
},
"parent": "/admin",
"path": "/admin/billing",
"title": "Billing",
"type": "protected",
},
{
"component": [Function],
"menu": {},
"parent": "/admin",
"path": "/admin-invoices",
"title": "Invoices",
"type": "protected",
},
{
"component": [Function],
"hidden": false,

View File

@ -7,9 +7,9 @@ import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList';
import { AddonList } from 'component/addons/AddonList/AddonList';
import Admin from 'component/admin';
import AdminApi from 'component/admin/api';
import AdminInvoice from 'component/admin/invoice/InvoiceAdminPage';
import AdminUsers from 'component/admin/users/UsersAdmin';
import { AuthSettings } from 'component/admin/auth/AuthSettings';
import { Billing } from 'component/admin/billing/Billing';
import Login from 'component/user/Login/Login';
import { C, EEA, P, RE, SE } from 'component/common/flags';
import { NewUser } from 'component/user/NewUser/NewUser';
@ -52,6 +52,7 @@ import { EditSegment } from 'component/segments/EditSegment/EditSegment';
import { SegmentsList } from 'component/segments/SegmentList/SegmentList';
import { IRoute } from 'interfaces/route';
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
import RedirectAdminInvoices from 'component/admin/billing/RedirectAdminInvoices/RedirectAdminInvoices';
export const routes: IRoute[] = [
// Splash
@ -455,12 +456,20 @@ export const routes: IRoute[] = [
menu: { adminSettings: true },
},
{
path: '/admin-invoices',
title: 'Invoices',
component: AdminInvoice,
hidden: true,
path: '/admin/billing',
parent: '/admin',
title: 'Billing',
component: Billing,
type: 'protected',
menu: { adminSettings: true },
menu: { adminSettings: true, isBilling: true },
},
{
path: '/admin-invoices',
parent: '/admin',
title: 'Invoices',
component: RedirectAdminInvoices,
type: 'protected',
menu: {},
},
{
path: '/admin',

View File

@ -67,7 +67,7 @@ exports[`renders an empty list correctly 1`] = `
/>
</button>
<hr
className="MuiDivider-root MuiDivider-middle MuiDivider-vertical tss-1suzbix-verticalSeparator mui-oezrwv-MuiDivider-root"
className="MuiDivider-root MuiDivider-middle MuiDivider-vertical tss-a0frlx-verticalSeparator mui-oezrwv-MuiDivider-root"
/>
<span
id="useId-0"

View File

@ -0,0 +1,21 @@
import useAPI from '../useApi/useApi';
const useInstanceStatusApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({
propagateErrors: true,
});
const extendTrial = async (): Promise<void> => {
const path = 'api/instance/extend';
const req = createRequest(path, { method: 'POST' }, 'extendTrial');
await makeRequest(req.caller, req.id);
};
return {
extendTrial,
loading,
errors,
};
};
export default useInstanceStatusApi;

View File

@ -1,31 +1,51 @@
import { IInstanceStatus } from 'interfaces/instance';
import { IInstanceStatus, InstancePlan } from 'interfaces/instance';
import { useApiGetter } from 'hooks/api/getters/useApiGetter/useApiGetter';
import { formatApiPath } from 'utils/formatPath';
import { isLocalhostDomain } from 'utils/env';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useEffect } from 'react';
export interface IUseInstanceStatusOutput {
instanceStatus?: IInstanceStatus;
refetchInstanceStatus: () => void;
isBilling: boolean;
loading: boolean;
error?: Error;
}
export const useInstanceStatus = (): IUseInstanceStatusOutput => {
const { uiConfig } = useUiConfig();
const {
flags: { UNLEASH_CLOUD },
} = uiConfig;
const { data, refetch, loading, error } = useApiGetter(
'useInstanceStatus',
fetchInstanceStatus
() => fetchInstanceStatus(UNLEASH_CLOUD)
);
useEffect(() => {
refetch();
}, [refetch, UNLEASH_CLOUD]);
const billingPlans = [
InstancePlan.PRO,
InstancePlan.COMPANY,
InstancePlan.TEAM,
];
return {
instanceStatus: data,
refetchInstanceStatus: refetch,
isBilling: billingPlans.includes(data?.plan ?? InstancePlan.UNKNOWN),
loading,
error,
};
};
const fetchInstanceStatus = async (): Promise<IInstanceStatus> => {
if (!enableInstanceStatusBarFeature()) {
const fetchInstanceStatus = async (
UNLEASH_CLOUD?: boolean
): Promise<IInstanceStatus> => {
if (!UNLEASH_CLOUD) {
return UNKNOWN_INSTANCE_STATUS;
}
@ -38,11 +58,6 @@ const fetchInstanceStatus = async (): Promise<IInstanceStatus> => {
return res.json();
};
// TODO(olav): Enable instance status bar feature outside of localhost.
const enableInstanceStatusBarFeature = () => {
return isLocalhostDomain();
};
export const UNKNOWN_INSTANCE_STATUS: IInstanceStatus = {
plan: 'unknown',
plan: InstancePlan.UNKNOWN,
};

View File

@ -14,6 +14,7 @@ export const defaultValue = {
CO: false,
SE: false,
T: false,
UNLEASH_CLOUD: false,
},
links: [
{

View File

@ -0,0 +1,49 @@
import { IUser } from 'interfaces/user';
import { useMemo } from 'react';
import { useInstanceStatus } from './api/getters/useInstanceStatus/useInstanceStatus';
import { STRIPE } from 'component/admin/billing/flags';
import { InstancePlan } from 'interfaces/instance';
export interface IUsersPlanOutput {
planUsers: IUser[];
isBillingUsers: boolean;
}
export const useUsersPlan = (users: IUser[]): IUsersPlanOutput => {
const { instanceStatus } = useInstanceStatus();
const isBillingUsers = STRIPE && instanceStatus?.plan === InstancePlan.PRO;
const planUsers = useMemo(
() => calculatePaidUsers(users, isBillingUsers, instanceStatus?.seats),
[users, isBillingUsers, instanceStatus?.seats]
);
return {
planUsers,
isBillingUsers,
};
};
const calculatePaidUsers = (
users: IUser[],
isBillingUsers: boolean,
seats: number = 0
) => {
if (!isBillingUsers || !seats) return users;
users
.sort((a, b) => a.createdAt.localeCompare(b.createdAt))
.forEach((user, index) => {
user.paid = false;
// If index is greater or equal to seat, the
// user isn't paid for and we will add use this
// to add costs and icons in the userlist
if (index >= seats) {
user.paid = true;
}
});
return users;
};

View File

@ -1,10 +1,11 @@
export interface IInstanceStatus {
plan: string;
plan: InstancePlan;
trialExpiry?: string;
trialStart?: string;
trialExtended?: number;
billingCenter?: string;
state?: InstanceState;
seats?: number;
}
export enum InstanceState {
@ -14,3 +15,10 @@ export enum InstanceState {
EXPIRED = 'EXPIRED',
CHURNED = 'CHURNED',
}
export enum InstancePlan {
PRO = 'Pro',
COMPANY = 'Company',
TEAM = 'Team',
UNKNOWN = 'Unknown',
}

View File

@ -1,5 +1,5 @@
export interface IInvoice {
amountFomratted: string;
amountFormatted: string;
invoicePDF: string;
invoiceURL: string;
paid: boolean;

View File

@ -16,4 +16,5 @@ interface IRouteMenu {
mobile?: boolean;
advanced?: boolean;
adminSettings?: boolean;
isBilling?: boolean;
}

View File

@ -29,6 +29,7 @@ export interface IFlags {
CO?: boolean;
SE?: boolean;
T?: boolean;
UNLEASH_CLOUD?: boolean;
}
export interface IVersionInfo {

View File

@ -11,6 +11,7 @@ export interface IUser {
seenAt: string | null;
username?: string;
isAPI: boolean;
paid?: boolean;
}
export interface IPermission {

View File

@ -135,6 +135,7 @@ p {
}
a {
cursor: pointer;
color: #615bc2;
}

View File

@ -13,6 +13,7 @@ export default createTheme({
},
boxShadows: {
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
},
typography: {
fontFamily: 'Sen, Roboto, sans-serif',
@ -61,26 +62,31 @@ export default createTheme({
light: colors.blue[50],
main: colors.blue[500],
dark: colors.blue[700],
border: colors.blue[200],
},
success: {
light: colors.green[50],
main: colors.green[500],
dark: colors.green[800],
border: colors.green[300],
},
warning: {
light: colors.orange[100],
main: colors.orange[800],
dark: colors.orange[900],
border: colors.orange[500],
},
error: {
light: colors.red[50],
main: colors.red[700],
dark: colors.red[800],
border: colors.red[300],
},
divider: colors.grey[300],
dividerAlternative: colors.grey[500],
dividerAlternative: colors.grey[400],
tableHeaderHover: colors.grey[400],
highlight: '#FFEACC',
secondaryContainer: colors.grey[200],
sidebarContainer: 'rgba(32,32,33, 0.2)',
grey: colors.grey,
text: {

View File

@ -24,6 +24,7 @@ declare module '@mui/material/styles' {
*/
boxShadows: {
main: string;
elevated: string;
};
}
@ -65,7 +66,11 @@ declare module '@mui/material/styles' {
*/
highlight: string;
/**
* For sidebar container background.
* Background color for secondary containers.
*/
secondaryContainer: string;
/**
* Background color for sidebar containers.
*/
sidebarContainer: string;
/**
@ -83,6 +88,19 @@ declare module '@mui/material/styles' {
interface Palette extends CustomPalette {}
interface PaletteOptions extends CustomPalette {}
interface PaletteColor {
light: string;
main: string;
dark: string;
border: string;
}
interface PaletteColorOptions {
light?: string;
main?: string;
dark?: string;
border?: string;
}
}
declare module '@mui/system/createTheme/shape' {

View File

@ -0,0 +1,10 @@
import { differenceInDays, parseISO } from 'date-fns';
import { IInstanceStatus } from 'interfaces/instance';
export const calculateTrialDaysRemaining = (
instanceStatus?: IInstanceStatus
): number | undefined => {
return instanceStatus?.trialExpiry
? differenceInDays(parseISO(instanceStatus.trialExpiry), new Date())
: undefined;
};