0
Fork 0
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:
Xiao Yijun 2023-07-18 16:51:57 +08:00 committed by GitHub
parent f241dd3818
commit e2fc6cb545
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 447 additions and 75 deletions

View file

@ -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) {

View file

@ -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);
}
}

View file

@ -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;

View file

@ -0,0 +1,9 @@
@use '@/scss/underscore' as _;
.container {
display: flex;
justify-content: space-between;
align-items: stretch;
gap: _.unit(7);
}

View file

@ -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;

View file

@ -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>
);

View file

@ -0,0 +1,3 @@
import { type TenantInfo } from '@logto/schemas/models';
export type CreateTenantData = Pick<TenantInfo, 'name' | 'tag'>;

View file

@ -1,7 +0,0 @@
@use '@/scss/underscore' as _;
.description {
margin-top: _.unit(1);
font: var(--font-body-2);
color: var(--color-text-secondary);
}

View file

@ -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;

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -0,0 +1,12 @@
@use '@/scss/underscore' as _;
.list {
margin-block: 0;
padding-inline: 0;
> li {
&:not(:first-child) {
margin-top: _.unit(3);
}
}
}

View 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;

View file

@ -6,7 +6,7 @@ import {
SubscriptionPlanTableGroupKey,
} from '@/types/subscriptions';
enum ReservedPlanId {
export enum ReservedPlanId {
free = 'free',
hobby = 'hobby',
pro = 'pro',

View file

@ -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';

View file

@ -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 {

View file

@ -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

View file

@ -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);
}
}
}
}

View file

@ -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;

View file

@ -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>
);

View file

@ -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>([