0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(console): add subscription info for tenant selector (#4200)

This commit is contained in:
Xiao Yijun 2023-07-21 15:29:30 +08:00 committed by GitHub
parent a54fd502bd
commit 41bc73c65d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 215 additions and 55 deletions

View file

@ -0,0 +1,8 @@
@use '@/scss/underscore' as _;
.skeleton {
@include _.shimmering-animation;
width: 48px;
height: 16px;
border-radius: 6px;
}

View file

@ -0,0 +1,7 @@
import * as styles from './index.module.scss';
function Skeleton() {
return <div className={styles.skeleton} />;
}
export default Skeleton;

View file

@ -0,0 +1,72 @@
import { conditional } from '@silverhand/essentials';
import { useMemo } from 'react';
import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag';
import useInvoices from '@/hooks/use-invoices';
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
import useSubscriptionUsage from '@/hooks/use-subscription-usage';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
import Skeleton from './Skeleton';
type Props = {
tenantId: string;
className?: string;
};
function TenantStatusTag({ tenantId, className }: Props) {
const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId);
const { data: invoices, error: fetchInvoiceError } = useInvoices(tenantId);
const { data: subscriptionPlan, error: fetchSubscriptionError } = useSubscriptionPlan(tenantId);
const isLoadingUsage = !usage && !fetchUsageError;
const isLoadingInvoice = !invoices && !fetchInvoiceError;
const isLoadingSubscription = !subscriptionPlan && !fetchSubscriptionError;
const latestUnpaidInvoice = useMemo(
() => conditional(invoices && getLatestUnpaidInvoice(invoices)),
[invoices]
);
if (isLoadingUsage || isLoadingInvoice || isLoadingSubscription) {
return <Skeleton />;
}
/**
* Tenant status priority:
* 1. suspend (WIP) @xiaoyijun
* 2. overdue
* 3. mau exceeded
*/
if (invoices && latestUnpaidInvoice) {
return (
<Tag className={className}>
<DynamicT forKey="tenants.status.overdue" />
</Tag>
);
}
if (subscriptionPlan && usage) {
const { activeUsers } = usage;
const {
quota: { mauLimit },
} = subscriptionPlan;
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
if (isMauExceeded) {
return (
<Tag className={className}>
<DynamicT forKey="tenants.status.mau_exceeded" />
</Tag>
);
}
}
return null;
}
export default TenantStatusTag;

View file

@ -0,0 +1,60 @@
@use '@/scss/underscore' as _;
.item {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(3) _.unit(2.5) _.unit(4);
margin: _.unit(1);
border-radius: 6px;
transition: background-color 0.2s ease-in-out;
justify-content: space-between;
&:hover {
background: var(--color-hover);
}
&:not(:disabled) {
cursor: pointer;
}
.info {
display: flex;
flex-direction: column;
margin-right: _.unit(4);
.meta {
display: flex;
align-items: center;
gap: _.unit(2);
.name {
font: var(--font-body-2);
@include _.text-ellipsis;
}
.statusTag {
background-color: var(--color-on-error-container);
color: var(--color-white);
font: var(--font-label-3);
}
}
.planName {
margin-top: _.unit(0.5);
font: var(--font-body-3);
color: var(--color-text-secondary);
}
}
.checkIcon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: transparent;
&.visible {
color: var(--color-primary-40);
}
}
}

View file

@ -0,0 +1,45 @@
import { type TenantInfo } from '@logto/schemas/models';
import classNames from 'classnames';
import Tick from '@/assets/icons/tick.svg';
import PlanName from '@/components/PlanName';
import { isProduction } from '@/consts/env';
import { DropdownItem } from '@/ds-components/Dropdown';
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
import TenantEnvTag from '../TenantEnvTag';
import Skeleton from './Skeleton';
import TenantStatusTag from './TenantStatusTag';
import * as styles from './index.module.scss';
type Props = {
tenantData: TenantInfo;
isSelected: boolean;
onClick: () => void;
};
function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
const { id, name, tag } = tenantData;
const { data: tenantPlan } = useSubscriptionPlan(id);
return (
<DropdownItem className={styles.item} onClick={onClick}>
<div className={styles.info}>
<div className={styles.meta}>
<div className={styles.name}>{name}</div>
<TenantEnvTag tag={tag} />
{!isProduction && <TenantStatusTag tenantId={id} className={styles.statusTag} />}
</div>
{!isProduction && (
<div className={styles.planName}>
{tenantPlan ? <PlanName name={tenantPlan.name} /> : <Skeleton />}
</div>
)}
</div>
<Tick className={classNames(styles.checkIcon, isSelected && styles.visible)} />
</DropdownItem>
);
}
export default TenantDropdownItem;

