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
@@ -4,7 +4,6 @@ import { useI18n } from '@affine/i18n';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmModal } from '../../../ui/modal';
import { notify } from '../../../ui/notification';
import { type InviteMethodType, ModalContent } from './modal-content';
import * as styles from './styles.css';
@@ -61,11 +60,7 @@ export const InviteTeamMemberModal = ({
onConfirm({
emails: inviteEmailsArray,
});
notify.success({
title: t['com.affine.payment.member.team.invite.notify.title'](),
message: t['com.affine.payment.member.team.invite.notify.message'](),
});
}, [inviteEmails, inviteMethod, onConfirm, setOpen, t]);
}, [inviteEmails, inviteMethod, onConfirm, setOpen]);
useEffect(() => {
if (!open) {
@@ -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: {
@@ -1,5 +1,8 @@
mutation cancelSubscription($plan: SubscriptionPlan = Pro) {
cancelSubscription(plan: $plan) {
mutation cancelSubscription(
$plan: SubscriptionPlan = Pro
$workspaceId: String
) {
cancelSubscription(plan: $plan, workspaceId: $workspaceId) {
id
status
nextBillAt
+11 -7
View File
@@ -98,8 +98,8 @@ export const cancelSubscriptionMutation = {
definitionName: 'cancelSubscription',
containsFile: false,
query: `
mutation cancelSubscription($plan: SubscriptionPlan = Pro) {
cancelSubscription(plan: $plan) {
mutation cancelSubscription($plan: SubscriptionPlan = Pro, $workspaceId: String) {
cancelSubscription(plan: $plan, workspaceId: $workspaceId) {
id
status
nextBillAt
@@ -188,7 +188,7 @@ export const createCopilotMessageMutation = {
id: 'createCopilotMessageMutation' as const,
operationName: 'createCopilotMessage',
definitionName: 'createCopilotMessage',
containsFile: true,
containsFile: false,
query: `
mutation createCopilotMessage($options: CreateChatMessageInput!) {
createCopilotMessage(options: $options)
@@ -960,8 +960,8 @@ export const resumeSubscriptionMutation = {
definitionName: 'resumeSubscription',
containsFile: false,
query: `
mutation resumeSubscription($plan: SubscriptionPlan = Pro) {
resumeSubscription(plan: $plan) {
mutation resumeSubscription($plan: SubscriptionPlan = Pro, $workspaceId: String) {
resumeSubscription(plan: $plan, workspaceId: $workspaceId) {
id
status
nextBillAt
@@ -1184,8 +1184,12 @@ export const updateSubscriptionMutation = {
definitionName: 'updateSubscriptionRecurring',
containsFile: false,
query: `
mutation updateSubscription($plan: SubscriptionPlan = Pro, $recurring: SubscriptionRecurring!) {
updateSubscriptionRecurring(plan: $plan, recurring: $recurring) {
mutation updateSubscription($plan: SubscriptionPlan = Pro, $recurring: SubscriptionRecurring!, $workspaceId: String) {
updateSubscriptionRecurring(
plan: $plan
recurring: $recurring
workspaceId: $workspaceId
) {
id
plan
recurring
@@ -1,5 +1,8 @@
mutation resumeSubscription($plan: SubscriptionPlan = Pro) {
resumeSubscription(plan: $plan) {
mutation resumeSubscription(
$plan: SubscriptionPlan = Pro
$workspaceId: String
) {
resumeSubscription(plan: $plan, workspaceId: $workspaceId) {
id
status
nextBillAt
@@ -1,8 +1,13 @@
mutation updateSubscription(
$plan: SubscriptionPlan = Pro
$recurring: SubscriptionRecurring!
$workspaceId: String
) {
updateSubscriptionRecurring(plan: $plan, recurring: $recurring) {
updateSubscriptionRecurring(
plan: $plan
recurring: $recurring
workspaceId: $workspaceId
) {
id
plan
recurring
+3
View File
@@ -1476,6 +1476,7 @@ export type SetBlobMutation = { __typename?: 'Mutation'; setBlob: string };
export type CancelSubscriptionMutationVariables = Exact<{
plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
}>;
export type CancelSubscriptionMutation = {
@@ -2256,6 +2257,7 @@ export type RemoveAvatarMutation = {
export type ResumeSubscriptionMutationVariables = Exact<{
plan?: InputMaybe<SubscriptionPlan>;
workspaceId?: InputMaybe<Scalars['String']['input']>;
}>;
export type ResumeSubscriptionMutation = {
@@ -2467,6 +2469,7 @@ export type UpdateServerRuntimeConfigsMutation = {
export type UpdateSubscriptionMutationVariables = Exact<{
plan?: InputMaybe<SubscriptionPlan>;
recurring: SubscriptionRecurring;
workspaceId?: InputMaybe<Scalars['String']['input']>;
}>;
export type UpdateSubscriptionMutation = {
@@ -1,5 +1,5 @@
{
"ar": 69,
"ar": 68,
"ca": 5,
"da": 5,
"de": 26,
@@ -12,13 +12,13 @@
"hi": 2,
"it-IT": 1,
"it": 1,
"ja": 92,
"ja": 91,
"ko": 73,
"pl": 0,
"pt-BR": 79,
"pt-BR": 78,
"ru": 67,
"sv-SE": 4,
"ur": 2,
"zh-Hans": 92,
"zh-Hant": 92
"zh-Hant": 91
}
@@ -982,12 +982,17 @@
"com.affine.payment.member.team.assign.confirm.description-4": "To confirm this transfer, please type the workspace name",
"com.affine.payment.member.team.assign.confirm.placeholder": "Type workspace name to confirm",
"com.affine.payment.member.team.assign.confirm.button": "Transfer Ownership",
"com.affine.payment.member.team.remove.confirm.title": "Remove member from workspace?",
"com.affine.payment.member.team.remove.confirm.description": "This action will revoke their access to all workspace resources immediately.",
"com.affine.payment.member.team.remove.confirm.confirm-button": "Remove Member",
"com.affine.payment.member.team.remove.confirm.cancel": "Cancel",
"com.affine.payment.modal.change.cancel": "Cancel",
"com.affine.payment.modal.change.confirm": "Change",
"com.affine.payment.modal.change.title": "Change your subscription",
"com.affine.payment.modal.downgrade.cancel": "Cancel subscription",
"com.affine.payment.modal.downgrade.caption": "You can still use AFFiNE Cloud Pro until the end of this billing period :)",
"com.affine.payment.modal.downgrade.confirm": "Keep AFFiNE Cloud Pro",
"com.affine.payment.modal.downgrade.team-confirm": "Keep Team plan",
"com.affine.payment.modal.downgrade.content": "We're sorry to see you go, but we're always working to improve, and your feedback is welcome. We hope to see you return in the future.",
"com.affine.payment.modal.downgrade.title": "Are you sure?",
"com.affine.payment.modal.resume.cancel": "Cancel",