0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -05:00

feat(console): add downgrade plan confirm modal (#4161)

This commit is contained in:
Xiao Yijun 2023-07-17 19:16:10 +08:00 committed by GitHub
parent c00cfedcbb
commit 513d56afec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 478 additions and 2 deletions

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14 7.33335C13.8231 7.33335 13.6536 7.40358 13.5286 7.52861C13.4035 7.65363 13.3333 7.8232 13.3333 8.00001V9.72668L9.13996 5.52668C9.07798 5.46419 9.00425 5.4146 8.92301 5.38075C8.84177 5.34691 8.75463 5.32948 8.66663 5.32948C8.57862 5.32948 8.49148 5.34691 8.41024 5.38075C8.329 5.4146 8.25527 5.46419 8.19329 5.52668L5.99996 7.72668L2.47329 4.19335C2.34776 4.06781 2.1775 3.99728 1.99996 3.99728C1.82243 3.99728 1.65216 4.06781 1.52663 4.19335C1.40109 4.31888 1.33057 4.48914 1.33057 4.66668C1.33057 4.84421 1.40109 5.01448 1.52663 5.14001L5.52663 9.14001C5.5886 9.2025 5.66234 9.25209 5.74358 9.28594C5.82482 9.31979 5.91195 9.33721 5.99996 9.33721C6.08797 9.33721 6.17511 9.31979 6.25635 9.28594C6.33758 9.25209 6.41132 9.2025 6.47329 9.14001L8.66663 6.94001L12.3933 10.6667H10.6666C10.4898 10.6667 10.3202 10.7369 10.1952 10.8619C10.0702 10.987 9.99996 11.1565 9.99996 11.3333C9.99996 11.5102 10.0702 11.6797 10.1952 11.8048C10.3202 11.9298 10.4898 12 10.6666 12H14C14.0871 11.999 14.1731 11.9808 14.2533 11.9467C14.4162 11.879 14.5456 11.7496 14.6133 11.5867C14.6475 11.5065 14.6656 11.4205 14.6666 11.3333V8.00001C14.6666 7.8232 14.5964 7.65363 14.4714 7.52861C14.3463 7.40358 14.1768 7.33335 14 7.33335Z" fill="#EB9918"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -8,6 +8,7 @@ import Button from '@/ds-components/Button';
import * as modalStyles from '@/scss/modal.module.scss';
import ModalLayout from '../ModalLayout';
import type { Props as ModalLayoutProps } from '../ModalLayout';
import * as styles from './index.module.scss';
@ -21,6 +22,7 @@ export type ConfirmModalProps = {
isOpen: boolean;
isConfirmButtonDisabled?: boolean;
isLoading?: boolean;
size?: ModalLayoutProps['size'];
onCancel?: () => void;
onConfirm?: () => void;
};
@ -35,6 +37,7 @@ function ConfirmModal({
isOpen,
isConfirmButtonDisabled = false,
isLoading = false,
size,
onCancel,
onConfirm,
}: ConfirmModalProps) {
@ -63,6 +66,7 @@ function ConfirmModal({
</>
}
className={classNames(styles.content, className)}
size={size}
onClose={onCancel}
>
{children}

View file

@ -10,7 +10,7 @@ import IconButton from '../IconButton';
import * as styles from './index.module.scss';
type Props = {
export type Props = {
children: ReactNode;
footer?: ReactNode;
onClose?: () => void;

View file

@ -0,0 +1,29 @@
@use '@/scss/underscore' as _;
.item {
margin-left: _.unit(4);
&.withChangeState {
list-style-type: none;
margin-left: unset;
}
.itemContent {
display: flex;
align-items: center;
gap: _.unit(2);
.icon {
width: 16px;
height: 16px;
}
&.notCapable {
text-decoration: line-through;
.icon {
color: var(--color-error-hover);
}
}
}
}

View file

@ -0,0 +1,53 @@
import { conditional } from '@silverhand/essentials';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import DescendArrow from '@/assets/icons/descend-arrow.svg';
import Failed from '@/assets/icons/failed.svg';
import {
quotaItemUnlimitedPhrasesMap,
quotaItemPhrasesMap,
quotaItemLimitedPhrasesMap,
} from '@/pages/TenantSettings/Subscription/quota-item-phrases';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import * as styles from './index.module.scss';
type Props = {
hasIcon?: boolean;
quotaKey: keyof SubscriptionPlanQuota;
quotaValue: SubscriptionPlanQuota[keyof SubscriptionPlanQuota];
};
function QuotaDiffItem({ hasIcon = false, quotaKey, quotaValue }: 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;
return (
<li className={classNames(styles.item, hasIcon && styles.withChangeState)}>
<span
className={classNames(styles.itemContent, hasIcon && isNotCapable && styles.notCapable)}
>
{hasIcon && <Icon className={styles.icon} />}
<span>
{isUnlimited && <>{t(quotaItemUnlimitedPhrasesMap[quotaKey])}</>}
{isNotCapable && <>{t(quotaItemPhrasesMap[quotaKey])}</>}
{isLimited && (
<>
{t(
quotaItemLimitedPhrasesMap[quotaKey],
conditional(typeof quotaValue === 'number' && { count: quotaValue }) ?? {}
)}
</>
)}
</span>
</span>
</li>
);
}
export default QuotaDiffItem;

View file

@ -0,0 +1,24 @@
@use '@/scss/underscore' as _;
.container {
flex: 1;
background-color: var(--color-layer-2);
border-radius: 8px;
padding: _.unit(5);
.title {
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);
}
}
}
}

View file

@ -0,0 +1,52 @@
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import PlanName from '@/components/PlanName';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
import QuotaDiffItem from './QuotaDiffItem';
import * as styles from './index.module.scss';
type Props = {
planName: string;
quotaDiff: Partial<SubscriptionPlanQuota>;
isTarget?: boolean;
};
function PlanQuotaDiffList({ 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}>
<Trans
components={{
name: <PlanName name={planName} />,
}}
>
{t(isTarget ? 'after' : 'before')}
</Trans>
</div>
<ul className={styles.list}>
{entries.map(([quotaKey, quotaValue]) => (
<QuotaDiffItem
key={quotaKey}
quotaKey={quotaKey}
quotaValue={quotaValue}
hasIcon={isTarget}
/>
))}
</ul>
</div>
);
}
export default PlanQuotaDiffList;

View file

@ -0,0 +1,17 @@
@use '@/scss/underscore' as _;
.container {
> :not(:first-child) {
margin: _.unit(6) 0 0;
}
}
.description {
font: var(--font-body-2);
}
.content {
display: flex;
justify-content: space-between;
gap: _.unit(3);
}

View file

@ -0,0 +1,52 @@
import { diff } from 'deep-object-diff';
import { useMemo } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import PlanName from '@/components/PlanName';
import { type SubscriptionPlan } from '@/types/subscriptions';
import PlanQuotaDiffList from './PlanQuotaDiffList';
import * as styles from './index.module.scss';
type Props = {
currentPlan: SubscriptionPlan;
targetPlan: SubscriptionPlan;
};
function DowngradeConfirmModalContent({ currentPlan, targetPlan }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { quota: currentQuota, name: currentPlanName } = currentPlan;
const { quota: targetQuota, name: targetPlanName } = targetPlan;
const currentQuotaDiff = useMemo(
() => diff(targetQuota, currentQuota),
[currentQuota, targetQuota]
);
const targetQuotaDiff = useMemo(
() => diff(currentQuota, targetQuota),
[currentQuota, targetQuota]
);
return (
<div className={styles.container}>
<div className={styles.description}>
<Trans
components={{
targetName: <PlanName name={targetPlanName} />,
currentName: <PlanName name={currentPlanName} />,
}}
>
{t('subscription.downgrade_modal.description')}
</Trans>
</div>
<div className={styles.content}>
<PlanQuotaDiffList planName={currentPlanName} quotaDiff={currentQuotaDiff} />
<PlanQuotaDiffList isTarget planName={targetPlanName} quotaDiff={targetQuotaDiff} />
</div>
</div>
);
}
export default DowngradeConfirmModalContent;

View file

@ -0,0 +1,26 @@
@use '@/scss/underscore' as _;
.container {
> :not(:first-child) {
margin: _.unit(6) 0 0;
}
}
.description {
font: var(--font-body-2);
}
.list {
background-color: var(--color-layer-2);
border-radius: 12px;
padding: _.unit(4);
list-style-position: inside;
> li:not(:first-child) {
margin-top: _.unit(3);
}
}
.buttonLink {
text-decoration: none;
}

View file

@ -0,0 +1,83 @@
import { conditional } from '@silverhand/essentials';
import { Trans, useTranslation } from 'react-i18next';
import PlanName from '@/components/PlanName';
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>([
'auditLogsRetentionDays',
'communitySupportEnabled',
'ticketSupportResponseTime',
]);
type Props = {
targetPlan: SubscriptionPlan;
};
function NotEligibleDowngradeModalContent({ targetPlan }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { name, quota } = targetPlan;
// eslint-disable-next-line no-restricted-syntax
const entries = Object.entries(quota) as Array<
[keyof SubscriptionPlanQuota, SubscriptionPlanQuota[keyof SubscriptionPlanQuota]]
>;
return (
<div className={styles.container}>
<div className={styles.description}>
<Trans
components={{
name: <PlanName name={name} />,
}}
>
{t('subscription.downgrade_modal.not_eligible_description')}
</Trans>
</div>
<ul className={styles.list}>
{entries.map(([quotaKey, quotaValue]) => {
if (
excludedQuotaKeys.has(quotaKey) ||
quotaValue === null || // Unlimited items
quotaValue === true // Eligible items
) {
return null;
}
return (
<li key={quotaKey}>
{quotaValue ? (
<Trans
components={{
item: (
<DynamicT
forKey={`subscription.quota_item.${quotaItemLimitedPhrasesMap[quotaKey]}`}
interpolation={conditional(
typeof quotaValue === 'number' && { count: quotaValue }
)}
/>
),
}}
>
{t('subscription.downgrade_modal.a_maximum_of')}
</Trans>
) : (
<DynamicT
forKey={`subscription.quota_item.${quotaItemNotEligiblePhrasesMap[quotaKey]}`}
/>
)}
</li>
);
})}
</ul>
</div>
);
}
export default NotEligibleDowngradeModalContent;

View file

@ -1,9 +1,13 @@
import { contactEmailLink } from '@/consts';
import Button from '@/ds-components/Button';
import Spacer from '@/ds-components/Spacer';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { type SubscriptionPlan } from '@/types/subscriptions';
import { isDowngradePlan } from '@/utils/subscription';
import DowngradeConfirmModalContent from '../DowngradeConfirmModalContent';
import NotEligibleDowngradeModalContent from '../NotEligibleDowngradeModalContent';
import * as styles from './index.module.scss';
type Props = {
@ -12,6 +16,39 @@ type Props = {
};
function SwitchPlanActionBar({ currentSubscriptionPlanId, subscriptionPlans }: Props) {
const { show } = useConfirmModal();
const handleDownGrade = async (targetPlan: SubscriptionPlan) => {
// Todo @xiaoyijun handle downgrade
await show({
ModalContent: () => <NotEligibleDowngradeModalContent targetPlan={targetPlan} />,
title: 'subscription.downgrade_modal.not_eligible',
confirmButtonText: 'general.got_it',
confirmButtonType: 'primary',
});
};
const onDowngradeClick = async (targetPlanId: string) => {
const currentPlan = subscriptionPlans.find(({ id }) => id === currentSubscriptionPlanId);
const targetPlan = subscriptionPlans.find(({ id }) => id === targetPlanId);
if (!currentPlan || !targetPlan) {
return;
}
const [result] = await show({
ModalContent: () => (
<DowngradeConfirmModalContent currentPlan={currentPlan} targetPlan={targetPlan} />
),
title: 'subscription.downgrade_modal.title',
confirmButtonText: 'subscription.downgrade_modal.downgrade',
size: 'large',
});
if (result) {
await handleDownGrade(targetPlan);
}
};
return (
<div className={styles.container}>
<Spacer />
@ -32,6 +69,11 @@ function SwitchPlanActionBar({ currentSubscriptionPlanId, subscriptionPlans }: P
type={isDowngrade ? 'default' : 'primary'}
disabled={isCurrentPlan}
onClick={async () => {
if (isDowngrade) {
await onDowngradeClick(planId);
// eslint-disable-next-line no-useless-return
return;
}
// Todo @xiaoyijun handle buy plan
}}
/>

View file

@ -0,0 +1,91 @@
import { type TFuncKey } from 'i18next';
import { type SubscriptionPlanQuota } from '@/types/subscriptions';
export const quotaItemPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.name',
applicationsLimit: 'applications_limit.name',
machineToMachineLimit: 'machine_to_machine_limit.name',
resourcesLimit: 'resources_limit.name',
scopesPerResourceLimit: 'scopes_per_resource_limit.name',
customDomainEnabled: 'custom_domain_enabled.name',
omniSignInEnabled: 'omni_sign_in_enabled.name',
builtInEmailConnectorEnabled: 'built_in_email_connector_enabled.name',
socialConnectorsLimit: 'social_connectors_limit.name',
standardConnectorsLimit: 'standard_connectors_limit.name',
rolesLimit: 'roles_limit.name',
scopesPerRoleLimit: 'scopes_per_role_limit.name',
hooksLimit: 'hooks_limit.name',
auditLogsRetentionDays: 'audit_logs_retention_days.name',
communitySupportEnabled: 'community_support_enabled.name',
ticketSupportResponseTime: 'customer_ticket_support.name',
};
export const quotaItemUnlimitedPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.unlimited',
applicationsLimit: 'applications_limit.unlimited',
machineToMachineLimit: 'machine_to_machine_limit.unlimited',
resourcesLimit: 'resources_limit.unlimited',
scopesPerResourceLimit: 'scopes_per_resource_limit.unlimited',
customDomainEnabled: 'custom_domain_enabled.unlimited',
omniSignInEnabled: 'omni_sign_in_enabled.unlimited',
builtInEmailConnectorEnabled: 'built_in_email_connector_enabled.unlimited',
socialConnectorsLimit: 'social_connectors_limit.unlimited',
standardConnectorsLimit: 'standard_connectors_limit.unlimited',
rolesLimit: 'roles_limit.unlimited',
scopesPerRoleLimit: 'scopes_per_role_limit.unlimited',
hooksLimit: 'hooks_limit.unlimited',
auditLogsRetentionDays: 'audit_logs_retention_days.unlimited',
communitySupportEnabled: 'community_support_enabled.unlimited',
ticketSupportResponseTime: 'customer_ticket_support.unlimited',
};
export const quotaItemLimitedPhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.limited',
applicationsLimit: 'applications_limit.limited',
machineToMachineLimit: 'machine_to_machine_limit.limited',
resourcesLimit: 'resources_limit.limited',
scopesPerResourceLimit: 'scopes_per_resource_limit.limited',
customDomainEnabled: 'custom_domain_enabled.limited',
omniSignInEnabled: 'omni_sign_in_enabled.limited',
builtInEmailConnectorEnabled: 'built_in_email_connector_enabled.limited',
socialConnectorsLimit: 'social_connectors_limit.limited',
standardConnectorsLimit: 'standard_connectors_limit.limited',
rolesLimit: 'roles_limit.limited',
scopesPerRoleLimit: 'scopes_per_role_limit.limited',
hooksLimit: 'hooks_limit.limited',
auditLogsRetentionDays: 'audit_logs_retention_days.limited',
communitySupportEnabled: 'community_support_enabled.limited',
ticketSupportResponseTime: 'customer_ticket_support.limited',
};
export const quotaItemNotEligiblePhrasesMap: Record<
keyof SubscriptionPlanQuota,
TFuncKey<'translation', 'admin_console.subscription.quota_item'>
> = {
mauLimit: 'mau_limit.not_eligible',
applicationsLimit: 'applications_limit.not_eligible',
machineToMachineLimit: 'machine_to_machine_limit.not_eligible',
resourcesLimit: 'resources_limit.not_eligible',
scopesPerResourceLimit: 'scopes_per_resource_limit.not_eligible',
customDomainEnabled: 'custom_domain_enabled.not_eligible',
omniSignInEnabled: 'omni_sign_in_enabled.not_eligible',
builtInEmailConnectorEnabled: 'built_in_email_connector_enabled.not_eligible',
socialConnectorsLimit: 'social_connectors_limit.not_eligible',
standardConnectorsLimit: 'standard_connectors_limit.not_eligible',
rolesLimit: 'roles_limit.not_eligible',
scopesPerRoleLimit: 'scopes_per_role_limit.not_eligible',
hooksLimit: 'hooks_limit.not_eligible',
auditLogsRetentionDays: 'audit_logs_retention_days.not_eligible',
communitySupportEnabled: 'community_support_enabled.not_eligible',
ticketSupportResponseTime: 'customer_ticket_support.not_eligible',
};

View file

@ -7,7 +7,7 @@ export enum ReservedPlanName {
Enterprise = 'Enterprise',
}
type SubscriptionPlanQuota = SubscriptionPlanResponse['quota'] & {
export type SubscriptionPlanQuota = SubscriptionPlanResponse['quota'] & {
communitySupportEnabled: boolean;
ticketSupportResponseTime: number;
};