diff --git a/packages/console/src/components/ContactUsPhraseLink/index.tsx b/packages/console/src/components/ContactUsPhraseLink/index.tsx
new file mode 100644
index 000000000..e87bf38a2
--- /dev/null
+++ b/packages/console/src/components/ContactUsPhraseLink/index.tsx
@@ -0,0 +1,18 @@
+import { type ReactNode } from 'react';
+
+import { contactEmailLink } from '@/consts';
+import TextLink from '@/ds-components/TextLink';
+
+type Props = {
+ children?: ReactNode;
+};
+
+function ContactUsPhraseLink({ children }: Props) {
+ return (
+
+ {children}
+
+ );
+}
+
+export default ContactUsPhraseLink;
diff --git a/packages/console/src/components/QuotaGuardFooter/index.module.scss b/packages/console/src/components/QuotaGuardFooter/index.module.scss
new file mode 100644
index 000000000..c085bcdad
--- /dev/null
+++ b/packages/console/src/components/QuotaGuardFooter/index.module.scss
@@ -0,0 +1,16 @@
+@use '@/scss/underscore' as _;
+
+.container {
+ display: flex;
+ align-items: center;
+ gap: _.unit(6);
+ padding: _.unit(6);
+ background-color: var(--color-info-container);
+ margin: 0 _.unit(-6) _.unit(-6);
+
+ .description {
+ flex: 1;
+ flex-shrink: 0;
+ font: var(--font-body-2);
+ }
+}
diff --git a/packages/console/src/components/QuotaGuardFooter/index.tsx b/packages/console/src/components/QuotaGuardFooter/index.tsx
new file mode 100644
index 000000000..36a0f9c5e
--- /dev/null
+++ b/packages/console/src/components/QuotaGuardFooter/index.tsx
@@ -0,0 +1,29 @@
+import { type ReactNode } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import Button from '@/ds-components/Button';
+
+import * as styles from './index.module.scss';
+
+type Props = {
+ children: ReactNode;
+};
+
+function QuotaGuardFooter({ children }: Props) {
+ const navigate = useNavigate();
+ return (
+
+
{children}
+
+ );
+}
+
+export default QuotaGuardFooter;
diff --git a/packages/console/src/ds-components/ModalLayout/index.module.scss b/packages/console/src/ds-components/ModalLayout/index.module.scss
index 0f255bb68..b79e1a5c6 100644
--- a/packages/console/src/ds-components/ModalLayout/index.module.scss
+++ b/packages/console/src/ds-components/ModalLayout/index.module.scss
@@ -9,6 +9,7 @@
padding: _.unit(6);
margin: 0 _.unit(6);
box-shadow: var(--shadow-3);
+ overflow: hidden;
.header {
display: flex;
diff --git a/packages/console/src/hooks/use-current-subscription.ts b/packages/console/src/hooks/use-current-subscription.ts
index 233824fe5..ffb6e5da0 100644
--- a/packages/console/src/hooks/use-current-subscription.ts
+++ b/packages/console/src/hooks/use-current-subscription.ts
@@ -3,14 +3,17 @@ import useSWR from 'swr';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type Subscription } from '@/cloud/types/router';
-import { isCloud } from '@/consts/env';
+import { isCloud, isProduction } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider';
const useCurrentSubscription = () => {
const { currentTenantId } = useContext(TenantsContext);
const cloudApi = useCloudApi();
return useSWR(
- isCloud && `/api/tenants/${currentTenantId}/subscription`,
+ /**
+ * Todo: @xiaoyijun remove this condition on subscription features ready.
+ */
+ !isProduction && isCloud && `/api/tenants/${currentTenantId}/subscription`,
async () =>
cloudApi.get('/api/tenants/:tenantId/subscription', {
params: { tenantId: currentTenantId },
diff --git a/packages/console/src/hooks/use-subscription-plans.ts b/packages/console/src/hooks/use-subscription-plans.ts
index d259585fd..9ef168865 100644
--- a/packages/console/src/hooks/use-subscription-plans.ts
+++ b/packages/console/src/hooks/use-subscription-plans.ts
@@ -4,7 +4,7 @@ import useSWRImmutable from 'swr/immutable';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
-import { isCloud } from '@/consts/env';
+import { isCloud, isProduction } from '@/consts/env';
import { reservedPlanIdOrder } from '@/consts/subscriptions';
import { type SubscriptionPlan } from '@/types/subscriptions';
import { addSupportQuotaToPlan } from '@/utils/subscription';
@@ -12,7 +12,10 @@ import { addSupportQuotaToPlan } from '@/utils/subscription';
const useSubscriptionPlans = () => {
const cloudApi = useCloudApi();
const useSwrResponse = useSWRImmutable(
- isCloud && '/api/subscription-plans',
+ /**
+ * Todo: @xiaoyijun remove this condition on subscription features ready.
+ */
+ !isProduction && isCloud && '/api/subscription-plans',
async () => cloudApi.get('/api/subscription-plans')
);
diff --git a/packages/console/src/pages/Webhooks/components/CreateFormModal/CreateForm.tsx b/packages/console/src/pages/Webhooks/components/CreateFormModal/CreateForm.tsx
index 72df14490..da30db3eb 100644
--- a/packages/console/src/pages/Webhooks/components/CreateFormModal/CreateForm.tsx
+++ b/packages/console/src/pages/Webhooks/components/CreateFormModal/CreateForm.tsx
@@ -1,15 +1,22 @@
import { type Hook, type CreateHook, type HookEvent, type HookConfig } from '@logto/schemas';
import { FormProvider, useForm } from 'react-hook-form';
+import { Trans, useTranslation } from 'react-i18next';
+import ContactUsPhraseLink from '@/components/ContactUsPhraseLink';
+import PlanName from '@/components/PlanName';
+import QuotaGuardFooter from '@/components/QuotaGuardFooter';
import Button from '@/ds-components/Button';
import ModalLayout from '@/ds-components/ModalLayout';
import useApi from '@/hooks/use-api';
+import useCurrentSubscriptionPlan from '@/hooks/use-current-subscription-plan';
import { trySubmitSafe } from '@/utils/form';
+import { isOverQuota } from '@/utils/quota';
import { type BasicWebhookFormType } from '../../types';
import BasicWebhookForm from '../BasicWebhookForm';
type Props = {
+ totalWebhookCount: number;
onClose: (createdHook?: Hook) => void;
};
@@ -20,7 +27,16 @@ type CreateHookPayload = Pick & {
};
};
-function CreateForm({ onClose }: Props) {
+function CreateForm({ totalWebhookCount, onClose }: Props) {
+ const { data: currentPlan } = useCurrentSubscriptionPlan();
+ const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
+
+ const shouldBlockCreation = isOverQuota({
+ quotaKey: 'hooksLimit',
+ usage: totalWebhookCount,
+ plan: currentPlan,
+ });
+
const formMethods = useForm();
const {
handleSubmit,
@@ -50,14 +66,27 @@ function CreateForm({ onClose }: Props) {
title="webhooks.create_form.title"
subtitle="webhooks.create_form.subtitle"
footer={
-
+ shouldBlockCreation && currentPlan ? (
+
+ ,
+ planName: ,
+ }}
+ >
+ {t('upsell.paywall.hooks', { count: currentPlan.quota.hooksLimit })}
+
+
+ ) : (
+
+ )
}
onClose={onClose}
>
diff --git a/packages/console/src/pages/Webhooks/components/CreateFormModal/index.tsx b/packages/console/src/pages/Webhooks/components/CreateFormModal/index.tsx
index f7b56e5a9..c5cd612d9 100644
--- a/packages/console/src/pages/Webhooks/components/CreateFormModal/index.tsx
+++ b/packages/console/src/pages/Webhooks/components/CreateFormModal/index.tsx
@@ -7,10 +7,11 @@ import CreateForm from './CreateForm';
type Props = {
isOpen: boolean;
+ totalWebhookCount: number;
onClose: (createdHook?: Hook) => void;
};
-function CreateFormModal({ isOpen, onClose }: Props) {
+function CreateFormModal({ isOpen, totalWebhookCount, onClose }: Props) {
return (
-
+
);
}
diff --git a/packages/console/src/pages/Webhooks/index.tsx b/packages/console/src/pages/Webhooks/index.tsx
index 8c623a6a1..46e314df4 100644
--- a/packages/console/src/pages/Webhooks/index.tsx
+++ b/packages/console/src/pages/Webhooks/index.tsx
@@ -158,19 +158,22 @@ function Webhooks() {
},
}}
widgets={
- {
- if (createdHook) {
- void mutate();
- toast.success(t('webhooks.webhook_created', { name: createdHook.name }));
- navigate(buildDetailsPathname(createdHook.id), { replace: true });
- return;
- }
+ totalCount !== undefined && (
+ {
+ if (createdHook) {
+ void mutate();
+ toast.success(t('webhooks.webhook_created', { name: createdHook.name }));
+ navigate(buildDetailsPathname(createdHook.id), { replace: true });
+ return;
+ }
- navigate({ pathname: webhooksPathname, search });
- }}
- />
+ navigate({ pathname: webhooksPathname, search });
+ }}
+ />
+ )
}
/>
);
diff --git a/packages/console/src/utils/quota.ts b/packages/console/src/utils/quota.ts
new file mode 100644
index 000000000..da654750a
--- /dev/null
+++ b/packages/console/src/utils/quota.ts
@@ -0,0 +1,35 @@
+import { isCloud, isProduction } from '@/consts/env';
+import { type SubscriptionPlan, type SubscriptionPlanQuota } from '@/types/subscriptions';
+
+type IsOverQuotaParameters = {
+ quotaKey: keyof SubscriptionPlanQuota;
+ usage: number;
+ plan?: SubscriptionPlan;
+};
+
+export const isOverQuota = ({ quotaKey, usage, plan }: IsOverQuotaParameters) => {
+ /**
+ * Todo: @xiaoyijun remove this condition on subscription features ready.
+ */
+ if (isProduction || !isCloud) {
+ return false;
+ }
+
+ // If the plan is not loaded, guarded by backend APIs
+ if (!plan) {
+ return false;
+ }
+
+ const quotaValue = plan.quota[quotaKey];
+
+ // Unlimited
+ if (quotaValue === null) {
+ return false;
+ }
+
+ if (typeof quotaValue === 'boolean') {
+ return !quotaValue;
+ }
+
+ return usage >= quotaValue;
+};