mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +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 { NavLink, useLocation } from 'react-router-dom';
|
||||||
import { Paper, Tab, Tabs } from '@mui/material';
|
import { Paper, Tab, Tabs } from '@mui/material';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
|
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
|
||||||
|
|
||||||
const navLinkStyle = {
|
const navLinkStyle = {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -30,6 +31,7 @@ const createNavLinkStyle = (props: {
|
|||||||
function AdminMenu() {
|
function AdminMenu() {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { isBilling } = useInstanceStatus();
|
||||||
const { flags } = uiConfig;
|
const { flags } = uiConfig;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -79,6 +81,19 @@ function AdminMenu() {
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
{isBilling && (
|
||||||
|
<Tab
|
||||||
|
value="/admin/billing"
|
||||||
|
label={
|
||||||
|
<NavLink
|
||||||
|
to="/admin/billing"
|
||||||
|
style={createNavLinkStyle}
|
||||||
|
>
|
||||||
|
Billing
|
||||||
|
</NavLink>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Paper>
|
</Paper>
|
||||||
);
|
);
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import classnames from 'classnames';
|
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 { SyntheticEvent, useContext } from 'react';
|
||||||
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
import { ADMIN } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||||
@ -27,6 +27,7 @@ interface IUserListItemProps {
|
|||||||
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
openDelDialog: (user: IUser) => (e: SyntheticEvent) => void;
|
||||||
locationSettings: ILocationSettings;
|
locationSettings: ILocationSettings;
|
||||||
search: string;
|
search: string;
|
||||||
|
isBillingUsers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserListItem = ({
|
const UserListItem = ({
|
||||||
@ -36,6 +37,7 @@ const UserListItem = ({
|
|||||||
openPwDialog,
|
openPwDialog,
|
||||||
locationSettings,
|
locationSettings,
|
||||||
search,
|
search,
|
||||||
|
isBillingUsers,
|
||||||
}: IUserListItemProps) => {
|
}: IUserListItemProps) => {
|
||||||
const { hasAccess } = useContext(AccessContext);
|
const { hasAccess } = useContext(AccessContext);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -43,7 +45,7 @@ const UserListItem = ({
|
|||||||
|
|
||||||
const renderTimeAgo = (date: string) => (
|
const renderTimeAgo = (date: string) => (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={`Last seen on: ${formatDateYMD(
|
title={`Last login: ${formatDateYMD(
|
||||||
date,
|
date,
|
||||||
locationSettings.locale
|
locationSettings.locale
|
||||||
)}`}
|
)}`}
|
||||||
@ -57,6 +59,27 @@ const UserListItem = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={user.id} className={styles.tableRow}>
|
<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}>
|
<TableCell className={styles.hideSM}>
|
||||||
<span data-loading>
|
<span data-loading>
|
||||||
{formatDateYMD(user.createdAt, locationSettings.locale)}
|
{formatDateYMD(user.createdAt, locationSettings.locale)}
|
||||||
|
@ -30,6 +30,7 @@ import { useUsersFilter } from 'hooks/useUsersFilter';
|
|||||||
import { useUsersSort } from 'hooks/useUsersSort';
|
import { useUsersSort } from 'hooks/useUsersSort';
|
||||||
import { TableCellSortable } from 'component/common/Table/TableCellSortable/TableCellSortable';
|
import { TableCellSortable } from 'component/common/Table/TableCellSortable/TableCellSortable';
|
||||||
import { useStyles } from './UserListItem/UserListItem.styles';
|
import { useStyles } from './UserListItem/UserListItem.styles';
|
||||||
|
import { useUsersPlan } from 'hooks/useUsersPlan';
|
||||||
|
|
||||||
interface IUsersListProps {
|
interface IUsersListProps {
|
||||||
search: string;
|
search: string;
|
||||||
@ -52,7 +53,8 @@ const UsersList = ({ search }: IUsersListProps) => {
|
|||||||
const [inviteLink, setInviteLink] = useState('');
|
const [inviteLink, setInviteLink] = useState('');
|
||||||
const [delUser, setDelUser] = useState<IUser>();
|
const [delUser, setDelUser] = useState<IUser>();
|
||||||
const ref = useLoading(loading);
|
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 { sorted, sort, setSort } = useUsersSort(filtered);
|
||||||
|
|
||||||
const filterUsersByQueryPage = (user: IUser) => {
|
const filterUsersByQueryPage = (user: IUser) => {
|
||||||
@ -144,6 +146,7 @@ const UsersList = ({ search }: IUsersListProps) => {
|
|||||||
locationSettings={locationSettings}
|
locationSettings={locationSettings}
|
||||||
renderRole={renderRole}
|
renderRole={renderRole}
|
||||||
search={search}
|
search={search}
|
||||||
|
isBillingUsers={isBillingUsers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@ -156,6 +159,17 @@ const UsersList = ({ search }: IUsersListProps) => {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow className={styles.tableCellHeader}>
|
<TableRow className={styles.tableCellHeader}>
|
||||||
|
<ConditionallyRender
|
||||||
|
condition={isBillingUsers}
|
||||||
|
show={
|
||||||
|
<TableCell
|
||||||
|
align="center"
|
||||||
|
className={classnames(styles.hideSM)}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
</TableCell>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TableCellSortable
|
<TableCellSortable
|
||||||
className={classnames(
|
className={classnames(
|
||||||
styles.hideSM,
|
styles.hideSM,
|
||||||
|
@ -16,7 +16,7 @@ interface IDialogue {
|
|||||||
secondaryButtonText?: string;
|
secondaryButtonText?: string;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClick?: (e: React.SyntheticEvent) => void;
|
onClick?: (e: React.SyntheticEvent) => void;
|
||||||
onClose?: (e: React.SyntheticEvent) => void;
|
onClose?: (e: React.SyntheticEvent, reason?: string) => void;
|
||||||
style?: object;
|
style?: object;
|
||||||
title: string;
|
title: string;
|
||||||
fullWidth?: boolean;
|
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 { 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 { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
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 }) => {
|
interface ITrialDialogProps {
|
||||||
const { instanceStatus } = useInstanceStatus();
|
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 (
|
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
|
<ConditionallyRender
|
||||||
condition={Boolean(instanceStatus)}
|
condition={isBilling && Boolean(instanceStatus)}
|
||||||
show={() => (
|
show={() => (
|
||||||
<InstanceStatusBarMemo instanceStatus={instanceStatus!} />
|
<>
|
||||||
|
<InstanceStatusBarMemo
|
||||||
|
instanceStatus={instanceStatus!}
|
||||||
|
/>
|
||||||
|
<TrialDialog
|
||||||
|
instanceStatus={instanceStatus!}
|
||||||
|
onExtendTrial={onExtendTrial}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{children}
|
{children}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
|
import { InstanceStatusBar } from 'component/common/InstanceStatus/InstanceStatusBar';
|
||||||
import { InstanceState } from 'interfaces/instance';
|
import { InstancePlan, InstanceState } from 'interfaces/instance';
|
||||||
import { render } from 'utils/testRenderer';
|
import { render } from 'utils/testRenderer';
|
||||||
import { screen } from '@testing-library/react';
|
import { screen } from '@testing-library/react';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
@ -18,7 +18,7 @@ test('InstanceStatusBar should be hidden when the trial is far from expired', as
|
|||||||
render(
|
render(
|
||||||
<InstanceStatusBar
|
<InstanceStatusBar
|
||||||
instanceStatus={{
|
instanceStatus={{
|
||||||
plan: 'pro',
|
plan: InstancePlan.PRO,
|
||||||
state: InstanceState.TRIAL,
|
state: InstanceState.TRIAL,
|
||||||
trialExpiry: addDays(new Date(), 15).toISOString(),
|
trialExpiry: addDays(new Date(), 15).toISOString(),
|
||||||
}}
|
}}
|
||||||
@ -34,7 +34,7 @@ test('InstanceStatusBar should warn when the trial is about to expire', async ()
|
|||||||
render(
|
render(
|
||||||
<InstanceStatusBar
|
<InstanceStatusBar
|
||||||
instanceStatus={{
|
instanceStatus={{
|
||||||
plan: 'pro',
|
plan: InstancePlan.PRO,
|
||||||
state: InstanceState.TRIAL,
|
state: InstanceState.TRIAL,
|
||||||
trialExpiry: addDays(new Date(), 5).toISOString(),
|
trialExpiry: addDays(new Date(), 5).toISOString(),
|
||||||
}}
|
}}
|
||||||
@ -49,7 +49,7 @@ test('InstanceStatusBar should warn when the trial has expired', async () => {
|
|||||||
render(
|
render(
|
||||||
<InstanceStatusBar
|
<InstanceStatusBar
|
||||||
instanceStatus={{
|
instanceStatus={{
|
||||||
plan: 'pro',
|
plan: InstancePlan.PRO,
|
||||||
state: InstanceState.TRIAL,
|
state: InstanceState.TRIAL,
|
||||||
trialExpiry: new Date().toISOString(),
|
trialExpiry: new Date().toISOString(),
|
||||||
}}
|
}}
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import { styled, Button } from '@mui/material';
|
import { styled, Button, Typography } from '@mui/material';
|
||||||
import { colors } from 'themes/colors';
|
|
||||||
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
import { IInstanceStatus, InstanceState } from 'interfaces/instance';
|
||||||
import { differenceInDays, parseISO } from 'date-fns';
|
|
||||||
import { INSTANCE_STATUS_BAR_ID } from 'utils/testIds';
|
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 {
|
interface IInstanceStatusBarProps {
|
||||||
instanceStatus: IInstanceStatus;
|
instanceStatus: IInstanceStatus;
|
||||||
@ -12,6 +16,8 @@ interface IInstanceStatusBarProps {
|
|||||||
export const InstanceStatusBar = ({
|
export const InstanceStatusBar = ({
|
||||||
instanceStatus,
|
instanceStatus,
|
||||||
}: IInstanceStatusBarProps) => {
|
}: IInstanceStatusBarProps) => {
|
||||||
|
const { hasAccess } = useContext(AccessContext);
|
||||||
|
|
||||||
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
|
const trialDaysRemaining = calculateTrialDaysRemaining(instanceStatus);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -20,14 +26,22 @@ export const InstanceStatusBar = ({
|
|||||||
trialDaysRemaining <= 0
|
trialDaysRemaining <= 0
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
<StyledWarningBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
||||||
<StyledInfoIcon />
|
<StyledWarningIcon />
|
||||||
<span>
|
<Typography
|
||||||
<strong>Heads up!</strong> Your free trial of the{' '}
|
sx={theme => ({
|
||||||
{instanceStatus.plan.toUpperCase()} version has expired.
|
fontSize: theme.fontSizes.smallBody,
|
||||||
</span>
|
})}
|
||||||
<ContactButton />
|
>
|
||||||
</StyledBar>
|
<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
|
trialDaysRemaining <= 10
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<StyledBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
<StyledInfoBar data-testid={INSTANCE_STATUS_BAR_ID}>
|
||||||
<StyledInfoIcon />
|
<StyledInfoIcon />
|
||||||
<span>
|
<Typography
|
||||||
|
sx={theme => ({
|
||||||
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<strong>Heads up!</strong> You have{' '}
|
<strong>Heads up!</strong> You have{' '}
|
||||||
<strong>{trialDaysRemaining} days</strong> remaining of your
|
<strong>{trialDaysRemaining} days</strong> left of your free{' '}
|
||||||
free trial of the {instanceStatus.plan.toUpperCase()}{' '}
|
{instanceStatus.plan} trial.
|
||||||
version.
|
</Typography>
|
||||||
</span>
|
<ConditionallyRender
|
||||||
<ContactButton />
|
condition={hasAccess(ADMIN)}
|
||||||
</StyledBar>
|
show={<UpgradeButton />}
|
||||||
|
/>
|
||||||
|
</StyledInfoBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContactButton = () => {
|
const UpgradeButton = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledButton
|
<StyledButton
|
||||||
href="mailto:support@getunleash.zendesk.com"
|
onClick={() => navigate('/admin/billing')}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
Contact us
|
Upgrade trial
|
||||||
</StyledButton>
|
</StyledButton>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateTrialDaysRemaining = (
|
const StyledWarningBar = styled('aside')(({ theme }) => ({
|
||||||
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 }) => ({
|
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: theme.spacing(2),
|
padding: theme.spacing(1),
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
borderBottom: '1px solid',
|
borderBottom: '1px solid',
|
||||||
borderColor: colors.blue[200],
|
borderColor: theme.palette.warning.border,
|
||||||
background: colors.blue[50],
|
background: theme.palette.warning.light,
|
||||||
color: colors.blue[700],
|
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 }) => ({
|
const StyledButton = styled(Button)(({ theme }) => ({
|
||||||
@ -93,7 +120,10 @@ const StyledButton = styled(Button)(({ theme }) => ({
|
|||||||
marginLeft: theme.spacing(2),
|
marginLeft: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// TODO - Cleanup to use theme instead of colors
|
const StyledWarningIcon = styled(WarningAmber)(({ theme }) => ({
|
||||||
const StyledInfoIcon = styled(Info)(({ theme }) => ({
|
color: theme.palette.warning.main,
|
||||||
color: colors.blue[500],
|
}));
|
||||||
|
|
||||||
|
const StyledInfoIcon = styled(InfoOutlined)(({ theme }) => ({
|
||||||
|
color: theme.palette.info.main,
|
||||||
}));
|
}));
|
||||||
|
@ -2,59 +2,63 @@
|
|||||||
|
|
||||||
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
|
exports[`InstanceStatusBar should warn when the trial has expired 1`] = `
|
||||||
<aside
|
<aside
|
||||||
class="mui-1rw10cs"
|
class="mui-jmsogz"
|
||||||
data-testid="INSTANCE_STATUS_BAR_ID"
|
data-testid="INSTANCE_STATUS_BAR_ID"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
|
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-prk1jy-MuiSvgIcon-root"
|
||||||
data-testid="InfoIcon"
|
data-testid="WarningAmberIcon"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<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>
|
</svg>
|
||||||
<span>
|
<p
|
||||||
<strong>
|
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
Contact us
|
<strong>
|
||||||
<span
|
Warning!
|
||||||
class="MuiTouchRipple-root mui-8je8zh-MuiTouchRipple-root"
|
</strong>
|
||||||
/>
|
Your free
|
||||||
</a>
|
Pro
|
||||||
|
|
||||||
|
trial has expired.
|
||||||
|
<strong>
|
||||||
|
Upgrade trial
|
||||||
|
</strong>
|
||||||
|
otherwise your
|
||||||
|
<strong>
|
||||||
|
account will be deleted.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
|
exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
|
||||||
<aside
|
<aside
|
||||||
class="mui-1rw10cs"
|
class="mui-yx2rkt"
|
||||||
data-testid="INSTANCE_STATUS_BAR_ID"
|
data-testid="INSTANCE_STATUS_BAR_ID"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
|
class="MuiSvgIcon-root MuiSvgIcon-fontSizeMedium mui-cle2im-MuiSvgIcon-root"
|
||||||
data-testid="InfoIcon"
|
data-testid="InfoOutlinedIcon"
|
||||||
focusable="false"
|
focusable="false"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
>
|
>
|
||||||
<path
|
<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>
|
</svg>
|
||||||
<span>
|
<p
|
||||||
|
class="MuiTypography-root MuiTypography-body1 mui-rviqjc-MuiTypography-root"
|
||||||
|
>
|
||||||
<strong>
|
<strong>
|
||||||
Heads up!
|
Heads up!
|
||||||
</strong>
|
</strong>
|
||||||
@ -64,20 +68,10 @@ exports[`InstanceStatusBar should warn when the trial is about to expire 1`] = `
|
|||||||
4
|
4
|
||||||
days
|
days
|
||||||
</strong>
|
</strong>
|
||||||
remaining of your free trial of the
|
left of your free
|
||||||
PRO
|
|
||||||
|
|
||||||
version.
|
Pro
|
||||||
</span>
|
trial.
|
||||||
<a
|
</p>
|
||||||
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>
|
|
||||||
</aside>
|
</aside>
|
||||||
`;
|
`;
|
||||||
|
@ -1,19 +1,20 @@
|
|||||||
import { VFC, ReactNode } from 'react';
|
import { FC } from 'react';
|
||||||
import { Box } from '@mui/material';
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
interface IDateCellProps {
|
interface ITextCellProps {
|
||||||
children?: ReactNode;
|
value?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TextCell: VFC<IDateCellProps> = ({ children }) => {
|
export const TextCell: FC<ITextCellProps> = ({ value, children }) => {
|
||||||
if (!children) {
|
const text = children ?? value;
|
||||||
|
if (!text) {
|
||||||
return <Box sx={{ py: 1.5, px: 2 }} />;
|
return <Box sx={{ py: 1.5, px: 2 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ py: 1.5, px: 2 }}>
|
<Box sx={{ py: 1.5, px: 2 }}>
|
||||||
<span data-loading role="tooltip">
|
<span data-loading role="tooltip">
|
||||||
{children}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -23,6 +23,8 @@ import { useAuthPermissions } from 'hooks/api/getters/useAuth/useAuthPermissions
|
|||||||
import { useStyles } from './Header.styles';
|
import { useStyles } from './Header.styles';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useId } from 'hooks/useId';
|
import { useId } from 'hooks/useId';
|
||||||
|
import { useInstanceStatus } from 'hooks/api/getters/useInstanceStatus/useInstanceStatus';
|
||||||
|
import { IRoute } from 'interfaces/route';
|
||||||
|
|
||||||
const Header: VFC = () => {
|
const Header: VFC = () => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@ -55,12 +57,15 @@ const Header: VFC = () => {
|
|||||||
}
|
}
|
||||||
}, [permissions]);
|
}, [permissions]);
|
||||||
|
|
||||||
|
const { isBilling } = useInstanceStatus();
|
||||||
const routes = getRoutes();
|
const routes = getRoutes();
|
||||||
|
|
||||||
const filteredMainRoutes = {
|
const filteredMainRoutes = {
|
||||||
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
|
mainNavRoutes: routes.mainNavRoutes.filter(filterByFlags(flags)),
|
||||||
mobileRoutes: routes.mobileRoutes.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) {
|
if (smallScreen) {
|
||||||
@ -191,4 +196,7 @@ const Header: VFC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const filterByBilling = (isBilling?: boolean) => (route: IRoute) =>
|
||||||
|
!route.menu.isBilling || isBilling;
|
||||||
|
|
||||||
export default Header;
|
export default Header;
|
||||||
|
@ -407,6 +407,25 @@ exports[`returns all baseRoutes 1`] = `
|
|||||||
"title": "Single sign-on",
|
"title": "Single sign-on",
|
||||||
"type": "protected",
|
"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],
|
"component": [Function],
|
||||||
"hidden": false,
|
"hidden": false,
|
||||||
|
@ -7,9 +7,9 @@ import { TagTypeList } from 'component/tags/TagTypeList/TagTypeList';
|
|||||||
import { AddonList } from 'component/addons/AddonList/AddonList';
|
import { AddonList } from 'component/addons/AddonList/AddonList';
|
||||||
import Admin from 'component/admin';
|
import Admin from 'component/admin';
|
||||||
import AdminApi from 'component/admin/api';
|
import AdminApi from 'component/admin/api';
|
||||||
import AdminInvoice from 'component/admin/invoice/InvoiceAdminPage';
|
|
||||||
import AdminUsers from 'component/admin/users/UsersAdmin';
|
import AdminUsers from 'component/admin/users/UsersAdmin';
|
||||||
import { AuthSettings } from 'component/admin/auth/AuthSettings';
|
import { AuthSettings } from 'component/admin/auth/AuthSettings';
|
||||||
|
import { Billing } from 'component/admin/billing/Billing';
|
||||||
import Login from 'component/user/Login/Login';
|
import Login from 'component/user/Login/Login';
|
||||||
import { C, EEA, P, RE, SE } from 'component/common/flags';
|
import { C, EEA, P, RE, SE } from 'component/common/flags';
|
||||||
import { NewUser } from 'component/user/NewUser/NewUser';
|
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 { SegmentsList } from 'component/segments/SegmentList/SegmentList';
|
||||||
import { IRoute } from 'interfaces/route';
|
import { IRoute } from 'interfaces/route';
|
||||||
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
|
import { EnvironmentTable } from 'component/environments/EnvironmentTable/EnvironmentTable';
|
||||||
|
import RedirectAdminInvoices from 'component/admin/billing/RedirectAdminInvoices/RedirectAdminInvoices';
|
||||||
|
|
||||||
export const routes: IRoute[] = [
|
export const routes: IRoute[] = [
|
||||||
// Splash
|
// Splash
|
||||||
@ -455,12 +456,20 @@ export const routes: IRoute[] = [
|
|||||||
menu: { adminSettings: true },
|
menu: { adminSettings: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin-invoices',
|
path: '/admin/billing',
|
||||||
title: 'Invoices',
|
parent: '/admin',
|
||||||
component: AdminInvoice,
|
title: 'Billing',
|
||||||
hidden: true,
|
component: Billing,
|
||||||
type: 'protected',
|
type: 'protected',
|
||||||
menu: { adminSettings: true },
|
menu: { adminSettings: true, isBilling: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin-invoices',
|
||||||
|
parent: '/admin',
|
||||||
|
title: 'Invoices',
|
||||||
|
component: RedirectAdminInvoices,
|
||||||
|
type: 'protected',
|
||||||
|
menu: {},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: '/admin',
|
||||||
|
@ -67,7 +67,7 @@ exports[`renders an empty list correctly 1`] = `
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<hr
|
<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
|
<span
|
||||||
id="useId-0"
|
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 { useApiGetter } from 'hooks/api/getters/useApiGetter/useApiGetter';
|
||||||
import { formatApiPath } from 'utils/formatPath';
|
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 {
|
export interface IUseInstanceStatusOutput {
|
||||||
instanceStatus?: IInstanceStatus;
|
instanceStatus?: IInstanceStatus;
|
||||||
refetchInstanceStatus: () => void;
|
refetchInstanceStatus: () => void;
|
||||||
|
isBilling: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useInstanceStatus = (): IUseInstanceStatusOutput => {
|
export const useInstanceStatus = (): IUseInstanceStatusOutput => {
|
||||||
|
const { uiConfig } = useUiConfig();
|
||||||
|
const {
|
||||||
|
flags: { UNLEASH_CLOUD },
|
||||||
|
} = uiConfig;
|
||||||
|
|
||||||
const { data, refetch, loading, error } = useApiGetter(
|
const { data, refetch, loading, error } = useApiGetter(
|
||||||
'useInstanceStatus',
|
'useInstanceStatus',
|
||||||
fetchInstanceStatus
|
() => fetchInstanceStatus(UNLEASH_CLOUD)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refetch();
|
||||||
|
}, [refetch, UNLEASH_CLOUD]);
|
||||||
|
|
||||||
|
const billingPlans = [
|
||||||
|
InstancePlan.PRO,
|
||||||
|
InstancePlan.COMPANY,
|
||||||
|
InstancePlan.TEAM,
|
||||||
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
instanceStatus: data,
|
instanceStatus: data,
|
||||||
refetchInstanceStatus: refetch,
|
refetchInstanceStatus: refetch,
|
||||||
|
isBilling: billingPlans.includes(data?.plan ?? InstancePlan.UNKNOWN),
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchInstanceStatus = async (): Promise<IInstanceStatus> => {
|
const fetchInstanceStatus = async (
|
||||||
if (!enableInstanceStatusBarFeature()) {
|
UNLEASH_CLOUD?: boolean
|
||||||
|
): Promise<IInstanceStatus> => {
|
||||||
|
if (!UNLEASH_CLOUD) {
|
||||||
return UNKNOWN_INSTANCE_STATUS;
|
return UNKNOWN_INSTANCE_STATUS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -38,11 +58,6 @@ const fetchInstanceStatus = async (): Promise<IInstanceStatus> => {
|
|||||||
return res.json();
|
return res.json();
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO(olav): Enable instance status bar feature outside of localhost.
|
|
||||||
const enableInstanceStatusBarFeature = () => {
|
|
||||||
return isLocalhostDomain();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const UNKNOWN_INSTANCE_STATUS: IInstanceStatus = {
|
export const UNKNOWN_INSTANCE_STATUS: IInstanceStatus = {
|
||||||
plan: 'unknown',
|
plan: InstancePlan.UNKNOWN,
|
||||||
};
|
};
|
||||||
|
@ -14,6 +14,7 @@ export const defaultValue = {
|
|||||||
CO: false,
|
CO: false,
|
||||||
SE: false,
|
SE: false,
|
||||||
T: false,
|
T: false,
|
||||||
|
UNLEASH_CLOUD: false,
|
||||||
},
|
},
|
||||||
links: [
|
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 {
|
export interface IInstanceStatus {
|
||||||
plan: string;
|
plan: InstancePlan;
|
||||||
trialExpiry?: string;
|
trialExpiry?: string;
|
||||||
trialStart?: string;
|
trialStart?: string;
|
||||||
trialExtended?: number;
|
trialExtended?: number;
|
||||||
billingCenter?: string;
|
billingCenter?: string;
|
||||||
state?: InstanceState;
|
state?: InstanceState;
|
||||||
|
seats?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum InstanceState {
|
export enum InstanceState {
|
||||||
@ -14,3 +15,10 @@ export enum InstanceState {
|
|||||||
EXPIRED = 'EXPIRED',
|
EXPIRED = 'EXPIRED',
|
||||||
CHURNED = 'CHURNED',
|
CHURNED = 'CHURNED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum InstancePlan {
|
||||||
|
PRO = 'Pro',
|
||||||
|
COMPANY = 'Company',
|
||||||
|
TEAM = 'Team',
|
||||||
|
UNKNOWN = 'Unknown',
|
||||||
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
export interface IInvoice {
|
export interface IInvoice {
|
||||||
amountFomratted: string;
|
amountFormatted: string;
|
||||||
invoicePDF: string;
|
invoicePDF: string;
|
||||||
invoiceURL: string;
|
invoiceURL: string;
|
||||||
paid: boolean;
|
paid: boolean;
|
||||||
|
@ -16,4 +16,5 @@ interface IRouteMenu {
|
|||||||
mobile?: boolean;
|
mobile?: boolean;
|
||||||
advanced?: boolean;
|
advanced?: boolean;
|
||||||
adminSettings?: boolean;
|
adminSettings?: boolean;
|
||||||
|
isBilling?: boolean;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,7 @@ export interface IFlags {
|
|||||||
CO?: boolean;
|
CO?: boolean;
|
||||||
SE?: boolean;
|
SE?: boolean;
|
||||||
T?: boolean;
|
T?: boolean;
|
||||||
|
UNLEASH_CLOUD?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IVersionInfo {
|
export interface IVersionInfo {
|
||||||
|
@ -11,6 +11,7 @@ export interface IUser {
|
|||||||
seenAt: string | null;
|
seenAt: string | null;
|
||||||
username?: string;
|
username?: string;
|
||||||
isAPI: boolean;
|
isAPI: boolean;
|
||||||
|
paid?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPermission {
|
export interface IPermission {
|
||||||
|
@ -135,6 +135,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
|
cursor: pointer;
|
||||||
color: #615bc2;
|
color: #615bc2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,6 +13,7 @@ export default createTheme({
|
|||||||
},
|
},
|
||||||
boxShadows: {
|
boxShadows: {
|
||||||
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
main: '0px 2px 4px rgba(129, 122, 254, 0.2)',
|
||||||
|
elevated: '0px 1px 20px rgba(45, 42, 89, 0.1)',
|
||||||
},
|
},
|
||||||
typography: {
|
typography: {
|
||||||
fontFamily: 'Sen, Roboto, sans-serif',
|
fontFamily: 'Sen, Roboto, sans-serif',
|
||||||
@ -61,26 +62,31 @@ export default createTheme({
|
|||||||
light: colors.blue[50],
|
light: colors.blue[50],
|
||||||
main: colors.blue[500],
|
main: colors.blue[500],
|
||||||
dark: colors.blue[700],
|
dark: colors.blue[700],
|
||||||
|
border: colors.blue[200],
|
||||||
},
|
},
|
||||||
success: {
|
success: {
|
||||||
light: colors.green[50],
|
light: colors.green[50],
|
||||||
main: colors.green[500],
|
main: colors.green[500],
|
||||||
dark: colors.green[800],
|
dark: colors.green[800],
|
||||||
|
border: colors.green[300],
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
light: colors.orange[100],
|
light: colors.orange[100],
|
||||||
main: colors.orange[800],
|
main: colors.orange[800],
|
||||||
dark: colors.orange[900],
|
dark: colors.orange[900],
|
||||||
|
border: colors.orange[500],
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
light: colors.red[50],
|
light: colors.red[50],
|
||||||
main: colors.red[700],
|
main: colors.red[700],
|
||||||
dark: colors.red[800],
|
dark: colors.red[800],
|
||||||
|
border: colors.red[300],
|
||||||
},
|
},
|
||||||
divider: colors.grey[300],
|
divider: colors.grey[300],
|
||||||
dividerAlternative: colors.grey[500],
|
dividerAlternative: colors.grey[400],
|
||||||
tableHeaderHover: colors.grey[400],
|
tableHeaderHover: colors.grey[400],
|
||||||
highlight: '#FFEACC',
|
highlight: '#FFEACC',
|
||||||
|
secondaryContainer: colors.grey[200],
|
||||||
sidebarContainer: 'rgba(32,32,33, 0.2)',
|
sidebarContainer: 'rgba(32,32,33, 0.2)',
|
||||||
grey: colors.grey,
|
grey: colors.grey,
|
||||||
text: {
|
text: {
|
||||||
|
@ -24,6 +24,7 @@ declare module '@mui/material/styles' {
|
|||||||
*/
|
*/
|
||||||
boxShadows: {
|
boxShadows: {
|
||||||
main: string;
|
main: string;
|
||||||
|
elevated: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,7 +66,11 @@ declare module '@mui/material/styles' {
|
|||||||
*/
|
*/
|
||||||
highlight: string;
|
highlight: string;
|
||||||
/**
|
/**
|
||||||
* For sidebar container background.
|
* Background color for secondary containers.
|
||||||
|
*/
|
||||||
|
secondaryContainer: string;
|
||||||
|
/**
|
||||||
|
* Background color for sidebar containers.
|
||||||
*/
|
*/
|
||||||
sidebarContainer: string;
|
sidebarContainer: string;
|
||||||
/**
|
/**
|
||||||
@ -83,6 +88,19 @@ declare module '@mui/material/styles' {
|
|||||||
|
|
||||||
interface Palette extends CustomPalette {}
|
interface Palette extends CustomPalette {}
|
||||||
interface PaletteOptions 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' {
|
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