feat(core): invoice service (#8124)
This commit is contained in:
@@ -10,31 +10,33 @@ import { Loading } from '@affine/component/ui/loading';
|
|||||||
import { getUpgradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
import { getUpgradeQuestionnaireLink } from '@affine/core/hooks/affine/use-subscription-notify';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { track } from '@affine/core/mixpanel';
|
import { track } from '@affine/core/mixpanel';
|
||||||
|
import {
|
||||||
|
AuthService,
|
||||||
|
InvoicesService,
|
||||||
|
SubscriptionService,
|
||||||
|
} from '@affine/core/modules/cloud';
|
||||||
import type { InvoicesQuery } from '@affine/graphql';
|
import type { InvoicesQuery } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
createCustomerPortalMutation,
|
createCustomerPortalMutation,
|
||||||
getInvoicesCountQuery,
|
|
||||||
invoicesQuery,
|
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
|
UserFriendlyError,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { i18nTime, Trans, useI18n } from '@affine/i18n';
|
import { i18nTime, Trans, useI18n } from '@affine/i18n';
|
||||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
openSettingModalAtom,
|
openSettingModalAtom,
|
||||||
type PlansScrollAnchor,
|
type PlansScrollAnchor,
|
||||||
} from '../../../../../atoms';
|
} from '../../../../../atoms';
|
||||||
import { useMutation } from '../../../../../hooks/use-mutation';
|
import { useMutation } from '../../../../../hooks/use-mutation';
|
||||||
import { useQuery } from '../../../../../hooks/use-query';
|
|
||||||
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
|
|
||||||
import { popupWindow } from '../../../../../utils';
|
import { popupWindow } from '../../../../../utils';
|
||||||
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
|
|
||||||
import { CancelAction, ResumeAction } from '../plans/actions';
|
import { CancelAction, ResumeAction } from '../plans/actions';
|
||||||
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
|
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
|
||||||
import { BelieverCard } from '../plans/lifetime/believer-card';
|
import { BelieverCard } from '../plans/lifetime/believer-card';
|
||||||
@@ -48,8 +50,6 @@ enum DescriptionI18NKey {
|
|||||||
Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime',
|
Lifetime = 'com.affine.payment.billing-setting.current-plan.description.lifetime',
|
||||||
}
|
}
|
||||||
|
|
||||||
const INVOICE_PAGE_SIZE = 12;
|
|
||||||
|
|
||||||
const getMessageKey = (
|
const getMessageKey = (
|
||||||
plan: SubscriptionPlan,
|
plan: SubscriptionPlan,
|
||||||
recurring: SubscriptionRecurring
|
recurring: SubscriptionRecurring
|
||||||
@@ -69,24 +69,14 @@ export const BillingSettings = () => {
|
|||||||
title={t['com.affine.payment.billing-setting.title']()}
|
title={t['com.affine.payment.billing-setting.title']()}
|
||||||
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
|
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
|
||||||
/>
|
/>
|
||||||
<SWRErrorBoundary FallbackComponent={SubscriptionSettingSkeleton}>
|
<SettingWrapper
|
||||||
<Suspense fallback={<SubscriptionSettingSkeleton />}>
|
title={t['com.affine.payment.billing-setting.information']()}
|
||||||
<SettingWrapper
|
>
|
||||||
title={t['com.affine.payment.billing-setting.information']()}
|
<SubscriptionSettings />
|
||||||
>
|
</SettingWrapper>
|
||||||
<SubscriptionSettings />
|
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
|
||||||
</SettingWrapper>
|
<BillingHistory />
|
||||||
</Suspense>
|
</SettingWrapper>
|
||||||
</SWRErrorBoundary>
|
|
||||||
<SWRErrorBoundary FallbackComponent={BillingHistorySkeleton}>
|
|
||||||
<Suspense fallback={<BillingHistorySkeleton />}>
|
|
||||||
<SettingWrapper
|
|
||||||
title={t['com.affine.payment.billing-setting.history']()}
|
|
||||||
>
|
|
||||||
<BillingHistory />
|
|
||||||
</SettingWrapper>
|
|
||||||
</Suspense>
|
|
||||||
</SWRErrorBoundary>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -485,39 +475,60 @@ const CancelSubscription = ({ loading }: { loading?: boolean }) => {
|
|||||||
|
|
||||||
const BillingHistory = () => {
|
const BillingHistory = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const { data: invoicesCountQueryResult } = useQuery({
|
|
||||||
query: getInvoicesCountQuery,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [skip, setSkip] = useState(0);
|
const invoicesService = useService(InvoicesService);
|
||||||
|
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
|
||||||
|
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
|
||||||
|
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
|
||||||
|
const error = useLiveData(invoicesService.invoices.error$);
|
||||||
|
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
|
||||||
|
|
||||||
const { data: invoicesQueryResult } = useQuery({
|
useEffect(() => {
|
||||||
query: invoicesQuery,
|
invoicesService.invoices.revalidate();
|
||||||
variables: { skip, take: INVOICE_PAGE_SIZE },
|
}, [invoicesService]);
|
||||||
});
|
|
||||||
|
|
||||||
const invoices = invoicesQueryResult.currentUser?.invoices ?? [];
|
const handlePageChange = useCallback(
|
||||||
const invoiceCount = invoicesCountQueryResult.currentUser?.invoiceCount ?? 0;
|
(_: number, pageNum: number) => {
|
||||||
|
invoicesService.invoices.setPageNum(pageNum);
|
||||||
|
invoicesService.invoices.revalidate();
|
||||||
|
},
|
||||||
|
[invoicesService]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invoiceCount === undefined) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <BillingHistorySkeleton />;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<span style={{ color: cssVar('errorColor') }}>
|
||||||
|
{error
|
||||||
|
? UserFriendlyError.fromAnyError(error).message
|
||||||
|
: 'Failed to load members'}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.history}>
|
<div className={styles.history}>
|
||||||
<div className={styles.historyContent}>
|
<div className={styles.historyContent}>
|
||||||
{invoices.length === 0 ? (
|
{invoiceCount === 0 ? (
|
||||||
<p className={styles.noInvoice}>
|
<p className={styles.noInvoice}>
|
||||||
{t['com.affine.payment.billing-setting.no-invoice']()}
|
{t['com.affine.payment.billing-setting.no-invoice']()}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
invoices.map(invoice => (
|
pageInvoices?.map(invoice => (
|
||||||
<InvoiceLine key={invoice.id} invoice={invoice} />
|
<InvoiceLine key={invoice.id} invoice={invoice} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{invoiceCount > INVOICE_PAGE_SIZE && (
|
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
|
||||||
<Pagination
|
<Pagination
|
||||||
totalCount={invoiceCount}
|
totalCount={invoiceCount}
|
||||||
countPerPage={INVOICE_PAGE_SIZE}
|
countPerPage={invoicesService.invoices.PAGE_SIZE}
|
||||||
onPageChange={skip => setSkip(skip)}
|
pageNum={pageNum}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -162,15 +162,15 @@ export const CloudWorkspaceMembersPanel = () => {
|
|||||||
if (workspaceQuota === null) {
|
if (workspaceQuota === null) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <MembersPanelFallback />;
|
return <MembersPanelFallback />;
|
||||||
}
|
} else {
|
||||||
if (error) {
|
|
||||||
return (
|
return (
|
||||||
<span style={{ color: cssVar('errorColor') }}>
|
<span style={{ color: cssVar('errorColor') }}>
|
||||||
{UserFriendlyError.fromAnyError(error).message}
|
{error
|
||||||
|
? UserFriendlyError.fromAnyError(error).message
|
||||||
|
: 'Failed to load members'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return; // never reach here
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,6 +261,7 @@ const MemberList = ({
|
|||||||
const memberCount = useLiveData(membersService.members.memberCount$);
|
const memberCount = useLiveData(membersService.members.memberCount$);
|
||||||
const pageNum = useLiveData(membersService.members.pageNum$);
|
const pageNum = useLiveData(membersService.members.pageNum$);
|
||||||
const isLoading = useLiveData(membersService.members.isLoading$);
|
const isLoading = useLiveData(membersService.members.isLoading$);
|
||||||
|
const error = useLiveData(membersService.members.error$);
|
||||||
const pageMembers = useLiveData(membersService.members.pageMembers$);
|
const pageMembers = useLiveData(membersService.members.pageMembers$);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -280,17 +281,25 @@ const MemberList = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={style.memberList}>
|
<div className={style.memberList}>
|
||||||
{isLoading && pageMembers === undefined ? (
|
{pageMembers === undefined ? (
|
||||||
<MemberListFallback
|
isLoading ? (
|
||||||
memberCount={
|
<MemberListFallback
|
||||||
memberCount
|
memberCount={
|
||||||
? Math.max(
|
memberCount
|
||||||
memberCount - pageNum * membersService.members.PAGE_SIZE,
|
? Math.max(
|
||||||
1
|
memberCount - pageNum * membersService.members.PAGE_SIZE,
|
||||||
)
|
1
|
||||||
: 1
|
)
|
||||||
}
|
: 1
|
||||||
/>
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: cssVar('errorColor') }}>
|
||||||
|
{error
|
||||||
|
? UserFriendlyError.fromAnyError(error).message
|
||||||
|
: 'Failed to load members'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
pageMembers?.map(member => (
|
pageMembers?.map(member => (
|
||||||
<MemberItem
|
<MemberItem
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { notify } from '@affine/component';
|
|
||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
|
||||||
import {
|
|
||||||
getWorkspacePublicPagesQuery,
|
|
||||||
PublicPageMode,
|
|
||||||
publishPageMutation,
|
|
||||||
revokePublicPageMutation,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import { type I18nKeys, useI18n } from '@affine/i18n';
|
|
||||||
import type { DocMode } from '@blocksuite/blocks';
|
|
||||||
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { type Workspace } from '@toeverything/infra';
|
|
||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
|
|
||||||
import { useMutation } from '../use-mutation';
|
|
||||||
import { useQuery } from '../use-query';
|
|
||||||
|
|
||||||
type NotificationKey =
|
|
||||||
| 'enableSuccessTitle'
|
|
||||||
| 'enableSuccessMessage'
|
|
||||||
| 'enableErrorTitle'
|
|
||||||
| 'enableErrorMessage'
|
|
||||||
| 'changeSuccessTitle'
|
|
||||||
| 'changeErrorTitle'
|
|
||||||
| 'changeErrorMessage'
|
|
||||||
| 'disableSuccessTitle'
|
|
||||||
| 'disableSuccessMessage'
|
|
||||||
| 'disableErrorTitle'
|
|
||||||
| 'disableErrorMessage';
|
|
||||||
|
|
||||||
const notificationToI18nKey = {
|
|
||||||
enableSuccessTitle:
|
|
||||||
'com.affine.share-menu.create-public-link.notification.success.title',
|
|
||||||
enableSuccessMessage:
|
|
||||||
'com.affine.share-menu.create-public-link.notification.success.message',
|
|
||||||
enableErrorTitle:
|
|
||||||
'com.affine.share-menu.create-public-link.notification.fail.title',
|
|
||||||
enableErrorMessage:
|
|
||||||
'com.affine.share-menu.create-public-link.notification.fail.message',
|
|
||||||
changeSuccessTitle:
|
|
||||||
'com.affine.share-menu.confirm-modify-mode.notification.success.title',
|
|
||||||
changeErrorTitle:
|
|
||||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.title',
|
|
||||||
changeErrorMessage:
|
|
||||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.message',
|
|
||||||
disableSuccessTitle:
|
|
||||||
'com.affine.share-menu.disable-publish-link.notification.success.title',
|
|
||||||
disableSuccessMessage:
|
|
||||||
'com.affine.share-menu.disable-publish-link.notification.success.message',
|
|
||||||
disableErrorTitle:
|
|
||||||
'com.affine.share-menu.disable-publish-link.notification.fail.title',
|
|
||||||
disableErrorMessage:
|
|
||||||
'com.affine.share-menu.disable-publish-link.notification.fail.message',
|
|
||||||
} satisfies Record<NotificationKey, I18nKeys>;
|
|
||||||
|
|
||||||
export function useIsSharedPage(
|
|
||||||
workspaceId: string,
|
|
||||||
pageId: string
|
|
||||||
): {
|
|
||||||
isSharedPage: boolean;
|
|
||||||
changeShare: (mode: DocMode) => void;
|
|
||||||
disableShare: () => void;
|
|
||||||
currentShareMode: DocMode;
|
|
||||||
enableShare: (mode: DocMode) => void;
|
|
||||||
} {
|
|
||||||
const t = useI18n();
|
|
||||||
const { data, mutate } = useQuery({
|
|
||||||
query: getWorkspacePublicPagesQuery,
|
|
||||||
variables: {
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { trigger: enableSharePage } = useMutation({
|
|
||||||
mutation: publishPageMutation,
|
|
||||||
});
|
|
||||||
const { trigger: disableSharePage } = useMutation({
|
|
||||||
mutation: revokePublicPageMutation,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [isSharedPage, currentShareMode] = useMemo(() => {
|
|
||||||
const publicPage = data?.workspace.publicPages.find(
|
|
||||||
publicPage => publicPage.id === pageId
|
|
||||||
);
|
|
||||||
const isPageShared = !!publicPage;
|
|
||||||
|
|
||||||
const currentShareMode: DocMode =
|
|
||||||
publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page';
|
|
||||||
|
|
||||||
return [isPageShared, currentShareMode];
|
|
||||||
}, [data?.workspace.publicPages, pageId]);
|
|
||||||
|
|
||||||
const enableShare = useCallback(
|
|
||||||
(mode: DocMode) => {
|
|
||||||
const publishMode =
|
|
||||||
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
|
|
||||||
|
|
||||||
enableSharePage({ workspaceId, pageId, mode: publishMode })
|
|
||||||
.then(() => {
|
|
||||||
notify.success({
|
|
||||||
title: t[notificationToI18nKey['enableSuccessTitle']](),
|
|
||||||
message: t[notificationToI18nKey['enableSuccessMessage']](),
|
|
||||||
style: 'normal',
|
|
||||||
icon: (
|
|
||||||
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return mutate();
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
notify.error({
|
|
||||||
title: t[notificationToI18nKey['enableErrorTitle']](),
|
|
||||||
message: t[notificationToI18nKey['enableErrorMessage']](),
|
|
||||||
});
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[enableSharePage, mutate, pageId, t, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const changeShare = useCallback(
|
|
||||||
(mode: DocMode) => {
|
|
||||||
const publishMode =
|
|
||||||
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
|
|
||||||
|
|
||||||
enableSharePage({ workspaceId, pageId, mode: publishMode })
|
|
||||||
.then(() => {
|
|
||||||
notify.success({
|
|
||||||
title: t[notificationToI18nKey['changeSuccessTitle']](),
|
|
||||||
message: t[
|
|
||||||
'com.affine.share-menu.confirm-modify-mode.notification.success.message'
|
|
||||||
]({
|
|
||||||
preMode:
|
|
||||||
publishMode === PublicPageMode.Edgeless
|
|
||||||
? t['Page']()
|
|
||||||
: t['Edgeless'](),
|
|
||||||
currentMode:
|
|
||||||
publishMode === PublicPageMode.Edgeless
|
|
||||||
? t['Edgeless']()
|
|
||||||
: t['Page'](),
|
|
||||||
}),
|
|
||||||
style: 'normal',
|
|
||||||
icon: (
|
|
||||||
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
|
|
||||||
),
|
|
||||||
});
|
|
||||||
return mutate();
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
notify.error({
|
|
||||||
title: t[notificationToI18nKey['changeErrorTitle']](),
|
|
||||||
message: t[notificationToI18nKey['changeErrorMessage']](),
|
|
||||||
});
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[enableSharePage, mutate, pageId, t, workspaceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const disableShare = useCallback(() => {
|
|
||||||
disableSharePage({ workspaceId, pageId })
|
|
||||||
.then(() => {
|
|
||||||
notify.success({
|
|
||||||
title: t[notificationToI18nKey['disableSuccessTitle']](),
|
|
||||||
message: t[notificationToI18nKey['disableSuccessMessage']](),
|
|
||||||
style: 'normal',
|
|
||||||
icon: <SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />,
|
|
||||||
});
|
|
||||||
return mutate();
|
|
||||||
})
|
|
||||||
.catch(e => {
|
|
||||||
notify.error({
|
|
||||||
title: t[notificationToI18nKey['disableErrorTitle']](),
|
|
||||||
message: t[notificationToI18nKey['disableErrorMessage']](),
|
|
||||||
});
|
|
||||||
console.error(e);
|
|
||||||
});
|
|
||||||
}, [disableSharePage, mutate, pageId, t, workspaceId]);
|
|
||||||
|
|
||||||
return useMemo(
|
|
||||||
() => ({
|
|
||||||
isSharedPage,
|
|
||||||
currentShareMode,
|
|
||||||
enableShare,
|
|
||||||
disableShare,
|
|
||||||
changeShare,
|
|
||||||
}),
|
|
||||||
[isSharedPage, currentShareMode, enableShare, disableShare, changeShare]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function usePublicPages(workspace: Workspace) {
|
|
||||||
const isLocalWorkspace = workspace.flavour === WorkspaceFlavour.LOCAL;
|
|
||||||
const { data } = useQuery(
|
|
||||||
isLocalWorkspace
|
|
||||||
? undefined
|
|
||||||
: {
|
|
||||||
query: getWorkspacePublicPagesQuery,
|
|
||||||
variables: {
|
|
||||||
workspaceId: workspace.id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
const maybeData = data as typeof data | undefined;
|
|
||||||
|
|
||||||
const publicPages: {
|
|
||||||
id: string;
|
|
||||||
mode: DocMode;
|
|
||||||
}[] = useMemo(
|
|
||||||
() =>
|
|
||||||
maybeData?.workspace.publicPages.map(i => ({
|
|
||||||
id: i.id,
|
|
||||||
mode: i.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page',
|
|
||||||
})) ?? [],
|
|
||||||
[maybeData?.workspace.publicPages]
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return `undefined` if the page is not public.
|
|
||||||
*/
|
|
||||||
const getPublicMode = useCallback(
|
|
||||||
(pageId: string) => {
|
|
||||||
return publicPages.find(i => i.id === pageId)?.mode;
|
|
||||||
},
|
|
||||||
[publicPages]
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
publicPages,
|
|
||||||
getPublicMode,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
|
||||||
import type { FeatureType } from '@affine/graphql';
|
|
||||||
import {
|
|
||||||
availableFeaturesQuery,
|
|
||||||
enabledFeaturesQuery,
|
|
||||||
setWorkspaceExperimentalFeatureMutation,
|
|
||||||
} from '@affine/graphql';
|
|
||||||
import type { WorkspaceMetadata } from '@toeverything/infra';
|
|
||||||
|
|
||||||
import { useAsyncCallback } from './affine-async-hooks';
|
|
||||||
import { useMutateQueryResource, useMutation } from './use-mutation';
|
|
||||||
import { useQueryImmutable } from './use-query';
|
|
||||||
|
|
||||||
const emptyFeatures: FeatureType[] = [];
|
|
||||||
|
|
||||||
export const useWorkspaceAvailableFeatures = (
|
|
||||||
workspaceMetadata: WorkspaceMetadata
|
|
||||||
) => {
|
|
||||||
const isCloudWorkspace =
|
|
||||||
workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
|
||||||
const { data } = useQueryImmutable(
|
|
||||||
isCloudWorkspace
|
|
||||||
? {
|
|
||||||
query: availableFeaturesQuery,
|
|
||||||
variables: {
|
|
||||||
id: workspaceMetadata.id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
return data?.workspace.availableFeatures ?? emptyFeatures;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useWorkspaceEnabledFeatures = (
|
|
||||||
workspaceMetadata: WorkspaceMetadata
|
|
||||||
) => {
|
|
||||||
const isCloudWorkspace =
|
|
||||||
workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD;
|
|
||||||
const { data } = useQueryImmutable(
|
|
||||||
isCloudWorkspace
|
|
||||||
? {
|
|
||||||
query: enabledFeaturesQuery,
|
|
||||||
variables: {
|
|
||||||
id: workspaceMetadata.id,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
);
|
|
||||||
return data?.workspace.features ?? emptyFeatures;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSetWorkspaceFeature = (
|
|
||||||
workspaceMetadata: WorkspaceMetadata
|
|
||||||
) => {
|
|
||||||
const { trigger, isMutating } = useMutation({
|
|
||||||
mutation: setWorkspaceExperimentalFeatureMutation,
|
|
||||||
});
|
|
||||||
const revalidate = useMutateQueryResource();
|
|
||||||
|
|
||||||
return {
|
|
||||||
trigger: useAsyncCallback(
|
|
||||||
async (feature: FeatureType, enable: boolean) => {
|
|
||||||
await trigger({
|
|
||||||
workspaceId: workspaceMetadata.id,
|
|
||||||
feature,
|
|
||||||
enable,
|
|
||||||
});
|
|
||||||
await revalidate(enabledFeaturesQuery, vars => {
|
|
||||||
return vars.id === workspaceMetadata.id;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[workspaceMetadata.id, revalidate, trigger]
|
|
||||||
),
|
|
||||||
isMutating,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { InvoicesQuery } from '@affine/graphql';
|
||||||
|
import {
|
||||||
|
backoffRetry,
|
||||||
|
catchErrorInto,
|
||||||
|
effect,
|
||||||
|
Entity,
|
||||||
|
exhaustMapSwitchUntilChanged,
|
||||||
|
fromPromise,
|
||||||
|
LiveData,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||||
|
|
||||||
|
import { isBackendError, isNetworkError } from '../error';
|
||||||
|
import type { InvoicesStore } from '../stores/invoices';
|
||||||
|
|
||||||
|
export type Invoice = NonNullable<
|
||||||
|
InvoicesQuery['currentUser']
|
||||||
|
>['invoices'][number];
|
||||||
|
|
||||||
|
export class Invoices extends Entity {
|
||||||
|
constructor(private readonly store: InvoicesStore) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
pageNum$ = new LiveData(0);
|
||||||
|
invoiceCount$ = new LiveData<number | undefined>(undefined);
|
||||||
|
pageInvoices$ = new LiveData<Invoice[] | undefined>(undefined);
|
||||||
|
|
||||||
|
isLoading$ = new LiveData(false);
|
||||||
|
error$ = new LiveData<any>(null);
|
||||||
|
|
||||||
|
readonly PAGE_SIZE = 8;
|
||||||
|
|
||||||
|
readonly revalidate = effect(
|
||||||
|
map(() => this.pageNum$.value),
|
||||||
|
exhaustMapSwitchUntilChanged(
|
||||||
|
(a, b) => a === b,
|
||||||
|
pageNum => {
|
||||||
|
return fromPromise(async signal => {
|
||||||
|
return this.store.fetchInvoices(
|
||||||
|
pageNum * this.PAGE_SIZE,
|
||||||
|
this.PAGE_SIZE,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(data => {
|
||||||
|
this.invoiceCount$.setValue(data.invoiceCount);
|
||||||
|
this.pageInvoices$.setValue(data.invoices);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
backoffRetry({
|
||||||
|
when: isNetworkError,
|
||||||
|
count: Infinity,
|
||||||
|
}),
|
||||||
|
backoffRetry({
|
||||||
|
when: isBackendError,
|
||||||
|
}),
|
||||||
|
catchErrorInto(this.error$),
|
||||||
|
onStart(() => {
|
||||||
|
this.pageInvoices$.setValue(undefined);
|
||||||
|
this.isLoading$.setValue(true);
|
||||||
|
}),
|
||||||
|
onComplete(() => this.isLoading$.setValue(false))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setPageNum(pageNum: number) {
|
||||||
|
this.pageNum$.setValue(pageNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
override dispose(): void {
|
||||||
|
this.revalidate.unsubscribe();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
export type { Invoice } from './entities/invoices';
|
||||||
export type { AuthAccountInfo } from './entities/session';
|
export type { AuthAccountInfo } from './entities/session';
|
||||||
export {
|
export {
|
||||||
BackendError,
|
BackendError,
|
||||||
@@ -8,6 +9,7 @@ export {
|
|||||||
export { AccountChanged, AuthService } from './services/auth';
|
export { AccountChanged, AuthService } from './services/auth';
|
||||||
export { FetchService } from './services/fetch';
|
export { FetchService } from './services/fetch';
|
||||||
export { GraphQLService } from './services/graphql';
|
export { GraphQLService } from './services/graphql';
|
||||||
|
export { InvoicesService } from './services/invoices';
|
||||||
export { ServerConfigService } from './services/server-config';
|
export { ServerConfigService } from './services/server-config';
|
||||||
export { SubscriptionService } from './services/subscription';
|
export { SubscriptionService } from './services/subscription';
|
||||||
export { UserCopilotQuotaService } from './services/user-copilot-quota';
|
export { UserCopilotQuotaService } from './services/user-copilot-quota';
|
||||||
@@ -25,6 +27,7 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
|
|
||||||
import { CloudDocMeta } from './entities/cloud-doc-meta';
|
import { CloudDocMeta } from './entities/cloud-doc-meta';
|
||||||
|
import { Invoices } from './entities/invoices';
|
||||||
import { ServerConfig } from './entities/server-config';
|
import { ServerConfig } from './entities/server-config';
|
||||||
import { AuthSession } from './entities/session';
|
import { AuthSession } from './entities/session';
|
||||||
import { Subscription } from './entities/subscription';
|
import { Subscription } from './entities/subscription';
|
||||||
@@ -36,6 +39,7 @@ import { AuthService } from './services/auth';
|
|||||||
import { CloudDocMetaService } from './services/cloud-doc-meta';
|
import { CloudDocMetaService } from './services/cloud-doc-meta';
|
||||||
import { FetchService } from './services/fetch';
|
import { FetchService } from './services/fetch';
|
||||||
import { GraphQLService } from './services/graphql';
|
import { GraphQLService } from './services/graphql';
|
||||||
|
import { InvoicesService } from './services/invoices';
|
||||||
import { ServerConfigService } from './services/server-config';
|
import { ServerConfigService } from './services/server-config';
|
||||||
import { SubscriptionService } from './services/subscription';
|
import { SubscriptionService } from './services/subscription';
|
||||||
import { UserCopilotQuotaService } from './services/user-copilot-quota';
|
import { UserCopilotQuotaService } from './services/user-copilot-quota';
|
||||||
@@ -44,6 +48,7 @@ import { UserQuotaService } from './services/user-quota';
|
|||||||
import { WebSocketService } from './services/websocket';
|
import { WebSocketService } from './services/websocket';
|
||||||
import { AuthStore } from './stores/auth';
|
import { AuthStore } from './stores/auth';
|
||||||
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
|
import { CloudDocMetaStore } from './stores/cloud-doc-meta';
|
||||||
|
import { InvoicesStore } from './stores/invoices';
|
||||||
import { ServerConfigStore } from './stores/server-config';
|
import { ServerConfigStore } from './stores/server-config';
|
||||||
import { SubscriptionStore } from './stores/subscription';
|
import { SubscriptionStore } from './stores/subscription';
|
||||||
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
|
import { UserCopilotQuotaStore } from './stores/user-copilot-quota';
|
||||||
@@ -78,6 +83,9 @@ export function configureCloudModule(framework: Framework) {
|
|||||||
.service(UserFeatureService)
|
.service(UserFeatureService)
|
||||||
.entity(UserFeature, [AuthService, UserFeatureStore])
|
.entity(UserFeature, [AuthService, UserFeatureStore])
|
||||||
.store(UserFeatureStore, [GraphQLService])
|
.store(UserFeatureStore, [GraphQLService])
|
||||||
|
.service(InvoicesService)
|
||||||
|
.store(InvoicesStore, [GraphQLService])
|
||||||
|
.entity(Invoices, [InvoicesStore])
|
||||||
.scope(WorkspaceScope)
|
.scope(WorkspaceScope)
|
||||||
.scope(DocScope)
|
.scope(DocScope)
|
||||||
.service(CloudDocMetaService)
|
.service(CloudDocMetaService)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { Service } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { Invoices } from '../entities/invoices';
|
||||||
|
|
||||||
|
export class InvoicesService extends Service {
|
||||||
|
invoices = this.framework.createEntity(Invoices);
|
||||||
|
}
|
||||||
24
packages/frontend/core/src/modules/cloud/stores/invoices.ts
Normal file
24
packages/frontend/core/src/modules/cloud/stores/invoices.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { invoicesQuery } from '@affine/graphql';
|
||||||
|
import { Store } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import type { GraphQLService } from '../services/graphql';
|
||||||
|
|
||||||
|
export class InvoicesStore extends Store {
|
||||||
|
constructor(private readonly graphqlService: GraphQLService) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchInvoices(skip: number, take: number, signal?: AbortSignal) {
|
||||||
|
const data = await this.graphqlService.gql({
|
||||||
|
query: invoicesQuery,
|
||||||
|
variables: { skip, take },
|
||||||
|
context: { signal },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data.currentUser) {
|
||||||
|
throw new Error('No logged in');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.currentUser;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,12 +5,13 @@ import {
|
|||||||
catchErrorInto,
|
catchErrorInto,
|
||||||
effect,
|
effect,
|
||||||
Entity,
|
Entity,
|
||||||
|
exhaustMapSwitchUntilChanged,
|
||||||
fromPromise,
|
fromPromise,
|
||||||
LiveData,
|
LiveData,
|
||||||
onComplete,
|
onComplete,
|
||||||
onStart,
|
onStart,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { distinctUntilChanged, EMPTY, map, mergeMap, switchMap } from 'rxjs';
|
import { EMPTY, map, mergeMap } from 'rxjs';
|
||||||
|
|
||||||
import { isBackendError, isNetworkError } from '../../cloud';
|
import { isBackendError, isNetworkError } from '../../cloud';
|
||||||
import type { WorkspaceMembersStore } from '../stores/members';
|
import type { WorkspaceMembersStore } from '../stores/members';
|
||||||
@@ -37,36 +38,38 @@ export class WorkspaceMembers extends Entity {
|
|||||||
|
|
||||||
readonly revalidate = effect(
|
readonly revalidate = effect(
|
||||||
map(() => this.pageNum$.value),
|
map(() => this.pageNum$.value),
|
||||||
distinctUntilChanged<number>(),
|
exhaustMapSwitchUntilChanged(
|
||||||
switchMap(pageNum => {
|
(a, b) => a === b,
|
||||||
return fromPromise(async signal => {
|
pageNum => {
|
||||||
return this.store.fetchMembers(
|
return fromPromise(async signal => {
|
||||||
this.workspaceService.workspace.id,
|
return this.store.fetchMembers(
|
||||||
pageNum * this.PAGE_SIZE,
|
this.workspaceService.workspace.id,
|
||||||
this.PAGE_SIZE,
|
pageNum * this.PAGE_SIZE,
|
||||||
signal
|
this.PAGE_SIZE,
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
}).pipe(
|
||||||
|
mergeMap(data => {
|
||||||
|
this.memberCount$.setValue(data.memberCount);
|
||||||
|
this.pageMembers$.setValue(data.members);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
backoffRetry({
|
||||||
|
when: isNetworkError,
|
||||||
|
count: Infinity,
|
||||||
|
}),
|
||||||
|
backoffRetry({
|
||||||
|
when: isBackendError,
|
||||||
|
}),
|
||||||
|
catchErrorInto(this.error$),
|
||||||
|
onStart(() => {
|
||||||
|
this.pageMembers$.setValue(undefined);
|
||||||
|
this.isLoading$.setValue(true);
|
||||||
|
}),
|
||||||
|
onComplete(() => this.isLoading$.setValue(false))
|
||||||
);
|
);
|
||||||
}).pipe(
|
}
|
||||||
mergeMap(data => {
|
)
|
||||||
this.memberCount$.setValue(data.memberCount);
|
|
||||||
this.pageMembers$.setValue(data.members);
|
|
||||||
return EMPTY;
|
|
||||||
}),
|
|
||||||
backoffRetry({
|
|
||||||
when: isNetworkError,
|
|
||||||
count: Infinity,
|
|
||||||
}),
|
|
||||||
backoffRetry({
|
|
||||||
when: isBackendError,
|
|
||||||
}),
|
|
||||||
catchErrorInto(this.error$),
|
|
||||||
onStart(() => {
|
|
||||||
this.pageMembers$.setValue(undefined);
|
|
||||||
this.isLoading$.setValue(true);
|
|
||||||
}),
|
|
||||||
onComplete(() => this.isLoading$.setValue(false))
|
|
||||||
);
|
|
||||||
})
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setPageNum(pageNum: number) {
|
setPageNum(pageNum: number) {
|
||||||
|
|||||||
@@ -750,6 +750,7 @@ export const invoicesQuery = {
|
|||||||
query: `
|
query: `
|
||||||
query invoices($take: Int!, $skip: Int!) {
|
query invoices($take: Int!, $skip: Int!) {
|
||||||
currentUser {
|
currentUser {
|
||||||
|
invoiceCount
|
||||||
invoices(take: $take, skip: $skip) {
|
invoices(take: $take, skip: $skip) {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
query invoices($take: Int!, $skip: Int!) {
|
query invoices($take: Int!, $skip: Int!) {
|
||||||
currentUser {
|
currentUser {
|
||||||
|
invoiceCount
|
||||||
invoices(take: $take, skip: $skip) {
|
invoices(take: $take, skip: $skip) {
|
||||||
id
|
id
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -1941,6 +1941,7 @@ export type InvoicesQuery = {
|
|||||||
__typename?: 'Query';
|
__typename?: 'Query';
|
||||||
currentUser: {
|
currentUser: {
|
||||||
__typename?: 'UserType';
|
__typename?: 'UserType';
|
||||||
|
invoiceCount: number;
|
||||||
invoices: Array<{
|
invoices: Array<{
|
||||||
__typename?: 'UserInvoice';
|
__typename?: 'UserInvoice';
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user