0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-24 22:05:56 -05:00

fix: fix console issues for add-on (#6443)

* fix: fix console issues for add-on

* refactor: refactor code

* refactor: update

* fix: fix method use case
This commit is contained in:
Darcy Ye 2024-08-15 15:17:32 +08:00 committed by GitHub
parent bb98ea8301
commit d2220f1205
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 133 additions and 68 deletions

View file

@ -37,6 +37,7 @@ function Footer({ selectedType, isLoading, onClickCreate, isThirdParty }: Props)
if (
selectedType === ApplicationType.MachineToMachine &&
isDevFeaturesEnabled &&
hasMachineToMachineAppsReachedLimit &&
planId === ReservedPlanId.Pro
) {
return (

View file

@ -49,6 +49,7 @@ function CreateForm({
}: Props) {
const {
handleSubmit,
watch,
control,
register,
formState: { errors, isSubmitting },
@ -122,7 +123,10 @@ function CreateForm({
title="applications.create"
subtitle={subtitleElement}
paywall={conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && ReservedPlanId.Pro
isDevFeaturesEnabled &&
watch('type') === ApplicationType.MachineToMachine &&
planId === ReservedPlanId.Pro &&
ReservedPlanId.Pro
)}
size={defaultCreateType ? 'medium' : 'large'}
footer={

View file

@ -19,10 +19,19 @@ export type Props = {
readonly usageKey: AdminConsoleKey;
readonly titleKey: AdminConsoleKey;
readonly tooltipKey: AdminConsoleKey;
readonly unitPrice: number;
readonly className?: string;
};
function ProPlanUsageCard({ usage, quota, usageKey, titleKey, tooltipKey, className }: Props) {
function ProPlanUsageCard({
usage,
quota,
unitPrice,
usageKey,
titleKey,
tooltipKey,
className,
}: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
@ -38,7 +47,9 @@ function ProPlanUsageCard({ usage, quota, usageKey, titleKey, tooltipKey, classN
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t(tooltipKey)}
{t(tooltipKey, {
price: unitPrice,
})}
</Trans>
}
>

View file

@ -14,7 +14,7 @@ import { formatPeriod } from '@/utils/subscription';
import ProPlanUsageCard, { type Props as ProPlanUsageCardProps } from './ProPlanUsageCard';
import styles from './index.module.scss';
import { usageKeys, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils';
import { usageKeys, usageKeyPriceMap, usageKeyMap, titleKeyMap, tooltipKeyMap } from './utils';
type Props = {
/** @deprecated */
@ -67,6 +67,7 @@ function PlanUsage({ currentSubscription, currentPlan, periodicUsage: rawPeriodi
usageKey: `subscription.usage.${usageKeyMap[key]}`,
titleKey: `subscription.usage.${titleKeyMap[key]}`,
tooltipKey: `subscription.usage.${tooltipKeyMap[key]}`,
unitPrice: usageKeyPriceMap[key],
...cond(
key === 'tokenLimit' &&
currentSubscriptionQuota.tokenLimit && { quota: currentSubscriptionQuota.tokenLimit }

View file

@ -1,6 +1,16 @@
import { type TFuncKey } from 'i18next';
import { type NewSubscriptionQuota } from '@/cloud/types/router';
import {
resourceAddOnUnitPrice,
machineToMachineAddOnUnitPrice,
tenantMembersAddOnUnitPrice,
mfaAddOnUnitPrice,
enterpriseSsoAddOnUnitPrice,
organizationAddOnUnitPrice,
tokenAddOnUnitPrice,
hooksAddOnUnitPrice,
} from '@/consts/subscriptions';
type UsageKey = Pick<
NewSubscriptionQuota,
@ -15,6 +25,7 @@ type UsageKey = Pick<
| 'hooksLimit'
>;
// We decide not to show `hooksLimit` usage in console for now.
export const usageKeys: Array<keyof UsageKey> = [
'mauLimit',
'organizationsEnabled',
@ -24,9 +35,20 @@ export const usageKeys: Array<keyof UsageKey> = [
'machineToMachineLimit',
'tenantMembersLimit',
'tokenLimit',
'hooksLimit',
];
export const usageKeyPriceMap: Record<keyof UsageKey, number> = {
mauLimit: 0,
organizationsEnabled: organizationAddOnUnitPrice,
mfaEnabled: mfaAddOnUnitPrice,
enterpriseSsoLimit: enterpriseSsoAddOnUnitPrice,
resourcesLimit: resourceAddOnUnitPrice,
machineToMachineLimit: machineToMachineAddOnUnitPrice,
tenantMembersLimit: tenantMembersAddOnUnitPrice,
tokenLimit: tokenAddOnUnitPrice,
hooksLimit: hooksAddOnUnitPrice,
};
export const usageKeyMap: Record<
keyof UsageKey,
TFuncKey<'translation', 'admin_console.subscription.usage'>

View file

@ -18,6 +18,8 @@ export const tenantMembersAddOnUnitPrice = 8;
export const mfaAddOnUnitPrice = 48;
export const enterpriseSsoAddOnUnitPrice = 48;
export const organizationAddOnUnitPrice = 48;
export const tokenAddOnUnitPrice = 80;
export const hooksAddOnUnitPrice = 2;
/* === Add-on unit price (in USD) === */
/**

View file

@ -103,14 +103,15 @@ const useSubscribe = () => {
// Should not use hard-coded plan update here, need to update the tenant's subscription data with response from corresponding API.
if (isDevFeaturesEnabled) {
const { id, ...rest } = await cloudApi.get('/api/tenants/:tenantId/subscription', {
const subscription = await cloudApi.get('/api/tenants/:tenantId/subscription', {
params: {
tenantId,
},
});
mutateSubscriptionQuotaAndUsages();
onCurrentSubscriptionUpdated();
onCurrentSubscriptionUpdated(subscription);
const { id, ...rest } = subscription;
updateTenant(tenantId, {
planId: rest.planId,

View file

@ -81,14 +81,13 @@ function CreateTenant() {
if (collaboratorEmails.length > 0) {
// Should not block the onboarding flow if the invitation fails.
try {
await Promise.all(
collaboratorEmails.map(async (email) =>
tenantCloudApi.post('/api/tenants/:tenantId/invitations', {
params: { tenantId: newTenant.id },
body: { invitee: email.value, roleName: TenantRole.Collaborator },
})
)
);
await tenantCloudApi.post('/api/tenants/:tenantId/invitations', {
params: { tenantId: newTenant.id },
body: {
invitee: collaboratorEmails.map(({ value }) => value),
roleName: TenantRole.Collaborator,
},
});
toast.success(t('tenant_members.messages.invitation_sent'));
} catch {
toast.error(t('tenants.create_modal.invitation_failed', { duration: 5 }));

View file

@ -53,7 +53,7 @@ function Footer({ isCreationLoading, onClickCreate }: Props) {
);
}
if (isDevFeaturesEnabled && planId === ReservedPlanId.Pro) {
if (isDevFeaturesEnabled && hasReachedLimit && planId === ReservedPlanId.Pro) {
return (
<AddOnNoticeFooter
isLoading={isCreationLoading}

View file

@ -13,6 +13,7 @@ import { GtagConversionId, reportToGoogle } from '@/components/Conversion/utils'
import PlanName from '@/components/PlanName';
import { isDevFeaturesEnabled } from '@/consts/env';
import { checkoutStateQueryKey } from '@/consts/subscriptions';
import { SubscriptionDataContext } from '@/contexts/SubscriptionDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import useLogtoSkus from '@/hooks/use-logto-skus';
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
@ -28,6 +29,7 @@ function CheckoutSuccessCallback() {
const { navigate } = useTenantPathname();
const cloudApi = useCloudApi({ hideErrorToast: true });
const { currentTenantId, navigateTenant } = useContext(TenantsContext);
const { onCurrentSubscriptionUpdated } = useContext(SubscriptionDataContext);
const { search } = useLocation();
const checkoutState = new URLSearchParams(search).get(checkoutStateQueryKey);
const { state, sessionId, callbackPage, isDowngrade } = getLocalCheckoutSession() ?? {};
@ -121,6 +123,8 @@ function CheckoutSuccessCallback() {
}
}
onCurrentSubscriptionUpdated(tenantSubscription);
// No need to check `isDowngrade` here, since a downgrade must occur in a tenant with a Pro
// plan, and the purchase conversion has already been reported using the same tenant ID. We
// use the tenant ID as the transaction ID, so there's no concern about duplicate conversion
@ -147,8 +151,10 @@ function CheckoutSuccessCallback() {
logtoSkus,
navigate,
navigateTenant,
onCurrentSubscriptionUpdated,
subscriptionPlans,
t,
tenantSubscription,
]);
if (!isValidSession && !isLoadingPlans) {

View file

@ -33,7 +33,8 @@ type Props = {
};
function MfaForm({ data, onMfaUpdated }: Props) {
const { currentPlan, currentSubscriptionQuota } = useContext(SubscriptionDataContext);
const { currentPlan, currentSubscriptionQuota, mutateSubscriptionQuotaAndUsages } =
useContext(SubscriptionDataContext);
const isMfaDisabled =
isCloud &&
!(isDevFeaturesEnabled ? currentSubscriptionQuota.mfaEnabled : currentPlan.quota.mfaEnabled);
@ -77,6 +78,7 @@ function MfaForm({ data, onMfaUpdated }: Props) {
json: { mfa: mfaConfig },
})
.json<SignInExperience>();
mutateSubscriptionQuotaAndUsages();
reset(convertMfaConfigToForm(updatedMfaConfig));
toast.success(t('general.saved'));
onMfaUpdated(updatedMfaConfig);

View file

@ -1,4 +1,4 @@
import { cond, conditional } from '@silverhand/essentials';
import { cond } from '@silverhand/essentials';
import { useContext, useMemo } from 'react';
import { type Subscription, type NewSubscriptionPeriodicUsage } from '@/cloud/types/router';
@ -28,9 +28,9 @@ type Props = {
};
function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodicUsage }: Props) {
const { currentTenant } = useContext(TenantsContext);
const { currentSku, currentSubscription, currentSubscriptionQuota } =
useContext(SubscriptionDataContext);
const { currentTenant } = useContext(TenantsContext);
const {
id,
name,
@ -40,7 +40,7 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi
const periodicUsage = useMemo(
() =>
rawPeriodicUsage ??
conditional(
cond(
currentTenant && {
mauLimit: currentTenant.usage.activeUsers,
tokenLimit: currentTenant.usage.tokenUsage,
@ -50,11 +50,12 @@ function CurrentPlan({ subscription, subscriptionPlan, periodicUsage: rawPeriodi
);
/**
* After the new pricing model goes live, `upcomingInvoice` will always exist. However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0.
* After the new pricing model goes live, `upcomingInvoice` will always exist. `upcomingInvoice` is updated more frequently than `currentSubscription.upcomingInvoice`.
* However, for compatibility reasons, the price of the SKU's corresponding `unitPrice` will be used as a fallback when it does not exist. If `unitPrice` also does not exist, it means that the tenant does not have any applicable paid subscription, and the bill will be 0.
*/
const upcomingCost = useMemo(
() => currentSubscription.upcomingInvoice?.subtotal ?? currentSku.unitPrice ?? 0,
[currentSku.unitPrice, currentSubscription.upcomingInvoice?.subtotal]
[currentSku.unitPrice, currentSubscription.upcomingInvoice]
);
if (!periodicUsage) {

View file

@ -1,4 +1,4 @@
import { useContext } from 'react';
import { useContext, useEffect } from 'react';
import useSWR from 'swr';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
@ -39,6 +39,12 @@ function Subscription() {
})
);
useEffect(() => {
if (isCloud && isDevFeaturesEnabled) {
onCurrentSubscriptionUpdated();
}
}, [onCurrentSubscriptionUpdated]);
if (isLoading) {
return <Skeleton />;
}

View file

@ -18,6 +18,7 @@ import Select, { type Option } from '@/ds-components/Select';
import TextLink from '@/ds-components/TextLink';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import modalStyles from '@/scss/modal.module.scss';
import { hasReachedSubscriptionQuotaLimit } from '@/utils/quota';
import InviteEmailsInput from '../InviteEmailsInput';
import useEmailInputUtils from '../InviteEmailsInput/hooks';
@ -41,6 +42,8 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
const { show } = useConfirmModal();
const {
currentSubscription: { planId },
currentSubscriptionQuota,
currentSubscriptionUsage: { tenantMembersLimit },
} = useContext(SubscriptionDataContext);
const formMethods = useForm<InviteMemberForm>({
@ -72,6 +75,12 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
[t]
);
const hasTenantMembersReachedLimit = hasReachedSubscriptionQuotaLimit({
quotaKey: 'tenantMembersLimit',
usage: tenantMembersLimit,
quota: currentSubscriptionQuota,
});
const onSubmit = handleSubmit(async ({ emails, role }) => {
if (role === TenantRole.Admin) {
const [result] = await show({
@ -89,19 +98,17 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
}
setIsLoading(true);
try {
await Promise.all(
emails.map(async (email) =>
cloudApi.post('/api/tenants/:tenantId/invitations', {
params: { tenantId: currentTenantId },
body: { invitee: email.value, roleName: role },
})
)
);
toast.success(t('tenant_members.messages.invitation_sent'));
onClose(true);
} finally {
setIsLoading(false);
if (emails.length > 0) {
try {
await cloudApi.post('/api/tenants/:tenantId/invitations', {
params: { tenantId: currentTenantId },
body: { invitee: emails.map(({ value }) => value), roleName: role },
});
toast.success(t('tenant_members.messages.invitation_sent'));
onClose(true);
} finally {
setIsLoading(false);
}
}
});
@ -123,24 +130,26 @@ function InviteMemberModal({ isOpen, onClose }: Props) {
subtitle="tenant_members.invite_modal.subtitle"
footer={
conditional(
isDevFeaturesEnabled && planId === ReservedPlanId.Pro && (
<AddOnNoticeFooter
isLoading={isLoading}
buttonTitle="tenant_members.invite_members"
onClick={onSubmit}
>
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
isDevFeaturesEnabled &&
hasTenantMembersReachedLimit &&
planId === ReservedPlanId.Pro && (
<AddOnNoticeFooter
isLoading={isLoading}
buttonTitle="tenant_members.invite_members"
onClick={onSubmit}
>
{t('upsell.add_on.footer.tenant_members', {
price: tenantMembersAddOnUnitPrice,
})}
</Trans>
</AddOnNoticeFooter>
)
<Trans
components={{
span: <span className={styles.strong} />,
a: <TextLink to="https://blog.logto.io/pricing-add-ons/" />,
}}
>
{t('upsell.add_on.footer.tenant_members', {
price: tenantMembersAddOnUnitPrice,
})}
</Trans>
</AddOnNoticeFooter>
)
) ?? (
<Footer
newInvitationCount={watch('emails').length}

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -11,7 +11,7 @@ const add_on = {
tenant_members:
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};

View file

@ -17,7 +17,7 @@ const add_on = {
'Additional members cost <span>${{price, number}} per mo / ea</span>. First month prorated based on your billing cycle. <a>Learn more</a>',
/** UNTRANSLATED */
organization:
'Organization is a <span>${{price, number}} per mo / ea</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
'Organization is a <span>${{price, number}} per mo</span> add-on for {{planName}}. First month prorated based on your billing cycle. <a>Learn more</a>',
},
};