mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(console): checkout integration (#4178)
This commit is contained in:
parent
1b0f9be88b
commit
6e094d959f
43 changed files with 369 additions and 22 deletions
|
@ -26,7 +26,7 @@
|
||||||
"@fontsource/roboto-mono": "^5.0.0",
|
"@fontsource/roboto-mono": "^5.0.0",
|
||||||
"@jest/types": "^29.5.0",
|
"@jest/types": "^29.5.0",
|
||||||
"@logto/app-insights": "workspace:^1.3.1",
|
"@logto/app-insights": "workspace:^1.3.1",
|
||||||
"@logto/cloud": "0.2.5-4d5e389",
|
"@logto/cloud": "0.2.5-2087c06",
|
||||||
"@logto/connector-kit": "workspace:^1.1.1",
|
"@logto/connector-kit": "workspace:^1.1.1",
|
||||||
"@logto/core-kit": "workspace:^2.0.1",
|
"@logto/core-kit": "workspace:^2.0.1",
|
||||||
"@logto/language-kit": "workspace:^1.0.0",
|
"@logto/language-kit": "workspace:^1.0.0",
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { ReservedPlanId } from '@/consts/subscriptions';
|
||||||
import DangerousRaw from '@/ds-components/DangerousRaw';
|
import DangerousRaw from '@/ds-components/DangerousRaw';
|
||||||
import ModalLayout from '@/ds-components/ModalLayout';
|
import ModalLayout from '@/ds-components/ModalLayout';
|
||||||
import TextLink from '@/ds-components/TextLink';
|
import TextLink from '@/ds-components/TextLink';
|
||||||
|
import useSubscribe from '@/hooks/use-subscribe';
|
||||||
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||||
import * as modalStyles from '@/scss/modal.module.scss';
|
import * as modalStyles from '@/scss/modal.module.scss';
|
||||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
|
@ -25,6 +26,7 @@ type Props = {
|
||||||
function SelectTenantPlanModal({ tenantData, onClose }: Props) {
|
function SelectTenantPlanModal({ tenantData, onClose }: Props) {
|
||||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { data: subscriptionPlans } = useSubscriptionPlans();
|
const { data: subscriptionPlans } = useSubscriptionPlans();
|
||||||
|
const { subscribe } = useSubscribe();
|
||||||
const cloudApi = useCloudApi();
|
const cloudApi = useCloudApi();
|
||||||
if (!subscriptionPlans || !tenantData) {
|
if (!subscriptionPlans || !tenantData) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -42,7 +44,8 @@ function SelectTenantPlanModal({ tenantData, onClose }: Props) {
|
||||||
toast.error(error instanceof Error ? error.message : String(error));
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Todo @xiaoyijun implement checkout
|
|
||||||
|
void subscribe({ planId, tenantData });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
3
packages/console/src/consts/pages.ts
Normal file
3
packages/console/src/consts/pages.ts
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
import { TenantSettingsTabs } from '.';
|
||||||
|
|
||||||
|
export const subscriptionPage = `/tenant-settings/${TenantSettingsTabs.Subscription}`;
|
|
@ -2,7 +2,7 @@ export type CamelCase<T> = T extends `${infer A}_${infer B}`
|
||||||
? `${A}${Capitalize<CamelCase<B>>}`
|
? `${A}${Capitalize<CamelCase<B>>}`
|
||||||
: T;
|
: T;
|
||||||
|
|
||||||
export type StorageType = 'appearance_mode' | 'linking_social_connector';
|
export type StorageType = 'appearance_mode' | 'linking_social_connector' | 'checkout_session';
|
||||||
|
|
||||||
export const getStorageKey = <T extends StorageType>(forType: T) =>
|
export const getStorageKey = <T extends StorageType>(forType: T) =>
|
||||||
`logto:admin_console:${forType}` as const;
|
`logto:admin_console:${forType}` as const;
|
||||||
|
@ -10,4 +10,5 @@ export const getStorageKey = <T extends StorageType>(forType: T) =>
|
||||||
export const storageKeys = Object.freeze({
|
export const storageKeys = Object.freeze({
|
||||||
appearanceMode: getStorageKey('appearance_mode'),
|
appearanceMode: getStorageKey('appearance_mode'),
|
||||||
linkingSocialConnector: getStorageKey('linking_social_connector'),
|
linkingSocialConnector: getStorageKey('linking_social_connector'),
|
||||||
|
checkoutSession: getStorageKey('checkout_session'),
|
||||||
} satisfies Record<CamelCase<StorageType>, string>);
|
} satisfies Record<CamelCase<StorageType>, string>);
|
||||||
|
|
|
@ -84,3 +84,7 @@ export const planTableGroupKeyMap: SubscriptionPlanTableGroupKeyMap = Object.fre
|
||||||
[SubscriptionPlanTableGroupKey.hooks]: ['hooksLimit'],
|
[SubscriptionPlanTableGroupKey.hooks]: ['hooksLimit'],
|
||||||
[SubscriptionPlanTableGroupKey.support]: ['communitySupportEnabled', 'ticketSupportResponseTime'],
|
[SubscriptionPlanTableGroupKey.support]: ['communitySupportEnabled', 'ticketSupportResponseTime'],
|
||||||
}) satisfies SubscriptionPlanTableGroupKeyMap;
|
}) satisfies SubscriptionPlanTableGroupKeyMap;
|
||||||
|
|
||||||
|
export const checkoutStateQueryKey = 'checkout-state';
|
||||||
|
|
||||||
|
export const checkoutSuccessCallbackPath = 'checkout-success';
|
||||||
|
|
81
packages/console/src/hooks/use-subscribe.ts
Normal file
81
packages/console/src/hooks/use-subscribe.ts
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
import { appendPath } from '@silverhand/essentials';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
|
import { type CreateTenantData } from '@/components/CreateTenantModal/type';
|
||||||
|
import { getBasename } from '@/consts';
|
||||||
|
import { checkoutStateQueryKey, checkoutSuccessCallbackPath } from '@/consts/subscriptions';
|
||||||
|
import { createLocalCheckoutSession } from '@/utils/checkout';
|
||||||
|
|
||||||
|
type SubscribeProps = {
|
||||||
|
planId: string;
|
||||||
|
callbackPage?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
tenantData?: CreateTenantData;
|
||||||
|
isDowngrade?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useSubscribe = () => {
|
||||||
|
const cloudApi = useCloudApi();
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
|
|
||||||
|
const subscribe = async ({
|
||||||
|
planId,
|
||||||
|
callbackPage,
|
||||||
|
tenantId,
|
||||||
|
tenantData,
|
||||||
|
isDowngrade = false,
|
||||||
|
}: SubscribeProps) => {
|
||||||
|
const state = nanoid(6);
|
||||||
|
|
||||||
|
const successSearchParam = new URLSearchParams({
|
||||||
|
[checkoutStateQueryKey]: state,
|
||||||
|
});
|
||||||
|
|
||||||
|
const successCallbackUrl = appendPath(
|
||||||
|
new URL(getBasename(), window.location.origin),
|
||||||
|
`${checkoutSuccessCallbackPath}?${successSearchParam.toString()}`
|
||||||
|
).href;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
createLocalCheckoutSession({
|
||||||
|
state,
|
||||||
|
sessionId,
|
||||||
|
callbackPage,
|
||||||
|
isDowngrade,
|
||||||
|
});
|
||||||
|
|
||||||
|
window.location.assign(redirectUri);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelSubscription = async (tenantId: string) => {
|
||||||
|
await cloudApi.delete('/api/tenants/:tenantId/subscription', {
|
||||||
|
params: {
|
||||||
|
tenantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
cancelSubscription,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useSubscribe;
|
121
packages/console/src/pages/CheckoutSuccessCallback/index.tsx
Normal file
121
packages/console/src/pages/CheckoutSuccessCallback/index.tsx
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
import { conditional, conditionalString } from '@silverhand/essentials';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useContext, useEffect } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
import { Navigate, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useTimer } from 'react-timer-hook';
|
||||||
|
import useSWR from 'swr';
|
||||||
|
|
||||||
|
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||||
|
import AppLoading from '@/components/AppLoading';
|
||||||
|
import PlanName from '@/components/PlanName';
|
||||||
|
import { checkoutStateQueryKey } from '@/consts/subscriptions';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||||
|
import { clearLocalCheckoutSession, getLocalCheckoutSession } from '@/utils/checkout';
|
||||||
|
|
||||||
|
const consoleHomePage = '/';
|
||||||
|
const subscriptionCheckingInterval = 1000;
|
||||||
|
const subscriptionCheckingTimeout = 10 * 1000;
|
||||||
|
|
||||||
|
function CheckoutSuccessCallback() {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' });
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const cloudApi = useCloudApi();
|
||||||
|
const { currentTenantId, navigateTenant } = useContext(TenantsContext);
|
||||||
|
const { search } = useLocation();
|
||||||
|
const checkoutState = new URLSearchParams(search).get(checkoutStateQueryKey);
|
||||||
|
const { state, sessionId, callbackPage, isDowngrade } = getLocalCheckoutSession() ?? {};
|
||||||
|
const { data: subscriptionPlans, error: fetchPlansError } = useSubscriptionPlans();
|
||||||
|
const isLoadingPlans = !subscriptionPlans && !fetchPlansError;
|
||||||
|
|
||||||
|
// Note: if we can't get the subscription results in 10 seconds, we will redirect to the console home page
|
||||||
|
useTimer({
|
||||||
|
autoStart: true,
|
||||||
|
expiryTimestamp: dayjs().add(subscriptionCheckingTimeout, 'millisecond').toDate(),
|
||||||
|
onExpire: () => {
|
||||||
|
toast.error(t('subscription_check_timeout'));
|
||||||
|
clearLocalCheckoutSession();
|
||||||
|
navigate(consoleHomePage, { replace: true });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: only handle the callback comes from the stripe success callback url
|
||||||
|
const isValidSession = state && state === checkoutState;
|
||||||
|
|
||||||
|
const { data: stripeCheckoutSession } = useSWR(
|
||||||
|
isValidSession && sessionId && `/api/checkout-session/${sessionId}`,
|
||||||
|
async () =>
|
||||||
|
cloudApi.get('/api/checkout-session/:id', {
|
||||||
|
params: {
|
||||||
|
id: conditionalString(sessionId),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
refreshInterval: subscriptionCheckingInterval,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkoutTenantId = stripeCheckoutSession?.tenantId;
|
||||||
|
const checkoutPlanId = stripeCheckoutSession?.planId;
|
||||||
|
|
||||||
|
const { data: tenantSubscription } = useSWR(
|
||||||
|
checkoutTenantId && `/api/tenants/${checkoutTenantId}/subscription`,
|
||||||
|
async () =>
|
||||||
|
cloudApi.get('/api/tenants/:tenantId/subscription', {
|
||||||
|
params: {
|
||||||
|
tenantId: conditionalString(checkoutTenantId),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ refreshInterval: subscriptionCheckingInterval }
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCheckoutSuccessful =
|
||||||
|
!isLoadingPlans &&
|
||||||
|
checkoutTenantId &&
|
||||||
|
stripeCheckoutSession.status === 'complete' &&
|
||||||
|
checkoutPlanId === tenantSubscription?.planId;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isCheckoutSuccessful) {
|
||||||
|
clearLocalCheckoutSession();
|
||||||
|
|
||||||
|
const checkoutPlan = subscriptionPlans?.find((plan) => plan.id === checkoutPlanId);
|
||||||
|
if (checkoutPlan) {
|
||||||
|
toast.success(
|
||||||
|
<Trans components={{ name: <PlanName name={checkoutPlan.name} /> }}>
|
||||||
|
{t(isDowngrade ? 'downgrade_success' : 'upgrade_success')}
|
||||||
|
</Trans>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkoutTenantId === currentTenantId) {
|
||||||
|
navigate(conditional(callbackPage) ?? consoleHomePage, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: the tenant is created after checkout.
|
||||||
|
navigateTenant(checkoutTenantId);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
callbackPage,
|
||||||
|
checkoutPlanId,
|
||||||
|
checkoutTenantId,
|
||||||
|
currentTenantId,
|
||||||
|
isCheckoutSuccessful,
|
||||||
|
isDowngrade,
|
||||||
|
navigate,
|
||||||
|
navigateTenant,
|
||||||
|
subscriptionPlans,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isValidSession && !isLoadingPlans) {
|
||||||
|
return <Navigate replace to={consoleHomePage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AppLoading />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CheckoutSuccessCallback;
|
|
@ -3,6 +3,8 @@ import { TrackOnce } from '@logto/app-insights/react';
|
||||||
import { Outlet, Route, Routes } from 'react-router-dom';
|
import { Outlet, Route, Routes } from 'react-router-dom';
|
||||||
import { SWRConfig } from 'swr';
|
import { SWRConfig } from 'swr';
|
||||||
|
|
||||||
|
import { isCloud, isProduction } from '@/consts/env';
|
||||||
|
import { checkoutSuccessCallbackPath } from '@/consts/subscriptions';
|
||||||
import AppBoundary from '@/containers/AppBoundary';
|
import AppBoundary from '@/containers/AppBoundary';
|
||||||
import AppContent from '@/containers/AppContent';
|
import AppContent from '@/containers/AppContent';
|
||||||
import ConsoleContent from '@/containers/ConsoleContent';
|
import ConsoleContent from '@/containers/ConsoleContent';
|
||||||
|
@ -13,6 +15,7 @@ import useSwrOptions from '@/hooks/use-swr-options';
|
||||||
import Callback from '@/pages/Callback';
|
import Callback from '@/pages/Callback';
|
||||||
import Welcome from '@/pages/Welcome';
|
import Welcome from '@/pages/Welcome';
|
||||||
|
|
||||||
|
import CheckoutSuccessCallback from '../CheckoutSuccessCallback';
|
||||||
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
import HandleSocialCallback from '../Profile/containers/HandleSocialCallback';
|
||||||
|
|
||||||
function Layout() {
|
function Layout() {
|
||||||
|
@ -38,6 +41,9 @@ export function ConsoleRoutes() {
|
||||||
<Route element={<ProtectedRoutes />}>
|
<Route element={<ProtectedRoutes />}>
|
||||||
<Route path="handle-social" element={<HandleSocialCallback />} />
|
<Route path="handle-social" element={<HandleSocialCallback />} />
|
||||||
<Route element={<TenantAccess />}>
|
<Route element={<TenantAccess />}>
|
||||||
|
{!isProduction && isCloud && (
|
||||||
|
<Route path={checkoutSuccessCallbackPath} element={<CheckoutSuccessCallback />} />
|
||||||
|
)}
|
||||||
<Route element={<AppContent />}>
|
<Route element={<AppContent />}>
|
||||||
<Route path="*" element={<ConsoleContent />} />
|
<Route path="*" element={<ConsoleContent />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
|
@ -1,7 +1,16 @@
|
||||||
|
import { useContext } from 'react';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import PlanName from '@/components/PlanName';
|
||||||
import { contactEmailLink } from '@/consts';
|
import { contactEmailLink } from '@/consts';
|
||||||
|
import { subscriptionPage } from '@/consts/pages';
|
||||||
|
import { ReservedPlanId } from '@/consts/subscriptions';
|
||||||
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
import Button from '@/ds-components/Button';
|
import Button from '@/ds-components/Button';
|
||||||
import Spacer from '@/ds-components/Spacer';
|
import Spacer from '@/ds-components/Spacer';
|
||||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||||
|
import useSubscribe from '@/hooks/use-subscribe';
|
||||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||||
import { isDowngradePlan } from '@/utils/subscription';
|
import { isDowngradePlan } from '@/utils/subscription';
|
||||||
|
|
||||||
|
@ -13,19 +22,48 @@ import * as styles from './index.module.scss';
|
||||||
type Props = {
|
type Props = {
|
||||||
currentSubscriptionPlanId: string;
|
currentSubscriptionPlanId: string;
|
||||||
subscriptionPlans: SubscriptionPlan[];
|
subscriptionPlans: SubscriptionPlan[];
|
||||||
|
onSubscriptionUpdated: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function SwitchPlanActionBar({ currentSubscriptionPlanId, subscriptionPlans }: Props) {
|
function SwitchPlanActionBar({
|
||||||
|
currentSubscriptionPlanId,
|
||||||
|
subscriptionPlans,
|
||||||
|
onSubscriptionUpdated,
|
||||||
|
}: Props) {
|
||||||
|
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console.subscription' });
|
||||||
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
|
const { subscribe, cancelSubscription } = useSubscribe();
|
||||||
const { show } = useConfirmModal();
|
const { show } = useConfirmModal();
|
||||||
|
|
||||||
const handleDownGrade = async (targetPlan: SubscriptionPlan) => {
|
const handleDownGrade = async (targetPlan: SubscriptionPlan) => {
|
||||||
// Todo @xiaoyijun handle downgrade
|
const { id: planId, name } = targetPlan;
|
||||||
await show({
|
try {
|
||||||
ModalContent: () => <NotEligibleDowngradeModalContent targetPlan={targetPlan} />,
|
if (planId === ReservedPlanId.free) {
|
||||||
title: 'subscription.downgrade_modal.not_eligible',
|
await cancelSubscription(currentTenantId);
|
||||||
confirmButtonText: 'general.got_it',
|
onSubscriptionUpdated();
|
||||||
confirmButtonType: 'primary',
|
toast.success(
|
||||||
});
|
<Trans components={{ name: <PlanName name={name} /> }}>{t('downgrade_success')}</Trans>
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await subscribe({
|
||||||
|
tenantId: currentTenantId,
|
||||||
|
planId,
|
||||||
|
isDowngrade: true,
|
||||||
|
callbackPage: subscriptionPage,
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
// Todo @xiaoyijun check if the error is not eligible downgrade error or not
|
||||||
|
await show({
|
||||||
|
ModalContent: () => <NotEligibleDowngradeModalContent targetPlan={targetPlan} />,
|
||||||
|
title: 'subscription.downgrade_modal.not_eligible',
|
||||||
|
confirmButtonText: 'general.got_it',
|
||||||
|
confirmButtonType: 'primary',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.error(error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDowngradeClick = async (targetPlanId: string) => {
|
const onDowngradeClick = async (targetPlanId: string) => {
|
||||||
|
@ -68,13 +106,17 @@ function SwitchPlanActionBar({ currentSubscriptionPlanId, subscriptionPlans }: P
|
||||||
}
|
}
|
||||||
type={isDowngrade ? 'default' : 'primary'}
|
type={isDowngrade ? 'default' : 'primary'}
|
||||||
disabled={isCurrentPlan}
|
disabled={isCurrentPlan}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
if (isDowngrade) {
|
if (isDowngrade) {
|
||||||
await onDowngradeClick(planId);
|
void onDowngradeClick(planId);
|
||||||
// eslint-disable-next-line no-useless-return
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Todo @xiaoyijun handle buy plan
|
void subscribe({
|
||||||
|
tenantId: currentTenantId,
|
||||||
|
planId,
|
||||||
|
callbackPage: subscriptionPage,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,11 @@ import * as styles from './index.module.scss';
|
||||||
|
|
||||||
function Subscription() {
|
function Subscription() {
|
||||||
const { data: subscriptionPlans, error: fetchPlansError } = useSubscriptionPlans();
|
const { data: subscriptionPlans, error: fetchPlansError } = useSubscriptionPlans();
|
||||||
const { data: currentSubscription, error: fetchSubscriptionError } = useCurrentSubscription();
|
const {
|
||||||
|
data: currentSubscription,
|
||||||
|
error: fetchSubscriptionError,
|
||||||
|
mutate: mutateSubscription,
|
||||||
|
} = useCurrentSubscription();
|
||||||
const { data: subscriptionUsage, error: fetchSubscriptionUsageError } =
|
const { data: subscriptionUsage, error: fetchSubscriptionUsageError } =
|
||||||
useCurrentSubscriptionUsage();
|
useCurrentSubscriptionUsage();
|
||||||
|
|
||||||
|
@ -50,6 +54,7 @@ function Subscription() {
|
||||||
<SwitchPlanActionBar
|
<SwitchPlanActionBar
|
||||||
currentSubscriptionPlanId={currentSubscription.planId}
|
currentSubscriptionPlanId={currentSubscription.planId}
|
||||||
subscriptionPlans={subscriptionPlans}
|
subscriptionPlans={subscriptionPlans}
|
||||||
|
onSubscriptionUpdated={mutateSubscription}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||||
|
|
||||||
export enum ReservedPlanName {
|
export enum ReservedPlanName {
|
||||||
|
@ -48,3 +50,12 @@ type SubscriptionPlanTableValue = SubscriptionPlanTable[keyof SubscriptionPlanTa
|
||||||
export type SubscriptionPlanTableRow = Record<string, SubscriptionPlanTableValue> & {
|
export type SubscriptionPlanTableRow = Record<string, SubscriptionPlanTableValue> & {
|
||||||
quotaKey: keyof SubscriptionPlanTable;
|
quotaKey: keyof SubscriptionPlanTable;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const localCheckoutSessionGuard = z.object({
|
||||||
|
state: z.string(),
|
||||||
|
sessionId: z.string(),
|
||||||
|
callbackPage: z.string().optional(),
|
||||||
|
isDowngrade: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
|
||||||
|
|
33
packages/console/src/utils/checkout.ts
Normal file
33
packages/console/src/utils/checkout.ts
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
import { type Optional } from '@silverhand/essentials';
|
||||||
|
|
||||||
|
import { storageKeys } from '@/consts';
|
||||||
|
import { type LocalCheckoutSession, localCheckoutSessionGuard } from '@/types/subscriptions';
|
||||||
|
|
||||||
|
import { safeParseJson } from './json';
|
||||||
|
|
||||||
|
export const createLocalCheckoutSession = (session: LocalCheckoutSession) => {
|
||||||
|
sessionStorage.setItem(storageKeys.checkoutSession, JSON.stringify(session));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLocalCheckoutSession = (): Optional<LocalCheckoutSession> => {
|
||||||
|
const sessionValue = sessionStorage.getItem(storageKeys.checkoutSession);
|
||||||
|
if (!sessionValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionJson = safeParseJson(sessionValue);
|
||||||
|
if (!sessionJson.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedSession = localCheckoutSessionGuard.safeParse(sessionJson.data);
|
||||||
|
if (!parsedSession.success) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedSession.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearLocalCheckoutSession = () => {
|
||||||
|
sessionStorage.removeItem(storageKeys.checkoutSession);
|
||||||
|
};
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'Unbenannt',
|
unnamed: 'Unbenannt',
|
||||||
view: 'Anzeigen',
|
view: 'Anzeigen',
|
||||||
hide: 'Verbergen',
|
hide: 'Verbergen',
|
||||||
|
unknown_error: 'Unbekannter Fehler, bitte versuchen Sie es später erneut.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -52,6 +52,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Erfolgreich auf <name/> hochgestuft',
|
upgrade_success: 'Erfolgreich auf <name/> hochgestuft',
|
||||||
downgrade_success: 'Erfolgreich auf <name/> herabgestuft',
|
downgrade_success: 'Erfolgreich auf <name/> herabgestuft',
|
||||||
|
subscription_check_timeout: 'Abo-Überprüfung ist abgelaufen. Bitte später aktualisieren.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: 'Unnamed',
|
unnamed: 'Unnamed',
|
||||||
view: 'View',
|
view: 'View',
|
||||||
hide: 'Hide',
|
hide: 'Hide',
|
||||||
|
unknown_error: 'Unknown error, please try again later.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -52,6 +52,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Successfully upgraded to <name/>',
|
upgrade_success: 'Successfully upgraded to <name/>',
|
||||||
downgrade_success: 'Successfully downgraded to <name/>',
|
downgrade_success: 'Successfully downgraded to <name/>',
|
||||||
|
subscription_check_timeout: 'Subscription check timed out. Please refresh later.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'Sin nombre',
|
unnamed: 'Sin nombre',
|
||||||
view: 'Ver',
|
view: 'Ver',
|
||||||
hide: 'Ocultar',
|
hide: 'Ocultar',
|
||||||
|
unknown_error: 'Error desconocido, por favor inténtalo de nuevo más tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -54,6 +54,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Actualizado con éxito a <name/>',
|
upgrade_success: 'Actualizado con éxito a <name/>',
|
||||||
downgrade_success: 'Degradado con éxito a <name/>',
|
downgrade_success: 'Degradado con éxito a <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
'La comprobación de suscripción expiró. Por favor, actualiza más tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'Sans nom',
|
unnamed: 'Sans nom',
|
||||||
view: 'Vue',
|
view: 'Vue',
|
||||||
hide: 'Cacher',
|
hide: 'Cacher',
|
||||||
|
unknown_error: 'Erreur inconnue, veuillez réessayer ultérieurement.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -56,6 +56,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Passé avec succès à <name/>',
|
upgrade_success: 'Passé avec succès à <name/>',
|
||||||
downgrade_success: 'Rétrogradé avec succès à <name/>',
|
downgrade_success: 'Rétrogradé avec succès à <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
"La vérification d'abonnement a expiré. Veuillez actualiser ultérieurement.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'Senza nome',
|
unnamed: 'Senza nome',
|
||||||
view: 'Vista',
|
view: 'Vista',
|
||||||
hide: 'Nascondi',
|
hide: 'Nascondi',
|
||||||
|
unknown_error: 'Errore sconosciuto, riprova più tardi.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -53,6 +53,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Aggiornamento effettuato con successo a <name/>',
|
upgrade_success: 'Aggiornamento effettuato con successo a <name/>',
|
||||||
downgrade_success: 'Degrado effettuato con successo a <name/>',
|
downgrade_success: 'Degrado effettuato con successo a <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
"Il controllo dell'abbonamento è scaduto. Si prega di riprovare più tardi.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: '名前がありません',
|
unnamed: '名前がありません',
|
||||||
view: '表示',
|
view: '表示',
|
||||||
hide: '非表示',
|
hide: '非表示',
|
||||||
|
unknown_error: '不明なエラーが発生しました。後で再試行してください。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -53,6 +53,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: '正常に<name/>にアップグレードされました',
|
upgrade_success: '正常に<name/>にアップグレードされました',
|
||||||
downgrade_success: '正常に<name/>にダウングレードされました',
|
downgrade_success: '正常に<name/>にダウングレードされました',
|
||||||
|
subscription_check_timeout:
|
||||||
|
'サブスクリプションのチェックがタイムアウトしました。後でもう一度更新してください。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: '이름없음',
|
unnamed: '이름없음',
|
||||||
view: '보기',
|
view: '보기',
|
||||||
hide: '숨기기',
|
hide: '숨기기',
|
||||||
|
unknown_error: '알 수 없는 오류가 발생했습니다. 나중에 다시 시도해주세요.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -52,6 +52,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: '성공적으로 <name/>으로 업그레이드되었습니다.',
|
upgrade_success: '성공적으로 <name/>으로 업그레이드되었습니다.',
|
||||||
downgrade_success: '성공적으로 <name/>으로 다운그레이드되었습니다.',
|
downgrade_success: '성공적으로 <name/>으로 다운그레이드되었습니다.',
|
||||||
|
subscription_check_timeout: '구독 확인이 타임아웃되었습니다. 나중에 다시 확인해주세요.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: 'Bez nazwy',
|
unnamed: 'Bez nazwy',
|
||||||
view: 'Pokaż',
|
view: 'Pokaż',
|
||||||
hide: 'Ukryj',
|
hide: 'Ukryj',
|
||||||
|
unknown_error: 'Nieznany błąd, spróbuj ponownie później.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Pomyślnie uaktualniono do <name/>',
|
upgrade_success: 'Pomyślnie uaktualniono do <name/>',
|
||||||
downgrade_success: 'Pomyślnie zdegradowano do <name/>',
|
downgrade_success: 'Pomyślnie zdegradowano do <name/>',
|
||||||
|
subscription_check_timeout: 'Czas sprawdzenia subskrypcji wygasł. Proszę odświeżyć później.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'Sem nome',
|
unnamed: 'Sem nome',
|
||||||
view: 'Visualizar',
|
view: 'Visualizar',
|
||||||
hide: 'Ocultar',
|
hide: 'Ocultar',
|
||||||
|
unknown_error: 'Erro desconhecido, por favor tente novamente mais tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -54,6 +54,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Atualizado com sucesso para <name/>',
|
upgrade_success: 'Atualizado com sucesso para <name/>',
|
||||||
downgrade_success: 'Downgrade realizado com sucesso para <name/>',
|
downgrade_success: 'Downgrade realizado com sucesso para <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
'A verificação de assinatura expirou. Por favor, atualize mais tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: 'Sem nome',
|
unnamed: 'Sem nome',
|
||||||
view: 'Ver',
|
view: 'Ver',
|
||||||
hide: 'Esconder',
|
hide: 'Esconder',
|
||||||
|
unknown_error: 'Erro desconhecido, por favor tente novamente mais tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -53,6 +53,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Atualizou com sucesso para <name/>',
|
upgrade_success: 'Atualizou com sucesso para <name/>',
|
||||||
downgrade_success: 'Downgrade concluído com sucesso para <name/>',
|
downgrade_success: 'Downgrade concluído com sucesso para <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
'A verificação de subscrição expirou. Por favor, atualize mais tarde.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: 'Без имени',
|
unnamed: 'Без имени',
|
||||||
view: 'Просмотр',
|
view: 'Просмотр',
|
||||||
hide: 'Скрыть',
|
hide: 'Скрыть',
|
||||||
|
unknown_error: 'Неизвестная ошибка, пожалуйста, попробуйте позже.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -52,6 +52,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Успешно повышен до <name/>',
|
upgrade_success: 'Успешно повышен до <name/>',
|
||||||
downgrade_success: 'Успешно понижен до <name/>',
|
downgrade_success: 'Успешно понижен до <name/>',
|
||||||
|
subscription_check_timeout: 'Время проверки подписки истекло. Пожалуйста, обновите позже.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -54,6 +54,7 @@ const general = {
|
||||||
unnamed: 'İsimsiz',
|
unnamed: 'İsimsiz',
|
||||||
view: 'Görünüm',
|
view: 'Görünüm',
|
||||||
hide: 'Gizle',
|
hide: 'Gizle',
|
||||||
|
unknown_error: 'Bilinmeyen hata, lütfen daha sonra tekrar deneyin.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -53,6 +53,8 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: 'Successfully upgraded to <name/>',
|
upgrade_success: 'Successfully upgraded to <name/>',
|
||||||
downgrade_success: 'Successfully downgraded to <name/>',
|
downgrade_success: 'Successfully downgraded to <name/>',
|
||||||
|
subscription_check_timeout:
|
||||||
|
'Abonelik kontrolü zaman aşımına uğradı. Lütfen daha sonra yenileyin.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: '未命名',
|
unnamed: '未命名',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
hide: '隐藏',
|
hide: '隐藏',
|
||||||
|
unknown_error: '未知错误,请稍后重试。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -50,6 +50,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: '成功升级到 <name/>',
|
upgrade_success: '成功升级到 <name/>',
|
||||||
downgrade_success: '成功降级到 <name/>',
|
downgrade_success: '成功降级到 <name/>',
|
||||||
|
subscription_check_timeout: '订阅检查超时,请稍后刷新。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: '未命名',
|
unnamed: '未命名',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
hide: '隱藏',
|
hide: '隱藏',
|
||||||
|
unknown_error: '未知錯誤,請稍後重試。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -49,6 +49,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: '升級成功至<name/>',
|
upgrade_success: '升級成功至<name/>',
|
||||||
downgrade_success: '成功降級至<name/>',
|
downgrade_success: '成功降級至<name/>',
|
||||||
|
subscription_check_timeout: '訂閱檢查已逾時,請稍後重新刷新。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -53,6 +53,7 @@ const general = {
|
||||||
unnamed: '未命名',
|
unnamed: '未命名',
|
||||||
view: '查看',
|
view: '查看',
|
||||||
hide: '隱藏',
|
hide: '隱藏',
|
||||||
|
unknown_error: '未知錯誤,請稍後重試。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default general;
|
export default general;
|
||||||
|
|
|
@ -49,6 +49,7 @@ const subscription = {
|
||||||
},
|
},
|
||||||
upgrade_success: '已成功升級到 <name/>',
|
upgrade_success: '已成功升級到 <name/>',
|
||||||
downgrade_success: '已成功降級到 <name/>',
|
downgrade_success: '已成功降級到 <name/>',
|
||||||
|
subscription_check_timeout: '訂閱檢查已逾時,請稍後重新刷新。',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default subscription;
|
export default subscription;
|
||||||
|
|
|
@ -2755,8 +2755,8 @@ importers:
|
||||||
specifier: workspace:^1.3.1
|
specifier: workspace:^1.3.1
|
||||||
version: link:../app-insights
|
version: link:../app-insights
|
||||||
'@logto/cloud':
|
'@logto/cloud':
|
||||||
specifier: 0.2.5-4d5e389
|
specifier: 0.2.5-2087c06
|
||||||
version: 0.2.5-4d5e389(zod@3.20.2)
|
version: 0.2.5-2087c06(zod@3.20.2)
|
||||||
'@logto/connector-kit':
|
'@logto/connector-kit':
|
||||||
specifier: workspace:^1.1.1
|
specifier: workspace:^1.1.1
|
||||||
version: link:../toolkit/connector-kit
|
version: link:../toolkit/connector-kit
|
||||||
|
@ -7210,8 +7210,8 @@ packages:
|
||||||
jose: 4.14.4
|
jose: 4.14.4
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@logto/cloud@0.2.5-4d5e389(zod@3.20.2):
|
/@logto/cloud@0.2.5-2087c06(zod@3.20.2):
|
||||||
resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==}
|
resolution: {integrity: sha512-v/zisil/t8XrlFxlVic+0O6T2J3FUzB5FDC1w3OYNzhXNbSpmbqlt5vyN9RIwXUGgAKjdhKQjoACpV7HpPFZcQ==}
|
||||||
engines: {node: ^18.12.0}
|
engines: {node: ^18.12.0}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@silverhand/essentials': 2.7.0
|
'@silverhand/essentials': 2.7.0
|
||||||
|
@ -7220,8 +7220,8 @@ packages:
|
||||||
- zod
|
- zod
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/@logto/cloud@0.2.5-4f1d80b(zod@3.20.2):
|
/@logto/cloud@0.2.5-4d5e389(zod@3.20.2):
|
||||||
resolution: {integrity: sha512-AF0YnJiXDMS3HQ2ugZcwJRBkspvNtlXk992IwTNFZxbZdinpPoVmPnDnHuekACcQY5bFRgHjMB2/o/GKkdLpWg==}
|
resolution: {integrity: sha512-vRJZGc0WvjE1rFJ0DNLaOHkhpe4TMdui/pvcTwGb/bDKzw/NM+4HtUoZj1a1DZ8Qqn24ex1WkTjxY8XGd3EruQ==}
|
||||||
engines: {node: ^18.12.0}
|
engines: {node: ^18.12.0}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@silverhand/essentials': 2.7.0
|
'@silverhand/essentials': 2.7.0
|
||||||
|
|
Loading…
Reference in a new issue