mirror of
https://github.com/logto-io/logto.git
synced 2025-03-24 22:41:28 -05:00
refactor(console): update upsell instruction for m2m app & resource creation (#5121)
This commit is contained in:
parent
745e26609a
commit
0691669d6f
8 changed files with 266 additions and 121 deletions
|
@ -7,10 +7,13 @@ import * as styles from './index.module.scss';
|
|||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
isLoading?: boolean;
|
||||
onClickUpgrade?: () => void;
|
||||
};
|
||||
|
||||
function QuotaGuardFooter({ children }: Props) {
|
||||
function QuotaGuardFooter({ children, isLoading, onClickUpgrade }: Props) {
|
||||
const { navigate } = useTenantPathname();
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>{children}</div>
|
||||
|
@ -18,7 +21,13 @@ function QuotaGuardFooter({ children }: Props) {
|
|||
size="large"
|
||||
type="primary"
|
||||
title="upsell.upgrade_plan"
|
||||
isLoading={isLoading}
|
||||
onClick={() => {
|
||||
if (onClickUpgrade) {
|
||||
onClickUpgrade();
|
||||
return;
|
||||
}
|
||||
// Navigate to subscription page by default
|
||||
navigate('/tenant-settings/subscription');
|
||||
}}
|
||||
/>
|
||||
|
|
37
packages/console/src/hooks/use-api-resources-usage.ts
Normal file
37
packages/console/src/hooks/use-api-resources-usage.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
import { isManagementApi } from '@logto/schemas';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { type ApiResource } from '@/consts';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
import useSubscriptionPlan from './use-subscription-plan';
|
||||
|
||||
const useApiResourcesUsage = () => {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
/**
|
||||
* Note: we only need to fetch all resources when the user is in cloud environment.
|
||||
* The oss version doesn't have the quota limit.
|
||||
*/
|
||||
const { data: allResources } = useSWR<ApiResource[]>(isCloud && 'api/resources');
|
||||
|
||||
const hasReachedLimit = useMemo(() => {
|
||||
const resourceCount =
|
||||
allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0;
|
||||
|
||||
return hasReachedQuotaLimit({
|
||||
quotaKey: 'resourcesLimit',
|
||||
plan: currentPlan,
|
||||
usage: resourceCount,
|
||||
});
|
||||
}, [allResources, currentPlan]);
|
||||
|
||||
return {
|
||||
hasReachedLimit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useApiResourcesUsage;
|
47
packages/console/src/hooks/use-applications-usage.ts
Normal file
47
packages/console/src/hooks/use-applications-usage.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { type Application, ApplicationType } from '@logto/schemas';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
import useSubscriptionPlan from './use-subscription-plan';
|
||||
|
||||
const useApplicationsUsage = () => {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
/**
|
||||
* Note: we only need to fetch all applications when the user is in cloud environment.
|
||||
* The oss version doesn't have the quota limit.
|
||||
*/
|
||||
const { data: allApplications } = useSWR<Application[]>(isCloud && 'api/applications');
|
||||
|
||||
const hasMachineToMachineAppsReachedLimit = useMemo(() => {
|
||||
const m2mAppCount =
|
||||
allApplications?.filter(({ type }) => type === ApplicationType.MachineToMachine).length ?? 0;
|
||||
|
||||
return hasReachedQuotaLimit({
|
||||
quotaKey: 'machineToMachineLimit',
|
||||
plan: currentPlan,
|
||||
usage: m2mAppCount,
|
||||
});
|
||||
}, [allApplications, currentPlan]);
|
||||
|
||||
const hasAppsReachedLimit = useMemo(
|
||||
() =>
|
||||
hasReachedQuotaLimit({
|
||||
quotaKey: 'applicationsLimit',
|
||||
plan: currentPlan,
|
||||
usage: allApplications?.length ?? 0,
|
||||
}),
|
||||
[allApplications?.length, currentPlan]
|
||||
);
|
||||
|
||||
return {
|
||||
hasMachineToMachineAppsReachedLimit,
|
||||
hasAppsReachedLimit,
|
||||
};
|
||||
};
|
||||
|
||||
export default useApplicationsUsage;
|
|
@ -1,7 +1,7 @@
|
|||
import { ReservedPlanId } from '@logto/schemas';
|
||||
import dayjs from 'dayjs';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useContext } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
@ -27,6 +27,7 @@ const useSubscribe = () => {
|
|||
const cloudApi = useCloudApi({ hideErrorToast: true });
|
||||
const { updateTenant } = useContext(TenantsContext);
|
||||
const { getUrl } = useTenantPathname();
|
||||
const [isSubscribeLoading, setIsSubscribeLoading] = useState(false);
|
||||
|
||||
const subscribe = async ({
|
||||
planId,
|
||||
|
@ -35,6 +36,11 @@ const useSubscribe = () => {
|
|||
tenantData,
|
||||
isDowngrade = false,
|
||||
}: SubscribeProps) => {
|
||||
if (isSubscribeLoading) {
|
||||
return;
|
||||
}
|
||||
setIsSubscribeLoading(true);
|
||||
|
||||
const state = nanoid(6);
|
||||
|
||||
const successSearchParam = new URLSearchParams({
|
||||
|
@ -45,29 +51,33 @@ const useSubscribe = () => {
|
|||
`${dropLeadingSlash(GlobalRoute.CheckoutSuccessCallback)}?${successSearchParam.toString()}`
|
||||
).href;
|
||||
|
||||
const { redirectUri, sessionId } = await cloudApi.post('/api/checkout-session', {
|
||||
body: {
|
||||
planId,
|
||||
successCallbackUrl,
|
||||
tenantId,
|
||||
tenantName: tenantData?.name,
|
||||
tenantTag: tenantData?.tag,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { redirectUri, sessionId } = await cloudApi.post('/api/checkout-session', {
|
||||
body: {
|
||||
planId,
|
||||
successCallbackUrl,
|
||||
tenantId,
|
||||
tenantName: tenantData?.name,
|
||||
tenantTag: tenantData?.tag,
|
||||
},
|
||||
});
|
||||
|
||||
if (!redirectUri) {
|
||||
toast.error(t('general.unknown_error'));
|
||||
return;
|
||||
if (!redirectUri) {
|
||||
toast.error(t('general.unknown_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
createLocalCheckoutSession({
|
||||
state,
|
||||
sessionId,
|
||||
callbackPage,
|
||||
isDowngrade,
|
||||
});
|
||||
|
||||
window.location.assign(redirectUri);
|
||||
} finally {
|
||||
setIsSubscribeLoading(false);
|
||||
}
|
||||
|
||||
createLocalCheckoutSession({
|
||||
state,
|
||||
sessionId,
|
||||
callbackPage,
|
||||
isDowngrade,
|
||||
});
|
||||
|
||||
window.location.assign(redirectUri);
|
||||
};
|
||||
|
||||
const cancelSubscription = async (tenantId: string) => {
|
||||
|
@ -110,6 +120,7 @@ const useSubscribe = () => {
|
|||
};
|
||||
|
||||
return {
|
||||
isSubscribeLoading,
|
||||
subscribe,
|
||||
cancelSubscription,
|
||||
visitManagePaymentPage,
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import { ReservedPlanId } from '@logto/schemas';
|
||||
import { cond } from '@silverhand/essentials';
|
||||
import { useContext } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import useApiResourcesUsage from '@/hooks/use-api-resources-usage';
|
||||
import useSubscribe from '@/hooks/use-subscribe';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
|
||||
type Props = {
|
||||
isCreationLoading: boolean;
|
||||
onClickCreate: () => void;
|
||||
};
|
||||
|
||||
function Footer({ isCreationLoading, onClickCreate }: Props) {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
const { hasReachedLimit } = useApiResourcesUsage();
|
||||
const { subscribe, isSubscribeLoading } = useSubscribe();
|
||||
|
||||
if (
|
||||
currentPlan &&
|
||||
hasReachedLimit &&
|
||||
/**
|
||||
* Todo @xiaoyijun [Pricing] Remove feature flag
|
||||
* We don't guard API resources quota limit for paid plan, since it's an add-on feature
|
||||
*/
|
||||
(!isDevFeaturesEnabled || currentPlan.id === ReservedPlanId.Free)
|
||||
) {
|
||||
return (
|
||||
<QuotaGuardFooter
|
||||
isLoading={isSubscribeLoading}
|
||||
onClickUpgrade={cond(
|
||||
isDevFeaturesEnabled &&
|
||||
(() => {
|
||||
void subscribe({
|
||||
// Todo @xiaoyijun [Pricing] Replace 'Hobby' with 'Pro' when pricing is ready, in MVP, we use 'Hobby' as the new pro plan id
|
||||
planId: ReservedPlanId.Hobby,
|
||||
tenantId: currentTenantId,
|
||||
callbackPage: '/api-resources/create',
|
||||
});
|
||||
})
|
||||
)}
|
||||
>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.resources', {
|
||||
count: currentPlan.quota.resourcesLimit ?? 0,
|
||||
})}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
isLoading={isCreationLoading}
|
||||
htmlType="submit"
|
||||
title="api_resources.create"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onClickCreate}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
|
@ -1,26 +1,18 @@
|
|||
import { isManagementApi, type Resource } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { type Resource } from '@logto/schemas';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import { type ApiResource } from '@/consts';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import TextLink from '@/ds-components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
import Footer from './Footer';
|
||||
|
||||
type FormData = {
|
||||
name: string;
|
||||
|
@ -32,10 +24,7 @@ type Props = {
|
|||
};
|
||||
|
||||
function CreateForm({ onClose }: Props) {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
const { data: allResources } = useSWR<ApiResource[]>('api/resources');
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
|
@ -43,15 +32,6 @@ function CreateForm({ onClose }: Props) {
|
|||
formState: { isSubmitting },
|
||||
} = useForm<FormData>();
|
||||
|
||||
const resourceCount =
|
||||
allResources?.filter(({ indicator }) => !isManagementApi(indicator)).length ?? 0;
|
||||
|
||||
const isResourcesReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'resourcesLimit',
|
||||
plan: currentPlan,
|
||||
usage: resourceCount,
|
||||
});
|
||||
|
||||
const api = useApi();
|
||||
|
||||
const onSubmit = handleSubmit(
|
||||
|
@ -79,31 +59,7 @@ function CreateForm({ onClose }: Props) {
|
|||
<ModalLayout
|
||||
title="api_resources.create"
|
||||
subtitle="api_resources.subtitle"
|
||||
footer={
|
||||
isResourcesReachLimit && currentPlan ? (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={currentPlan.name} />,
|
||||
}}
|
||||
>
|
||||
{t('upsell.paywall.resources', {
|
||||
count: currentPlan.quota.resourcesLimit ?? 0,
|
||||
})}
|
||||
</Trans>
|
||||
</QuotaGuardFooter>
|
||||
) : (
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
htmlType="submit"
|
||||
title="api_resources.create"
|
||||
size="large"
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
footer={<Footer isCreationLoading={isSubmitting} onClickCreate={onSubmit} />}
|
||||
onClose={onClose}
|
||||
>
|
||||
<form>
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
import { type Application, ApplicationType, ReservedPlanId } from '@logto/schemas';
|
||||
import { useContext, useMemo } from 'react';
|
||||
import { ApplicationType, ReservedPlanId } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import QuotaGuardFooter from '@/components/QuotaGuardFooter';
|
||||
import { isDevFeaturesEnabled } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import useApplicationsUsage from '@/hooks/use-applications-usage';
|
||||
import useSubscribe from '@/hooks/use-subscribe';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import { hasReachedQuotaLimit } from '@/utils/quota';
|
||||
|
||||
type Props = {
|
||||
selectedType?: ApplicationType;
|
||||
|
@ -21,57 +22,70 @@ function Footer({ selectedType, isLoading, onClickCreate }: Props) {
|
|||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.upsell.paywall' });
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
const { data: allApplications } = useSWR<Application[]>('api/applications');
|
||||
|
||||
const m2mAppCount = useMemo(
|
||||
() =>
|
||||
allApplications?.filter(({ type }) => type === ApplicationType.MachineToMachine).length ?? 0,
|
||||
[allApplications]
|
||||
);
|
||||
|
||||
const nonM2mApplicationCount = allApplications ? allApplications.length - m2mAppCount : 0;
|
||||
|
||||
const isM2mAppsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'machineToMachineLimit',
|
||||
plan: currentPlan,
|
||||
usage: m2mAppCount,
|
||||
});
|
||||
|
||||
const isNonM2mAppsReachLimit = hasReachedQuotaLimit({
|
||||
quotaKey: 'applicationsLimit',
|
||||
plan: currentPlan,
|
||||
usage: nonM2mApplicationCount,
|
||||
});
|
||||
const { subscribe, isSubscribeLoading } = useSubscribe();
|
||||
const { hasAppsReachedLimit, hasMachineToMachineAppsReachedLimit } = useApplicationsUsage();
|
||||
|
||||
if (currentPlan && selectedType) {
|
||||
const { id: planId, name: planName, quota } = currentPlan;
|
||||
|
||||
if (selectedType === ApplicationType.MachineToMachine && isM2mAppsReachLimit) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
{quota.machineToMachineLimit === 0 && planId === ReservedPlanId.Free ? (
|
||||
if (selectedType === ApplicationType.MachineToMachine && hasMachineToMachineAppsReachedLimit) {
|
||||
// Todo @xiaoyijun [Pricing] Remove feature flag
|
||||
if (isDevFeaturesEnabled && planId === ReservedPlanId.Free) {
|
||||
return (
|
||||
<QuotaGuardFooter
|
||||
isLoading={isSubscribeLoading}
|
||||
onClickUpgrade={() => {
|
||||
void subscribe({
|
||||
// Todo @xiaoyijun [Pricing] Replace 'Hobby' with 'Pro' when pricing is ready, in MVP, we use 'Hobby' as the new pro plan id
|
||||
planId: ReservedPlanId.Hobby,
|
||||
tenantId: currentTenantId,
|
||||
callbackPage: '/applications/create',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
}}
|
||||
>
|
||||
{t('deprecated_machine_to_machine_feature')}
|
||||
{t('machine_to_machine_feature')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={planName} />,
|
||||
}}
|
||||
>
|
||||
{t('machine_to_machine', { count: quota.machineToMachineLimit ?? 0 })}
|
||||
</Trans>
|
||||
)}
|
||||
</QuotaGuardFooter>
|
||||
);
|
||||
</QuotaGuardFooter>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo @xiaoyijun [Pricing] Remove feature flag
|
||||
* For paid plan (pro plan), we don't guard the m2m app creation since it's an add-on feature.
|
||||
*/
|
||||
if (!isDevFeaturesEnabled) {
|
||||
// Todo @xiaoyijun [Pricing] Deprecate this logic when pricing is ready
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
{quota.machineToMachineLimit === 0 && planId === ReservedPlanId.Free ? (
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
}}
|
||||
>
|
||||
{t('deprecated_machine_to_machine_feature')}
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans
|
||||
components={{
|
||||
a: <ContactUsPhraseLink />,
|
||||
planName: <PlanName name={planName} />,
|
||||
}}
|
||||
>
|
||||
{t('machine_to_machine', { count: quota.machineToMachineLimit ?? 0 })}
|
||||
</Trans>
|
||||
)}
|
||||
</QuotaGuardFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedType !== ApplicationType.MachineToMachine && isNonM2mAppsReachLimit) {
|
||||
if (hasAppsReachedLimit) {
|
||||
return (
|
||||
<QuotaGuardFooter>
|
||||
<Trans
|
||||
|
|
|
@ -1,20 +1,16 @@
|
|||
import type { Application } from '@logto/schemas';
|
||||
import { ApplicationType } from '@logto/schemas';
|
||||
import { useContext } from 'react';
|
||||
import { useController, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Modal from 'react-modal';
|
||||
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import RadioGroup, { Radio } from '@/ds-components/RadioGroup';
|
||||
import TextInput from '@/ds-components/TextInput';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { applicationTypeI18nKey } from '@/types/applications';
|
||||
import { trySubmitSafe } from '@/utils/form';
|
||||
|
@ -37,9 +33,6 @@ type Props = {
|
|||
};
|
||||
|
||||
function CreateForm({ defaultCreateType, defaultCreateFrameworkName, onClose }: Props) {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { data: currentPlan } = useSubscriptionPlan(currentTenantId);
|
||||
const isMachineToMachineDisabled = isCloud && !currentPlan?.quota.machineToMachineLimit;
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
|
|
Loading…
Add table
Reference in a new issue