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:
parent
616cc8de24
commit
7093b49962
48
frontend/src/component/admin/billing/Billing.tsx
Normal file
48
frontend/src/component/admin/billing/Billing.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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,
|
||||
}));
|
@ -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),
|
||||
}));
|
@ -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`,
|
||||
}));
|
@ -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),
|
||||
}));
|
@ -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,
|
||||
}));
|
@ -0,0 +1,7 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
const RedirectAdminInvoices = () => {
|
||||
return <Navigate to="/admin/billing" replace />;
|
||||
};
|
||||
|
||||
export default RedirectAdminInvoices;
|
1
frontend/src/component/admin/billing/flags.ts
Normal file
1
frontend/src/component/admin/billing/flags.ts
Normal file
@ -0,0 +1 @@
|
||||
export const STRIPE = false;
|
@ -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;
|
@ -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;
|
@ -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>
|
||||
);
|
||||
|
@ -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)}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
19
frontend/src/component/common/GridCol/GridCol.tsx
Normal file
19
frontend/src/component/common/GridCol/GridCol.tsx
Normal 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>
|
||||
);
|
||||
};
|
21
frontend/src/component/common/GridRow/GridRow.tsx
Normal file
21
frontend/src/component/common/GridRow/GridRow.tsx
Normal 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),
|
||||
}));
|
@ -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}
|
||||
|
@ -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(),
|
||||
}}
|
||||
|
@ -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,
|
||||
}));
|
||||
|
@ -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>
|
||||
`;
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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"
|
||||
|
@ -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;
|
@ -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,
|
||||
};
|
||||
|
@ -14,6 +14,7 @@ export const defaultValue = {
|
||||
CO: false,
|
||||
SE: false,
|
||||
T: false,
|
||||
UNLEASH_CLOUD: false,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
|
49
frontend/src/hooks/useUsersPlan.ts
Normal file
49
frontend/src/hooks/useUsersPlan.ts
Normal 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;
|
||||
};
|
@ -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',
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
export interface IInvoice {
|
||||
amountFomratted: string;
|
||||
amountFormatted: string;
|
||||
invoicePDF: string;
|
||||
invoiceURL: string;
|
||||
paid: boolean;
|
||||
|
@ -16,4 +16,5 @@ interface IRouteMenu {
|
||||
mobile?: boolean;
|
||||
advanced?: boolean;
|
||||
adminSettings?: boolean;
|
||||
isBilling?: boolean;
|
||||
}
|
||||
|
@ -29,6 +29,7 @@ export interface IFlags {
|
||||
CO?: boolean;
|
||||
SE?: boolean;
|
||||
T?: boolean;
|
||||
UNLEASH_CLOUD?: boolean;
|
||||
}
|
||||
|
||||
export interface IVersionInfo {
|
||||
|
@ -11,6 +11,7 @@ export interface IUser {
|
||||
seenAt: string | null;
|
||||
username?: string;
|
||||
isAPI: boolean;
|
||||
paid?: boolean;
|
||||
}
|
||||
|
||||
export interface IPermission {
|
||||
|
@ -135,6 +135,7 @@ p {
|
||||
}
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
color: #615bc2;
|
||||
}
|
||||
|
||||
|
@ -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: {
|
||||
|
@ -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' {
|
||||
|
10
frontend/src/utils/billing.ts
Normal file
10
frontend/src/utils/billing.ts
Normal 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;
|
||||
};
|
Loading…
Reference in New Issue
Block a user