fix(core): optimize upgrade to team page (#9099)

This commit is contained in:
JimmFly
2024-12-11 17:59:46 +08:00
committed by GitHub
parent b864cbf9d3
commit 4eb31444a9
16 changed files with 307 additions and 107 deletions
@@ -13,7 +13,11 @@ import {
SubscriptionService,
WorkspaceSubscriptionService,
} from '../../../../../modules/cloud';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
import {
ConfirmLoadingModal,
DowngradeModal,
DowngradeTeamModal,
} from './modals';
/**
* Cancel action with modal & request
@@ -115,7 +119,10 @@ export const CancelTeamAction = ({
const account = authService.session.account$.value;
const prevRecurring = workspaceSubscription?.recurring;
setIsMutating(true);
await subscription.cancelSubscription(idempotencyKey);
await subscription.cancelSubscription(
idempotencyKey,
SubscriptionPlan.Team
);
await subscription.waitForRevalidation();
// refresh idempotency key
setIdempotencyKey(nanoid());
@@ -136,18 +143,22 @@ export const CancelTeamAction = ({
setIsMutating(false);
}
}, [
authService.session.account$.value,
workspaceSubscription,
authService,
workspaceSubscription?.recurring,
subscription,
idempotencyKey,
onOpenChange,
downgradeNotify,
]);
if (workspaceSubscription?.canceledAt) {
return null;
}
return (
<>
{children}
<DowngradeModal
<DowngradeTeamModal
open={open}
onCancel={downgrade}
onOpenChange={onOpenChange}
@@ -208,3 +219,45 @@ export const ResumeAction = ({
</>
);
};
export const TeamResumeAction = ({
children,
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
} & PropsWithChildren) => {
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(WorkspaceSubscriptionService).subscription;
const resume = useAsyncCallback(async () => {
try {
setIsMutating(true);
await subscription.resumeSubscription(
idempotencyKey,
SubscriptionPlan.Team
);
await subscription.waitForRevalidation();
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
return (
<>
{children}
<ConfirmLoadingModal
type={'resume'}
open={open}
onConfirm={resume}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};
@@ -129,3 +129,62 @@ export const DowngradeModal = ({
</Modal>
);
};
export const DowngradeTeamModal = ({
open,
loading,
onOpenChange,
onCancel,
}: {
loading?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
onCancel?: () => void;
}) => {
const t = useI18n();
const canceled = useRef(false);
useEffect(() => {
if (!loading && open && canceled.current) {
onOpenChange?.(false);
canceled.current = false;
}
}, [loading, open, onOpenChange]);
return (
<Modal
title={t['com.affine.payment.modal.downgrade.title']()}
open={open}
contentOptions={{}}
width={480}
onOpenChange={onOpenChange}
>
<div className={styles.downgradeContentWrapper}>
<p className={styles.downgradeContent}>
{t['com.affine.payment.modal.downgrade.content']()}
</p>
</div>
<footer className={styles.downgradeFooter}>
<Button
onClick={() => {
canceled.current = true;
onCancel?.();
}}
loading={loading}
>
{t['com.affine.payment.modal.downgrade.cancel']()}
</Button>
<DialogTrigger asChild>
<Button
disabled={loading}
onClick={() => onOpenChange?.(false)}
variant="primary"
>
{t['com.affine.payment.modal.downgrade.team-confirm']()}
</Button>
</DialogTrigger>
</footer>
</Modal>
);
};
@@ -153,7 +153,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
// team
if (detail.plan === SubscriptionPlan.Team) {
return <UpgradeToTeam />;
return <UpgradeToTeam recurring={recurring} />;
}
// lifetime
@@ -247,10 +247,11 @@ const Downgrade = ({ disabled }: { disabled?: boolean }) => {
);
};
const UpgradeToTeam = () => {
const UpgradeToTeam = ({ recurring }: { recurring: SubscriptionRecurring }) => {
const t = useI18n();
const serverService = useService(ServerService);
const url = `${serverService.server.baseUrl}/upgrade-to-team`;
const url = `${serverService.server.baseUrl}/upgrade-to-team?recurring=${recurring}`;
return (
<a
className={styles.planAction}
@@ -15,6 +15,7 @@ import {
WorkspaceInvoicesService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { UrlService } from '@affine/core/modules/url';
import {
createCustomerPortalMutation,
@@ -36,7 +37,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import {
CancelTeamAction,
ResumeAction,
TeamResumeAction,
} from '../../general-setting/plans/actions';
import * as styles from './styles.css';
@@ -60,7 +61,7 @@ export const WorkspaceSettingBilling = ({
useEffect(() => {
subscriptionService?.subscription.revalidate();
}, [subscriptionService]);
}, [subscriptionService?.subscription]);
if (workspace === null) {
return null;
@@ -97,8 +98,10 @@ export const WorkspaceSettingBilling = ({
const TeamCard = () => {
const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const workspaceQuotaService = useService(WorkspaceQuotaService);
const subscriptionService = useService(SubscriptionService);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const workspaceMemberCount = workspaceQuota?.memberCount;
const teamSubscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
@@ -108,7 +111,13 @@ const TeamCard = () => {
useEffect(() => {
workspaceSubscriptionService.subscription.revalidate();
}, [workspaceSubscriptionService.subscription]);
workspaceQuotaService.quota.revalidate();
subscriptionService.prices.revalidate();
}, [
subscriptionService,
workspaceQuotaService,
workspaceSubscriptionService,
]);
const expiration = teamSubscription?.end;
const nextBillingDate = teamSubscription?.nextBillAt;
@@ -147,12 +156,22 @@ const TeamCard = () => {
}, [expiration, nextBillingDate, t]);
const amount = teamSubscription
? teamPrices
? teamPrices && workspaceMemberCount
? teamSubscription.recurring === SubscriptionRecurring.Monthly
? String((teamPrices.amount ?? 0) / 100)
: String((teamPrices.yearlyAmount ?? 0) / 100)
? String(
(teamPrices.amount ? teamPrices.amount * workspaceMemberCount : 0) /
100
)
: String(
(teamPrices.yearlyAmount
? teamPrices.yearlyAmount * workspaceMemberCount
: 0) / 100
)
: '?'
: '0';
const handleClick = useCallback(() => {
setOpenCancelModal(true);
}, []);
return (
<div className={styles.planCard}>
@@ -171,7 +190,11 @@ const TeamCard = () => {
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<Button variant="primary" className={styles.cancelPlanButton}>
<Button
variant="primary"
className={styles.cancelPlanButton}
onClick={handleClick}
>
{t[
'com.affine.settings.workspace.billing.team-workspace.cancel-plan'
]()}
@@ -207,11 +230,11 @@ const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
}
)}
>
<ResumeAction open={open} onOpenChange={setOpen}>
<TeamResumeAction open={open} onOpenChange={setOpen}>
<Button onClick={handleClick}>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
</TeamResumeAction>
</SettingRow>
);
};
@@ -1,4 +1,4 @@
import { MenuItem, notify } from '@affine/component';
import { MenuItem, notify, useConfirmModal } from '@affine/component';
import {
type Member,
WorkspacePermissionService,
@@ -21,31 +21,55 @@ export const MemberOptions = ({
}) => {
const t = useI18n();
const permission = useService(WorkspacePermissionService).permission;
const { openConfirmModal } = useConfirmModal();
const openRemoveConfirmModal = useCallback(
(successNotify: { title: string; message: string }) => {
openConfirmModal({
title: t['com.affine.payment.member.team.remove.confirm.title'](),
description:
t['com.affine.payment.member.team.remove.confirm.description'](),
confirmText:
t['com.affine.payment.member.team.remove.confirm.confirm-button'](),
cancelText: t['com.affine.payment.member.team.remove.confirm.cancel'](),
confirmButtonOptions: {
variant: 'error',
},
onConfirm: () =>
permission
.revokeMember(member.id)
.then(result => {
if (result) {
notify.success({
title: successNotify.title,
message: successNotify.message,
});
}
})
.catch(error => {
notify.error({
title: 'Operation failed',
message: error.message,
});
}),
});
},
[member.id, openConfirmModal, permission, t]
);
const handleAssignOwner = useCallback(() => {
openAssignModal();
}, [openAssignModal]);
const handleRevoke = useCallback(() => {
permission
.revokeMember(member.id)
.then(result => {
if (result) {
notify.success({
title: t['com.affine.payment.member.team.revoke.notify.title'](),
message: t['com.affine.payment.member.team.revoke.notify.message']({
name: member.name || member.email || member.id,
}),
});
}
})
.catch(error => {
notify.error({
title: 'Operation failed',
message: error.message,
});
});
}, [permission, member, t]);
openRemoveConfirmModal({
title: t['com.affine.payment.member.team.revoke.notify.title'](),
message: t['com.affine.payment.member.team.revoke.notify.message']({
name: member.name || member.email || member.id,
}),
});
}, [openRemoveConfirmModal, member, t]);
const handleApprove = useCallback(() => {
permission
.approveMember(member.id)
@@ -70,48 +94,22 @@ export const MemberOptions = ({
}, [member, permission, t]);
const handleDecline = useCallback(() => {
permission
.revokeMember(member.id)
.then(result => {
if (result) {
notify.success({
title: t['com.affine.payment.member.team.decline.notify.title'](),
message: t['com.affine.payment.member.team.decline.notify.message'](
{
name: member.name || member.email || member.id,
}
),
});
}
})
.catch(error => {
notify.error({
title: 'Operation failed',
message: error.message,
});
});
}, [member, permission, t]);
openRemoveConfirmModal({
title: t['com.affine.payment.member.team.decline.notify.title'](),
message: t['com.affine.payment.member.team.decline.notify.message']({
name: member.name || member.email || member.id,
}),
});
}, [member, openRemoveConfirmModal, t]);
const handleRemove = useCallback(() => {
permission
.revokeMember(member.id)
.then(result => {
if (result) {
notify.success({
title: t['com.affine.payment.member.team.remove.notify.title'](),
message: t['com.affine.payment.member.team.remove.notify.message']({
name: member.name || member.email || member.id,
}),
});
}
})
.catch(error => {
notify.error({
title: 'Operation failed',
message: error.message,
});
});
}, [member, permission, t]);
openRemoveConfirmModal({
title: t['com.affine.payment.member.team.remove.notify.title'](),
message: t['com.affine.payment.member.team.remove.notify.message']({
name: member.name || member.email || member.id,
}),
});
}, [member, openRemoveConfirmModal, t]);
const handleChangeToAdmin = useCallback(() => {
permission
@@ -12,6 +12,7 @@ import { AuthPageContainer } from '@affine/component/auth-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { PureWorkspaceCard } from '@affine/core/components/workspace-selector/workspace-card';
import { AuthService } from '@affine/core/modules/cloud';
import { buildShowcaseWorkspace } from '@affine/core/utils/first-app-data';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
@@ -24,8 +25,10 @@ import {
WorkspacesService,
} from '@toeverything/infra';
import { useCallback, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Upgrade } from '../../dialogs/setting/general-setting/plans/plan-card';
import { PageNotFound } from '../404';
import * as styles from './styles.css';
const benefitList: I18nString[] = [
@@ -36,6 +39,20 @@ const benefitList: I18nString[] = [
];
export const Component = () => {
const authService = useService(AuthService);
const authStatus = useLiveData(authService.session.status$);
const [params] = useSearchParams();
const recurring = params.get('recurring');
const authIsRevalidating = useLiveData(authService.session.isRevalidating$);
if (authStatus === 'unauthenticated' && !authIsRevalidating) {
return <PageNotFound noPermission />;
}
return <UpgradeToTeam recurring={recurring} />;
};
export const UpgradeToTeam = ({ recurring }: { recurring: string | null }) => {
const t = useI18n();
const workspacesList = useService(WorkspacesService).list;
const workspaces = useLiveData(workspacesList.workspaces$);
@@ -111,6 +128,7 @@ export const Component = () => {
{t['com.affine.upgrade-to-team-page.benefit.description']()}
</div>
<UpgradeDialog
recurring={recurring}
open={openUpgrade}
onOpenChange={setOpenUpgrade}
workspaceName={name}
@@ -132,16 +150,25 @@ const UpgradeDialog = ({
onOpenChange,
workspaceName,
workspaceId,
recurring,
}: {
open: boolean;
workspaceName: string;
workspaceId: string;
recurring: string | null;
onOpenChange: (open: boolean) => void;
}) => {
const t = useI18n();
const onClose = useCallback(() => {
onOpenChange(false);
}, [onOpenChange]);
const currentRecurring =
recurring &&
recurring.toLowerCase() === SubscriptionRecurring.Yearly.toLowerCase()
? SubscriptionRecurring.Yearly
: SubscriptionRecurring.Monthly;
return (
<Modal width={480} open={open} onOpenChange={onOpenChange}>
<div className={styles.dialogTitle}>
@@ -163,7 +190,7 @@ const UpgradeDialog = ({
<Button onClick={onClose}>{t['Cancel']()}</Button>
<Upgrade
className={styles.upgradeButtonInDialog}
recurring={SubscriptionRecurring.Monthly}
recurring={currentRecurring}
plan={SubscriptionPlan.Team}
onCheckoutSuccess={onClose}
checkoutInput={{
@@ -44,7 +44,12 @@ export class WorkspaceSubscription extends Entity {
if (!this.store) {
throw new Error('Subscription store not available');
}
await this.store.mutateResumeSubscription(idempotencyKey, plan);
await this.store.mutateResumeSubscription(
idempotencyKey,
plan,
undefined,
this.workspaceService.workspace.id
);
await this.waitForRevalidation();
}
@@ -52,7 +57,12 @@ export class WorkspaceSubscription extends Entity {
if (!this.store) {
throw new Error('Subscription store not available');
}
await this.store.mutateCancelSubscription(idempotencyKey, plan);
await this.store.mutateCancelSubscription(
idempotencyKey,
plan,
undefined,
this.workspaceService.workspace.id
);
await this.waitForRevalidation();
}
@@ -64,7 +74,12 @@ export class WorkspaceSubscription extends Entity {
if (!this.store) {
throw new Error('Subscription store not available');
}
await this.store.setSubscriptionRecurring(idempotencyKey, recurring, plan);
await this.store.setSubscriptionRecurring(
idempotencyKey,
recurring,
plan,
this.workspaceService.workspace.id
);
await this.waitForRevalidation();
}
@@ -96,12 +96,14 @@ export class SubscriptionStore extends Store {
async mutateResumeSubscription(
idempotencyKey: string,
plan?: SubscriptionPlan,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
workspaceId?: string
) {
const data = await this.gqlService.gql({
query: resumeSubscriptionMutation,
variables: {
plan,
workspaceId,
},
context: {
signal: abortSignal,
@@ -116,12 +118,14 @@ export class SubscriptionStore extends Store {
async mutateCancelSubscription(
idempotencyKey: string,
plan?: SubscriptionPlan,
abortSignal?: AbortSignal
abortSignal?: AbortSignal,
workspaceId?: string
) {
const data = await this.gqlService.gql({
query: cancelSubscriptionMutation,
variables: {
plan,
workspaceId,
},
context: {
signal: abortSignal,
@@ -162,13 +166,15 @@ export class SubscriptionStore extends Store {
setSubscriptionRecurring(
idempotencyKey: string,
recurring: SubscriptionRecurring,
plan?: SubscriptionPlan
plan?: SubscriptionPlan,
workspaceId?: string
) {
return this.gqlService.gql({
query: updateSubscriptionMutation,
variables: {
plan,
recurring,
workspaceId,
},
context: {
headers: {