mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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 TenantLandingPageImageDark from '@/assets/images/tenant-landing-page-dark.svg';
|
||||
import TenantLandingPageImage from '@/assets/images/tenant-landing-page.svg';
|
||||
import CreateTenantModal from '@/components/CreateTenantModal';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import useTheme from '@/hooks/use-theme';
|
||||
|
||||
import CreateTenantModal from './CreateTenantModal';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -52,6 +52,7 @@ function TenantLandingPageContent({ className }: Props) {
|
|||
/>
|
||||
</div>
|
||||
<CreateTenantModal
|
||||
skipPlanSelection
|
||||
isOpen={isCreateModalOpen}
|
||||
onClose={async (tenant?: TenantInfo) => {
|
||||
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 { Theme } from '@logto/schemas';
|
||||
import { TenantTag, type TenantInfo } from '@logto/schemas/models';
|
||||
import { useState } from 'react';
|
||||
import { Controller, FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
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 CreateTenantHeaderIcon from '@/assets/icons/create-tenant-header.svg';
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { isProduction } from '@/consts/env';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
|
@ -17,11 +19,15 @@ import TextInput from '@/ds-components/TextInput';
|
|||
import useTheme from '@/hooks/use-theme';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
|
||||
import SelectTenantPlanModal from './SelectTenantPlanModal';
|
||||
import * as styles from './index.module.scss';
|
||||
import { type CreateTenantData } from './type';
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onClose: (tenant?: TenantInfo) => void;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
skipPlanSelection?: boolean;
|
||||
};
|
||||
|
||||
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 [tenantData, setTenantData] = useState<CreateTenantData>();
|
||||
const theme = useTheme();
|
||||
const methods = useForm<Pick<TenantInfo, 'name' | 'tag'>>({
|
||||
const methods = useForm<CreateTenantData>({
|
||||
defaultValues: { tag: TenantTag.Development },
|
||||
});
|
||||
|
||||
const {
|
||||
reset,
|
||||
control,
|
||||
|
@ -55,7 +63,7 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
|
||||
const cloudApi = useCloudApi();
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
const createTenant = async (data: CreateTenantData) => {
|
||||
try {
|
||||
const { name, tag } = data;
|
||||
const newTenant = await cloudApi.post('/api/tenants', { body: { name, tag } });
|
||||
|
@ -64,7 +72,15 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
} catch (error: unknown) {
|
||||
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 (
|
||||
<Modal
|
||||
|
@ -90,7 +106,7 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
title="tenants.create_modal.create_button"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
onClick={onCreateClick}
|
||||
/>
|
||||
}
|
||||
onClose={onClose}
|
||||
|
@ -122,6 +138,18 @@ function CreateTenantModal({ isOpen, onClose }: Props) {
|
|||
</div>
|
||||
</FormField>
|
||||
</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>
|
||||
</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 { ReservedPlanName } from '@/types/subscriptions';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const registeredPlanDescriptionPhrasesMap: Record<
|
||||
string,
|
||||
TFuncKey<'translation', 'admin_console.subscription'> | undefined
|
||||
|
@ -23,11 +21,7 @@ function PlanDescription({ planName }: Props) {
|
|||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.description}>
|
||||
<DynamicT forKey={`subscription.${description}`} />
|
||||
</div>
|
||||
);
|
||||
return <DynamicT forKey={`subscription.${description}`} />;
|
||||
}
|
||||
|
||||
export default PlanDescription;
|
||||
|
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
.item {
|
||||
margin-left: _.unit(4);
|
||||
font: var(--font-body-2);
|
||||
|
||||
&.withChangeState {
|
||||
&.withIcon {
|
||||
list-style-type: none;
|
||||
margin-left: unset;
|
||||
}
|
||||
|
@ -16,14 +17,18 @@
|
|||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.notCapable {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
&.capable {
|
||||
color: var(--color-on-success-container);
|
||||
}
|
||||
}
|
||||
|
||||
&.notCapable {
|
||||
.lineThrough {
|
||||
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 Failed from '@/assets/icons/failed.svg';
|
||||
import Success from '@/assets/icons/success.svg';
|
||||
import {
|
||||
quotaItemUnlimitedPhrasesMap,
|
||||
quotaItemPhrasesMap,
|
||||
quotaItemLimitedPhrasesMap,
|
||||
} from '@/pages/TenantSettings/Subscription/quota-item-phrases';
|
||||
} from '@/consts/quota-item-phrases';
|
||||
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -17,23 +18,26 @@ type Props = {
|
|||
hasIcon?: boolean;
|
||||
quotaKey: 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 isUnlimited = quotaValue === null;
|
||||
const isNotCapable = quotaValue === 0 || quotaValue === false;
|
||||
const isLimited = Boolean(quotaValue);
|
||||
|
||||
const Icon = isNotCapable ? Failed : DescendArrow;
|
||||
const Icon = isNotCapable ? Failed : isDiffItem ? DescendArrow : Success;
|
||||
|
||||
return (
|
||||
<li className={classNames(styles.item, hasIcon && styles.withChangeState)}>
|
||||
<span
|
||||
className={classNames(styles.itemContent, hasIcon && isNotCapable && styles.notCapable)}
|
||||
>
|
||||
{hasIcon && <Icon className={styles.icon} />}
|
||||
<span>
|
||||
<li className={classNames(styles.item, hasIcon && styles.withIcon)}>
|
||||
<span className={styles.itemContent}>
|
||||
{hasIcon && (
|
||||
<Icon
|
||||
className={classNames(styles.icon, isNotCapable ? styles.notCapable : styles.capable)}
|
||||
/>
|
||||
)}
|
||||
<span className={classNames(isDiffItem && isNotCapable && styles.lineThrough)}>
|
||||
{isUnlimited && <>{t(quotaItemUnlimitedPhrasesMap[quotaKey])}</>}
|
||||
{isNotCapable && <>{t(quotaItemPhrasesMap[quotaKey])}</>}
|
||||
{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,
|
||||
} from '@/types/subscriptions';
|
||||
|
||||
enum ReservedPlanId {
|
||||
export enum ReservedPlanId {
|
||||
free = 'free',
|
||||
hobby = 'hobby',
|
||||
pro = 'pro',
|
||||
|
|
|
@ -7,7 +7,7 @@ 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 '@/cloud/pages/Main/TenantLandingPage/TenantLandingPageContent/CreateTenantModal';
|
||||
import CreateTenantModal from '@/components/CreateTenantModal';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Divider from '@/ds-components/Divider';
|
||||
import Dropdown, { DropdownItem } from '@/ds-components/Dropdown';
|
||||
|
|
|
@ -6,6 +6,12 @@
|
|||
.name {
|
||||
font: var(--font-title-1);
|
||||
}
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(1);
|
||||
font: var(--font-body-2);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.notification {
|
||||
|
|
|
@ -25,7 +25,9 @@ function CurrentPlan({ subscription, subscriptionPlan, subscriptionUsage }: Prop
|
|||
<div className={styles.name}>
|
||||
<PlanName name={name} />
|
||||
</div>
|
||||
<PlanDescription planName={name} />
|
||||
<div className={styles.description}>
|
||||
<PlanDescription planName={name} />
|
||||
</div>
|
||||
</div>
|
||||
<FormField title="subscription.plan_usage">
|
||||
<PlanUsage
|
||||
|
|
|
@ -10,15 +10,4 @@
|
|||
font: var(--font-title-2);
|
||||
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 PlanName from '@/components/PlanName';
|
||||
import PlanQuotaList from '@/components/PlanQuotaList';
|
||||
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||
|
||||
import QuotaDiffItem from './QuotaDiffItem';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -13,17 +12,11 @@ type Props = {
|
|||
isTarget?: boolean;
|
||||
};
|
||||
|
||||
function PlanQuotaDiffList({ planName, quotaDiff, isTarget = false }: Props) {
|
||||
function PlanQuotaDiffCard({ planName, quotaDiff, isTarget = false }: Props) {
|
||||
const { t } = useTranslation(undefined, {
|
||||
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 (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.title}>
|
||||
|
@ -35,18 +28,9 @@ function PlanQuotaDiffList({ planName, quotaDiff, isTarget = false }: Props) {
|
|||
{t(isTarget ? 'after' : 'before')}
|
||||
</Trans>
|
||||
</div>
|
||||
<ul className={styles.list}>
|
||||
{entries.map(([quotaKey, quotaValue]) => (
|
||||
<QuotaDiffItem
|
||||
key={quotaKey}
|
||||
quotaKey={quotaKey}
|
||||
quotaValue={quotaValue}
|
||||
hasIcon={isTarget}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
<PlanQuotaList isDiff quota={quotaDiff} hasIcon={isTarget} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PlanQuotaDiffList;
|
||||
export default PlanQuotaDiffCard;
|
|
@ -5,7 +5,7 @@ import { Trans, useTranslation } from 'react-i18next';
|
|||
import PlanName from '@/components/PlanName';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
import PlanQuotaDiffList from './PlanQuotaDiffList';
|
||||
import PlanQuotaDiffCard from './PlanQuotaDiffCard';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -42,8 +42,8 @@ function DowngradeConfirmModalContent({ currentPlan, targetPlan }: Props) {
|
|||
</Trans>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<PlanQuotaDiffList planName={currentPlanName} quotaDiff={currentQuotaDiff} />
|
||||
<PlanQuotaDiffList isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
|
||||
<PlanQuotaDiffCard planName={currentPlanName} quotaDiff={currentQuotaDiff} />
|
||||
<PlanQuotaDiffCard isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -2,11 +2,13 @@ import { conditional } from '@silverhand/essentials';
|
|||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import PlanName from '@/components/PlanName';
|
||||
import {
|
||||
quotaItemLimitedPhrasesMap,
|
||||
quotaItemNotEligiblePhrasesMap,
|
||||
} from '@/consts/quota-item-phrases';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
|
||||
|
||||
import { quotaItemLimitedPhrasesMap, quotaItemNotEligiblePhrasesMap } from '../quota-item-phrases';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const excludedQuotaKeys = new Set<keyof SubscriptionPlanQuota>([
|
||||
|
|
Loading…
Reference in a new issue