mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
feat(console): select plan before tenant creation (#4175)
This commit is contained in:
parent
f241dd3818
commit
e2fc6cb545
23 changed files with 447 additions and 75 deletions
|
@ -6,12 +6,12 @@ import { useContext, useState } from 'react';
|
||||||
import Plus from '@/assets/icons/plus.svg';
|
import Plus from '@/assets/icons/plus.svg';
|
||||||
import TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark.svg';
|
import TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark.svg';
|
||||||
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
|
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
|
||||||
|
import CreateTenantModal from '@/components/CreateTenantModal';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import useTheme from '@/hooks/use-theme';
|
import useTheme from '@/hooks/use-theme';
|
||||||
|
|
||||||
import CreateTenantModal from './CreateTenantModal';
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -52,6 +52,7 @@ function TenantLandingPageContent({ className }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<CreateTenantModal
|
<CreateTenantModal
|
||||||
|
skipPlanSelection
|
||||||
isOpen={isCreateModalOpen}
|
isOpen={isCreateModalOpen}
|
||||||
onClose={async (tenant?: TenantInfo) => {
|
onClose={async (tenant?: TenantInfo) => {
|
||||||
if (tenant) {
|
if (tenant) {
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-divider);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planInfo {
|
||||||
|
padding: _.unit(6);
|
||||||
|
border-bottom: 1px solid var(--color-divider);
|
||||||
|
|
||||||
|
> div:not(:first-child) {
|
||||||
|
margin-top: _.unit(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font: var(--font-headline-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceInfo {
|
||||||
|
> div:not(:first-child) {
|
||||||
|
margin-top: _.unit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.priceLabel {
|
||||||
|
font: var(--font-body-3);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font: var(--font-headline-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unitPrices {
|
||||||
|
margin-left: _.unit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: _.unit(1);
|
||||||
|
font: var(--font-body-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
padding: _.unit(6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: _.unit(4);
|
||||||
|
|
||||||
|
&.exceedFreeTenantsTip {
|
||||||
|
font: var(--font-body-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
font: var(--font-label-2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.linkIcon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: _.unit(8);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { maxFreeTenantLimit } from '@logto/schemas';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useContext, useMemo } from 'react';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import ArrowRight from '@/assets/icons/arrow-right.svg';
|
||||||
|
import PlanDescription from '@/components/PlanDescription';
|
||||||
|
import PlanName from '@/components/PlanName';
|
||||||
|
import PlanQuotaList from '@/components/PlanQuotaList';
|
||||||
|
import { ReservedPlanId } from '@/consts/subscriptions';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
import Button from '@/ds-components/Button';
|
||||||
|
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||||
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
|
import TextLink from '@/ds-components/TextLink';
|
||||||
|
import { type SubscriptionPlanQuota, type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
|
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
const featuredQuotaKeys: Array<keyof SubscriptionPlanQuota> = [
|
||||||
|
'mauLimit',
|
||||||
|
'machineToMachineLimit',
|
||||||
|
'standardConnectorsLimit',
|
||||||
|
'rolesLimit',
|
||||||
|
'scopesPerRoleLimit',
|
||||||
|
'auditLogsRetentionDays',
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
onSelect: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PlanCardItem({ plan, onSelect }: Props) {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.create_tenant' });
|
||||||
|
const { tenants } = useContext(TenantsContext);
|
||||||
|
const { stripeProducts, id: planId, name: planName, quota } = plan;
|
||||||
|
|
||||||
|
const basePrice = useMemo(
|
||||||
|
() => stripeProducts.find(({ type }) => type === 'flat')?.price.unitAmountDecimal ?? 0,
|
||||||
|
[stripeProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const tierPrices = useMemo(() => {
|
||||||
|
const prices = stripeProducts
|
||||||
|
.filter(({ type }) => type !== 'flat')
|
||||||
|
.map(({ price: { unitAmountDecimal } }) => `$${Number(unitAmountDecimal) / 100}`);
|
||||||
|
|
||||||
|
return prices.length > 0 ? prices.join(' ') : '$0.00';
|
||||||
|
}, [stripeProducts]);
|
||||||
|
|
||||||
|
const isFreePlan = planId === ReservedPlanId.free;
|
||||||
|
|
||||||
|
// Todo: @xiaoyijun filter our all free tenants
|
||||||
|
const isFreeTenantExceeded = tenants.length >= maxFreeTenantLimit;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<div className={styles.planInfo}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
<PlanName name={planName} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceInfo}>
|
||||||
|
<div className={styles.priceLabel}>{t('base_price')}</div>
|
||||||
|
<div className={styles.price}>
|
||||||
|
${t('monthly_price', { value: Number(basePrice) / 100 })}
|
||||||
|
</div>
|
||||||
|
<div className={styles.priceLabel}>
|
||||||
|
{t('mau_unit_price')}
|
||||||
|
<span className={styles.unitPrices}>{tierPrices}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.description}>
|
||||||
|
<PlanDescription planName={planName} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.content}>
|
||||||
|
<PlanQuotaList
|
||||||
|
hasIcon
|
||||||
|
quota={quota}
|
||||||
|
featuredQuotaKeys={featuredQuotaKeys}
|
||||||
|
className={styles.list}
|
||||||
|
/>
|
||||||
|
{isFreePlan && isFreeTenantExceeded && (
|
||||||
|
<div className={classNames(styles.tip, styles.exceedFreeTenantsTip)}>
|
||||||
|
{t('free_tenants_limit', { count: maxFreeTenantLimit })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!isFreePlan && (
|
||||||
|
<div className={styles.tip}>
|
||||||
|
<TextLink
|
||||||
|
isTrailingIcon
|
||||||
|
href="https://logto.io/pricing"
|
||||||
|
icon={<ArrowRight className={styles.linkIcon} />}
|
||||||
|
target="_blank"
|
||||||
|
className={styles.link}
|
||||||
|
>
|
||||||
|
<DynamicT forKey="upsell.create_tenant.view_all_features" />
|
||||||
|
</TextLink>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
title={
|
||||||
|
<DangerousRaw>
|
||||||
|
<Trans components={{ name: <PlanName name={planName} /> }}>
|
||||||
|
{t(isFreePlan ? 'select_plan' : 'upgrade_to')}
|
||||||
|
</Trans>
|
||||||
|
</DangerousRaw>
|
||||||
|
}
|
||||||
|
disabled={isFreePlan && isFreeTenantExceeded}
|
||||||
|
type={isFreePlan ? 'outline' : 'primary'}
|
||||||
|
size="large"
|
||||||
|
onClick={onSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlanCardItem;
|
|
@ -0,0 +1,9 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: _.unit(7);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { type TenantInfo } from '@logto/schemas/models';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import Modal from 'react-modal';
|
||||||
|
|
||||||
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
|
import { ReservedPlanId } from '@/consts/subscriptions';
|
||||||
|
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||||
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
|
import TextLink from '@/ds-components/TextLink';
|
||||||
|
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||||
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
|
|
||||||
|
import { type CreateTenantData } from '../type';
|
||||||
|
|
||||||
|
import PlanCardItem from './PlanCardItem';
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
tenantData?: CreateTenantData;
|
||||||
|
onClose: (tenant?: TenantInfo) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SelectTenantPlanModal({ tenantData, onClose }: Props) {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
const { data: subscriptionPlans } = useSubscriptionPlans();
|
||||||
|
const cloudApi = useCloudApi();
|
||||||
|
if (!subscriptionPlans || !tenantData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectPlan = async (plan: SubscriptionPlan) => {
|
||||||
|
const { id: planId } = plan;
|
||||||
|
if (planId === ReservedPlanId.free) {
|
||||||
|
try {
|
||||||
|
const { name, tag } = tenantData;
|
||||||
|
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
||||||
|
|
||||||
|
onClose(newTenant);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Todo @xiaoyijun implement checkout
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
shouldCloseOnEsc
|
||||||
|
isOpen={Boolean(tenantData)}
|
||||||
|
className={modalStyles.content}
|
||||||
|
overlayClassName={modalStyles.overlay}
|
||||||
|
onRequestClose={() => {
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ModalLayout
|
||||||
|
title="upsell.create_tenant.title"
|
||||||
|
subtitle={
|
||||||
|
<DangerousRaw>
|
||||||
|
<Trans
|
||||||
|
components={{
|
||||||
|
a: <TextLink href="https://logto.io/pricing" target="_blank" />,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('upsell.create_tenant.description')}
|
||||||
|
</Trans>
|
||||||
|
</DangerousRaw>
|
||||||
|
}
|
||||||
|
size="xlarge"
|
||||||
|
onClose={onClose}
|
||||||
|
>
|
||||||
|
<div className={styles.container}>
|
||||||
|
{subscriptionPlans.map((plan) => (
|
||||||
|
<PlanCardItem
|
||||||
|
key={plan.id}
|
||||||
|
plan={plan}
|
||||||
|
onSelect={() => {
|
||||||
|
void handleSelectPlan(plan);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ModalLayout>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectTenantPlanModal;
|
|
@ -1,6 +1,7 @@
|
||||||
import type { AdminConsoleKey } from '@logto/phrases';
|
import type { AdminConsoleKey } from '@logto/phrases';
|
||||||
import { Theme } from '@logto/schemas';
|
import { Theme } from '@logto/schemas';
|
||||||
import { TenantTag, type TenantInfo } from '@logto/schemas/models';
|
import { TenantTag, type TenantInfo } from '@logto/schemas/models';
|
||||||
|
import { useState } from 'react';
|
||||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
@ -9,6 +10,7 @@ import Modal from 'react-modal';
|
||||||
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
|
import CreateTenantHeaderIconDark from '@/assets/icons/create-tenant-header-dark.svg';
|
||||||
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
|
import CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
|
||||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
|
import { isProduction } from '@/consts/env';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import FormField from '@/ds-components/FormField';
|
import FormField from '@/ds-components/FormField';
|
||||||
import ModalLayout from '@/ds-components/ModalLayout';
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
|
@ -17,11 +19,15 @@ import TextInput from '@/ds-components/TextInput';
|
||||||
import useTheme from '@/hooks/use-theme';
|
import useTheme from '@/hooks/use-theme';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
|
|
||||||
|
import SelectTenantPlanModal from './SelectTenantPlanModal';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
import { type CreateTenantData } from './type';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: (tenant?: TenantInfo) => void;
|
onClose: (tenant?: TenantInfo) => void;
|
||||||
|
// eslint-disable-next-line react/boolean-prop-naming
|
||||||
|
skipPlanSelection?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagOptions: Array<{ title: AdminConsoleKey; value: TenantTag }> = [
|
const tagOptions: Array<{ title: AdminConsoleKey; value: TenantTag }> = [
|
||||||
|
@ -39,12 +45,14 @@ const tagOptions: Array<{ title: AdminConsoleKey; value: TenantTag }> = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function CreateTenantModal({ isOpen, onClose }: Props) {
|
function CreateTenantModal({ isOpen, onClose, skipPlanSelection = false }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
const [tenantData, setTenantData] = useState<CreateTenantData>();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const methods = useForm<Pick<TenantInfo, 'name' | 'tag'>>({
|
const methods = useForm<CreateTenantData>({
|
||||||
defaultValues: { tag: TenantTag.Development },
|
defaultValues: { tag: TenantTag.Development },
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
reset,
|
reset,
|
||||||
control,
|
control,
|
||||||
|
@ -55,7 +63,7 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
||||||
|
|
||||||
const cloudApi = useCloudApi();
|
const cloudApi = useCloudApi();
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const createTenant = async (data: CreateTenantData) => {
|
||||||
try {
|
try {
|
||||||
const { name, tag } = data;
|
const { name, tag } = data;
|
||||||
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
||||||
|
@ -64,7 +72,15 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Note: create tenant directly if it's from landing page,
|
||||||
|
* since we want the user to get into the console as soon as possible
|
||||||
|
*/
|
||||||
|
const shouldSkipPlanSelection = skipPlanSelection || isProduction;
|
||||||
|
|
||||||
|
const onCreateClick = handleSubmit(shouldSkipPlanSelection ? createTenant : setTenantData);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
@ -90,7 +106,7 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
||||||
title="tenants.create_modal.create_button"
|
title="tenants.create_modal.create_button"
|
||||||
size="large"
|
size="large"
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={onSubmit}
|
onClick={onCreateClick}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
@ -122,6 +138,18 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
||||||
</div>
|
</div>
|
||||||
</FormField>
|
</FormField>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
|
<SelectTenantPlanModal
|
||||||
|
tenantData={tenantData}
|
||||||
|
onClose={(tenant) => {
|
||||||
|
setTenantData(undefined);
|
||||||
|
if (tenant) {
|
||||||
|
/**
|
||||||
|
* Note: only close the create tenant modal when tenant is created successfully
|
||||||
|
*/
|
||||||
|
onClose(tenant);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</ModalLayout>
|
</ModalLayout>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { type TenantInfo } from '@logto/schemas/models';
|
||||||
|
|
||||||
|
export type CreateTenantData = Pick<TenantInfo, 'name' | 'tag'>;
|
|
@ -1,7 +0,0 @@
|
||||||
@use '@/scss/underscore' as _;
|
|
||||||
|
|
||||||
.description {
|
|
||||||
margin-top: _.unit(1);
|
|
||||||
font: var(--font-body-2);
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
}
|
|
|
@ -3,8 +3,6 @@ import { type TFuncKey } from 'i18next';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { ReservedPlanName } from '@/types/subscriptions';
|
import { ReservedPlanName } from '@/types/subscriptions';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
|
||||||
|
|
||||||
const registeredPlanDescriptionPhrasesMap: Record<
|
const registeredPlanDescriptionPhrasesMap: Record<
|
||||||
string,
|
string,
|
||||||
TFuncKey<'translation', 'admin_console.subscription'> | undefined
|
TFuncKey<'translation', 'admin_console.subscription'> | undefined
|
||||||
|
@ -23,11 +21,7 @@ function PlanDescription({ planName }: Props) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <DynamicT forKey={`subscription.${description}`} />;
|
||||||
<div className={styles.description}>
|
|
||||||
<DynamicT forKey={`subscription.${description}`} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlanDescription;
|
export default PlanDescription;
|
||||||
|
|
|
@ -2,8 +2,9 @@
|
||||||
|
|
||||||
.item {
|
.item {
|
||||||
margin-left: _.unit(4);
|
margin-left: _.unit(4);
|
||||||
|
font: var(--font-body-2);
|
||||||
|
|
||||||
&.withChangeState {
|
&.withIcon {
|
||||||
list-style-type: none;
|
list-style-type: none;
|
||||||
margin-left: unset;
|
margin-left: unset;
|
||||||
}
|
}
|
||||||
|
@ -16,14 +17,18 @@
|
||||||
.icon {
|
.icon {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
|
|
||||||
|
&.notCapable {
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.capable {
|
||||||
|
color: var(--color-on-success-container);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.notCapable {
|
.lineThrough {
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
|
|
||||||
.icon {
|
|
||||||
color: var(--color-error-hover);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -4,11 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import DescendArrow from '@/assets/icons/descend-arrow.svg';
|
import DescendArrow from '@/assets/icons/descend-arrow.svg';
|
||||||
import Failed from '@/assets/icons/failed.svg';
|
import Failed from '@/assets/icons/failed.svg';
|
||||||
|
import Success from '@/assets/icons/success.svg';
|
||||||
import {
|
import {
|
||||||
quotaItemUnlimitedPhrasesMap,
|
quotaItemUnlimitedPhrasesMap,
|
||||||
quotaItemPhrasesMap,
|
quotaItemPhrasesMap,
|
||||||
quotaItemLimitedPhrasesMap,
|
quotaItemLimitedPhrasesMap,
|
||||||
} from '@/pages/TenantSettings/Subscription/quota-item-phrases';
|
} from '@/consts/quota-item-phrases';
|
||||||
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
@ -17,23 +18,26 @@ type Props = {
|
||||||
hasIcon?: boolean;
|
hasIcon?: boolean;
|
||||||
quotaKey: keyof SubscriptionPlanQuota;
|
quotaKey: keyof SubscriptionPlanQuota;
|
||||||
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
|
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
|
||||||
|
isDiffItem?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function QuotaDiffItem({ hasIcon = false, quotaKey, quotaValue }: Props) {
|
function QuotaItem({ hasIcon, quotaKey, quotaValue, isDiffItem }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription.quota_item' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription.quota_item' });
|
||||||
const isUnlimited = quotaValue === null;
|
const isUnlimited = quotaValue === null;
|
||||||
const isNotCapable = quotaValue === 0 || quotaValue === false;
|
const isNotCapable = quotaValue === 0 || quotaValue === false;
|
||||||
const isLimited = Boolean(quotaValue);
|
const isLimited = Boolean(quotaValue);
|
||||||
|
|
||||||
const Icon = isNotCapable ? Failed : DescendArrow;
|
const Icon = isNotCapable ? Failed : isDiffItem ? DescendArrow : Success;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className={classNames(styles.item, hasIcon && styles.withChangeState)}>
|
<li className={classNames(styles.item, hasIcon && styles.withIcon)}>
|
||||||
<span
|
<span className={styles.itemContent}>
|
||||||
className={classNames(styles.itemContent, hasIcon && isNotCapable && styles.notCapable)}
|
{hasIcon && (
|
||||||
>
|
<Icon
|
||||||
{hasIcon && <Icon className={styles.icon} />}
|
className={classNames(styles.icon, isNotCapable ? styles.notCapable : styles.capable)}
|
||||||
<span>
|
/>
|
||||||
|
)}
|
||||||
|
<span className={classNames(isDiffItem && isNotCapable && styles.lineThrough)}>
|
||||||
{isUnlimited && <>{t(quotaItemUnlimitedPhrasesMap[quotaKey])}</>}
|
{isUnlimited && <>{t(quotaItemUnlimitedPhrasesMap[quotaKey])}</>}
|
||||||
{isNotCapable && <>{t(quotaItemPhrasesMap[quotaKey])}</>}
|
{isNotCapable && <>{t(quotaItemPhrasesMap[quotaKey])}</>}
|
||||||
{isLimited && (
|
{isLimited && (
|
||||||
|
@ -50,4 +54,4 @@ function QuotaDiffItem({ hasIcon = false, quotaKey, quotaValue }: Props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default QuotaDiffItem;
|
export default QuotaItem;
|
|
@ -0,0 +1,12 @@
|
||||||
|
@use '@/scss/underscore' as _;
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin-block: 0;
|
||||||
|
padding-inline: 0;
|
||||||
|
|
||||||
|
> li {
|
||||||
|
&:not(:first-child) {
|
||||||
|
margin-top: _.unit(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
packages/console/src/components/PlanQuotaList/index.tsx
Normal file
47
packages/console/src/components/PlanQuotaList/index.tsx
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||||
|
|
||||||
|
import QuotaItem from './QuotaItem';
|
||||||
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
quota: Partial<SubscriptionPlanQuota>;
|
||||||
|
featuredQuotaKeys?: Array<keyof SubscriptionPlanQuota>;
|
||||||
|
className?: string;
|
||||||
|
isDiff?: boolean;
|
||||||
|
hasIcon?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PlanQuotaList({ quota, featuredQuotaKeys, isDiff, hasIcon, className }: Props) {
|
||||||
|
const items = useMemo(() => {
|
||||||
|
// Todo: @xiaoyijun LOG-6540 order keys
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
|
const entries = Object.entries(quota) as Array<
|
||||||
|
[keyof SubscriptionPlanQuota, SubscriptionPlanQuota[keyof SubscriptionPlanQuota]]
|
||||||
|
>;
|
||||||
|
|
||||||
|
const featuredEntries = featuredQuotaKeys
|
||||||
|
? entries.filter(([key]) => featuredQuotaKeys.includes(key))
|
||||||
|
: entries;
|
||||||
|
|
||||||
|
return featuredEntries;
|
||||||
|
}, [quota, featuredQuotaKeys]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={classNames(styles.list, className)}>
|
||||||
|
{items.map(([quotaKey, quotaValue]) => (
|
||||||
|
<QuotaItem
|
||||||
|
key={quotaKey}
|
||||||
|
isDiffItem={isDiff}
|
||||||
|
quotaKey={quotaKey}
|
||||||
|
quotaValue={quotaValue}
|
||||||
|
hasIcon={hasIcon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PlanQuotaList;
|
|
@ -6,7 +6,7 @@ import {
|
||||||
SubscriptionPlanTableGroupKey,
|
SubscriptionPlanTableGroupKey,
|
||||||
} from '@/types/subscriptions';
|
} from '@/types/subscriptions';
|
||||||
|
|
||||||
enum ReservedPlanId {
|
export enum ReservedPlanId {
|
||||||
free = 'free',
|
free = 'free',
|
||||||
hobby = 'hobby',
|
hobby = 'hobby',
|
||||||
pro = 'pro',
|
pro = 'pro',
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
|
import KeyboardArrowDown from '@/assets/icons/keyboard-arrow-down.svg';
|
||||||
import PlusSign from '@/assets/icons/plus.svg';
|
import PlusSign from '@/assets/icons/plus.svg';
|
||||||
import Tick from '@/assets/icons/tick.svg';
|
import Tick from '@/assets/icons/tick.svg';
|
||||||
import CreateTenantModal from '@/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/CreateTenantModal';
|
import CreateTenantModal from '@/components/CreateTenantModal';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import Divider from '@/ds-components/Divider';
|
import Divider from '@/ds-components/Divider';
|
||||||
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
|
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
|
||||||
|
|
|
@ -6,6 +6,12 @@
|
||||||
.name {
|
.name {
|
||||||
font: var(--font-title-1);
|
font: var(--font-title-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: _.unit(1);
|
||||||
|
font: var(--font-body-2);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification {
|
.notification {
|
||||||
|
|
|
@ -25,7 +25,9 @@ function CurrentPlan({ subscription, subscriptionPlan, subscriptionUsage }: Prop
|
||||||
<div className={styles.name}>
|
<div className={styles.name}>
|
||||||
<PlanName name={name} />
|
<PlanName name={name} />
|
||||||
</div>
|
</div>
|
||||||
<PlanDescription planName={name} />
|
<div className={styles.description}>
|
||||||
|
<PlanDescription planName={name} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FormField title="subscription.plan_usage">
|
<FormField title="subscription.plan_usage">
|
||||||
<PlanUsage
|
<PlanUsage
|
||||||
|
|
|
@ -10,15 +10,4 @@
|
||||||
font: var(--font-title-2);
|
font: var(--font-title-2);
|
||||||
margin-bottom: _.unit(3);
|
margin-bottom: _.unit(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
|
||||||
font: var(--font-body-2);
|
|
||||||
padding-inline-start: 0;
|
|
||||||
|
|
||||||
> li {
|
|
||||||
&:not(:first-child) {
|
|
||||||
margin-top: _.unit(3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -1,10 +1,9 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import PlanName from '@/components/PlanName';
|
import PlanName from '@/components/PlanName';
|
||||||
|
import PlanQuotaList from '@/components/PlanQuotaList';
|
||||||
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||||
|
|
||||||
import QuotaDiffItem from './QuotaDiffItem';
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -13,17 +12,11 @@ type Props = {
|
||||||
isTarget?: boolean;
|
isTarget?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function PlanQuotaDiffList({ planName, quotaDiff, isTarget = false }: Props) {
|
function PlanQuotaDiffCard({ planName, quotaDiff, isTarget = false }: Props) {
|
||||||
const { t } = useTranslation(undefined, {
|
const { t } = useTranslation(undefined, {
|
||||||
keyPrefix: 'admin_console.subscription.downgrade_modal',
|
keyPrefix: 'admin_console.subscription.downgrade_modal',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Todo: @xiaoyijun LOG-6540 order keys
|
|
||||||
// eslint-disable-next-line no-restricted-syntax
|
|
||||||
const entries = useMemo(() => Object.entries(quotaDiff), [quotaDiff]) as Array<
|
|
||||||
[keyof SubscriptionPlanQuota, SubscriptionPlanQuota[keyof SubscriptionPlanQuota]]
|
|
||||||
>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.title}>
|
<div className={styles.title}>
|
||||||
|
@ -35,18 +28,9 @@ function PlanQuotaDiffList({ planName, quotaDiff, isTarget = false }: Props) {
|
||||||
{t(isTarget ? 'after' : 'before')}
|
{t(isTarget ? 'after' : 'before')}
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
<ul className={styles.list}>
|
<PlanQuotaList isDiff quota={quotaDiff} hasIcon={isTarget} />
|
||||||
{entries.map(([quotaKey, quotaValue]) => (
|
|
||||||
<QuotaDiffItem
|
|
||||||
key={quotaKey}
|
|
||||||
quotaKey={quotaKey}
|
|
||||||
quotaValue={quotaValue}
|
|
||||||
hasIcon={isTarget}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PlanQuotaDiffList;
|
export default PlanQuotaDiffCard;
|
|
@ -5,7 +5,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
||||||
import PlanName from '@/components/PlanName';
|
import PlanName from '@/components/PlanName';
|
||||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
|
|
||||||
import PlanQuotaDiffList from './PlanQuotaDiffList';
|
import PlanQuotaDiffCard from './PlanQuotaDiffCard';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -42,8 +42,8 @@ function DowngradeConfirmModalContent({ currentPlan, targetPlan }: Props) {
|
||||||
</Trans>
|
</Trans>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<PlanQuotaDiffList planName={currentPlanName} quotaDiff={currentQuotaDiff} />
|
<PlanQuotaDiffCard planName={currentPlanName} quotaDiff={currentQuotaDiff} />
|
||||||
<PlanQuotaDiffList isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
|
<PlanQuotaDiffCard isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -2,11 +2,13 @@ import { conditional } from '@silverhand/essentials';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import PlanName from '@/components/PlanName';
|
import PlanName from '@/components/PlanName';
|
||||||
|
import {
|
||||||
|
quotaItemLimitedPhrasesMap,
|
||||||
|
quotaItemNotEligiblePhrasesMap,
|
||||||
|
} from '@/consts/quota-item-phrases';
|
||||||
import DynamicT from '@/ds-components/DynamicT';
|
import DynamicT from '@/ds-components/DynamicT';
|
||||||
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
|
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||||
|
|
||||||
import { quotaItemLimitedPhrasesMap, quotaItemNotEligiblePhrasesMap } from '../quota-item-phrases';
|
|
||||||
|
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
|
|
||||||
const excludedQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
|
const excludedQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
|
||||||
|
|
Loading…
Reference in a new issue