View file

@ -71,46 +71,6 @@ $dropdown-item-height: 40px;
}
}
.dropdownItem {
display: flex;
align-items: center;
padding: _.unit(2.5) _.unit(3) _.unit(2.5) _.unit(4);
margin: _.unit(1);
border-radius: 6px;
transition: background-color 0.2s ease-in-out;
&:hover {
background: var(--color-hover);
}
&:not(:disabled) {
cursor: pointer;
}
.dropdownName {
font: var(--font-body-2);
margin-right: _.unit(2);
@include _.text-ellipsis;
}
.dropdownTag {
font: var(--font-body-3);
margin-right: _.unit(4);
}
.checkIcon {
width: 20px;
height: 20px;
flex-shrink: 0;
color: transparent;
margin-left: auto;
&.visible {
color: var(--color-primary-40);
}
}
}
.createTenantButton {
all: unset;
/**

View file

@ -6,14 +6,14 @@ import { useTranslation } from 'react-i18next';
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
import PlusSign from '@/assets/icons/plus.svg';
import Tick from '@/assets/icons/tick.svg';
import CreateTenantModal from '@/components/CreateTenantModal';
import { TenantsContext } from '@/contexts/TenantsProvider';
import Divider from '@/ds-components/Divider';
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
import Dropdown from '@/ds-components/Dropdown';
import OverlayScrollbar from '@/ds-components/OverlayScrollbar';
import { onKeyDownHandler } from '@/utils/a11y';
import TenantDropdownItem from './TenantDropdownItem';
import TenantEnvTag from './TenantEnvTag';
import * as styles from './index.module.scss';
@ -70,21 +70,16 @@ export default function TenantSelector() {
}}
>
<OverlayScrollbar className={styles.scrollableContent}>
{tenants.map(({ id, name, tag }) => (
<DropdownItem
key={id}
className={styles.dropdownItem}
{tenants.map((tenantData) => (
<TenantDropdownItem
key={tenantData.id}
tenantData={tenantData}
isSelected={tenantData.id === currentTenantId}
onClick={() => {
navigateTenant(id);
navigateTenant(tenantData.id);
setShowDropdown(false);
}}
>
<div className={styles.dropdownName}>{name}</div>
<TenantEnvTag className={styles.dropdownTag} tag={tag} />
<Tick
className={classNames(styles.checkIcon, id === currentTenantId && styles.visible)}
/>
</DropdownItem>
/>
))}
</OverlayScrollbar>
<Divider />

View file

@ -60,4 +60,6 @@ export const localCheckoutSessionGuard = z.object({
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];
export type Invoice = InvoicesResponse['invoices'][number];
export type InvoiceStatus = Invoice['status'];

View file

@ -6,6 +6,7 @@ import {
reservedPlanIdOrder,
ticketSupportResponseTimeMap,
} from '@/consts/subscriptions';
import { type Invoice } from '@/types/subscriptions';
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
const { name, quota } = subscriptionPlanResponse;
@ -35,9 +36,19 @@ type FormatPeriodOptions = {
periodEnd: Date;
displayYear?: boolean;
};
export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeriodOptions) => {
const format = displayYear ? 'MMM D, YYYY' : 'MMM D';
const formattedStart = dayjs(periodStart).format(format);
const formattedEnd = dayjs(periodEnd).format(format);
return `${formattedStart} - ${formattedEnd}`;
};
export const getLatestUnpaidInvoice = (invoices: Invoice[]) =>
invoices
.slice()
.sort(
(invoiceA, invoiceB) =>
new Date(invoiceB.createdAt).getTime() - new Date(invoiceA.createdAt).getTime()
)
.find(({ status }) => status === 'uncollectible');