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:
parent
b3fc33524e
commit
1453e1a2a1
10 changed files with 102 additions and 152 deletions
|
@ -26,7 +26,7 @@
|
|||
"@fontsource/roboto-mono": "^5.0.0",
|
||||
"@jest/types": "^29.5.0",
|
||||
"@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/core-kit": "workspace:^2.0.1",
|
||||
"@logto/language-kit": "workspace:^1.0.0",
|
||||
|
@ -60,7 +60,7 @@
|
|||
"@types/react-helmet": "^6.1.6",
|
||||
"@types/react-modal": "^3.13.1",
|
||||
"@types/react-syntax-highlighter": "^15.5.1",
|
||||
"@withtyped/client": "^0.7.21",
|
||||
"@withtyped/client": "^0.7.22",
|
||||
"buffer": "^5.7.1",
|
||||
"classnames": "^2.3.1",
|
||||
"clean-deep": "^3.4.0",
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { useContext, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import PlanUsage from '@/components/PlanUsage';
|
||||
import { contactEmailLink } from '@/consts';
|
||||
import { subscriptionPage } from '@/consts/pages';
|
||||
|
@ -21,35 +19,22 @@ import PlanName from '../PlanName';
|
|||
import * as styles from './index.module.scss';
|
||||
|
||||
function MauExceededModal() {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const cloudApi = useCloudApi();
|
||||
const { currentTenant } = useContext(TenantsContext);
|
||||
const { usage, subscription } = currentTenant ?? {};
|
||||
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { navigate } = useTenantPathname();
|
||||
const [hasClosed, setHasClosed] = useState(false);
|
||||
|
||||
const [hasClosed, setHasClosed] = useState(false);
|
||||
const handleCloseModal = () => {
|
||||
setHasClosed(true);
|
||||
};
|
||||
|
||||
const { data: subscriptionPlans } = useSubscriptionPlans();
|
||||
|
||||
const { data: currentSubscription } = useSWRImmutable(
|
||||
`/api/tenants/${currentTenantId}/subscription`,
|
||||
async () =>
|
||||
cloudApi.get('/api/tenants/:tenantId/subscription', { params: { tenantId: currentTenantId } })
|
||||
);
|
||||
const currentPlan = subscriptionPlans?.find((plan) => plan.id === subscription?.planId);
|
||||
|
||||
const { data: currentUsage } = useSWRImmutable(
|
||||
`/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) {
|
||||
if (!subscription || !usage || !currentPlan || hasClosed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -58,7 +43,7 @@ function MauExceededModal() {
|
|||
name: planName,
|
||||
} = currentPlan;
|
||||
|
||||
const isMauExceeded = mauLimit !== null && currentUsage.activeUsers >= mauLimit;
|
||||
const isMauExceeded = mauLimit !== null && usage.activeUsers >= mauLimit;
|
||||
|
||||
if (!isMauExceeded) {
|
||||
return null;
|
||||
|
@ -102,8 +87,8 @@ function MauExceededModal() {
|
|||
</InlineNotification>
|
||||
<FormField title="subscription.plan_usage">
|
||||
<PlanUsage
|
||||
subscriptionUsage={currentUsage}
|
||||
currentSubscription={currentSubscription}
|
||||
subscriptionUsage={usage}
|
||||
currentSubscription={subscription}
|
||||
currentPlan={currentPlan}
|
||||
/>
|
||||
</FormField>
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import ReactModal from 'react-modal';
|
||||
import useSWRImmutable from 'swr/immutable';
|
||||
|
||||
import { useCloudApi } from '@/cloud/hooks/use-cloud-api';
|
||||
import { contactEmailLink } from '@/consts';
|
||||
import { isCloud } from '@/consts/env';
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import Button from '@/ds-components/Button';
|
||||
import FormField from '@/ds-components/FormField';
|
||||
|
@ -13,7 +11,6 @@ import InlineNotification from '@/ds-components/InlineNotification';
|
|||
import ModalLayout from '@/ds-components/ModalLayout';
|
||||
import useSubscribe from '@/hooks/use-subscribe';
|
||||
import * as modalStyles from '@/scss/modal.module.scss';
|
||||
import { getLatestUnpaidInvoice } from '@/utils/subscription';
|
||||
|
||||
import BillInfo from '../BillInfo';
|
||||
|
||||
|
@ -22,26 +19,17 @@ import * as styles from './index.module.scss';
|
|||
function PaymentOverdueModal() {
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const { currentTenant, currentTenantId } = useContext(TenantsContext);
|
||||
const cloudApi = useCloudApi();
|
||||
const { data: invoicesResponse } = useSWRImmutable(
|
||||
`/api/tenants/${currentTenantId}/invoices`,
|
||||
async () =>
|
||||
cloudApi.get('/api/tenants/:tenantId/invoices', { params: { tenantId: currentTenantId } })
|
||||
);
|
||||
const { openInvoices = [] } = currentTenant ?? {};
|
||||
|
||||
const { visitManagePaymentPage } = useSubscribe();
|
||||
const [isActionLoading, setIsActionLoading] = useState(false);
|
||||
|
||||
const latestUnpaidInvoice = useMemo(
|
||||
() => conditional(invoicesResponse && getLatestUnpaidInvoice(invoicesResponse.invoices)),
|
||||
[invoicesResponse]
|
||||
);
|
||||
|
||||
const [hasClosed, setHasClosed] = useState(false);
|
||||
const handleCloseModal = () => {
|
||||
setHasClosed(true);
|
||||
};
|
||||
|
||||
if (!invoicesResponse || !latestUnpaidInvoice || hasClosed) {
|
||||
if (!isCloud || openInvoices.length === 0 || hasClosed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -82,7 +70,12 @@ function PaymentOverdueModal() {
|
|||
</InlineNotification>
|
||||
)}
|
||||
<FormField title="upsell.payment_overdue_modal.unpaid_bills">
|
||||
<BillInfo cost={latestUnpaidInvoice.amountDue} />
|
||||
<BillInfo
|
||||
cost={openInvoices.reduce(
|
||||
(total, currentInvoice) => total + currentInvoice.amountDue,
|
||||
0
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
</ModalLayout>
|
||||
</ReactModal>
|
||||
|
|
|
@ -1,35 +1,16 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { type TenantResponse } from '@/cloud/types/router';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import Tag from '@/ds-components/Tag';
|
||||
import useInvoices from '@/hooks/use-invoices';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import useSubscriptionUsage from '@/hooks/use-subscription-usage';
|
||||
import { getLatestUnpaidInvoice } from '@/utils/subscription';
|
||||
import { type SubscriptionPlan } from '@/types/subscriptions';
|
||||
|
||||
type Props = {
|
||||
tenantId: string;
|
||||
tenantData: TenantResponse;
|
||||
tenantPlan: SubscriptionPlan;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function TenantStatusTag({ tenantId, className }: Props) {
|
||||
const { data: usage, error: fetchUsageError } = useSubscriptionUsage(tenantId);
|
||||
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;
|
||||
}
|
||||
function TenantStatusTag({ tenantData, tenantPlan, className }: Props) {
|
||||
const { usage, openInvoices } = tenantData;
|
||||
|
||||
/**
|
||||
* Tenant status priority:
|
||||
|
@ -38,7 +19,7 @@ function TenantStatusTag({ tenantId, className }: Props) {
|
|||
* 3. mau exceeded
|
||||
*/
|
||||
|
||||
if (invoices && latestUnpaidInvoice) {
|
||||
if (openInvoices.length > 0) {
|
||||
return (
|
||||
<Tag className={className}>
|
||||
<DynamicT forKey="tenants.status.overdue" />
|
||||
|
@ -46,12 +27,11 @@ function TenantStatusTag({ tenantId, className }: Props) {
|
|||
);
|
||||
}
|
||||
|
||||
if (subscriptionPlan && usage) {
|
||||
const { activeUsers } = usage;
|
||||
|
||||
const {
|
||||
quota: { mauLimit },
|
||||
} = subscriptionPlan;
|
||||
} = tenantPlan;
|
||||
|
||||
const isMauExceeded = mauLimit !== null && activeUsers >= mauLimit;
|
||||
|
||||
|
@ -62,7 +42,6 @@ function TenantStatusTag({ tenantId, className }: Props) {
|
|||
</Tag>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import Tick from '@/assets/icons/tick.svg';
|
||||
import { type TenantResponse } from '@/cloud/types/router';
|
||||
import PlanName from '@/components/PlanName';
|
||||
import { DropdownItem } from '@/ds-components/Dropdown';
|
||||
import useSubscriptionPlan from '@/hooks/use-subscription-plan';
|
||||
import useSubscriptionPlans from '@/hooks/use-subscription-plans';
|
||||
|
||||
import TenantEnvTag from '../TenantEnvTag';
|
||||
|
||||
|
@ -18,8 +19,18 @@ type Props = {
|
|||
};
|
||||
|
||||
function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
|
||||
const { id, name, tag } = tenantData;
|
||||
const { data: tenantPlan } = useSubscriptionPlan(id);
|
||||
const {
|
||||
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 (
|
||||
<DropdownItem className={styles.item} onClick={onClick}>
|
||||
|
@ -27,9 +38,15 @@ function TenantDropdownItem({ tenantData, isSelected, onClick }: Props) {
|
|||
<div className={styles.meta}>
|
||||
<div className={styles.name}>{name}</div>
|
||||
<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 className={styles.planName}>{tenantPlan && <PlanName name={tenantPlan.name} />}</div>
|
||||
</div>
|
||||
<Tick className={classNames(styles.checkIcon, isSelected && styles.visible)} />
|
||||
</DropdownItem>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { defaultManagementApi, defaultTenantId } from '@logto/schemas';
|
||||
import { TenantTag } from '@logto/schemas/models';
|
||||
import { conditionalArray, noop } from '@silverhand/essentials';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useMemo, createContext, useState } from 'react';
|
||||
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.
|
||||
* - OSS has a fixed tenant with ID `default` and no cloud API to dynamically fetch tenants.
|
||||
*/
|
||||
const initialTenants = Object.freeze(
|
||||
conditionalArray(
|
||||
!isCloud && {
|
||||
const defaultTenantResponse: TenantResponse = {
|
||||
id: tenantId,
|
||||
name: `tenant_${tenantId}`,
|
||||
tag: TenantTag.Development,
|
||||
indicator,
|
||||
planId: `${ReservedPlanId.free}`, // `planId` is string type.
|
||||
}
|
||||
)
|
||||
);
|
||||
subscription: {
|
||||
status: 'active',
|
||||
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>({
|
||||
tenants: initialTenants,
|
||||
|
|
|
@ -1,32 +1,29 @@
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { useContext, useState } from 'react';
|
||||
|
||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||
import DynamicT from '@/ds-components/DynamicT';
|
||||
import InlineNotification from '@/ds-components/InlineNotification';
|
||||
import useInvoices from '@/hooks/use-invoices';
|
||||
import useSubscribe from '@/hooks/use-subscribe';
|
||||
import { getLatestUnpaidInvoice } from '@/utils/subscription';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function PaymentOverdueNotification({ className }: Props) {
|
||||
const { currentTenantId } = useContext(TenantsContext);
|
||||
const { currentTenant, currentTenantId } = useContext(TenantsContext);
|
||||
const { openInvoices = [] } = currentTenant ?? {};
|
||||
const { visitManagePaymentPage } = useSubscribe();
|
||||
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;
|
||||
}
|
||||
|
||||
const totalAmountDue = openInvoices.reduce(
|
||||
(total, currentInvoice) => total + currentInvoice.amountDue,
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<InlineNotification
|
||||
severity="error"
|
||||
|
@ -41,7 +38,7 @@ function PaymentOverdueNotification({ className }: Props) {
|
|||
>
|
||||
<DynamicT
|
||||
forKey="subscription.payment_error"
|
||||
interpolation={{ price: latestUnpaidInvoice.amountDue / 100 }}
|
||||
interpolation={{ price: totalAmountDue / 100 }}
|
||||
/>
|
||||
</InlineNotification>
|
||||
);
|
||||
|
|
|
@ -76,6 +76,4 @@ export const localCheckoutSessionGuard = z.object({
|
|||
|
||||
export type LocalCheckoutSession = z.infer<typeof localCheckoutSessionGuard>;
|
||||
|
||||
export type Invoice = InvoicesResponse['invoices'][number];
|
||||
|
||||
export type InvoiceStatus = Invoice['status'];
|
||||
export type InvoiceStatus = InvoicesResponse['invoices'][number]['status'];
|
||||
|
|
|
@ -5,7 +5,6 @@ import { tryReadResponseErrorBody } from '@/cloud/hooks/use-cloud-api';
|
|||
import { type SubscriptionPlanResponse } from '@/cloud/types/router';
|
||||
import { communitySupportEnabledMap, ticketSupportResponseTimeMap } from '@/consts/plan-quotas';
|
||||
import { reservedPlanIdOrder } from '@/consts/subscriptions';
|
||||
import { type Invoice } from '@/types/subscriptions';
|
||||
|
||||
export const addSupportQuotaToPlan = (subscriptionPlanResponse: SubscriptionPlanResponse) => {
|
||||
const { id, quota } = subscriptionPlanResponse;
|
||||
|
@ -43,15 +42,6 @@ export const formatPeriod = ({ periodStart, periodEnd, displayYear }: FormatPeri
|
|||
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.
|
||||
* Need a better solution to handle this case by sharing the error type between the console and cloud. - LOG-6608
|
||||
|
|
|
@ -2834,8 +2834,8 @@ importers:
|
|||
specifier: workspace:^1.3.1
|
||||
version: link:../app-insights
|
||||
'@logto/cloud':
|
||||
specifier: 0.2.5-1795c3d
|
||||
version: 0.2.5-1795c3d(zod@3.20.2)
|
||||
specifier: 0.2.5-71b7fea
|
||||
version: 0.2.5-71b7fea(zod@3.20.2)
|
||||
'@logto/connector-kit':
|
||||
specifier: workspace:^1.1.1
|
||||
version: link:../toolkit/connector-kit
|
||||
|
@ -2936,8 +2936,8 @@ importers:
|
|||
specifier: ^15.5.1
|
||||
version: 15.5.1
|
||||
'@withtyped/client':
|
||||
specifier: ^0.7.21
|
||||
version: 0.7.21(zod@3.20.2)
|
||||
specifier: ^0.7.22
|
||||
version: 0.7.22(zod@3.20.2)
|
||||
buffer:
|
||||
specifier: ^5.7.1
|
||||
version: 5.7.1
|
||||
|
@ -7330,12 +7330,12 @@ packages:
|
|||
jose: 4.14.4
|
||||
dev: true
|
||||
|
||||
/@logto/cloud@0.2.5-1795c3d(zod@3.20.2):
|
||||
resolution: {integrity: sha512-zxy9zr5swOxbzYJNYtKXofj2tSIS9565d+1pT6RSbmx3Hn+JG6uzsb75PZXW9vlmmm7p1sGZeTQ+xVzKNFPsMg==}
|
||||
/@logto/cloud@0.2.5-71b7fea(zod@3.20.2):
|
||||
resolution: {integrity: sha512-howllmEV6kWAgusP+2OSloG5bZQ146UiKn0PpA7xi9HcpgM6Fd1NPuNjc3BZdInJ5Qn0en6LOZL7c2EwTRx3jw==}
|
||||
engines: {node: ^18.12.0}
|
||||
dependencies:
|
||||
'@silverhand/essentials': 2.8.4
|
||||
'@withtyped/server': 0.12.8(zod@3.20.2)
|
||||
'@withtyped/server': 0.12.9(zod@3.20.2)
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
dev: true
|
||||
|
@ -10048,15 +10048,6 @@ packages:
|
|||
eslint-visitor-keys: 3.4.1
|
||||
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):
|
||||
resolution: {integrity: sha512-emNtcO0jc0dFWhvL7eUIRYzhTfn+JqgIvCmXb8ZUFOR8wdSSGrr9VDlm+wgQD06DEBBpmqtTHMMHTNXJdUC/Qw==}
|
||||
dependencies:
|
||||
|
@ -10064,17 +10055,6 @@ packages:
|
|||
'@withtyped/shared': 0.2.2
|
||||
transitivePeerDependencies:
|
||||
- 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):
|
||||
resolution: {integrity: sha512-K5zoV9D+WpawbghtlJKF1KOshKkBjq+gYzNRWuZk13YmFWFLcmZn+QCblNP55z9IGdcHWpTRknqb1APuicdzgA==}
|
||||
|
|
Loading…
Reference in a new issue