0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(console): retrieve subscription data from tenant response (#4430)

refactor(console): apply tenant subscription data from tenant response
This commit is contained in:
Xiao Yijun 2023-09-06 10:22:10 +08:00 committed by GitHub
parent b3fc33524e
commit 1453e1a2a1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 102 additions and 152 deletions

View file

@ -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-1795c3d", "@logto/cloud": "0.2.5-71b7fea",
"@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",
@ -60,7 +60,7 @@
"@types/react-helmet": "^6.1.6", "@types/react-helmet": "^6.1.6",
"@types/react-modal": "^3.13.1", "@types/react-modal": "^3.13.1",
"@types/react-syntax-highlighter": "^15.5.1", "@types/react-syntax-highlighter": "^15.5.1",
"@withtyped/client": "^0.7.21", "@withtyped/client": "^0.7.22",
"buffer": "^5.7.1", "buffer": "^5.7.1",
"classnames": "^2.3.1", "classnames": "^2.3.1",
"clean-deep": "^3.4.0", "clean-deep": "^3.4.0",

View file

@ -1,9 +1,7 @@
import { useContext, useState } from 'react'; import { useContext, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import useSWRImmutable from 'swr/immutable';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import PlanUsage from '@/components/PlanUsage'; import PlanUsage from '@/components/PlanUsage';
import { contactEmailLink } from '@/consts'; import { contactEmailLink } from '@/consts';
import { subscriptionPage } from '@/consts/pages'; import { subscriptionPage } from '@/consts/pages';
@ -21,35 +19,22 @@ import PlanName from '../PlanName';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
function MauExceededModal() { function MauExceededModal() {
const { currentTenantId } = useContext(TenantsContext); const { currentTenant } = useContext(TenantsContext);
const cloudApi = useCloudApi(); const { usage, subscription } = currentTenant ?? {};
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { navigate } = useTenantPathname(); const { navigate } = useTenantPathname();
const [hasClosed, setHasClosed] = useState(false);
const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => { const handleCloseModal = () => {
setHasClosed(true); setHasClosed(true);
}; };
const { data: subscriptionPlans } = useSubscriptionPlans(); const { data: subscriptionPlans } = useSubscriptionPlans();
const { data: currentSubscription } = useSWRImmutable( const currentPlan = subscriptionPlans?.find((plan) => plan.id === subscription?.planId);
`/api/tenants/${currentTenantId}/subscription`,
async () =>
cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId: currentTenantId } })
);
const { data: currentUsage } = useSWRImmutable( if (!subscription || !usage || !currentPlan || hasClosed) {
`/api/tenants/${currentTenantId}/usage`,
async () =>
cloudApi.get('/api/tenants/:tenantId/usage', { params: { tenantId: currentTenantId } })
);
const currentPlan =
currentSubscription &&
subscriptionPlans?.find((plan) => plan.id === currentSubscription.planId);
if (!currentPlan || !currentUsage || hasClosed) {
return null; return null;
} }
@ -58,7 +43,7 @@ function MauExceededModal() {
name: planName, name: planName,
} = currentPlan; } = currentPlan;
const isMauExceeded = mauLimit !== null && currentUsage.activeUsers >= mauLimit; const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit;
if (!isMauExceeded) { if (!isMauExceeded) {
return null; return null;
@ -102,8 +87,8 @@ function MauExceededModal() {
</InlineNotification> </InlineNotification>
<FormField title="subscription.plan_usage"> <FormField title="subscription.plan_usage">
<PlanUsage <PlanUsage
subscriptionUsage={currentUsage} subscriptionUsage={usage}
currentSubscription={currentSubscription} currentSubscription={subscription}
currentPlan={currentPlan} currentPlan={currentPlan}
/> />
</FormField> </FormField>

View file

@ -1,11 +1,9 @@
import { conditional } from '@silverhand/essentials'; import { useContext, useState } from 'react';
import { useContext, useMemo, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next'; import { Trans, useTranslation } from 'react-i18next';
import ReactModal from 'react-modal'; import ReactModal from 'react-modal';
import useSWRImmutable from 'swr/immutable';
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
import { contactEmailLink } from '@/consts'; import { contactEmailLink } from '@/consts';
import { isCloud } from '@/consts/env';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import Button from '@/ds-components/Button'; import Button from '@/ds-components/Button';
import FormField from '@/ds-components/FormField'; import FormField from '@/ds-components/FormField';
@ -13,7 +11,6 @@ import InlineNotification from '@/ds-components/InlineNotification';
import ModalLayout from '@/ds-components/ModalLayout'; import ModalLayout from '@/ds-components/ModalLayout';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import * as modalStyles from '@/scss/modal.module.scss'; import * as modalStyles from '@/scss/modal.module.scss';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
import BillInfo from '../BillInfo'; import BillInfo from '../BillInfo';
@ -22,26 +19,17 @@ import * as styles from './index.module.scss';
function PaymentOverdueModal() { function PaymentOverdueModal() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { currentTenant, currentTenantId } = useContext(TenantsContext); const { currentTenant, currentTenantId } = useContext(TenantsContext);
const cloudApi = useCloudApi(); const { openInvoices = [] } = currentTenant ?? {};
const { data: invoicesResponse } = useSWRImmutable(
`/api/tenants/${currentTenantId}/invoices`,
async () =>
cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId: currentTenantId } })
);
const { visitManagePaymentPage } = useSubscribe(); const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
const latestUnpaidInvoice = useMemo(
() => conditional(invoicesResponse && getLatestUnpaidInvoice(invoicesResponse.invoices)),
[invoicesResponse]
);
const [hasClosed, setHasClosed] = useState(false); const [hasClosed, setHasClosed] = useState(false);
const handleCloseModal = () => { const handleCloseModal = () => {
setHasClosed(true); setHasClosed(true);
}; };
if (!invoicesResponse || !latestUnpaidInvoice || hasClosed) { if (!isCloud || openInvoices.length === 0 || hasClosed) {
return null; return null;
} }
@ -82,7 +70,12 @@ function PaymentOverdueModal() {
</InlineNotification> </InlineNotification>
)} )}
<FormField title="upsell.payment_overdue_modal.unpaid_bills"> <FormField title="upsell.payment_overdue_modal.unpaid_bills">
<BillInfo cost={latestUnpaidInvoice.amountDue} /> <BillInfo
cost={openInvoices.reduce(
(total, currentInvoice) => total + currentInvoice.amountDue,
0
)}
/>
</FormField> </FormField>
</ModalLayout> </ModalLayout>
</ReactModal> </ReactModal>

