feat(core): impl team workspace (#8920)

AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877
UI for team workspaces :
- add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`)
- update team plans on pricing page ( settings —> pricing plans )
- update reaching the usage/member limit modal
- update invite member modal
- update member CRUD options
This commit is contained in:
JimmFly
2024-12-10 06:31:35 +00:00
parent 5d25580eff
commit 612310bc26
77 changed files with 3788 additions and 1044 deletions
@@ -0,0 +1,27 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const ulStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '8px',
marginTop: '12px',
});
export const liStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'start',
fontSize: cssVar('fontBase'),
});
export const prefixDot = style({
background: cssVarV2('icon/activated'),
width: '5px',
height: '5px',
borderRadius: '50%',
marginRight: '12px',
marginTop: '10px',
});
@@ -4,13 +4,15 @@ import { UserQuotaService } from '@affine/core/modules/cloud';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { useI18n } from '@affine/i18n';
import { type I18nString, useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import bytes from 'bytes';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
import * as styles from './cloud-quota-modal.css';
export const CloudQuotaModal = () => {
const t = useI18n();
const currentWorkspace = useService(WorkspaceService).workspace;
@@ -39,10 +41,6 @@ export const CloudQuotaModal = () => {
)
);
const isFreePlanOwner = useMemo(() => {
return isOwner && userQuota?.name === 'free';
}, [isOwner, userQuota]);
const globalDialogService = useService(GlobalDialogService);
const handleUpgradeConfirm = useCallback(() => {
globalDialogService.open('setting', {
@@ -55,18 +53,8 @@ export const CloudQuotaModal = () => {
}, [globalDialogService, setOpen]);
const description = useMemo(() => {
if (userQuota && isFreePlanOwner) {
return t['com.affine.payment.blob-limit.description.owner.free']({
planName: userQuota.name,
currentQuota: userQuota.blobLimit,
upgradeQuota: '100MB',
});
}
if (isOwner && userQuota && userQuota.name.toLowerCase() === 'pro') {
return t['com.affine.payment.blob-limit.description.owner.pro']({
planName: userQuota.name,
quota: userQuota.blobLimit,
});
if (userQuota && isOwner) {
return <OwnerDescription quota={userQuota.blobLimit} />;
}
if (workspaceQuota) {
return t['com.affine.payment.blob-limit.description.member']({
@@ -76,7 +64,7 @@ export const CloudQuotaModal = () => {
// loading
return null;
}
}, [userQuota, isFreePlanOwner, isOwner, workspaceQuota, t]);
}, [userQuota, isOwner, workspaceQuota, t]);
useEffect(() => {
if (!workspaceQuota) {
@@ -100,16 +88,36 @@ export const CloudQuotaModal = () => {
title={t['com.affine.payment.blob-limit.title']()}
onOpenChange={setOpen}
description={description}
cancelButtonOptions={{
hidden: !isFreePlanOwner,
}}
onConfirm={handleUpgradeConfirm}
confirmText={
isFreePlanOwner ? t['com.affine.payment.upgrade']() : t['Got it']()
}
confirmText={t['com.affine.payment.upgrade']()}
confirmButtonOptions={{
variant: 'primary',
}}
/>
);
};
const tips: I18nString[] = [
'com.affine.payment.blob-limit.description.owner.tips-1',
'com.affine.payment.blob-limit.description.owner.tips-2',
'com.affine.payment.blob-limit.description.owner.tips-3',
];
const OwnerDescription = ({ quota }: { quota: string }) => {
const t = useI18n();
return (
<div>
{t['com.affine.payment.blob-limit.description.owner']({
quota: quota,
})}
<ul className={styles.ulStyle}>
{tips.map((tip, index) => (
<li className={styles.liStyle} key={index}>
<div className={styles.prefixDot} />
{t.t(tip)}
</li>
))}
</ul>
</div>
);
};
@@ -1,30 +0,0 @@
import type { Permission } from '@affine/graphql';
import { inviteByEmailMutation } from '@affine/graphql';
import { useCallback } from 'react';
import { useMutation } from '../use-mutation';
import { useMutateCloud } from './use-mutate-cloud';
export function useInviteMember(workspaceId: string) {
const { trigger, isMutating } = useMutation({
mutation: inviteByEmailMutation,
});
const mutate = useMutateCloud();
return {
invite: useCallback(
async (email: string, permission: Permission, sendInviteMail = false) => {
const res = await trigger({
workspaceId,
email,
permission,
sendInviteMail,
});
await mutate();
// return is successful
return res?.invite;
},
[mutate, trigger, workspaceId]
),
isMutating,
};
}
@@ -1,24 +0,0 @@
import { revokeMemberPermissionMutation } from '@affine/graphql';
import { useCallback } from 'react';
import { useMutation } from '../use-mutation';
import { useMutateCloud } from './use-mutate-cloud';
export function useRevokeMemberPermission(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: revokeMemberPermissionMutation,
});
return useCallback(
async (userId: string) => {
const res = await trigger({
workspaceId,
userId,
});
await mutate();
return res;
},
[mutate, trigger, workspaceId]
);
}
@@ -19,7 +19,13 @@ type TypeFormInfo = {
const getTypeFormLink = (id: string, info: TypeFormInfo) => {
const plans = Array.isArray(info.plan) ? info.plan : [info.plan];
const product_id = plans
.map(plan => (plan === SubscriptionPlan.AI ? 'ai' : 'cloud'))
.map(plan =>
plan === SubscriptionPlan.AI
? 'ai'
: plan === SubscriptionPlan.Team
? 'team'
: 'cloud'
)
.join('-');
const product_price =
info.recurring === SubscriptionRecurring.Monthly
@@ -46,7 +52,11 @@ export const generateSubscriptionCallbackLink = (
throw new Error('Account is required');
}
const baseUrl =
plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success';
plan === SubscriptionPlan.AI
? '/ai-upgrade-success'
: plan === SubscriptionPlan.Team
? '/upgrade-success/team'
: '/upgrade-success';
let name = account?.info?.name ?? '';
if (name.includes(separator)) {
@@ -38,17 +38,17 @@ export const OverCapacityNotification = () => {
}
if (isOwner) {
notify.warning({
title: t['com.affine.payment.storage-limit.title'](),
title: t['com.affine.payment.storage-limit.new-title'](),
message:
t['com.affine.payment.storage-limit.description.owner'](),
t['com.affine.payment.storage-limit.new-description.owner'](),
action: {
label: t['com.affine.payment.storage-limit.view'](),
label: t['com.affine.payment.upgrade'](),
onClick: jumpToPricePlan,
},
});
} else {
notify.warning({
title: t['com.affine.payment.storage-limit.title'](),
title: t['com.affine.payment.storage-limit.new-title'](),
message:
t['com.affine.payment.storage-limit.description.member'](),
});
@@ -1,15 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const iconWrapper = style({
position: 'absolute',
top: '16px',
left: '16px',
fontSize: '24px',
cursor: 'pointer',
color: cssVar('textPrimaryColor'),
selectors: {
'&:visited': {
color: cssVar('textPrimaryColor'),
},
},
});
@@ -1,36 +0,0 @@
import { Empty } from '@affine/component';
import { Logo1Icon } from '@blocksuite/icons/rc';
export const SharePageNotFoundError = () => {
return (
<div
style={{
width: '100%',
height: '100vh',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
}}
>
<a
href="https://affine.pro/"
target="_blank"
rel="noreferrer"
style={{
position: 'absolute',
top: '16px',
left: '16px',
fontSize: '24px',
cursor: 'pointer',
color: 'inherit',
}}
>
<Logo1Icon />
</a>
<Empty
description={'You do not have access or this content does not exist.'}
/>
</div>
);
};
@@ -14,6 +14,7 @@ import {
LocalWorkspaceIcon,
NoNetworkIcon,
SettingsIcon,
TeamWorkspaceIcon,
UnsyncIcon,
} from '@blocksuite/icons/rc';
import {
@@ -28,6 +29,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react';
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
import * as styles from './styles.css';
export { PureWorkspaceCard } from './pure-workspace-card';
const CloudWorkspaceStatus = () => {
return (
@@ -240,6 +242,7 @@ export const WorkspaceCard = forwardRef<
avatarSize?: number;
disable?: boolean;
hideCollaborationIcon?: boolean;
hideTeamWorkspaceIcon?: boolean;
active?: boolean;
onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void;
onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void;
@@ -256,6 +259,7 @@ export const WorkspaceCard = forwardRef<
className,
disable,
hideCollaborationIcon,
hideTeamWorkspaceIcon,
active,
...props
},
@@ -325,6 +329,9 @@ export const WorkspaceCard = forwardRef<
{hideCollaborationIcon || information?.isOwner ? null : (
<CollaborationIcon className={styles.collaborationIcon} />
)}
{hideTeamWorkspaceIcon || !information?.isTeam ? null : (
<TeamWorkspaceIcon className={styles.collaborationIcon} />
)}
{onClickOpenSettings && (
<div className={styles.settingButton} onClick={onOpenSettings}>
<SettingsIcon width={16} height={16} />
@@ -336,7 +343,7 @@ export const WorkspaceCard = forwardRef<
{active && (
<div className={styles.activeContainer}>
<DoneIcon className={styles.activeIcon} />{' '}
<DoneIcon className={styles.activeIcon} />
</div>
)}
</div>
@@ -0,0 +1,81 @@
import { Skeleton } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { DoneIcon } from '@blocksuite/icons/rc';
import { type WorkspaceMetadata } from '@toeverything/infra';
import clsx from 'clsx';
import type { HTMLAttributes } from 'react';
import { forwardRef } from 'react';
import * as styles from './styles.css';
export const PureWorkspaceCard = forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & {
workspaceMetadata: WorkspaceMetadata;
avatarSize?: number;
disable?: boolean;
active?: boolean;
}
>(
(
{
workspaceMetadata,
avatarSize = 32,
className,
disable,
active,
...props
},
ref
) => {
const information = useWorkspaceInfo(workspaceMetadata);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
return (
<div
className={clsx(
styles.container,
disable ? styles.disable : null,
className
)}
role="button"
tabIndex={0}
data-testid="workspace-card"
ref={ref}
{...props}
>
<div className={styles.infoContainer}>
{information ? (
<WorkspaceAvatar
meta={workspaceMetadata}
rounded={3}
data-testid="workspace-avatar"
size={avatarSize}
name={name}
colorfulFallback
/>
) : (
<Skeleton width={avatarSize} height={avatarSize} />
)}
<div className={styles.workspaceTitleContainer}>
{information ? (
<span className={styles.workspaceName}>{information.name}</span>
) : (
<Skeleton width={100} />
)}
</div>
</div>
{active && (
<div className={styles.activeContainer}>
<DoneIcon className={styles.activeIcon} />
</div>
)}
</div>
);
}
);
PureWorkspaceCard.displayName = 'PureWorkspaceCard';