0
Fork 0
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:
Xiao Yijun 2023-12-20 13:51:38 +08:00 committed by GitHub
parent 745e26609a
commit 0691669d6f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 266 additions and 121 deletions

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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