View file

@ -1,35 +1,16 @@
import { conditional } from '@silverhand/essentials'; import { type TenantResponse } from '@/cloud/types/router';
import { useMemo } from 'react';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import Tag from '@/ds-components/Tag'; import Tag from '@/ds-components/Tag';
import useInvoices from '@/hooks/use-invoices'; import { type SubscriptionPlan } from '@/types/subscriptions';
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
import useSubscriptionUsage from '@/hooks/use-subscription-usage';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
type Props = { type Props = {
tenantId: string; tenantData: TenantResponse;
tenantPlan: SubscriptionPlan;
className?: string; className?: string;
}; };
function TenantStatusTag({ tenantId, className }: Props) { function TenantStatusTag({ tenantData, tenantPlan, className }: Props) {
const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId); const { usage, openInvoices } = tenantData;
const { data: invoices, error: fetchInvoiceError } = useInvoices(tenantId);
const { data: subscriptionPlan, error: fetchSubscriptionError } = useSubscriptionPlan(tenantId);
const isLoadingUsage = !usage && !fetchUsageError;
const isLoadingInvoice = !invoices && !fetchInvoiceError;
const isLoadingSubscription = !subscriptionPlan && !fetchSubscriptionError;
const latestUnpaidInvoice = useMemo(
() => conditional(invoices && getLatestUnpaidInvoice(invoices)),
[invoices]
);
if (isLoadingUsage || isLoadingInvoice || isLoadingSubscription) {
return null;
}
/** /**
* Tenant status priority: * Tenant status priority:
@ -38,7 +19,7 @@ function TenantStatusTag({ tenantId, className }: Props) {
* 3. mau exceeded * 3. mau exceeded
*/ */
if (invoices && latestUnpaidInvoice) { if (openInvoices.length > 0) {
return ( return (
<Tag className={className}> <Tag className={className}>
<DynamicT forKey="tenants.status.overdue" /> <DynamicT forKey="tenants.status.overdue" />
@ -46,22 +27,20 @@ function TenantStatusTag({ tenantId, className }: Props) {
); );
} }
if (subscriptionPlan && usage) { const { activeUsers } = usage;
const { activeUsers } = usage;
const { const {
quota: { mauLimit }, quota: { mauLimit },
} = subscriptionPlan; } = tenantPlan;
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit; const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
if (isMauExceeded) { if (isMauExceeded) {
return ( return (
<Tag className={className}> <Tag className={className}>
<DynamicT forKey="tenants.status.mau_exceeded" /> <DynamicT forKey="tenants.status.mau_exceeded" />
</Tag> </Tag>
); );
}
} }
return null; return null;

View file

@ -1,10 +1,11 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useMemo } from 'react';
import Tick from '@/assets/icons/tick.svg'; import Tick from '@/assets/icons/tick.svg';
import { type TenantResponse } from '@/cloud/types/router'; import { type TenantResponse } from '@/cloud/types/router';
import PlanName from '@/components/PlanName'; import PlanName from '@/components/PlanName';
import { DropdownItem } from '@/ds-components/Dropdown'; import { DropdownItem } from '@/ds-components/Dropdown';
import useSubscriptionPlan from '@/hooks/use-subscription-plan'; import useSubscriptionPlans from '@/hooks/use-subscription-plans';
import TenantEnvTag from '../TenantEnvTag'; import TenantEnvTag from '../TenantEnvTag';
@ -18,8 +19,18 @@ type Props = {
}; };
function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) { function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
const { id, name, tag } = tenantData; const {
const { data: tenantPlan } = useSubscriptionPlan(id); name,
tag,
subscription: { planId },
} = tenantData;
const { data: plans } = useSubscriptionPlans();
const tenantPlan = useMemo(() => plans?.find((plan) => plan.id === planId), [plans, planId]);
if (!tenantPlan) {
return null;
}
return ( return (
<DropdownItem className={styles.item} onClick={onClick}> <DropdownItem className={styles.item} onClick={onClick}>
@ -27,9 +38,15 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
<div className={styles.meta}> <div className={styles.meta}>
<div className={styles.name}>{name}</div> <div className={styles.name}>{name}</div>
<TenantEnvTag tag={tag} /> <TenantEnvTag tag={tag} />
<TenantStatusTag tenantId={id} className={styles.statusTag} /> <TenantStatusTag
tenantData={tenantData}
tenantPlan={tenantPlan}
className={styles.statusTag}
/>
</div>
<div className={styles.planName}>
<PlanName name={tenantPlan.name} />
</div> </div>
<div className={styles.planName}>{tenantPlan && <PlanName name={tenantPlan.name} />}</div>
</div> </div>
<Tick className={classNames(styles.checkIcon, isSelected && styles.visible)} /> <Tick className={classNames(styles.checkIcon, isSelected && styles.visible)} />
</DropdownItem> </DropdownItem>

View file

@ -1,6 +1,7 @@
import { defaultManagementApi, defaultTenantId } from '@logto/schemas'; import { defaultManagementApi, defaultTenantId } from '@logto/schemas';
import { TenantTag } from '@logto/schemas/models'; import { TenantTag } from '@logto/schemas/models';
import { conditionalArray, noop } from '@silverhand/essentials'; import { conditionalArray, noop } from '@silverhand/essentials';
import dayjs from 'dayjs';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { useCallback, useMemo, createContext, useState } from 'react'; import { useCallback, useMemo, createContext, useState } from 'react';
import { useMatch, useNavigate } from 'react-router-dom'; import { useMatch, useNavigate } from 'react-router-dom';
@ -65,17 +66,27 @@ const { tenantId, indicator } = defaultManagementApi.resource;
* - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API. * - For cloud, the initial tenants data is empty, and it will be fetched from the cloud API.
* - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants. * - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants.
*/ */
const initialTenants = Object.freeze( const defaultTenantResponse: TenantResponse = {
conditionalArray( id: tenantId,
!isCloud && { name: `tenant_${tenantId}`,
id: tenantId, tag: TenantTag.Development,
name: `tenant_${tenantId}`, indicator,
tag: TenantTag.Development, subscription: {
indicator, status: 'active',
planId: `${ReservedPlanId.free}`, // `planId` is string type. planId: ReservedPlanId.free,
} currentPeriodStart: dayjs().toDate(),
) currentPeriodEnd: dayjs().add(1, 'month').toDate(),
); },
usage: {
activeUsers: 0,
cost: 0,
},
openInvoices: [],
isSuspended: false,
planId: ReservedPlanId.free, // Reserved for compatibility with cloud
};
const initialTenants = Object.freeze(conditionalArray(!isCloud && defaultTenantResponse));
export const TenantsContext = createContext<Tenants>({ export const TenantsContext = createContext<Tenants>({
tenants: initialTenants, tenants: initialTenants,

View file

@ -1,32 +1,29 @@
import { conditional } from '@silverhand/essentials'; import { useContext, useState } from 'react';
import { useContext, useMemo, useState } from 'react';
import { TenantsContext } from '@/contexts/TenantsProvider'; import { TenantsContext } from '@/contexts/TenantsProvider';
import DynamicT from '@/ds-components/DynamicT'; import DynamicT from '@/ds-components/DynamicT';
import InlineNotification from '@/ds-components/InlineNotification'; import InlineNotification from '@/ds-components/InlineNotification';
import useInvoices from '@/hooks/use-invoices';
import useSubscribe from '@/hooks/use-subscribe'; import useSubscribe from '@/hooks/use-subscribe';
import { getLatestUnpaidInvoice } from '@/utils/subscription';
type Props = { type Props = {
className?: string; className?: string;
}; };
function PaymentOverdueNotification({ className }: Props) { function PaymentOverdueNotification({ className }: Props) {
const { currentTenantId } = useContext(TenantsContext); const { currentTenant, currentTenantId } = useContext(TenantsContext);
const { openInvoices = [] } = currentTenant ?? {};
const { visitManagePaymentPage } = useSubscribe(); const { visitManagePaymentPage } = useSubscribe();
const [isActionLoading, setIsActionLoading] = useState(false); const [isActionLoading, setIsActionLoading] = useState(false);
const { data: invoices, error } = useInvoices(currentTenantId);
const isLoadingInvoices = !invoices && !error;
const latestUnpaidInvoice = useMemo(
() => conditional(invoices && getLatestUnpaidInvoice(invoices)),
[invoices]
);
if (isLoadingInvoices || !latestUnpaidInvoice) { if (openInvoices.length === 0) {
return null; return null;
} }
const totalAmountDue = openInvoices.reduce(
(total, currentInvoice) => total + currentInvoice.amountDue,
0
);
return ( return (
<InlineNotification <InlineNotification
severity="error" severity="error"
@ -41,7 +38,7 @@ function PaymentOverdueNotification({ className }: Props) {
> >
<DynamicT <DynamicT
forKey="subscription.payment_error" forKey="subscription.payment_error"
interpolation={{ price: latestUnpaidInvoice.amountDue / 100 }} interpolation={{ price: totalAmountDue / 100 }}
/> />
</InlineNotification> </InlineNotification>
); );

View file

@ -76,6 +76,4 @@ export const localCheckoutSessionGuard = z.object({
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>; export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
export type Invoice = InvoicesResponse['invoices'][number]; export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];
export type InvoiceStatus = Invoice['status'];

View file

@ -5,7 +5,6 @@ import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
import { type SubscriptionPlanResponse } from '@/cloud/types/router'; import { type SubscriptionPlanResponse } from '@/cloud/types/router';
import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas'; import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
import { reservedPlanIdOrder } from '@/consts/subscriptions'; import { reservedPlanIdOrder } from '@/consts/subscriptions';
import { type Invoice } from '@/types/subscriptions';
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => { export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
const { id, quota } = subscriptionPlanResponse; const { id, quota } = subscriptionPlanResponse;
@ -43,15 +42,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
return `${formattedStart} - ${formattedEnd}`; return `${formattedStart} - ${formattedEnd}`;
}; };
export const getLatestUnpaidInvoice = (invoices: Invoice[]) =>
invoices
.slice()
.sort(
(invoiceA, invoiceB) =>
new Date(invoiceB.createdAt).getTime() - new Date(invoiceA.createdAt).getTime()
)
.find(({ status }) => status === 'uncollectible');
/** /**
* Note: this is a temporary solution to handle the case when the user tries to downgrade but the quota limit is exceeded. * Note: this is a temporary solution to handle the case when the user tries to downgrade but the quota limit is exceeded.
* Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608 * Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608

View file

@ -2834,8 +2834,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-1795c3d specifier: 0.2.5-71b7fea
version: 0.2.5-1795c3d(zod@3.20.2) version: 0.2.5-71b7fea(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
@ -2936,8 +2936,8 @@ importers:
specifier: ^15.5.1 specifier: ^15.5.1
version: 15.5.1 version: 15.5.1
'@withtyped/client': '@withtyped/client':
specifier: ^0.7.21 specifier: ^0.7.22
version: 0.7.21(zod@3.20.2) version: 0.7.22(zod@3.20.2)
buffer: buffer:
specifier: ^5.7.1 specifier: ^5.7.1
version: 5.7.1 version: 5.7.1
@ -7330,12 +7330,12 @@ packages:
jose: 4.14.4 jose: 4.14.4
dev: true dev: true
/@logto/cloud@0.2.5-1795c3d(zod@3.20.2): /@logto/cloud@0.2.5-71b7fea(zod@3.20.2):
resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==} resolution: {integrity: sha512-howllmEV6kWAgusP+2OSloG5bZQ146UiKn0PpA7xi9HcpgM6Fd1NPuNjc3BZdInJ5Qn0en6LOZL7c2EwTRx3jw==}
engines: {node: ^18.12.0} engines: {node: ^18.12.0}
dependencies: dependencies:
'@silverhand/essentials': 2.8.4 '@silverhand/essentials': 2.8.4
'@withtyped/server': 0.12.8(zod@3.20.2) '@withtyped/server': 0.12.9(zod@3.20.2)
transitivePeerDependencies: transitivePeerDependencies:
- zod - zod
dev: true dev: true
@ -10048,15 +10048,6 @@ packages:
eslint-visitor-keys: 3.4.1 eslint-visitor-keys: 3.4.1
dev: true dev: true
/@withtyped/client@0.7.21(zod@3.20.2):
resolution: {integrity: sha512-N9dvH5nqIwaT7YxaIm83RRQf9AEjxwJ4ugJviZJSxtWy8zLul2/odEMc6epieylFVa6CcLg82yJmRSlqPtJiTw==}
dependencies:
'@withtyped/server': 0.12.8(zod@3.20.2)
'@withtyped/shared': 0.2.2
transitivePeerDependencies:
- zod
dev: true
/@withtyped/client@0.7.22(zod@3.20.2): /@withtyped/client@0.7.22(zod@3.20.2):
resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==} resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==}
dependencies: dependencies:
@ -10064,17 +10055,6 @@ packages:
'@withtyped/shared': 0.2.2 '@withtyped/shared': 0.2.2
transitivePeerDependencies: transitivePeerDependencies:
- zod - zod
dev: false
/@withtyped/server@0.12.8(zod@3.20.2):
resolution: {integrity: sha512-fv9feTOKJhtlaoYM/Kbs2gSTcIXlmu4OMUFwGmK5jqdbVNIOkDBIPxtcC5ZEwevWFgOcd5OqBW+FvbjiaF27Fw==}
peerDependencies:
zod: ^3.19.1
dependencies:
'@silverhand/essentials': 2.8.4
'@withtyped/shared': 0.2.2
zod: 3.20.2
dev: true
/@withtyped/server@0.12.9(zod@3.20.2): /@withtyped/server@0.12.9(zod@3.20.2):
resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==} resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==}