diff --git a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts index a2070d8bd..9b42d6a41 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/workspace.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/workspace.ts @@ -224,6 +224,21 @@ export class WorkspaceResolver { return data?.user?.id === user.id; } + @Query(() => Boolean, { + description: 'Get is admin of workspace', + complexity: 2, + }) + async isAdmin( + @CurrentUser() user: CurrentUser, + @Args('workspaceId') workspaceId: string + ) { + return this.permissions.tryCheckWorkspaceIs( + workspaceId, + user.id, + Permission.Admin + ); + } + @Query(() => [WorkspaceType], { description: 'Get all accessible workspaces for current user', complexity: 2, diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index f69726a14..c138ef63f 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -594,6 +594,9 @@ type Query { """send workspace invitation""" getInviteInfo(inviteId: String!): InvitationType! + """Get is admin of workspace""" + isAdmin(workspaceId: String!): Boolean! + """Get is owner of workspace""" isOwner(workspaceId: String!): Boolean! diff --git a/packages/common/infra/src/modules/workspace/entities/profile.ts b/packages/common/infra/src/modules/workspace/entities/profile.ts index 005b92519..a4ed2445f 100644 --- a/packages/common/infra/src/modules/workspace/entities/profile.ts +++ b/packages/common/infra/src/modules/workspace/entities/profile.ts @@ -22,6 +22,8 @@ export interface WorkspaceProfileInfo { avatar?: string; name?: string; isOwner?: boolean; + isAdmin?: boolean; + isTeam?: boolean; } /** diff --git a/packages/common/infra/src/modules/workspace/stores/profile-cache.ts b/packages/common/infra/src/modules/workspace/stores/profile-cache.ts index 4ae9d84c3..621739d40 100644 --- a/packages/common/infra/src/modules/workspace/stores/profile-cache.ts +++ b/packages/common/infra/src/modules/workspace/stores/profile-cache.ts @@ -19,11 +19,12 @@ export class WorkspaceProfileCacheStore extends Store { } const info = data as WorkspaceProfileInfo; - return { avatar: info.avatar, name: info.name, isOwner: info.isOwner, + isAdmin: info.isAdmin, + isTeam: info.isTeam, }; }) ); diff --git a/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.dark.png b/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.dark.png new file mode 100644 index 000000000..b2245574b Binary files /dev/null and b/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.dark.png differ diff --git a/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.light.png b/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.light.png new file mode 100644 index 000000000..744015987 Binary files /dev/null and b/packages/frontend/component/src/components/affine-other-page-layout/assets/dot-bg.light.png differ diff --git a/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.dark.png b/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.dark.png new file mode 100644 index 000000000..001459256 Binary files /dev/null and b/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.dark.png differ diff --git a/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.light.png b/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.light.png new file mode 100644 index 000000000..3753c79a9 Binary files /dev/null and b/packages/frontend/component/src/components/affine-other-page-layout/assets/other-page.light.png differ diff --git a/packages/frontend/component/src/components/affine-other-page-layout/index.css.ts b/packages/frontend/component/src/components/affine-other-page-layout/index.css.ts index dfa8b094b..f52494cec 100644 --- a/packages/frontend/component/src/components/affine-other-page-layout/index.css.ts +++ b/packages/frontend/component/src/components/affine-other-page-layout/index.css.ts @@ -7,7 +7,8 @@ export const root = style({ flexDirection: 'column', fontSize: cssVar('fontBase'), position: 'relative', - background: cssVar('backgroundPrimaryColor'), + backgroundColor: cssVar('backgroundPrimaryColor'), + backgroundSize: 'cover', }); export const affineLogo = style({ color: 'inherit', diff --git a/packages/frontend/component/src/components/affine-other-page-layout/layout.tsx b/packages/frontend/component/src/components/affine-other-page-layout/layout.tsx index 5f734e989..04c5eada9 100644 --- a/packages/frontend/component/src/components/affine-other-page-layout/layout.tsx +++ b/packages/frontend/component/src/components/affine-other-page-layout/layout.tsx @@ -1,8 +1,11 @@ import { Button } from '@affine/component/ui/button'; import { useI18n } from '@affine/i18n'; import { Logo1Icon } from '@blocksuite/icons/rc'; +import { useTheme } from 'next-themes'; import { type ReactNode, useCallback } from 'react'; +import dotBgDark from './assets/dot-bg.dark.png'; +import dotBgLight from './assets/dot-bg.light.png'; import { DesktopNavbar } from './desktop-navbar'; import * as styles from './index.css'; import { MobileNavbar } from './mobile-navbar'; @@ -18,8 +21,15 @@ export const AffineOtherPageLayout = ({ open(BUILD_CONFIG.downloadUrl, '_blank'); }, []); + const { resolvedTheme } = useTheme(); + const backgroundImage = + resolvedTheme === 'dark' && dotBgDark ? dotBgDark : dotBgLight; + return ( -
+
{BUILD_CONFIG.isElectron ? (
) : ( diff --git a/packages/frontend/component/src/components/auth-components/auth-page-container.tsx b/packages/frontend/component/src/components/auth-components/auth-page-container.tsx index 5ee0dbc1a..af44b628f 100644 --- a/packages/frontend/component/src/components/auth-components/auth-page-container.tsx +++ b/packages/frontend/component/src/components/auth-components/auth-page-container.tsx @@ -1,8 +1,14 @@ import type { FC, PropsWithChildren, ReactNode } from 'react'; -import { Empty } from '../../ui/empty'; +import { ThemedImg } from '../../ui/themed-img'; import { AffineOtherPageLayout } from '../affine-other-page-layout'; -import { authPageContainer, hideInSmallScreen } from './share.css'; +import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png'; +import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png'; +import { + authPageContainer, + hideInSmallScreen, + illustration, +} from './share.css'; export const AuthPageContainer: FC< PropsWithChildren<{ @@ -20,7 +26,12 @@ export const AuthPageContainer: FC< {children}
- +
diff --git a/packages/frontend/component/src/components/auth-components/share.css.ts b/packages/frontend/component/src/components/auth-components/share.css.ts index 5337caef6..511fc6edc 100644 --- a/packages/frontend/component/src/components/auth-components/share.css.ts +++ b/packages/frontend/component/src/components/auth-components/share.css.ts @@ -159,14 +159,33 @@ export const authPageContainer = style({ globalStyle(`${authPageContainer} .wrapper`, { display: 'flex', alignItems: 'center', + width: '100%', + justifyContent: 'center', + overflow: 'hidden', '@media': { 'screen and (max-width: 1024px)': { flexDirection: 'column', + justifyContent: 'flex-start', }, }, }); globalStyle(`${authPageContainer} .content`, { - maxWidth: '700px', + maxWidth: '810px', + '@media': { + 'screen and (min-width: 1024px)': { + marginLeft: '200px', + minWidth: '500px', + marginRight: '60px', + flexGrow: 1, + flexShrink: 0, + flexBasis: 0, + }, + 'screen and (max-width: 1024px)': { + maxWidth: '600px', + width: '100%', + margin: 'auto', + }, + }, }); globalStyle(`${authPageContainer} .title`, { fontSize: cssVar('fontTitle'), @@ -203,3 +222,8 @@ export const hideInSmallScreen = style({ }, }, }); + +export const illustration = style({ + flexShrink: 0, + width: '670px', +}); diff --git a/packages/frontend/component/src/components/member-components/accept-invite-page.tsx b/packages/frontend/component/src/components/member-components/accept-invite-page.tsx index d6d08bfec..782dff1d6 100644 --- a/packages/frontend/component/src/components/member-components/accept-invite-page.tsx +++ b/packages/frontend/component/src/components/member-components/accept-invite-page.tsx @@ -4,7 +4,6 @@ import { useI18n } from '@affine/i18n'; import { Avatar } from '../../ui/avatar'; import { Button } from '../../ui/button'; -import { FlexWrapper } from '../../ui/layout'; import * as styles from './styles.css'; export const AcceptInvitePage = ({ onOpenWorkspace, @@ -18,23 +17,29 @@ export const AcceptInvitePage = ({ - - {inviteInfo.user.name} - {t['invited you to join']()} - - {inviteInfo.workspace.name} - +
+
+ + {inviteInfo.user.name} +
+
{t['invited you to join']()}
+
+ + + {inviteInfo.workspace.name} + +
+
} > + } onClick={onReset} /> + + ) : ( + + )} + + + ); +}; diff --git a/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx b/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx new file mode 100644 index 000000000..65b4409ad --- /dev/null +++ b/packages/frontend/component/src/components/member-components/invite-team-modal/modal-content.tsx @@ -0,0 +1,103 @@ +import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { EmailIcon, LinkIcon } from '@blocksuite/icons/rc'; + +import { RadioGroup } from '../../../ui/radio'; +import { EmailInvite } from './email-invite'; +import { LinkInvite } from './link-invite'; +import * as styles from './styles.css'; + +export type InviteMethodType = 'email' | 'link'; +export const ModalContent = ({ + inviteEmail, + setInviteEmail, + inviteMethod, + onInviteMethodChange, + handleConfirm, + isMutating, + isValidEmail, + copyTextToClipboard, + onGenerateInviteLink, + onRevokeInviteLink, + importCSV, +}: { + inviteEmail: string; + importCSV: React.ReactNode; + setInviteEmail: (value: string) => void; + inviteMethod: InviteMethodType; + onInviteMethodChange: (value: InviteMethodType) => void; + handleConfirm: () => void; + isMutating: boolean; + isValidEmail: boolean; + copyTextToClipboard: (text: string) => Promise; + onGenerateInviteLink: ( + expireTime: WorkspaceInviteLinkExpireTime + ) => Promise; + onRevokeInviteLink: () => Promise; +}) => { + const t = useI18n(); + + return ( +
+
{t['com.affine.payment.member.team.invite.description']()}
+ } + label={t[ + 'com.affine.payment.member.team.invite.email-invite' + ]()} + /> + ), + value: 'email', + }, + { + label: ( + } + label={t['com.affine.payment.member.team.invite.invite-link']()} + /> + ), + value: 'link', + }, + ]} + /> + {inviteMethod === 'email' ? ( + + ) : ( + + )} +
+ ); +}; + +const RadioItem = ({ + icon, + label, +}: { + icon: React.ReactNode; + label: string; +}) => { + return ( +
+ {icon} +
{label}
+
+ ); +}; diff --git a/packages/frontend/component/src/components/member-components/styles.css.tsx b/packages/frontend/component/src/components/member-components/invite-team-modal/styles.css.ts similarity index 51% rename from packages/frontend/component/src/components/member-components/styles.css.tsx rename to packages/frontend/component/src/components/member-components/invite-team-modal/styles.css.ts index de4758f0d..3946f8fb9 100644 --- a/packages/frontend/component/src/components/member-components/styles.css.tsx +++ b/packages/frontend/component/src/components/member-components/invite-team-modal/styles.css.ts @@ -1,8 +1,10 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { globalStyle, style } from '@vanilla-extract/css'; export const inviteModalTitle = style({ fontWeight: '600', - fontSize: 'var(--affine-font-h-6)', + fontSize: cssVar('fontH6'), marginBottom: '20px', }); @@ -19,7 +21,7 @@ export const inviteModalButtonContainer = style({ export const inviteName = style({ marginLeft: '4px', marginRight: '10px', - color: 'var(--affine-black)', + color: cssVarV2('text/primary'), }); export const pagination = style({ @@ -36,27 +38,27 @@ export const pageItem = style({ alignItems: 'center', width: '20px', height: '20px', - fontSize: 'var(--affine-font-xs)', - color: 'var(--affine-text-primary-color)', + fontSize: cssVar('fontXs'), + color: cssVarV2('text/primary'), borderRadius: '4px', selectors: { '&:hover': { - background: 'var(--affine-hover-color)', + background: cssVarV2('layer/background/hoverOverlay'), }, '&.active': { - color: 'var(--affine-primary-color)', + color: cssVarV2('text/emphasis'), cursor: 'default', pointerEvents: 'none', }, '&.label': { - color: 'var(--affine-icon-color)', + color: cssVarV2('icon/primary'), fontSize: '16px', }, '&.disabled': { opacity: '.4', cursor: 'default', - color: 'var(--affine-disable-color)', + color: cssVarV2('text/disable'), pointerEvents: 'none', }, }, @@ -67,3 +69,40 @@ globalStyle(`${pageItem} a`, { justifyContent: 'center', alignItems: 'center', }); + +export const modalContent = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +export const modalSubTitle = style({ + fontSize: cssVar('fontSm'), + fontWeight: '500', +}); + +export const radioItem = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', +}); + +export const iconStyle = style({ + color: cssVarV2('icon/primary'), + fontSize: '16px', +}); + +export const errorHint = style({ + color: cssVarV2('status/error'), + fontSize: cssVar('fontXs'), +}); + +export const contentStyle = style({ + paddingLeft: '0', + paddingRight: '0', +}); + +export const invitationLinkContent = style({ + display: 'flex', + gap: '8px', +}); diff --git a/packages/frontend/component/src/components/member-components/member-limit-modal.css.ts b/packages/frontend/component/src/components/member-components/member-limit-modal.css.ts new file mode 100644 index 000000000..96cbc5c68 --- /dev/null +++ b/packages/frontend/component/src/components/member-components/member-limit-modal.css.ts @@ -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', +}); diff --git a/packages/frontend/component/src/components/member-components/member-limit-modal.tsx b/packages/frontend/component/src/components/member-components/member-limit-modal.tsx index 76f3470e5..913ca8c8e 100644 --- a/packages/frontend/component/src/components/member-components/member-limit-modal.tsx +++ b/packages/frontend/component/src/components/member-components/member-limit-modal.tsx @@ -2,6 +2,8 @@ import { ConfirmModal } from '@affine/component/ui/modal'; import { useI18n } from '@affine/i18n'; import { useCallback } from 'react'; +import * as styles from './member-limit-modal.css'; + export interface MemberLimitModalProps { isFreePlan: boolean; open: boolean; @@ -22,27 +24,18 @@ export const MemberLimitModal = ({ const t = useI18n(); const handleConfirm = useCallback(() => { setOpen(false); - if (isFreePlan) { - onConfirm(); - } - }, [onConfirm, setOpen, isFreePlan]); + onConfirm(); + }, [onConfirm, setOpen]); return ( + } + confirmText={t['com.affine.payment.upgrade']()} confirmButtonOptions={{ variant: 'primary', }} @@ -50,3 +43,41 @@ export const MemberLimitModal = ({ > ); }; + +export const ConfirmDescription = ({ + isFreePlan, + plan, + quota, +}: { + isFreePlan: boolean; + plan: string; + quota: string; +}) => { + const t = useI18n(); + return ( +
+ {t['com.affine.payment.member-limit.description']({ + planName: plan, + quota: quota, + })} +
    + {isFreePlan && ( +
  • +
    + {t[ + 'com.affine.payment.member-limit.description.tips-for-free-plan' + ]()} +
  • + )} +
  • +
    + {t['com.affine.payment.member-limit.description.tips-1']()} +
  • +
  • +
    + {t['com.affine.payment.member-limit.description.tips-2']()} +
  • +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/member-components/styles.css.ts b/packages/frontend/component/src/components/member-components/styles.css.ts new file mode 100644 index 000000000..b52bf41c6 --- /dev/null +++ b/packages/frontend/component/src/components/member-components/styles.css.ts @@ -0,0 +1,113 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { globalStyle, style } from '@vanilla-extract/css'; + +export const inviteModalTitle = style({ + fontWeight: '600', + fontSize: cssVar('fontH6'), + marginBottom: '20px', +}); + +export const inviteModalContent = style({ + marginBottom: '10px', +}); + +export const inviteModalButtonContainer = style({ + display: 'flex', + justifyContent: 'flex-end', + // marginTop: 10, +}); + +export const inviteName = style({ + color: cssVarV2('text/primary'), +}); + +export const content = style({ + display: 'flex', + flexWrap: 'wrap', + alignItems: 'center', + gap: '8px', +}); + +export const userWrapper = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', +}); + +export const pagination = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '6px', + marginTop: 5, +}); + +export const pageItem = style({ + display: 'inline-flex', + justifyContent: 'center', + alignItems: 'center', + width: '20px', + height: '20px', + fontSize: cssVar('fontXs'), + color: cssVarV2('text/primary'), + borderRadius: '4px', + + selectors: { + '&:hover': { + background: cssVarV2('layer/background/hoverOverlay'), + }, + '&.active': { + color: cssVarV2('text/emphasis'), + cursor: 'default', + pointerEvents: 'none', + }, + '&.label': { + color: cssVarV2('icon/primary'), + fontSize: '16px', + }, + '&.disabled': { + opacity: '.4', + cursor: 'default', + color: cssVarV2('text/disable'), + pointerEvents: 'none', + }, + }, +}); +globalStyle(`${pageItem} a`, { + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', +}); + +export const modalContent = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +export const modalSubTitle = style({ + fontSize: cssVar('fontSm'), + fontWeight: '500', +}); + +export const radioItem = style({ + display: 'flex', + alignItems: 'center', + gap: '4px', +}); + +export const iconStyle = style({ + color: cssVarV2('icon/primary'), + fontSize: '16px', +}); + +export const errorHint = style({ + color: cssVarV2('status/error'), + fontSize: cssVar('fontXs'), +}); + +export const importButton = style({ + padding: '4px 8px', +}); diff --git a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx index 63311da9b..2de9681ba 100644 --- a/packages/frontend/component/src/components/not-found-page/not-found-page.tsx +++ b/packages/frontend/component/src/components/not-found-page/not-found-page.tsx @@ -3,10 +3,13 @@ import { SignOutIcon } from '@blocksuite/icons/rc'; import { Avatar } from '../../ui/avatar'; import { Button, IconButton } from '../../ui/button'; +import { ThemedImg } from '../../ui/themed-img'; import { AffineOtherPageLayout } from '../affine-other-page-layout'; +import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png'; +import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png'; import type { User } from '../auth-components'; -import { NotFoundPattern } from './not-found-pattern'; import { + illustration, largeButtonEffect, notFoundPageContainer, wrapper, @@ -32,7 +35,12 @@ export const NoPermissionOrNotFound = ({ {user ? ( <>
- +

{t['404.hint']()}

@@ -76,7 +84,12 @@ export const NotFoundPage = ({
- +

{t['404.hint']()}

diff --git a/packages/frontend/component/src/components/not-found-page/not-found-pattern.tsx b/packages/frontend/component/src/components/not-found-page/not-found-pattern.tsx deleted file mode 100644 index c0abf8df1..000000000 --- a/packages/frontend/component/src/components/not-found-page/not-found-pattern.tsx +++ /dev/null @@ -1,121 +0,0 @@ -export const NotFoundPattern = () => { - return ( - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/packages/frontend/component/src/components/not-found-page/styles.css.ts b/packages/frontend/component/src/components/not-found-page/styles.css.ts index 2b88791db..639eb3c46 100644 --- a/packages/frontend/component/src/components/not-found-page/styles.css.ts +++ b/packages/frontend/component/src/components/not-found-page/styles.css.ts @@ -20,3 +20,8 @@ export const wrapper = style({ export const largeButtonEffect = style({ boxShadow: `${cssVar('largeButtonEffect')} !important`, }); + +export const illustration = style({ + maxWidth: '100%', + width: '670px', +}); diff --git a/packages/frontend/component/src/type.d.ts b/packages/frontend/component/src/type.d.ts index e63e171da..bcafcc1e7 100644 --- a/packages/frontend/component/src/type.d.ts +++ b/packages/frontend/component/src/type.d.ts @@ -2,3 +2,8 @@ declare module '*.mp4' { const src: string; export default src; } + +declare module '*.png' { + const src: string; + export default src; +} diff --git a/packages/frontend/component/src/ui/modal/confirm-modal.tsx b/packages/frontend/component/src/ui/modal/confirm-modal.tsx index f69f06457..774aabfc0 100644 --- a/packages/frontend/component/src/ui/modal/confirm-modal.tsx +++ b/packages/frontend/component/src/ui/modal/confirm-modal.tsx @@ -12,7 +12,9 @@ import { Modal } from './modal'; const styles = BUILD_CONFIG.isMobileEdition ? mobileStyles : desktopStyles; export interface ConfirmModalProps extends ModalProps { + customConfirmButton?: () => React.ReactNode; confirmButtonOptions?: Omit; + childrenContentClassName?: string; onConfirm?: (() => void) | (() => Promise); onCancel?: () => void; confirmText?: React.ReactNode; @@ -29,6 +31,7 @@ export interface ConfirmModalProps extends ModalProps { export const ConfirmModal = ({ children, confirmButtonOptions, + customConfirmButton: CustomConfirmButton, // FIXME: we need i18n confirmText, cancelText = 'Cancel', @@ -40,6 +43,7 @@ export const ConfirmModal = ({ autoFocusConfirm = true, headerClassName, descriptionClassName, + childrenContentClassName, contentOptions, ...props }: ConfirmModalProps) => { @@ -66,7 +70,11 @@ export const ConfirmModal = ({ descriptionClassName={clsx(styles.description, descriptionClassName)} {...props} > - {children ?
{children}
: null} + {children ? ( +
+ {children} +
+ ) : null}
- + {CustomConfirmButton ? ( + + ) : ( + + )}
); diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.css.ts b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.css.ts new file mode 100644 index 000000000..96cbc5c68 --- /dev/null +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx index ba96d9479..385d7a779 100644 --- a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx @@ -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 ; } 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 ( +
+ {t['com.affine.payment.blob-limit.description.owner']({ + quota: quota, + })} +
    + {tips.map((tip, index) => ( +
  • +
    + {t.t(tip)} +
  • + ))} +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts b/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts deleted file mode 100644 index b56e5969f..000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-invite-member.ts +++ /dev/null @@ -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, - }; -} diff --git a/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts b/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts deleted file mode 100644 index 2dbd84443..000000000 --- a/packages/frontend/core/src/components/hooks/affine/use-revoke-member-permission.ts +++ /dev/null @@ -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] - ); -} diff --git a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx index 5c01cc724..0bf082c7f 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx +++ b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx @@ -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)) { diff --git a/packages/frontend/core/src/components/over-capacity/index.tsx b/packages/frontend/core/src/components/over-capacity/index.tsx index fbb86dea1..8df383baf 100644 --- a/packages/frontend/core/src/components/over-capacity/index.tsx +++ b/packages/frontend/core/src/components/over-capacity/index.tsx @@ -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'](), }); diff --git a/packages/frontend/core/src/components/share-page-not-found-error.css.ts b/packages/frontend/core/src/components/share-page-not-found-error.css.ts deleted file mode 100644 index 0ea0b7869..000000000 --- a/packages/frontend/core/src/components/share-page-not-found-error.css.ts +++ /dev/null @@ -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'), - }, - }, -}); diff --git a/packages/frontend/core/src/components/share-page-not-found-error.tsx b/packages/frontend/core/src/components/share-page-not-found-error.tsx deleted file mode 100644 index 5350581a5..000000000 --- a/packages/frontend/core/src/components/share-page-not-found-error.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Empty } from '@affine/component'; -import { Logo1Icon } from '@blocksuite/icons/rc'; - -export const SharePageNotFoundError = () => { - return ( -
- - - - -
- ); -}; diff --git a/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx index 487cfa76a..aeded8663 100644 --- a/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/workspace-selector/workspace-card/index.tsx @@ -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 : ( )} + {hideTeamWorkspaceIcon || !information?.isTeam ? null : ( + + )} {onClickOpenSettings && (
@@ -336,7 +343,7 @@ export const WorkspaceCard = forwardRef< {active && (
- {' '} +
)}
diff --git a/packages/frontend/core/src/components/workspace-selector/workspace-card/pure-workspace-card.tsx b/packages/frontend/core/src/components/workspace-selector/workspace-card/pure-workspace-card.tsx new file mode 100644 index 000000000..6e70d4298 --- /dev/null +++ b/packages/frontend/core/src/components/workspace-selector/workspace-card/pure-workspace-card.tsx @@ -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 & { + 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 ( +
+
+ {information ? ( + + ) : ( + + )} +
+ {information ? ( + {information.name} + ) : ( + + )} +
+
+ {active && ( +
+ +
+ )} +
+ ); + } +); + +PureWorkspaceCard.displayName = 'PureWorkspaceCard'; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx index e0fe87bd7..037cfccb6 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/actions.tsx @@ -45,8 +45,7 @@ export const CancelAction = ({ const prevRecurring = subscription.pro$.value?.recurring; setIsMutating(true); await subscription.cancelSubscription(idempotencyKey); - subscription.revalidate(); - await subscription.isRevalidating$.waitFor(v => !v); + await subscription.waitForRevalidation(); // refresh idempotency key setIdempotencyKey(nanoid()); onOpenChange(false); @@ -92,6 +91,68 @@ export const CancelAction = ({ ); }; +export const CancelTeamAction = ({ + children, + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +} & PropsWithChildren) => { + const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); + const [isMutating, setIsMutating] = useState(false); + const subscription = useService(SubscriptionService).subscription; + const teamSubscription = useLiveData(subscription.team$); + const authService = useService(AuthService); + const downgradeNotify = useDowngradeNotify(); + + const downgrade = useAsyncCallback(async () => { + try { + const account = authService.session.account$.value; + const prevRecurring = teamSubscription?.recurring; + setIsMutating(true); + await subscription.cancelSubscription(idempotencyKey); + await subscription.waitForRevalidation(); + // refresh idempotency key + setIdempotencyKey(nanoid()); + onOpenChange(false); + + if (account && prevRecurring) { + downgradeNotify( + getDowngradeQuestionnaireLink({ + email: account.email ?? '', + id: account.id, + name: account.info?.name ?? '', + plan: SubscriptionPlan.Team, + recurring: prevRecurring, + }) + ); + } + } finally { + setIsMutating(false); + } + }, [ + authService.session.account$.value, + teamSubscription, + subscription, + idempotencyKey, + onOpenChange, + downgradeNotify, + ]); + + return ( + <> + {children} + + + ); +}; + /** * Resume payment action with modal & request * @param param0 @@ -114,8 +175,7 @@ export const ResumeAction = ({ try { setIsMutating(true); await subscription.resumeSubscription(idempotencyKey); - subscription.revalidate(); - await subscription.isRevalidating$.waitFor(v => !v); + await subscription.waitForRevalidation(); // refresh idempotency key setIdempotencyKey(nanoid()); onOpenChange(false); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx index b435b4c5b..64867c8a4 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx @@ -74,20 +74,15 @@ const proBenefits: BenefitsGetter = t => ({ }); const teamBenefits: BenefitsGetter = t => ({ - [t['com.affine.payment.cloud.team.benefit.g1']()]: [ + [t['com.affine.payment.cloud.team-workspace.benefit.g1']()]: [ { - title: t['com.affine.payment.cloud.team.benefit.g1-1'](), + title: t['com.affine.payment.cloud.team-workspace.benefit.g1-1'](), icon: , }, - ...([2, 3, 4] as const).map(i => ({ - title: t[`com.affine.payment.cloud.team.benefit.g1-${i}`](), + ...([2, 3, 4, 5, 6] as const).map(i => ({ + title: t[`com.affine.payment.cloud.team-workspace.benefit.g1-${i}`](), })), ], - [t['com.affine.payment.cloud.team.benefit.g2']()]: [ - { title: t['com.affine.payment.cloud.team.benefit.g2-1']() }, - { title: t['com.affine.payment.cloud.team.benefit.g2-2']() }, - { title: t['com.affine.payment.cloud.team.benefit.g2-3']() }, - ], }); export function getPlanDetail(t: T) { @@ -138,12 +133,34 @@ export function getPlanDetail(t: T) { [ SubscriptionPlan.Team, { - type: 'dynamic', + type: 'fixed', plan: SubscriptionPlan.Team, - contact: true, - name: t['com.affine.payment.cloud.team.name'](), - description: t['com.affine.payment.cloud.team.description'](), - titleRenderer: () => t['com.affine.payment.cloud.team.title'](), + price: '2', + yearlyPrice: '2', + name: t['com.affine.payment.cloud.team-workspace.name'](), + description: t['com.affine.payment.cloud.team-workspace.description'](), + titleRenderer: (recurring, detail) => { + const price = + recurring === SubscriptionRecurring.Yearly + ? detail.yearlyPrice + : detail.price; + return ( + <> + {t['com.affine.payment.cloud.team-workspace.title.price-monthly']( + { + price: '$' + price, + } + )} + {recurring === SubscriptionRecurring.Yearly ? ( + + {t[ + 'com.affine.payment.cloud.team-workspace.title.billed-yearly' + ]()} + + ) : null} + + ); + }, benefits: teamBenefits(t), }, ], diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/lifetime/lifetime-plan.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/lifetime/lifetime-plan.tsx index eff6c6216..e30d9991a 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/lifetime/lifetime-plan.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/lifetime/lifetime-plan.tsx @@ -1,6 +1,6 @@ import { Button } from '@affine/component'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; -import { SubscriptionRecurring } from '@affine/graphql'; +import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; import { Trans, useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; @@ -46,6 +46,7 @@ export const LifetimePlan = () => { {t['com.affine.payment.lifetime.purchase']()} diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx index 4be70cafc..aef3731d1 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx @@ -2,7 +2,11 @@ import { Button, type ButtonProps } from '@affine/component/ui/button'; import { Tooltip } from '@affine/component/ui/tooltip'; import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; +import { + AuthService, + ServerService, + SubscriptionService, +} from '@affine/core/modules/cloud'; import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { type CreateCheckoutSessionInput, @@ -91,6 +95,20 @@ export const PlanCard = (props: PlanCardProps) => { ); }; +const getSignUpText = ( + plan: SubscriptionPlan, + t: ReturnType +) => { + switch (plan) { + case SubscriptionPlan.Free: + return t['com.affine.payment.sign-up-free'](); + case SubscriptionPlan.Team: + return t['com.affine.payment.start-free-trial'](); + default: + return t['com.affine.payment.buy-pro'](); + } +}; + const ActionButton = ({ detail, recurring }: PlanCardProps) => { const t = useI18n(); const loggedIn = @@ -105,12 +123,18 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$); const isFree = detail.plan === SubscriptionPlan.Free; + const signUpText = useMemo( + () => getSignUpText(detail.plan, t), + [detail.plan, t] + ); + // branches: // if contact => 'Contact Sales' // if not signed in: // if free => 'Sign up free' // else => 'Buy Pro' // else + // if team => 'Start 14-day free trial' // if isBeliever => 'Included in Lifetime' // if onetime // if free => 'Included in Pro' @@ -122,20 +146,14 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // if currentRecurring !== recurring => 'Change to {recurring} Billing' // else => 'Upgrade' - // contact - if (detail.type === 'dynamic') { - return ; - } - // not signed in if (!loggedIn) { - return ( - - {detail.plan === SubscriptionPlan.Free - ? t['com.affine.payment.sign-up-free']() - : t['com.affine.payment.buy-pro']()} - - ); + return {signUpText}; + } + + // team + if (detail.plan === SubscriptionPlan.Team) { + return ; } // lifetime @@ -183,7 +201,10 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { disabled={isCanceled} /> ) : ( - + ); }; @@ -226,19 +247,10 @@ const Downgrade = ({ disabled }: { disabled?: boolean }) => { ); }; -const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => { +const UpgradeToTeam = () => { const t = useI18n(); - const url = useMemo(() => { - switch (plan) { - case SubscriptionPlan.Team: - return 'https://6dxre9ihosp.typeform.com/to/niBcdkvs'; - case SubscriptionPlan.Enterprise: - return 'https://6dxre9ihosp.typeform.com/to/rFfobTjf'; - default: - return 'https://affine.pro/pricing'; - } - }, [plan]); - + const serverService = useService(ServerService); + const url = `${serverService.server.baseUrl}/upgrade-to-team`; return ( { ); @@ -261,43 +272,51 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => { export const Upgrade = ({ className, recurring, + plan, children, checkoutInput, + onCheckoutSuccess, + onBeforeCheckout, ...btnProps }: ButtonProps & { recurring: SubscriptionRecurring; + plan: SubscriptionPlan; checkoutInput?: Partial; + onBeforeCheckout?: () => void; + onCheckoutSuccess?: () => void; }) => { const t = useI18n(); const authService = useService(AuthService); - const onBeforeCheckout = useCallback(() => { + const handleBeforeCheckout = useCallback(() => { track.$.settingsPanel.plans.checkout({ - plan: SubscriptionPlan.Pro, + plan: plan, recurring: recurring, }); - }, [recurring]); + onBeforeCheckout?.(); + }, [onBeforeCheckout, plan, recurring]); const checkoutOptions = useMemo( () => ({ recurring, - plan: SubscriptionPlan.Pro, + plan: plan, variant: null, coupon: null, successCallbackLink: generateSubscriptionCallbackLink( authService.session.account$.value, - SubscriptionPlan.Pro, + plan, recurring ), ...checkoutInput, }), - [authService.session.account$.value, checkoutInput, recurring] + [authService.session.account$.value, checkoutInput, plan, recurring] ); return ( ( + +
+

+ ${amount} + + / + {teamSubscription?.recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()} + +

+
+ ); +}; + +const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => { + const t = useI18n(); + const [open, setOpen] = useState(false); + const handleClick = useCallback(() => { + setOpen(true); + }, []); + + return ( + + + + + + ); +}; + +const TypeFormLink = () => { + const t = useI18n(); + const subscriptionService = useService(SubscriptionService); + const authService = useService(AuthService); + + const team = useLiveData(subscriptionService.subscription.team$); + const account = useLiveData(authService.session.account$); + + if (!account) return null; + + const plan = []; + if (team) plan.push(SubscriptionPlan.Team); + + const link = getUpgradeQuestionnaireLink({ + name: account.info?.name, + id: account.id, + email: account.email, + recurring: team?.recurring ?? SubscriptionRecurring.Yearly, + plan, + }); + + return ( + + + + + + ); +}; + +const PaymentMethodUpdater = () => { + const { isMutating, trigger } = useMutation({ + mutation: createCustomerPortalMutation, + }); + const urlService = useService(UrlService); + const t = useI18n(); + + const update = useAsyncCallback(async () => { + await trigger(null, { + onSuccess: data => { + urlService.openPopupWindow(data.createCustomerPortal); + }, + }); + }, [trigger, urlService]); + + return ( + + + + ); +}; + +const BillingHistory = () => { + const t = useI18n(); + + 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$); + + useEffect(() => { + invoicesService.invoices.revalidate(); + }, [invoicesService]); + + const handlePageChange = useCallback( + (_: number, pageNum: number) => { + invoicesService.invoices.setPageNum(pageNum); + invoicesService.invoices.revalidate(); + }, + [invoicesService] + ); + + if (invoiceCount === undefined) { + if (isLoading) { + return ; + } else { + return ( + + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} + + ); + } + } + + return ( +
+
+ {invoiceCount === 0 ? ( +

+ {t['com.affine.payment.billing-setting.no-invoice']()} +

+ ) : ( + pageInvoices?.map(invoice => ( + + )) + )} +
+ + {invoiceCount > invoicesService.invoices.PAGE_SIZE && ( + + )} +
+ ); +}; + +const InvoiceLine = ({ + invoice, +}: { + invoice: NonNullable['invoices'][0]; +}) => { + const t = useI18n(); + const urlService = useService(UrlService); + + const open = useCallback(() => { + if (invoice.link) { + urlService.openPopupWindow(invoice.link); + } + }, [invoice.link, urlService]); + + return ( + + + + ); +}; + +const BillingHistorySkeleton = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts new file mode 100644 index 000000000..74ddd960b --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts @@ -0,0 +1,67 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const paymentMethod = style({ + marginTop: '24px', +}); + +export const history = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: '24px', +}); +export const historyContent = style({ + width: '100%', +}); + +export const noInvoice = style({ + color: cssVar('textSecondaryColor'), + fontSize: cssVar('fontXs'), +}); + +export const subscriptionSettingSkeleton = style({ + display: 'flex', + flexDirection: 'column', + gap: '24px', +}); + +export const billingHistorySkeleton = style({ + display: 'flex', + flexDirection: 'column', + minHeight: '72px', + alignItems: 'center', + justifyContent: 'center', +}); + +export const planCard = style({ + display: 'flex', + justifyContent: 'space-between', + padding: '12px', + border: `1px solid ${cssVar('borderColor')}`, + borderRadius: '8px', +}); + +export const currentPlan = style({ + flex: '1 0 0', +}); + +export const planPrice = style({ + fontSize: cssVar('fontH6'), + fontWeight: 600, +}); + +export const billingFrequency = style({ + fontSize: cssVar('fontBase'), +}); + +export const currentPlanName = style({ + fontSize: cssVar('fontXs'), + fontWeight: 500, + color: cssVar('textEmphasisColor'), + cursor: 'pointer', +}); + +export const cancelPlanButton = style({ + marginTop: '8px', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx index 7ccd48ffa..56ff2a7a5 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/index.tsx @@ -2,6 +2,7 @@ import type { SettingTab } from '@affine/core/modules/dialogs/constant'; import type { WorkspaceMetadata } from '@toeverything/infra'; import type { SettingState } from '../types'; +import { WorkspaceSettingBilling } from './billing'; import { WorkspaceSettingDetail } from './new-workspace-setting-detail'; import { WorkspaceSettingProperties } from './properties'; @@ -29,6 +30,8 @@ export const WorkspaceSetting = ({ return ( ); + case 'workspace:billing': + return ; } return null; }; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx new file mode 100644 index 000000000..2624b4591 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/ai.tsx @@ -0,0 +1,52 @@ +import { Switch } from '@affine/component'; +import { + SettingRow, + SettingWrapper, +} from '@affine/component/setting-components'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { ServerService } from '@affine/core/modules/cloud'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { WorkspaceShareSettingService } from '@affine/core/modules/share-setting'; +import { useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; + +export const AiSetting = () => { + const t = useI18n(); + const shareSetting = useService(WorkspaceShareSettingService).sharePreview; + const serverService = useService(ServerService); + const serverEnableAi = useLiveData( + serverService.server.features$.map(f => f?.copilot) + ); + const workspaceEnableAi = useLiveData(shareSetting.enableAi$); + const loading = useLiveData(shareSetting.isLoading$); + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + + const toggleAi = useAsyncCallback( + async (checked: boolean) => { + await shareSetting.setEnableAi(checked); + }, + [shareSetting] + ); + + if (!isOwner || !serverEnableAi) { + return null; + } + + return ( + + + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx index 5b11e565b..1f884a921 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/index.tsx @@ -11,6 +11,7 @@ import { ArrowRightSmallIcon } from '@blocksuite/icons/rc'; import { FrameworkScope } from '@toeverything/infra'; import { useCallback } from 'react'; +import { AiSetting } from './ai'; import { DeleteLeaveWorkspace } from './delete-leave-workspace'; import { EnableCloudPanel } from './enable-cloud'; import { DesktopExportPanel } from './export'; @@ -70,6 +71,7 @@ export const WorkspaceSettingDetail = ({ + {BUILD_CONFIG.isElectron && ( diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx index d740aa5f6..27c3517f5 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/labels.tsx @@ -1,7 +1,7 @@ import { WorkspacePermissionService } from '@affine/core/modules/permissions'; import { useI18n } from '@affine/i18n'; -import type { Workspace } from '@toeverything/infra'; import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { useEffect, useMemo } from 'react'; import * as style from './style.css'; @@ -12,6 +12,7 @@ type WorkspaceStatus = | 'selfHosted' | 'joinedWorkspace' | 'availableOffline' + | 'teamWorkspace' | 'publishedToWeb'; type LabelProps = { @@ -38,42 +39,55 @@ const Label = ({ value, background }: LabelProps) => { const getConditions = ( isOwner: boolean | null, - workspace: Workspace + flavour: string, + isTeam: boolean | null ): labelConditionsProps[] => { return [ { condition: !isOwner, label: 'joinedWorkspace' }, - { condition: workspace.flavour === 'local', label: 'local' }, + { condition: flavour === 'local', label: 'local' }, { - condition: workspace.flavour === 'affine-cloud', + condition: flavour === 'affine-cloud', label: 'syncCloud', }, + { + condition: !!isTeam, + label: 'teamWorkspace', + }, + { + condition: flavour !== 'affine-cloud' && flavour !== 'local', + label: 'selfHosted', + }, ]; }; const getLabelMap = (t: ReturnType): LabelMap => ({ local: { value: t['com.affine.settings.workspace.state.local'](), - background: 'var(--affine-tag-orange)', + background: cssVarV2('chip/label/orange'), }, syncCloud: { value: t['com.affine.settings.workspace.state.sync-affine-cloud'](), - background: 'var(--affine-tag-blue)', + background: cssVarV2('chip/label/blue'), }, selfHosted: { value: t['com.affine.settings.workspace.state.self-hosted'](), - background: 'var(--affine-tag-purple)', + background: cssVarV2('chip/label/purple'), }, joinedWorkspace: { value: t['com.affine.settings.workspace.state.joined'](), - background: 'var(--affine-tag-yellow)', + background: cssVarV2('chip/label/yellow'), }, availableOffline: { value: t['com.affine.settings.workspace.state.available-offline'](), - background: 'var(--affine-tag-green)', + background: cssVarV2('chip/label/green'), }, publishedToWeb: { value: t['com.affine.settings.workspace.state.published'](), - background: 'var(--affine-tag-blue)', + background: cssVarV2('chip/label/blue'), + }, + teamWorkspace: { + value: t['com.affine.settings.workspace.state.team'](), + background: cssVarV2('chip/label/purple'), }, }); @@ -81,6 +95,7 @@ export const LabelsPanel = () => { const workspace = useService(WorkspaceService).workspace; const permissionService = useService(WorkspacePermissionService); const isOwner = useLiveData(permissionService.permission.isOwner$); + const isTeam = useLiveData(permissionService.permission.isTeam$); const t = useI18n(); useEffect(() => { @@ -90,8 +105,8 @@ export const LabelsPanel = () => { const labelMap = useMemo(() => getLabelMap(t), [t]); const labelConditions = useMemo( - () => getConditions(isOwner, workspace), - [isOwner, workspace] + () => getConditions(isOwner, workspace.flavour, isTeam), + [isOwner, isTeam, workspace.flavour] ); return ( diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx deleted file mode 100644 index 7c7598597..000000000 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members.tsx +++ /dev/null @@ -1,420 +0,0 @@ -import { notify } from '@affine/component'; -import type { InviteModalProps } from '@affine/component/member-components'; -import { - InviteModal, - MemberLimitModal, - Pagination, -} from '@affine/component/member-components'; -import { SettingRow } from '@affine/component/setting-components'; -import { Avatar } from '@affine/component/ui/avatar'; -import { Button, IconButton } from '@affine/component/ui/button'; -import { Loading } from '@affine/component/ui/loading'; -import { Menu, MenuItem } from '@affine/component/ui/menu'; -import { Tooltip } from '@affine/component/ui/tooltip'; -import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; -import { useInviteMember } from '@affine/core/components/hooks/affine/use-invite-member'; -import { useRevokeMemberPermission } from '@affine/core/components/hooks/affine/use-revoke-member-permission'; -import { - type Member, - WorkspaceMembersService, - WorkspacePermissionService, -} from '@affine/core/modules/permissions'; -import { WorkspaceQuotaService } from '@affine/core/modules/quota'; -import { Permission, UserFriendlyError } from '@affine/graphql'; -import { useI18n } from '@affine/i18n'; -import { track } from '@affine/track'; -import { MoreVerticalIcon } from '@blocksuite/icons/rc'; -import { - useEnsureLiveData, - useLiveData, - useService, - WorkspaceService, -} from '@toeverything/infra'; -import { cssVar } from '@toeverything/theme'; -import clsx from 'clsx'; -import { clamp } from 'lodash-es'; -import type { ReactElement } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { - type AuthAccountInfo, - AuthService, - ServerService, - SubscriptionService, -} from '../../../../../modules/cloud'; -import type { SettingState } from '../../types'; -import * as style from './style.css'; - -type OnRevoke = (memberId: string) => void; -const MembersPanelLocal = () => { - const t = useI18n(); - return ( - -
- - - -
-
- ); -}; - -export const CloudWorkspaceMembersPanel = ({ - onChangeSettingState, -}: { - onChangeSettingState: (settingState: SettingState) => void; -}) => { - const serverService = useService(ServerService); - const hasPaymentFeature = useLiveData( - serverService.server.features$.map(f => f?.payment) - ); - const workspace = useService(WorkspaceService).workspace; - - const permissionService = useService(WorkspacePermissionService); - const isOwner = useLiveData(permissionService.permission.isOwner$); - useEffect(() => { - permissionService.permission.revalidate(); - }, [permissionService]); - - const workspaceQuotaService = useService(WorkspaceQuotaService); - useEffect(() => { - workspaceQuotaService.quota.revalidate(); - }, [workspaceQuotaService]); - const isLoading = useLiveData(workspaceQuotaService.quota.isLoading$); - const error = useLiveData(workspaceQuotaService.quota.error$); - const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$); - const subscriptionService = useService(SubscriptionService); - const plan = useLiveData( - subscriptionService.subscription.pro$.map(s => s?.plan) - ); - const isLimited = - workspaceQuota && workspaceQuota.memberLimit - ? workspaceQuota.memberCount >= workspaceQuota.memberLimit - : null; - - const t = useI18n(); - const { invite, isMutating } = useInviteMember(workspace.id); - const revokeMemberPermission = useRevokeMemberPermission(workspace.id); - - const [open, setOpen] = useState(false); - - const openModal = useCallback(() => { - setOpen(true); - }, []); - - const onInviteConfirm = useCallback( - async ({ email, permission }) => { - const success = await invite( - email, - permission, - // send invite email - true - ); - if (success) { - notify.success({ - title: t['Invitation sent'](), - message: t['Invitation sent hint'](), - }); - setOpen(false); - } - }, - [invite, t] - ); - - const handleUpgradeConfirm = useCallback(() => { - onChangeSettingState({ - activeTab: 'plans', - scrollAnchor: 'cloudPricingPlan', - }); - track.$.settingsPanel.workspace.viewPlans({ - control: 'inviteMember', - }); - }, [onChangeSettingState]); - - const onRevoke = useCallback( - async memberId => { - const res = await revokeMemberPermission(memberId); - if (res?.revoke) { - notify.success({ title: t['Removed successfully']() }); - } - }, - [revokeMemberPermission, t] - ); - - const desc = useMemo(() => { - if (!workspaceQuota) return null; - return ( - - {t['com.affine.payment.member.description2']()} - {hasPaymentFeature ? ( -
- - {t['com.affine.payment.member.description.choose-plan']()} - -
- ) : null} -
- ); - }, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]); - - if (workspaceQuota === null) { - if (isLoading) { - return ; - } else { - return ( - - {error - ? UserFriendlyError.fromAnyError(error).message - : 'Failed to load members'} - - ); - } - } - - return ( - <> - - {isOwner ? ( - <> - - {isLimited ? ( - - ) : ( - - )} - - ) : null} - - -
- -
- - ); -}; -export const MembersPanelFallback = () => { - const t = useI18n(); - - return ( - <> - -
- -
- - ); -}; - -const MemberListFallback = ({ memberCount }: { memberCount?: number }) => { - // prevent page jitter - const height = useMemo(() => { - if (memberCount) { - // height and margin-bottom - return memberCount * 58 + (memberCount - 1) * 6; - } - return 'auto'; - }, [memberCount]); - const t = useI18n(); - - return ( -
- - {t['com.affine.settings.member.loading']()} -
- ); -}; - -const MemberList = ({ - isOwner, - onRevoke, -}: { - isOwner: boolean; - onRevoke: OnRevoke; -}) => { - const membersService = useService(WorkspaceMembersService); - const memberCount = useLiveData(membersService.members.memberCount$); - const pageNum = useLiveData(membersService.members.pageNum$); - const isLoading = useLiveData(membersService.members.isLoading$); - const error = useLiveData(membersService.members.error$); - const pageMembers = useLiveData(membersService.members.pageMembers$); - - useEffect(() => { - membersService.members.revalidate(); - }, [membersService]); - - const session = useService(AuthService).session; - const account = useEnsureLiveData(session.account$); - - const handlePageChange = useCallback( - (_: number, pageNum: number) => { - membersService.members.setPageNum(pageNum); - membersService.members.revalidate(); - }, - [membersService] - ); - - return ( -
- {pageMembers === undefined ? ( - isLoading ? ( - - ) : ( - - {error - ? UserFriendlyError.fromAnyError(error).message - : 'Failed to load members'} - - ) - ) : ( - pageMembers?.map(member => ( - - )) - )} - {memberCount !== undefined && - memberCount > membersService.members.PAGE_SIZE && ( - - )} -
- ); -}; - -const MemberItem = ({ - member, - isOwner, - currentAccount, - onRevoke, -}: { - member: Member; - isOwner: boolean; - currentAccount: AuthAccountInfo; - onRevoke: OnRevoke; -}) => { - const t = useI18n(); - - const handleRevoke = useCallback(() => { - onRevoke(member.id); - }, [onRevoke, member.id]); - - const operationButtonInfo = useMemo(() => { - return { - show: isOwner && currentAccount.id !== member.id, - leaveOrRevokeText: t['Remove from workspace'](), - }; - }, [currentAccount.id, isOwner, member.id, t]); - - return ( -
- -
- {member.name ? ( - <> -
{member.name}
-
{member.email}
- - ) : ( -
{member.email}
- )} -
-
- {member.accepted - ? member.permission === Permission.Owner - ? 'Workspace Owner' - : 'Member' - : 'Pending'} -
- - {operationButtonInfo.leaveOrRevokeText} - - } - > - - - - -
- ); -}; - -export const MembersPanel = ({ - onChangeSettingState, -}: { - onChangeSettingState: (settingState: SettingState) => void; -}): ReactElement | null => { - const workspace = useService(WorkspaceService).workspace; - if (workspace.flavour === 'local') { - return ; - } - return ( - - - - ); -}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx new file mode 100644 index 000000000..7eeb0d61b --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/cloud-members-panel.tsx @@ -0,0 +1,300 @@ +import { Button, Loading, notify } from '@affine/component'; +import { + InviteModal, + type InviteModalProps, + InviteTeamMemberModal, + type InviteTeamMemberModalProps, + MemberLimitModal, +} from '@affine/component/member-components'; +import { SettingRow } from '@affine/component/setting-components'; +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { Upload } from '@affine/core/components/pure/file-upload'; +import { ServerService, SubscriptionService } from '@affine/core/modules/cloud'; +import { WorkspacePermissionService } from '@affine/core/modules/permissions'; +import { WorkspaceQuotaService } from '@affine/core/modules/quota'; +import { copyTextToClipboard } from '@affine/core/utils/clipboard'; +import { emailRegex } from '@affine/core/utils/email-regex'; +import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql'; +import { UserFriendlyError } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { track } from '@affine/track'; +import { ExportIcon } from '@blocksuite/icons/rc'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { SettingState } from '../../../types'; +import { MemberList } from './member-list'; +import * as styles from './styles.css'; + +const parseCSV = async (blob: Blob): Promise => { + try { + const textContent = await blob.text(); + const emails = textContent + .split('\n') + .map(email => email.trim()) + .filter(email => email.length > 0 && emailRegex.test(email)); + + return emails; + } catch (error) { + console.error('Error parsing CSV:', error); + throw new Error('Failed to parse CSV'); + } +}; + +export const CloudWorkspaceMembersPanel = ({ + onChangeSettingState, + isTeam, +}: { + onChangeSettingState: (settingState: SettingState) => void; + isTeam?: boolean; +}) => { + const serverService = useService(ServerService); + const hasPaymentFeature = useLiveData( + serverService.server.features$.map(f => f?.payment) + ); + const permissionService = useService(WorkspacePermissionService); + const isOwner = useLiveData(permissionService.permission.isOwner$); + const isAdmin = useLiveData(permissionService.permission.isAdmin$); + useEffect(() => { + permissionService.permission.revalidate(); + }, [permissionService]); + + const workspaceQuotaService = useService(WorkspaceQuotaService); + useEffect(() => { + workspaceQuotaService.quota.revalidate(); + }, [workspaceQuotaService]); + const isLoading = useLiveData(workspaceQuotaService.quota.isLoading$); + const error = useLiveData(workspaceQuotaService.quota.error$); + const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$); + const subscriptionService = useService(SubscriptionService); + const plan = useLiveData( + subscriptionService.subscription.pro$.map(s => s?.plan) + ); + const isLimited = + workspaceQuota && workspaceQuota.memberLimit + ? workspaceQuota.memberCount >= workspaceQuota.memberLimit + : null; + + const t = useI18n(); + + const [open, setOpen] = useState(false); + const [isMutating, setIsMutating] = useState(false); + + const openModal = useCallback(() => { + setOpen(true); + }, []); + + const onGenerateInviteLink = useCallback( + async (expireTime: WorkspaceInviteLinkExpireTime) => { + const link = + await permissionService.permission.generateInviteLink(expireTime); + return link; + }, + [permissionService.permission] + ); + + const onRevokeInviteLink = useCallback(async () => { + const success = await permissionService.permission.revokeInviteLink(); + return success; + }, [permissionService.permission]); + + const onInviteConfirm = useCallback( + async ({ email, permission }) => { + setIsMutating(true); + const success = await permissionService.permission.inviteMember( + email, + permission, + true + ); + if (success) { + notify.success({ + title: t['Invitation sent'](), + message: t['Invitation sent hint'](), + }); + setOpen(false); + } + setIsMutating(false); + }, + [permissionService.permission, t] + ); + const onInviteBatchConfirm = useCallback< + InviteTeamMemberModalProps['onConfirm'] + >( + async ({ emails }) => { + setIsMutating(true); + const success = await permissionService.permission.inviteMembers( + emails, + true + ); + if (success) { + notify.success({ + title: t['Invitation sent'](), + message: t['Invitation sent hint'](), + }); + setOpen(false); + } + setIsMutating(false); + }, + [permissionService.permission, t] + ); + + const onImportCSV = useAsyncCallback( + async (file: File) => { + setIsMutating(true); + const emails = await parseCSV(file); + onInviteBatchConfirm({ emails }); + setIsMutating(false); + }, + [onInviteBatchConfirm] + ); + + const handleUpgradeConfirm = useCallback(() => { + onChangeSettingState({ + activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', + }); + track.$.settingsPanel.workspace.viewPlans({ + control: 'inviteMember', + }); + }, [onChangeSettingState]); + + const desc = useMemo(() => { + if (!workspaceQuota) return null; + + if (isTeam) { + return {t['com.affine.payment.member.team.description']()}; + } + return ( + + {t['com.affine.payment.member.description2']()} + {hasPaymentFeature ? ( +
+ + {t['com.affine.payment.member.description.choose-plan']()} + +
+ ) : null} +
+ ); + }, [handleUpgradeConfirm, hasPaymentFeature, isTeam, t, workspaceQuota]); + + const title = useMemo(() => { + if (isTeam) { + return `${t['Members']()} (${workspaceQuota?.memberCount})`; + } + return `${t['Members']()} (${workspaceQuota?.memberCount}/${workspaceQuota?.memberLimit})`; + }, [isTeam, t, workspaceQuota?.memberCount, workspaceQuota?.memberLimit]); + + if (workspaceQuota === null) { + if (isLoading) { + return ; + } else { + return ( + + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} + + ); + } + } + + return ( + <> + + {isOwner ? ( + <> + + {isTeam ? ( + } + /> + ) : isLimited ? ( + + ) : ( + + )} + + ) : null} + + +
+ +
+ + ); +}; + +export const MembersPanelFallback = () => { + const t = useI18n(); + + return ( + <> + +
+ +
+ + ); +}; + +const MemberListFallback = ({ memberCount }: { memberCount?: number }) => { + // prevent page jitter + const height = useMemo(() => { + if (memberCount) { + // height and margin-bottom + return memberCount * 58 + (memberCount - 1) * 6; + } + return 'auto'; + }, [memberCount]); + const t = useI18n(); + + return ( +
+ + {t['com.affine.settings.member.loading']()} +
+ ); +}; + +const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => { + const t = useI18n(); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx new file mode 100644 index 000000000..d841d6e17 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/confirm-assign-modal.tsx @@ -0,0 +1,68 @@ +import { ConfirmModal, Input } from '@affine/component'; +import type { Member } from '@affine/core/modules/permissions'; +import { useI18n } from '@affine/i18n'; +import { cssVar } from '@toeverything/theme'; + +import * as styles from './styles.css'; + +export const ConfirmAssignModal = ({ + open, + setOpen, + member, + inputValue, + setInputValue, + isEquals, + onConfirm, +}: { + open: boolean; + setOpen: (value: boolean) => void; + isEquals: boolean; + member: Member; + inputValue: string; + onConfirm: () => void; + setInputValue: (value: string) => void; +}) => { + const t = useI18n(); + + return ( + +
+
+

+ {t['com.affine.payment.member.team.assign.confirm.description']({ + name: member.name || member.email || member.id, + })} +

+

+ {t['com.affine.payment.member.team.assign.confirm.description-1']()} +

+

+ {t['com.affine.payment.member.team.assign.confirm.description-2']()} +

+

+ {t['com.affine.payment.member.team.assign.confirm.description-3']()} +

+
+
+ {t['com.affine.payment.member.team.assign.confirm.description-4']()} + +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx new file mode 100644 index 000000000..d41b06bf9 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/index.tsx @@ -0,0 +1,44 @@ +import { Button, Tooltip } from '@affine/component'; +import { SettingRow } from '@affine/component/setting-components'; +import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; +import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info'; +import { useI18n } from '@affine/i18n'; +import { useService, WorkspaceService } from '@toeverything/infra'; +import type { ReactElement } from 'react'; + +import type { SettingState } from '../../../types'; +import { CloudWorkspaceMembersPanel } from './cloud-members-panel'; +import * as styles from './styles.css'; + +export const MembersPanel = ({ + onChangeSettingState, +}: { + onChangeSettingState: (settingState: SettingState) => void; +}): ReactElement | null => { + const workspace = useService(WorkspaceService).workspace; + const isTeam = useWorkspaceInfo(workspace.meta)?.isTeam; + if (workspace.flavour === 'local') { + return ; + } + return ( + + + + ); +}; + +const MembersPanelLocal = () => { + const t = useI18n(); + return ( + +
+ + + +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx new file mode 100644 index 000000000..b89fc485d --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-list.tsx @@ -0,0 +1,262 @@ +import { Avatar, IconButton, Loading, Menu, notify } from '@affine/component'; +import { Pagination } from '@affine/component/member-components'; +import { type AuthAccountInfo, AuthService } from '@affine/core/modules/cloud'; +import { + type Member, + WorkspaceMembersService, + WorkspacePermissionService, +} from '@affine/core/modules/permissions'; +import { + Permission, + UserFriendlyError, + WorkspaceMemberStatus, +} from '@affine/graphql'; +import { type I18nString, useI18n } from '@affine/i18n'; +import { MoreVerticalIcon } from '@blocksuite/icons/rc'; +import { + useEnsureLiveData, + useLiveData, + useService, + WorkspaceService, +} from '@toeverything/infra'; +import clsx from 'clsx'; +import { clamp } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { ConfirmAssignModal } from './confirm-assign-modal'; +import { MemberOptions } from './member-option'; +import * as styles from './styles.css'; + +export const MemberList = ({ + isOwner, + isAdmin, +}: { + isOwner: boolean; + isAdmin: boolean; +}) => { + const membersService = useService(WorkspaceMembersService); + const memberCount = useLiveData(membersService.members.memberCount$); + const pageNum = useLiveData(membersService.members.pageNum$); + const isLoading = useLiveData(membersService.members.isLoading$); + const error = useLiveData(membersService.members.error$); + const pageMembers = useLiveData(membersService.members.pageMembers$); + + useEffect(() => { + membersService.members.revalidate(); + }, [membersService]); + + const session = useService(AuthService).session; + const account = useEnsureLiveData(session.account$); + + const handlePageChange = useCallback( + (_: number, pageNum: number) => { + membersService.members.setPageNum(pageNum); + membersService.members.revalidate(); + }, + [membersService] + ); + + return ( +
+ {pageMembers === undefined ? ( + isLoading ? ( + + ) : ( + + {error + ? UserFriendlyError.fromAnyError(error).message + : 'Failed to load members'} + + ) + ) : ( + pageMembers?.map(member => ( + + )) + )} + {memberCount !== undefined && + memberCount > membersService.members.PAGE_SIZE && ( + + )} +
+ ); +}; + +const MemberItem = ({ + member, + isOwner, + isAdmin, + currentAccount, +}: { + member: Member; + isAdmin: boolean; + isOwner: boolean; + currentAccount: AuthAccountInfo; +}) => { + const t = useI18n(); + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(''); + const workspace = useService(WorkspaceService).workspace; + const workspaceName = useLiveData(workspace.name$); + const permission = useService(WorkspacePermissionService).permission; + const isEquals = workspaceName === inputValue; + + const show = isOwner && currentAccount.id !== member.id; + + const handleOpenAssignModal = useCallback(() => { + setOpen(true); + }, []); + + const confirmAssign = useCallback(() => { + permission + .adjustMemberPermission(member.id, Permission.Owner) + .then(result => { + if (result) { + setOpen(false); + notify.success({ + title: t['com.affine.payment.member.team.assign.notify.title'](), + message: t['com.affine.payment.member.team.assign.notify.message']({ + name: member.name || member.email || member.id, + }), + }); + } + }) + .catch(error => { + notify.error({ + title: 'Operation failed', + message: error.message, + }); + }); + }, [permission, member, t]); + + const memberStatus = useMemo(() => getMemberStatus(member), [member]); + + return ( +
+ +
+ {member.name ? ( + <> +
{member.name}
+
{member.email}
+ + ) : ( +
{member.email}
+ )} +
+
+ {t.t(memberStatus)} +
+ + } + > + + + + + +
+ ); +}; + +const getMemberStatus = (member: Member): I18nString => { + if (member.status === WorkspaceMemberStatus.Pending) { + return 'Pending'; + } else if (member.status === WorkspaceMemberStatus.UnderReview) { + return 'Under-Review'; + } else if (member.status === WorkspaceMemberStatus.Accepted) { + switch (member.permission) { + case Permission.Owner: + return 'Workspace Owner'; + case Permission.Admin: + return 'Admin'; + case Permission.Write: + return 'Collaborator'; + default: + return 'Member'; + } + } else { + return 'Need-More-Seats'; + } +}; + +export const MemberListFallback = ({ + memberCount, +}: { + memberCount?: number; +}) => { + // prevent page jitter + const height = useMemo(() => { + if (memberCount) { + // height and margin-bottom + return memberCount * 58 + (memberCount - 1) * 6; + } + return 'auto'; + }, [memberCount]); + const t = useI18n(); + + return ( +
+ + {t['com.affine.settings.member.loading']()} +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx new file mode 100644 index 000000000..0640e00bb --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/member-option.tsx @@ -0,0 +1,236 @@ +import { MenuItem, notify } from '@affine/component'; +import { + type Member, + WorkspacePermissionService, +} from '@affine/core/modules/permissions'; +import { Permission, WorkspaceMemberStatus } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import { useService } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +export const MemberOptions = ({ + member, + isOwner, + isAdmin, + openAssignModal, +}: { + member: Member; + isOwner: boolean; + isAdmin: boolean; + openAssignModal: () => void; +}) => { + const t = useI18n(); + const permission = useService(WorkspacePermissionService).permission; + + 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]); + const handleApprove = useCallback(() => { + permission + .approveMember(member.id) + .then(result => { + if (result) { + notify.success({ + title: t['com.affine.payment.member.team.approve.notify.title'](), + message: t['com.affine.payment.member.team.approve.notify.message']( + { + name: member.name || member.email || member.id, + } + ), + }); + } + }) + .catch(error => { + notify.error({ + title: 'Operation failed', + message: error.message, + }); + }); + }, [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]); + + 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]); + + const handleChangeToAdmin = useCallback(() => { + permission + .adjustMemberPermission(member.id, Permission.Admin) + .then(result => { + if (result) { + notify.success({ + title: t['com.affine.payment.member.team.change.notify.title'](), + message: t[ + 'com.affine.payment.member.team.change.admin.notify.message' + ]({ + name: member.name || member.email || member.id, + }), + }); + } + }) + .catch(error => { + notify.error({ + title: 'Operation failed', + message: error.message, + }); + }); + }, [member, permission, t]); + const handleChangeToCollaborator = useCallback(() => { + permission + .adjustMemberPermission(member.id, Permission.Write) + .then(result => { + if (result) { + notify.success({ + title: t['com.affine.payment.member.team.change.notify.title'](), + message: t[ + 'com.affine.payment.member.team.change.collaborator.notify.message' + ]({ + name: member.name || member.email || member.id, + }), + }); + } + }) + .catch(error => { + notify.error({ + title: 'Operation failed', + message: error.message, + }); + }); + }, [member, permission, t]); + + const operationButtonInfo = useMemo(() => { + return [ + { + label: t['com.affine.payment.member.team.approve'](), + onClick: handleApprove, + show: member.status === WorkspaceMemberStatus.UnderReview, + }, + { + label: t['com.affine.payment.member.team.decline'](), + onClick: handleDecline, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.UnderReview, + }, + { + label: t['com.affine.payment.member.team.revoke'](), + onClick: handleRevoke, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Pending, + }, + { + label: t['com.affine.payment.member.team.remove'](), + onClick: handleRemove, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Accepted, + }, + { + label: t['com.affine.payment.member.team.change.collaborator'](), + onClick: handleChangeToCollaborator, + show: + (isAdmin || isOwner) && + member.status === WorkspaceMemberStatus.Accepted && + member.permission === Permission.Admin, + }, + { + label: t['com.affine.payment.member.team.change.admin'](), + onClick: handleChangeToAdmin, + show: + isOwner && + member.permission === Permission.Write && + member.status === WorkspaceMemberStatus.Accepted, + }, + { + label: t['com.affine.payment.member.team.assign'](), + onClick: handleAssignOwner, + show: isOwner && member.status === WorkspaceMemberStatus.Accepted, + }, + ]; + }, [ + handleApprove, + handleAssignOwner, + handleChangeToAdmin, + handleChangeToCollaborator, + handleDecline, + handleRemove, + handleRevoke, + isAdmin, + isOwner, + member, + t, + ]); + + return ( + <> + {operationButtonInfo.map(item => + item.show ? ( + + {item.label} + + ) : null + )} + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts new file mode 100644 index 000000000..4d6f2419c --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/members/styles.css.ts @@ -0,0 +1,132 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const fakeWrapper = style({ + position: 'relative', + opacity: 0.4, + marginTop: '24px', + selectors: { + '&::after': { + content: '""', + width: '100%', + height: '100%', + position: 'absolute', + left: 0, + top: 0, + cursor: 'not-allowed', + }, + }, +}); + +export const membersPanel = style({ + padding: '4px', + borderRadius: '12px', + background: cssVarV2('layer/background/primary'), + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', +}); + +export const goUpgradeWrapper = style({ + display: 'inline-flex', + alignItems: 'center', +}); + +export const goUpgrade = style({ + fontSize: cssVar('fontXs'), + color: cssVarV2('text/emphasis'), + cursor: 'pointer', + marginLeft: '4px', + display: 'inline', +}); + +export const errorStyle = style({ + color: cssVarV2('status/error'), +}); + +export const membersFallback = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'flexStart', + color: cssVarV2('text/secondary'), + gap: '4px', + padding: '8px', + fontSize: cssVar('fontXs'), +}); + +export const memberListItem = style({ + padding: '0 4px 0 16px', + height: '58px', + display: 'flex', + width: '100%', + alignItems: 'center', + selectors: { + '&:hover': { + background: cssVarV2('layer/background/hoverOverlay'), + borderRadius: '8px', + }, + '&:not(:last-of-type)': { + marginBottom: '6px', + }, + }, +}); +export const memberContainer = style({ + width: '250px', + display: 'flex', + flexDirection: 'column', + flexShrink: 0, + marginLeft: '12px', + marginRight: '20px', +}); + +export const roleOrStatus = style({ + flexGrow: 1, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + fontSize: cssVar('fontSm'), + selectors: { + '&.pending': { + color: cssVarV2('text/emphasis'), + }, + }, +}); + +export const memberName = style({ + fontSize: cssVar('fontSm'), + color: cssVarV2('text/primary'), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + lineHeight: '22px', +}); + +export const memberEmail = style({ + fontSize: cssVar('fontXs'), + color: cssVarV2('text/secondary'), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + lineHeight: '20px', +}); + +export const confirmAssignModalContent = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + padding: '0', +}); + +export const confirmInputContainer = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + marginTop: '12px', + marginBottom: '20px', +}); + +export const importButton = style({ + padding: '4px 8px', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts index a7583b353..5288bf9a8 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/new-workspace-setting-detail/style.css.ts @@ -1,16 +1,12 @@ import { cssVar } from '@toeverything/theme'; -import { globalStyle, style } from '@vanilla-extract/css'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; export const profileWrapper = style({ display: 'flex', alignItems: 'flex-end', marginTop: '12px', }); -export const profileHandlerWrapper = style({ - flexGrow: '1', - display: 'flex', - alignItems: 'center', - marginLeft: '20px', -}); + export const labelWrapper = style({ width: '100%', display: 'flex', @@ -19,148 +15,10 @@ export const labelWrapper = style({ gap: '10px', flexWrap: 'wrap', }); -export const avatarWrapper = style({ - width: '56px', - height: '56px', - borderRadius: '50%', - position: 'relative', - cursor: 'pointer', - flexShrink: '0', - selectors: { - '&.disable': { - cursor: 'default', - pointerEvents: 'none', - }, - }, -}); -globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, { - display: 'flex', -}); -globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, { - display: 'flex', -}); -globalStyle(`${avatarWrapper} .camera-icon-wrapper`, { - width: '56px', - height: '56px', - borderRadius: '50%', - position: 'absolute', - display: 'none', - justifyContent: 'center', - alignItems: 'center', - backgroundColor: 'rgba(60, 61, 63, 0.5)', - zIndex: '1', - color: cssVar('white'), - fontSize: '24px', -}); -export const urlButton = style({ - width: 'calc(100% - 64px - 15px)', - justifyContent: 'left', - textAlign: 'left', -}); -globalStyle(`${urlButton} span`, { - width: '100%', - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - color: cssVar('placeholderColor'), - fontWeight: '500', -}); -export const fakeWrapper = style({ - position: 'relative', - opacity: 0.4, - marginTop: '24px', - selectors: { - '&::after': { - content: '""', - width: '100%', - height: '100%', - position: 'absolute', - left: 0, - top: 0, - cursor: 'not-allowed', - }, - }, -}); -export const membersFallback = style({ - display: 'flex', - justifyContent: 'center', - alignItems: 'flexStart', - color: cssVar('textSecondaryColor'), - gap: '4px', - padding: '8px', - fontSize: cssVar('fontXs'), -}); -export const membersPanel = style({ - padding: '4px', - borderRadius: '12px', - background: cssVar('backgroundPrimaryColor'), - border: `1px solid ${cssVar('borderColor')}`, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', -}); -export const memberList = style({}); -export const memberListItem = style({ - padding: '0 4px 0 16px', - height: '58px', - display: 'flex', - width: '100%', - alignItems: 'center', - selectors: { - '&:hover': { - background: cssVar('hoverColor'), - borderRadius: '8px', - }, - '&:not(:last-of-type)': { - marginBottom: '6px', - }, - }, -}); -export const memberContainer = style({ - width: '250px', - display: 'flex', - flexDirection: 'column', - flexShrink: 0, - marginLeft: '12px', - marginRight: '20px', -}); -export const roleOrStatus = style({ - // width: '20%', - flexGrow: 1, - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - fontSize: cssVar('fontSm'), - selectors: { - '&.pending': { - color: cssVar('primaryColor'), - }, - }, -}); -export const memberName = style({ - fontSize: cssVar('fontSm'), - color: cssVar('textPrimaryColor'), - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - lineHeight: '22px', -}); -export const memberEmail = style({ - fontSize: cssVar('fontXs'), - color: cssVar('textSecondaryColor'), - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - lineHeight: '20px', -}); -export const iconButton = style({}); -globalStyle(`${memberListItem}:hover ${iconButton}`, { - opacity: 1, - pointerEvents: 'all', -}); + export const label = style({ fontSize: cssVar('fontXs'), - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), marginBottom: '5px', }); export const workspaceLabel = style({ @@ -173,23 +31,7 @@ export const workspaceLabel = style({ padding: '2px 10px', border: `1px solid ${cssVar('white30')}`, fontSize: cssVar('fontXs'), - color: cssVar('textPrimaryColor'), + color: cssVarV2('text/primary'), lineHeight: '20px', whiteSpace: 'nowrap', }); -export const goUpgrade = style({ - fontSize: cssVar('fontXs'), - color: cssVar('textEmphasisColor'), - cursor: 'pointer', - marginLeft: '4px', - display: 'inline', -}); -export const goUpgradeWrapper = style({ - display: 'inline-flex', - alignItems: 'center', -}); -export const arrowRight = style({ - fontSize: '16px', - color: cssVar('textEmphasisColor'), - cursor: 'pointer', -}); diff --git a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx index 3adf84515..d41ddd60b 100644 --- a/packages/frontend/core/src/desktop/pages/subscribe/index.tsx +++ b/packages/frontend/core/src/desktop/pages/subscribe/index.tsx @@ -34,6 +34,8 @@ const products = { pro: 'pro_yearly', 'monthly-pro': 'pro_monthly', believer: 'pro_lifetime', + team: 'team_yearly', + 'monthly-team': 'team_monthly', 'oneyear-ai': 'ai_yearly_onetime', 'oneyear-pro': 'pro_yearly_onetime', 'onemonth-pro': 'pro_monthly_onetime', @@ -42,6 +44,7 @@ const products = { const allowedPlan = { ai: SubscriptionPlan.AI, pro: SubscriptionPlan.Pro, + team: SubscriptionPlan.Team, }; const allowedRecurring = { monthly: SubscriptionRecurring.Monthly, diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx new file mode 100644 index 000000000..e63b0cd2e --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/team/index.tsx @@ -0,0 +1,57 @@ +import { Button } from '@affine/component'; +import { AuthPageContainer } from '@affine/component/auth-components'; +import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; +import { Trans, useI18n } from '@affine/i18n'; +import { useCallback } from 'react'; +import { useSearchParams } from 'react-router-dom'; + +import * as styles from './styles.css'; + +/** + * /upgrade-success/team page + * + * only on web + */ +export const Component = () => { + const t = useI18n(); + const [params] = useSearchParams(); + + const { jumpToIndex, jumpToOpenInApp } = useNavigateHelper(); + const openAffine = useCallback(() => { + if (params.get('schema')) { + jumpToOpenInApp('bring-to-front'); + } else { + jumpToIndex(); + } + }, [jumpToIndex, jumpToOpenInApp, params]); + + const subtitle = ( +
+
{t['com.affine.payment.upgrade-success-page.team.text-1']()}
+
+ + ), + }} + /> +
+
+ ); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts new file mode 100644 index 000000000..578ec70be --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/team/styles.css.ts @@ -0,0 +1,15 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; +export const leftContentText = style({ + fontSize: cssVar('fontBase'), + fontWeight: 400, + lineHeight: '1.6', + maxWidth: '548px', +}); +export const mail = style({ + color: cssVar('linkColor'), + textDecoration: 'none', + ':visited': { + color: cssVar('linkColor'), + }, +}); diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx new file mode 100644 index 000000000..b8153d797 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/index.tsx @@ -0,0 +1,330 @@ +import { + Button, + Divider, + Input, + Menu, + MenuItem, + MenuTrigger, + Modal, + notify, +} from '@affine/component'; +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 { buildShowcaseWorkspace } from '@affine/core/utils/first-app-data'; +import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant'; +import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; +import { type I18nString, Trans, useI18n } from '@affine/i18n'; +import { DoneIcon, NewPageIcon } from '@blocksuite/icons/rc'; +import { + useLiveData, + useService, + type WorkspaceMetadata, + WorkspacesService, +} from '@toeverything/infra'; +import { useCallback, useMemo, useState } from 'react'; + +import { Upgrade } from '../../dialogs/setting/general-setting/plans/plan-card'; +import * as styles from './styles.css'; + +const benefitList: I18nString[] = [ + 'com.affine.upgrade-to-team-page.benefit.g1', + 'com.affine.upgrade-to-team-page.benefit.g2', + 'com.affine.upgrade-to-team-page.benefit.g3', + 'com.affine.upgrade-to-team-page.benefit.g4', +]; + +export const Component = () => { + const t = useI18n(); + const workspacesList = useService(WorkspacesService).list; + const workspaces = useLiveData(workspacesList.workspaces$); + const [openUpgrade, setOpenUpgrade] = useState(false); + const [openCreate, setOpenCreate] = useState(false); + + const [selectedWorkspace, setSelectedWorkspace] = + useState(null); + + const information = useWorkspaceInfo(selectedWorkspace || undefined); + + const name = information?.name ?? UNTITLED_WORKSPACE_NAME; + + const menuTriggerText = useMemo(() => { + if (selectedWorkspace) { + return name; + } + return t[ + 'com.affine.upgrade-to-team-page.workspace-selector.placeholder' + ](); + }, [name, selectedWorkspace, t]); + + const onUpgradeButtonClick = useCallback(() => { + setOpenUpgrade(true); + }, []); + + const onClickCreateWorkspace = useCallback(() => { + setOpenCreate(true); + }, []); + + return ( + +
+ + } + contentOptions={{ + style: { + width: '410px', + }, + }} + > + + {menuTriggerText} + + +
+ +
+
+
{t['com.affine.upgrade-to-team-page.benefit.title']()}
+
    + {benefitList.map((benefit, index) => ( +
  • + + {t.t(benefit)} +
  • + ))} +
+
+ {t['com.affine.upgrade-to-team-page.benefit.description']()} +
+ + +
+
+
+ ); +}; + +const UpgradeDialog = ({ + open, + onOpenChange, + workspaceName, + workspaceId, +}: { + open: boolean; + workspaceName: string; + workspaceId: string; + onOpenChange: (open: boolean) => void; +}) => { + const t = useI18n(); + const onClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + return ( + +
+ {t['com.affine.upgrade-to-team-page.upgrade-confirm.title']()} +
+
+ , + }} + values={{ + workspaceName, + }} + /> +
+ +
+ + + {t['com.affine.payment.upgrade']()} + +
+
+ ); +}; +const WorkspaceSelector = ({ + metas, + onSelect, + onClickCreateWorkspace, +}: { + metas: WorkspaceMetadata[]; + onClickCreateWorkspace: () => void; + onSelect: (meta: WorkspaceMetadata) => void; +}) => { + const t = useI18n(); + + const cloudWorkspaces = useMemo( + () => + metas.filter( + ({ flavour }) => flavour === 'affine-cloud' + ) as WorkspaceMetadata[], + [metas] + ); + + const handleSelect = useCallback( + (workspace: WorkspaceMetadata) => { + onSelect(workspace); + }, + [onSelect] + ); + + return ( +
+ {cloudWorkspaces.length > 0 && + cloudWorkspaces.map(workspace => ( + + ))} + {cloudWorkspaces.length > 0 && } + } + onClick={onClickCreateWorkspace} + > +
+ {t[ + 'com.affine.upgrade-to-team-page.workspace-selector.create-workspace' + ]()} +
+
+
+ ); +}; + +const WorkspaceItem = ({ + meta, + onSelect, +}: { + meta: WorkspaceMetadata; + onSelect: (meta: WorkspaceMetadata) => void; +}) => { + const information = useWorkspaceInfo(meta); + + const onClick = useCallback(() => { + onSelect(meta); + }, [onSelect, meta]); + if (information?.isTeam || !information?.isOwner) { + return null; + } + + return ( + + + + ); +}; + +const CreateWorkspaceDialog = ({ + open, + onOpenChange, + onSelect, +}: { + open: boolean; + onSelect: (workspace: WorkspaceMetadata) => void; + onOpenChange: (open: boolean) => void; +}) => { + const t = useI18n(); + const onClose = useCallback(() => { + onOpenChange(false); + }, [onOpenChange]); + const [name, setName] = useState(''); + const workspacesService = useService(WorkspacesService); + + const onCreate = useCallback(async () => { + const newWorkspace = await buildShowcaseWorkspace( + workspacesService, + 'affine-cloud', + name + ); + notify.success({ + title: 'Workspace Created', + }); + onSelect(newWorkspace.meta); + onOpenChange(false); + }, [name, onOpenChange, onSelect, workspacesService]); + + const onBeforeCheckout = useAsyncCallback(async () => { + await onCreate(); + }, [onCreate]); + + return ( + +
+ {t[ + 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title' + ]()} +
+ +
+
+ {t[ + 'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description' + ]()} +
+ +
+ +
+ + +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts new file mode 100644 index 000000000..e16860baa --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-to-team/styles.css.ts @@ -0,0 +1,109 @@ +import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; +import { globalStyle, style } from '@vanilla-extract/css'; + +export const root = style({ + display: 'flex', + flexDirection: 'column', + '@media': { + 'screen and (max-width: 1024px)': { + margin: 'auto', + }, + }, +}); + +export const menuTrigger = style({ + width: '410px', + height: '40px', + fontSize: cssVar('fontBase'), + fontWeight: 500, + color: cssVarV2('text/placeholder'), +}); + +export const upgradeButton = style({ + marginTop: '16px', + marginBottom: '28px', +}); + +export const contentContainer = style({ + display: 'flex', + flexDirection: 'column', + fontSize: cssVar('fontBase'), + lineHeight: '24px', +}); + +export const liStyle = style({ + display: 'flex', + alignItems: 'center', + gap: '10px', +}); + +export const doneIcon = style({ + color: cssVarV2('icon/activated'), + fontSize: '20px', +}); + +export const workspaceItem = style({ + padding: '8px 12px', + height: '44px', +}); + +globalStyle(`${workspaceItem} > div`, { + gap: '12px', +}); + +export const createWorkspaceItem = style({ + padding: '8px 12px', + gap: '12px', +}); + +export const itemContent = style({ + fontSize: cssVar('fontSm'), + fontWeight: 500, + lineHeight: '22px', + color: cssVarV2('text/emphasis'), +}); + +export const itemIcon = style({ + borderRadius: '4px', + borderColor: cssVarV2('layer/insideBorder/border'), + borderWidth: '1px', + borderStyle: 'solid', + color: cssVarV2('icon/primary'), +}); + +export const plainMenuItem = style({ + padding: 0, + ':hover': { + backgroundColor: 'unset', + }, +}); + +export const createConfirmContent = style({ + display: 'flex', + flexDirection: 'column', + gap: '12px', + marginBottom: '40px', +}); + +export const dialogTitle = style({ + fontSize: cssVar('fontH6'), + fontWeight: 600, +}); + +export const dialogMessage = style({ + fontSize: cssVar('fontBase'), + lineHeight: '24px', + marginTop: '12px', + marginBottom: '40px', +}); + +export const dialogFooter = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: '20px', +}); + +export const upgradeButtonInDialog = style({ + width: 'unset', +}); diff --git a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx index 582bf52ab..8d4219960 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/share/share-page.tsx @@ -4,7 +4,6 @@ import { useActiveBlocksuiteEditor } from '@affine/core/components/hooks/use-blo import { usePageDocumentTitle } from '@affine/core/components/hooks/use-global-state'; import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper'; import { PageDetailEditor } from '@affine/core/components/page-detail-editor'; -import { SharePageNotFoundError } from '@affine/core/components/share-page-not-found-error'; import { AppContainer } from '@affine/core/desktop/components/app-container'; import { AuthService, @@ -107,7 +106,6 @@ export const SharePage = ({ }, [shareReaderService, docId, workspaceId]); let element: ReactNode = null; - if (isLoading) { element = null; } else if (data) { @@ -125,7 +123,8 @@ export const SharePage = ({ /> ); } else if (error) { - element = ; + // TODO(@JimmFly): handle error + element = ; } else { element = ; } diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx index 6632f72e5..d311ade62 100644 --- a/packages/frontend/core/src/desktop/router.tsx +++ b/packages/frontend/core/src/desktop/router.tsx @@ -64,6 +64,10 @@ export const topLevelRoutes = [ path: '/upgrade-success', lazy: () => import('./pages/upgrade-success'), }, + { + path: '/upgrade-success/team', + lazy: () => import('./pages/upgrade-success/team'), + }, { path: '/ai-upgrade-success', lazy: () => import('./pages/ai-upgrade-success'), @@ -80,6 +84,10 @@ export const topLevelRoutes = [ path: '/subscribe', lazy: () => import('./pages/subscribe'), }, + { + path: '/upgrade-to-team', + lazy: () => import('./pages/upgrade-to-team'), + }, { path: '/try-cloud', loader: () => { diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts index 73b94e006..ee2783a7f 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription-prices.ts @@ -27,6 +27,9 @@ export class SubscriptionPrices extends Entity { aiPrice$ = this.prices$.map(prices => prices ? prices.find(price => price.plan === 'AI') : null ); + teamPrice$ = this.prices$.map(prices => + prices ? prices.find(price => price.plan === 'Team') : null + ); readableLifetimePrice$ = this.proPrice$.map(price => price?.lifetimeAmount diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts index b70ef49b2..ea6d993da 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts @@ -42,6 +42,11 @@ export class Subscription extends Entity { ? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI) : null ); + team$ = this.subscription$.map(subscriptions => + subscriptions + ? subscriptions.find(sub => sub.plan === SubscriptionPlan.Team) + : null + ); isBeliever$ = this.pro$.map( sub => sub?.recurring === SubscriptionRecurring.Lifetime ); diff --git a/packages/frontend/core/src/modules/dialogs/constant.ts b/packages/frontend/core/src/modules/dialogs/constant.ts index 53c772ee4..9eaa9855a 100644 --- a/packages/frontend/core/src/modules/dialogs/constant.ts +++ b/packages/frontend/core/src/modules/dialogs/constant.ts @@ -11,7 +11,7 @@ export type SettingTab = | 'experimental-features' | 'editor' | 'account' - | `workspace:${'preference' | 'properties'}`; + | `workspace:${'preference' | 'properties' | 'billing' | 'license'}`; export type GLOBAL_DIALOG_SCHEMA = { 'create-workspace': (props: { serverId?: string; forcedCloud?: boolean }) => { diff --git a/packages/frontend/core/src/modules/permissions/entities/permission.ts b/packages/frontend/core/src/modules/permissions/entities/permission.ts index 19c8bc8c6..3f2c2c424 100644 --- a/packages/frontend/core/src/modules/permissions/entities/permission.ts +++ b/packages/frontend/core/src/modules/permissions/entities/permission.ts @@ -1,4 +1,8 @@ import { DebugLogger } from '@affine/debug'; +import type { + Permission, + WorkspaceInviteLinkExpireTime, +} from '@affine/graphql'; import type { WorkspaceService } from '@toeverything/infra'; import { backoffRetry, @@ -7,11 +11,10 @@ import { Entity, fromPromise, LiveData, - mapInto, onComplete, onStart, } from '@toeverything/infra'; -import { exhaustMap } from 'rxjs'; +import { EMPTY, exhaustMap, mergeMap } from 'rxjs'; import { isBackendError, isNetworkError } from '../../cloud'; import type { WorkspacePermissionStore } from '../stores/permission'; @@ -20,6 +23,8 @@ const logger = new DebugLogger('affine:workspace-permission'); export class WorkspacePermission extends Entity { isOwner$ = new LiveData(null); + isAdmin$ = new LiveData(null); + isTeam$ = new LiveData(null); isLoading$ = new LiveData(false); error$ = new LiveData(null); @@ -34,12 +39,18 @@ export class WorkspacePermission extends Entity { exhaustMap(() => { return fromPromise(async signal => { if (this.workspaceService.workspace.flavour !== 'local') { - return await this.store.fetchIsOwner( + const info = await this.store.fetchWorkspaceInfo( this.workspaceService.workspace.id, signal ); + + return { + isOwner: info.isOwner, + isAdmin: info.isAdmin, + isTeam: info.workspace.team, + }; } else { - return true; + return { isOwner: true, isAdmin: false, isTeam: false }; } }).pipe( backoffRetry({ @@ -49,7 +60,12 @@ export class WorkspacePermission extends Entity { backoffRetry({ when: isBackendError, }), - mapInto(this.isOwner$), + mergeMap(({ isOwner, isAdmin, isTeam }) => { + this.isAdmin$.next(isAdmin); + this.isOwner$.next(isOwner); + this.isTeam$.next(isTeam); + return EMPTY; + }), catchErrorInto(this.error$, error => { logger.error('Failed to fetch isOwner', error); }), @@ -58,4 +74,89 @@ export class WorkspacePermission extends Entity { ); }) ); + + async inviteMember( + email: string, + permission: Permission, + sendInviteMail?: boolean + ) { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to invite members'); + } + return await this.store.inviteMember( + this.workspaceService.workspace.id, + email, + permission, + sendInviteMail + ); + } + + async inviteMembers(emails: string[], sendInviteMail?: boolean) { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to invite members'); + } + return await this.store.inviteBatch( + this.workspaceService.workspace.id, + emails, + sendInviteMail + ); + } + + async generateInviteLink(expireTime: WorkspaceInviteLinkExpireTime) { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to generate invite link'); + } + return await this.store.generateInviteLink( + this.workspaceService.workspace.id, + expireTime + ); + } + + async revokeInviteLink() { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to revoke invite link'); + } + return await this.store.revokeInviteLink( + this.workspaceService.workspace.id + ); + } + + async revokeMember(userId: string) { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to revoke members'); + } + return await this.store.revokeMemberPermission( + this.workspaceService.workspace.id, + userId + ); + } + + async acceptInvite(inviteId: string, sendAcceptMail?: boolean) { + return await this.store.acceptInvite( + this.workspaceService.workspace.id, + inviteId, + sendAcceptMail + ); + } + + async approveMember(userId: string) { + if (!this.isAdmin$.value && !this.isOwner$.value) { + throw new Error('User has no permission to accept invite'); + } + return await this.store.approveMember( + this.workspaceService.workspace.id, + userId + ); + } + + async adjustMemberPermission(userId: string, permission: Permission) { + if (!this.isAdmin$.value) { + throw new Error('User has no permission to adjust member permissions'); + } + return await this.store.adjustMemberPermission( + this.workspaceService.workspace.id, + userId, + permission + ); + } } diff --git a/packages/frontend/core/src/modules/permissions/stores/permission.ts b/packages/frontend/core/src/modules/permissions/stores/permission.ts index 6a745f8f2..1528244f2 100644 --- a/packages/frontend/core/src/modules/permissions/stores/permission.ts +++ b/packages/frontend/core/src/modules/permissions/stores/permission.ts @@ -1,5 +1,18 @@ import type { WorkspaceServerService } from '@affine/core/modules/cloud'; -import { getIsOwnerQuery, leaveWorkspaceMutation } from '@affine/graphql'; +import { + acceptInviteByInviteIdMutation, + approveWorkspaceTeamMemberMutation, + getWorkspaceInfoQuery, + grantWorkspaceTeamMemberMutation, + inviteByEmailMutation, + inviteByEmailsMutation, + inviteLinkMutation, + leaveWorkspaceMutation, + type Permission, + revokeInviteLinkMutation, + revokeMemberPermissionMutation, + type WorkspaceInviteLinkExpireTime, +} from '@affine/graphql'; import { Store } from '@toeverything/infra'; export class WorkspacePermissionStore extends Store { @@ -7,19 +20,161 @@ export class WorkspacePermissionStore extends Store { super(); } - async fetchIsOwner(workspaceId: string, signal?: AbortSignal) { + async fetchWorkspaceInfo(workspaceId: string, signal?: AbortSignal) { if (!this.workspaceServerService.server) { throw new Error('No Server'); } - const isOwner = await this.workspaceServerService.server.gql({ - query: getIsOwnerQuery, + const info = await this.workspaceServerService.server.gql({ + query: getWorkspaceInfoQuery, variables: { workspaceId, }, context: { signal }, }); - return isOwner.isOwner; + return info; + } + + async inviteMember( + workspaceId: string, + email: string, + permission: Permission, + sendInviteMail = false + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const invite = await this.workspaceServerService.server.gql({ + query: inviteByEmailMutation, + variables: { + workspaceId, + email, + permission, + sendInviteMail, + }, + }); + return invite.invite; + } + + async inviteBatch( + workspaceId: string, + emails: string[], + sendInviteMail = false + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const inviteBatch = await this.workspaceServerService.server.gql({ + query: inviteByEmailsMutation, + variables: { + workspaceId, + emails, + sendInviteMail, + }, + }); + return inviteBatch.inviteBatch; + } + + async generateInviteLink( + workspaceId: string, + expireTime: WorkspaceInviteLinkExpireTime + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const inviteLink = await this.workspaceServerService.server.gql({ + query: inviteLinkMutation, + variables: { + workspaceId, + expireTime, + }, + }); + return inviteLink.inviteLink; + } + + async revokeInviteLink(workspaceId: string, signal?: AbortSignal) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const revoke = await this.workspaceServerService.server.gql({ + query: revokeInviteLinkMutation, + variables: { + workspaceId, + }, + context: { signal }, + }); + return revoke.revokeInviteLink; + } + + async revokeMemberPermission( + workspaceId: string, + userId: string, + signal?: AbortSignal + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const revoke = await this.workspaceServerService.server.gql({ + query: revokeMemberPermissionMutation, + variables: { + workspaceId, + userId, + }, + context: { signal }, + }); + return revoke.revoke; + } + + async acceptInvite( + workspaceId: string, + inviteId: string, + sendAcceptMail = false + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const accept = await this.workspaceServerService.server.gql({ + query: acceptInviteByInviteIdMutation, + variables: { + workspaceId, + inviteId, + sendAcceptMail, + }, + }); + return accept.acceptInviteById; + } + + async approveMember(workspaceId: string, userId: string) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const member = await this.workspaceServerService.server.gql({ + query: approveWorkspaceTeamMemberMutation, + variables: { + workspaceId, + userId, + }, + }); + return member.approveMember; + } + + async adjustMemberPermission( + workspaceId: string, + userId: string, + permission: Permission + ) { + if (!this.workspaceServerService.server) { + throw new Error('No Server'); + } + const member = await this.workspaceServerService.server.gql({ + query: grantWorkspaceTeamMemberMutation, + variables: { + workspaceId, + userId, + permission, + }, + }); + return member.grantMember; } /** diff --git a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts index 7d66eb0e8..e99a0336e 100644 --- a/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts +++ b/packages/frontend/core/src/modules/share-setting/entities/share-setting.ts @@ -81,6 +81,14 @@ export class WorkspaceShareSetting extends Entity { await this.waitForRevalidation(); } + async setEnableAi(enableAi: EnableAi) { + await this.store.updateWorkspaceEnableAi( + this.workspaceService.workspace.id, + enableAi + ); + await this.waitForRevalidation(); + } + override dispose(): void { this.revalidate.unsubscribe(); } diff --git a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts index fe5046736..23df74562 100644 --- a/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts +++ b/packages/frontend/core/src/modules/workspace-engine/impls/cloud.ts @@ -2,7 +2,7 @@ import { DebugLogger } from '@affine/debug'; import { createWorkspaceMutation, deleteWorkspaceMutation, - getIsOwnerQuery, + getWorkspaceInfoQuery, getWorkspacesQuery, } from '@affine/graphql'; import { DocCollection } from '@blocksuite/affine/store'; @@ -212,9 +212,11 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { } ) ); + error$ = new LiveData(null); isRevalidating$ = new LiveData(false); workspaces$ = new LiveData([]); + async getWorkspaceProfile( id: string, signal?: AbortSignal @@ -228,11 +230,13 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { const localData = await docStorage.doc.get(id); const cloudData = await cloudStorage.pull(id); - const isOwner = await this.getIsOwner(id, signal); + const info = await this.getWorkspaceInfo(id, signal); if (!cloudData && !localData) { return { - isOwner, + isOwner: info.isOwner, + isAdmin: info.isAdmin, + isTeam: info.workspace.team, }; } @@ -247,7 +251,9 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { return { name: bs.meta.name, avatar: bs.meta.avatar, - isOwner, + isOwner: info.isOwner, + isAdmin: info.isAdmin, + isTeam: info.workspace.team, }; } async getWorkspaceBlob(id: string, blob: string): Promise { @@ -300,16 +306,14 @@ class CloudWorkspaceFlavourProvider implements WorkspaceFlavourProvider { workspace.scope.get(WorkspaceServerService).bindServer(this.server); } - private async getIsOwner(workspaceId: string, signal?: AbortSignal) { - return ( - await this.graphqlService.gql({ - query: getIsOwnerQuery, - variables: { - workspaceId, - }, - context: { signal }, - }) - ).isOwner; + private async getWorkspaceInfo(workspaceId: string, signal?: AbortSignal) { + return await this.graphqlService.gql({ + query: getWorkspaceInfoQuery, + variables: { + workspaceId, + }, + context: { signal }, + }); } private waitForLoaded() { diff --git a/packages/frontend/graphql/src/graphql/get-is-admin.gql b/packages/frontend/graphql/src/graphql/get-is-admin.gql new file mode 100644 index 000000000..8e9199ab9 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-is-admin.gql @@ -0,0 +1,3 @@ +query getIsAdmin($workspaceId: String!) { + isAdmin(workspaceId: $workspaceId) +} diff --git a/packages/frontend/graphql/src/graphql/get-workspace-info.gql b/packages/frontend/graphql/src/graphql/get-workspace-info.gql new file mode 100644 index 000000000..3ebd59a25 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-info.gql @@ -0,0 +1,7 @@ +query getWorkspaceInfo($workspaceId: String!) { + isAdmin(workspaceId: $workspaceId) + isOwner(workspaceId: $workspaceId) + workspace(id: $workspaceId) { + team + } +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 0d9e5ceb0..6a9761a1b 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -400,6 +400,17 @@ query getInviteInfo($inviteId: String!) { }`, }; +export const getIsAdminQuery = { + id: 'getIsAdminQuery' as const, + operationName: 'getIsAdmin', + definitionName: 'isAdmin', + containsFile: false, + query: ` +query getIsAdmin($workspaceId: String!) { + isAdmin(workspaceId: $workspaceId) +}`, +}; + export const getIsOwnerQuery = { id: 'getIsOwnerQuery' as const, operationName: 'getIsOwner', @@ -611,6 +622,21 @@ query getWorkspaceFeatures($workspaceId: String!) { }`, }; +export const getWorkspaceInfoQuery = { + id: 'getWorkspaceInfoQuery' as const, + operationName: 'getWorkspaceInfo', + definitionName: 'isAdmin,isOwner,workspace', + containsFile: false, + query: ` +query getWorkspaceInfo($workspaceId: String!) { + isAdmin(workspaceId: $workspaceId) + isOwner(workspaceId: $workspaceId) + workspace(id: $workspaceId) { + team + } +}`, +}; + export const getWorkspacePageMetaByIdQuery = { id: 'getWorkspacePageMetaByIdQuery' as const, operationName: 'getWorkspacePageMetaById', @@ -1307,6 +1333,25 @@ mutation inviteByEmail($workspaceId: String!, $email: String!, $permission: Perm }`, }; +export const inviteByEmailsMutation = { + id: 'inviteByEmailsMutation' as const, + operationName: 'inviteByEmails', + definitionName: 'inviteBatch', + containsFile: false, + query: ` +mutation inviteByEmails($workspaceId: String!, $emails: [String!]!, $sendInviteMail: Boolean) { + inviteBatch( + workspaceId: $workspaceId + emails: $emails + sendInviteMail: $sendInviteMail + ) { + email + inviteId + sentSuccess + } +}`, +}; + export const acceptInviteByInviteIdMutation = { id: 'acceptInviteByInviteIdMutation' as const, operationName: 'acceptInviteByInviteId', diff --git a/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql b/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql new file mode 100644 index 000000000..a21e22e22 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/workspace-intive-by-emails.gql @@ -0,0 +1,15 @@ +mutation inviteByEmails( + $workspaceId: String! + $emails: [String!]! + $sendInviteMail: Boolean +) { + inviteBatch( + workspaceId: $workspaceId + emails: $emails + sendInviteMail: $sendInviteMail + ) { + email + inviteId + sentSuccess + } +} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index e9509f954..19e80086d 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -891,6 +891,8 @@ export interface Query { error: ErrorDataUnion; /** send workspace invitation */ getInviteInfo: InvitationType; + /** Get is admin of workspace */ + isAdmin: Scalars['Boolean']['output']; /** Get is owner of workspace */ isOwner: Scalars['Boolean']['output']; /** @@ -936,6 +938,10 @@ export interface QueryGetInviteInfoArgs { inviteId: Scalars['String']['input']; } +export interface QueryIsAdminArgs { + workspaceId: Scalars['String']['input']; +} + export interface QueryIsOwnerArgs { workspaceId: Scalars['String']['input']; } @@ -1738,6 +1744,12 @@ export type GetInviteInfoQuery = { }; }; +export type GetIsAdminQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetIsAdminQuery = { __typename?: 'Query'; isAdmin: boolean }; + export type GetIsOwnerQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; @@ -1922,6 +1934,17 @@ export type GetWorkspaceFeaturesQuery = { workspace: { __typename?: 'WorkspaceType'; features: Array }; }; +export type GetWorkspaceInfoQueryVariables = Exact<{ + workspaceId: Scalars['String']['input']; +}>; + +export type GetWorkspaceInfoQuery = { + __typename?: 'Query'; + isAdmin: boolean; + isOwner: boolean; + workspace: { __typename?: 'WorkspaceType'; team: boolean }; +}; + export type GetWorkspacePageMetaByIdQueryVariables = Exact<{ id: Scalars['String']['input']; pageId: Scalars['String']['input']; @@ -2542,6 +2565,22 @@ export type InviteByEmailMutationVariables = Exact<{ export type InviteByEmailMutation = { __typename?: 'Mutation'; invite: string }; +export type InviteByEmailsMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + emails: Array | Scalars['String']['input']; + sendInviteMail?: InputMaybe; +}>; + +export type InviteByEmailsMutation = { + __typename?: 'Mutation'; + inviteBatch: Array<{ + __typename?: 'InviteResult'; + email: string; + inviteId: string | null; + sentSuccess: boolean; + }>; +}; + export type AcceptInviteByInviteIdMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; inviteId: Scalars['String']['input']; @@ -2684,6 +2723,11 @@ export type Queries = variables: GetInviteInfoQueryVariables; response: GetInviteInfoQuery; } + | { + name: 'getIsAdminQuery'; + variables: GetIsAdminQueryVariables; + response: GetIsAdminQuery; + } | { name: 'getIsOwnerQuery'; variables: GetIsOwnerQueryVariables; @@ -2744,6 +2788,11 @@ export type Queries = variables: GetWorkspaceFeaturesQueryVariables; response: GetWorkspaceFeaturesQuery; } + | { + name: 'getWorkspaceInfoQuery'; + variables: GetWorkspaceInfoQueryVariables; + response: GetWorkspaceInfoQuery; + } | { name: 'getWorkspacePageMetaByIdQuery'; variables: GetWorkspacePageMetaByIdQueryVariables; @@ -3061,6 +3110,11 @@ export type Mutations = variables: InviteByEmailMutationVariables; response: InviteByEmailMutation; } + | { + name: 'inviteByEmailsMutation'; + variables: InviteByEmailsMutationVariables; + response: InviteByEmailsMutation; + } | { name: 'acceptInviteByInviteIdMutation'; variables: AcceptInviteByInviteIdMutationVariables; diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 83cca997d..00d5cce5d 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -1,24 +1,24 @@ { - "ar": 74, + "ar": 70, "ca": 5, - "da": 6, - "de": 27, + "da": 5, + "de": 26, "el-GR": 0, "en": 100, "es-AR": 13, - "es-CL": 15, + "es-CL": 14, "es": 13, - "fr": 66, + "fr": 62, "hi": 2, "it-IT": 1, "it": 1, - "ja": 98, - "ko": 78, + "ja": 93, + "ko": 73, "pl": 0, - "pt-BR": 84, - "ru": 72, + "pt-BR": 80, + "ru": 68, "sv-SE": 4, - "ur": 3, - "zh-Hans": 98, - "zh-Hant": 98 + "ur": 2, + "zh-Hans": 93, + "zh-Hant": 93 } \ No newline at end of file diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 8e86d2d7b..256eda0c7 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -75,6 +75,10 @@ "Page": "Page", "Pen": "Pen", "Pending": "Pending", + "Collaborator": "Collaborator", + "Under-Review": "Under Review", + "Need-More-Seats": "Need More Seats", + "Admin": "Admin", "Publish": "Publish", "Published to Web": "Published to web", "Quick Search": "Quick search", @@ -116,7 +120,7 @@ "Version": "Version", "Visit Workspace": "Visit workspace", "Workspace Name": "Workspace name", - "Workspace Owner": "Workspace owner", + "Workspace Owner": "Workspace Owner", "Workspace Profile": "Workspace profile", "Workspace Settings": "Workspace settings", "Workspace Settings with name": "{{name}}'s settings", @@ -843,8 +847,11 @@ "com.affine.payment.billing-type-form.title": "Tell us your use case", "com.affine.payment.blob-limit.description.local": "The maximum file upload size for local workspaces is {{quota}}.", "com.affine.payment.blob-limit.description.member": "The maximum file upload size for this joined workspace is {{quota}}. You can contact the owner of this workspace.", - "com.affine.payment.blob-limit.description.owner.free": "{{planName}} users can upload files with a maximum size of {{currentQuota}}. You can upgrade your account to unlock a maximum file size of {{upgradeQuota}}.", - "com.affine.payment.blob-limit.description.owner.pro": "{{planName}} users can upload files with a maximum size of {{quota}}.", + + "com.affine.payment.blob-limit.description.owner": "The maximum file upload size for this workspace is {{quota}}. To proceed, you can:", + "com.affine.payment.blob-limit.description.owner.tips-1": "Upgrade your account for larger file upload limits", + "com.affine.payment.blob-limit.description.owner.tips-2": "Upgrade the workspace plan to increase storage for all member", + "com.affine.payment.blob-limit.description.owner.tips-3": "Compress your file and upload again", "com.affine.payment.blob-limit.title": "You have reached the limit", "com.affine.payment.book-a-demo": "Book a demo", "com.affine.payment.buy-pro": "Buy Pro", @@ -880,22 +887,22 @@ "com.affine.payment.cloud.pro.benefit.g1-8": "Real-time syncing & collaboration for more people.", "com.affine.payment.cloud.pro.description": "For family and small teams.", "com.affine.payment.cloud.pro.name": "Pro", - "com.affine.payment.cloud.pro.title.billed-yearly": "billed annually", + "com.affine.payment.cloud.pro.title.billed-yearly": "annually", "com.affine.payment.cloud.pro.title.price-monthly": "{{price}} per month", - "com.affine.payment.cloud.team.benefit.g1": "Both in Team & Enterprise", - "com.affine.payment.cloud.team.benefit.g1-1": "Everything in AFFiNE Pro.", - "com.affine.payment.cloud.team.benefit.g1-2": "Advanced permission control, page history and review mode.", - "com.affine.payment.cloud.team.benefit.g1-3": "Pay for seats, fits all team size.", - "com.affine.payment.cloud.team.benefit.g1-4": "Email & Slack support.", - "com.affine.payment.cloud.team.benefit.g2": "Enterprise only", - "com.affine.payment.cloud.team.benefit.g2-1": "SSO authorization.", - "com.affine.payment.cloud.team.benefit.g2-2": "Solutions & best practices for dedicated needs.", - "com.affine.payment.cloud.team.benefit.g2-3": "Embed-able & integrations with IT support.", - "com.affine.payment.cloud.team.description": "Best for scalable teams.", - "com.affine.payment.cloud.team.name": "Team / Enterprise", - "com.affine.payment.cloud.team.title": "Coming soon", + "com.affine.payment.cloud.team-workspace.benefit.g1": "Include in Team Workspace", + "com.affine.payment.cloud.team-workspace.benefit.g1-1": "Everything in AFFiNE Pro.", + "com.affine.payment.cloud.team-workspace.benefit.g1-2": "100 GB initial storage + 20 GB per seat.", + "com.affine.payment.cloud.team-workspace.benefit.g1-3": "500 MB of maximum file size.", + "com.affine.payment.cloud.team-workspace.benefit.g1-4": "Unlimited team members (10+ seats).", + "com.affine.payment.cloud.team-workspace.benefit.g1-5": "Multiple admin roles.", + "com.affine.payment.cloud.team-workspace.benefit.g1-6": "Priority customer support", + "com.affine.payment.cloud.team-workspace.description": "Best for scalable teams.", + "com.affine.payment.cloud.team-workspace.name": "Team Workspace", + "com.affine.payment.cloud.team-workspace.title.billed-yearly": "annually", + "com.affine.payment.cloud.team-workspace.title.price-monthly": "{{price}} per seat/month", "com.affine.payment.contact-sales": "Contact sales", "com.affine.payment.current-plan": "Current plan", + "com.affine.payment.start-free-trial": "Start 14-day free trial", "com.affine.payment.discount-amount": "{{amount}}% off", "com.affine.payment.downgrade": "Downgrade", "com.affine.payment.downgraded-notify.content": "We'd like to hear more about where we fall short, so that we can make AFFiNE better.", @@ -919,14 +926,62 @@ "com.affine.payment.lifetime.purchased": "Purchased", "com.affine.payment.lifetime.title": "Believer Plan", "com.affine.payment.member-limit.free.confirm": "Upgrade", - "com.affine.payment.member-limit.free.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. You can upgrade your account to unlock more members.", + "com.affine.payment.member-limit.description": "Workspaces created by {{planName}} users are limited to {{quota}} members. To add more collaborators, you can:", + "com.affine.payment.member-limit.description.tips-for-free-plan": "Upgrade to AFFiNE Pro for expanded member capacity", + "com.affine.payment.member-limit.description.tips-1": "Convert to a Team Workspace for unlimited collaboration", + "com.affine.payment.member-limit.description.tips-2": "Or create a new workspace", "com.affine.payment.member-limit.pro.confirm": "Got it", - "com.affine.payment.member-limit.pro.description": "Each {{planName}} user can invite up to {{quota}} members to join their workspace. If you want to continue adding collaboration members, you can create a new workspace.", "com.affine.payment.member-limit.title": "You have reached the limit", "com.affine.payment.member.description": "Manage members here. {{planName}} users can invite up to {{memberLimit}}", "com.affine.payment.member.description.choose-plan": "Choose your plan", "com.affine.payment.member.description.go-upgrade": "go upgrade", "com.affine.payment.member.description2": "Looking to collaborate with more people?", + "com.affine.payment.member.team.description": "Work together with unlimited team members.", + "com.affine.payment.member.team.invite.title": "Invite team members", + "com.affine.payment.member.team.invite.description": "Invite new members to join your workspace via email or share an invite link", + "com.affine.payment.member.team.invite.email-invite": "Email Invite", + "com.affine.payment.member.team.invite.invite-link": "Invite Link", + "com.affine.payment.member.team.invite.email-addresses": "Email addresses", + "com.affine.payment.member.team.invite.email-placeholder": "Enter email addresses (separated by commas)", + "com.affine.payment.member.team.invite.import-csv": "Import CSV", + "com.affine.payment.member.team.invite.send-invites": "Send Invites", + "com.affine.payment.member.team.invite.link-expiration": "Link expiration", + "com.affine.payment.member.team.invite.expiration-date": "{{number}} days", + "com.affine.payment.member.team.invite.invitation-link": "Invitation link", + "com.affine.payment.member.team.invite.invitation-link.description": "Generate a link to invite members to your workspace", + "com.affine.payment.member.team.invite.generate": "Generate", + "com.affine.payment.member.team.invite.copy": "Copy", + "com.affine.payment.member.team.invite.done": "Done", + "com.affine.payment.member.team.invite.notify.title": "Invitation sent", + "com.affine.payment.member.team.invite.notify.message": "Invited members have been notified with email to join this Workspace.", + "com.affine.payment.member.team.revoke": "Revoke invitation", + "com.affine.payment.member.team.approve": "Approve", + "com.affine.payment.member.team.decline": "Decline", + "com.affine.payment.member.team.remove": "Remove member", + "com.affine.payment.member.team.change.admin": "Change role to admin", + "com.affine.payment.member.team.change.collaborator": "Change role to collaborator", + "com.affine.payment.member.team.assign": "Assign as owner", + "com.affine.payment.member.team.revoke.notify.title": "Invitation Revoked", + "com.affine.payment.member.team.revoke.notify.message": "You have canceled the invitation for {{name}}", + "com.affine.payment.member.team.approve.notify.title": "Request approved", + "com.affine.payment.member.team.approve.notify.message": "You have approved the {{name}}’s request to join this workspace", + "com.affine.payment.member.team.decline.notify.title": "Request declined", + "com.affine.payment.member.team.decline.notify.message": "You have declined the {{name}}’s request to join this workspace", + "com.affine.payment.member.team.remove.notify.title": "Member removed", + "com.affine.payment.member.team.remove.notify.message": "You have removed {{name}} from this workspace", + "com.affine.payment.member.team.change.notify.title": "Role Updated", + "com.affine.payment.member.team.change.admin.notify.message": "You have successfully promoted {{name}} to Admin.", + "com.affine.payment.member.team.change.collaborator.notify.message": "You have successfully changed {{name}} s role to collaborator.", + "com.affine.payment.member.team.assign.notify.title": "Owner assigned", + "com.affine.payment.member.team.assign.notify.message": "You have successfully assigned {{name}} as the owner of this workspace.", + "com.affine.payment.member.team.assign.confirm.title": "Confirm new workspace owner", + "com.affine.payment.member.team.assign.confirm.description": "You are about to transfer workspace ownership to {{name}}. Please review the following changes carefully:", + "com.affine.payment.member.team.assign.confirm.description-1": "This action cannot be undone", + "com.affine.payment.member.team.assign.confirm.description-2": "Your role will be changed to Admin", + "com.affine.payment.member.team.assign.confirm.description-3": "You will lose ownership rights to the entire workspace", + "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.modal.change.cancel": "Cancel", "com.affine.payment.modal.change.confirm": "Change", "com.affine.payment.modal.change.title": "Change your subscription", @@ -949,7 +1004,8 @@ "com.affine.payment.sign-up-free": "Sign up free", "com.affine.payment.storage-limit.description.member": "Cloud storage is insufficient. Please contact the owner of that workspace.", "com.affine.payment.storage-limit.description.owner": "Cloud storage is insufficient. You can upgrade your account to unlock more cloud storage.", - "com.affine.payment.storage-limit.title": "Sync failed", + "com.affine.payment.storage-limit.new-description.owner": "Unable to sync due to insufficient storage space. You can remove excess content, upgrade your account, or increase your workspace storage to resolve this issue.", + "com.affine.payment.storage-limit.new-title": "Sync failed due to storage space limit", "com.affine.payment.storage-limit.view": "View", "com.affine.payment.subtitle-active": "You are currently on the {{currentPlan}} plan. If you have any questions, please contact our <3>customer support.", "com.affine.payment.subtitle-canceled": "You are currently on the {{plan}} plan. After the current billing period ends, your account will automatically switch to the Free plan.", @@ -969,6 +1025,8 @@ "com.affine.payment.upgrade-success-page.support": "If you have any questions, please contact our <1> customer support.", "com.affine.payment.upgrade-success-page.text": "Congratulations! Your AFFiNE account has been successfully upgraded to a Pro account.", "com.affine.payment.upgrade-success-page.title": "Upgrade successful!", + "com.affine.payment.upgrade-success-page.team.text-1": "Congratulations! Your workspace has been successfully upgraded to a Team Workspace. Now you can invite unlimited members to collaborate in this workspace.", + "com.affine.payment.upgrade-success-page.team.text-2": "If you have any questions, please contact our <1>customer support.", "com.affine.peek-view-controls.close": "Close", "com.affine.peek-view-controls.open-doc": "Open this doc", "com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab", @@ -1262,12 +1320,21 @@ "com.affine.settings.workspace.experimental-features.enable-mobile-edgeless-editing.description": "Once enabled, users can edit edgeless canvas.", "com.affine.settings.workspace.not-owner": "Only an owner can edit the workspace avatar and name. Changes will be shown for everyone.", "com.affine.settings.workspace.preferences": "Preference", + "com.affine.settings.workspace.billing": "Billing", + "com.affine.settings.workspace.billing.team-workspace": "Team Workspace", + "com.affine.settings.workspace.billing.team-workspace.description.free-trail": "Your workspace is in a free trail period.", + "com.affine.settings.workspace.billing.team-workspace.description.billed.annually": "Your workspace is billed annually.", + "com.affine.settings.workspace.billing.team-workspace.description.billed.monthly": "Your workspace is billed monthly.", + "com.affine.settings.workspace.billing.team-workspace.not-renewed": "Your subscription will end on {{date}}", + "com.affine.settings.workspace.billing.team-workspace.next-billing-date": "Next billing date: {{date}}", + "com.affine.settings.workspace.billing.team-workspace.cancel-plan": "Cancel Plan", "com.affine.settings.workspace.state.local": "Local", "com.affine.settings.workspace.state.sync-affine-cloud": "Sync with AFFiNE Cloud", "com.affine.settings.workspace.state.self-hosted": "Self-Hosted Server", "com.affine.settings.workspace.state.joined": "Joined Workspace", "com.affine.settings.workspace.state.available-offline": "Available Offline", "com.affine.settings.workspace.state.published": "Published to Web", + "com.affine.settings.workspace.state.team": "Team Workspace", "com.affine.settings.workspace.properties": "Properties", "com.affine.settings.workspace.properties.add_property": "Add property", "com.affine.settings.workspace.properties.all": "All", @@ -1288,6 +1355,9 @@ "com.affine.settings.workspace.sharing.title": "Sharing", "com.affine.settings.workspace.sharing.url-preview.description": "Allow URL unfurling by Slack & other social apps, even if a doc is only accessible by workspace members.", "com.affine.settings.workspace.sharing.url-preview.title": "Always enable url preview", + "com.affine.settings.workspace.affine-ai.title": "AFFiNE AI", + "com.affine.settings.workspace.affine-ai.label": "Allow AFFiNE AI Assistant", + "com.affine.settings.workspace.affine-ai.description": "Allow workspace members to use AFFiNE AI features. This setting doesn't affect billing. Workspace members use AFFiNE AI through their personal accounts.", "com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.", "com.affine.share-menu.ShareMode": "Share mode", "com.affine.share-menu.SharePage": "Share doc", @@ -1504,5 +1574,21 @@ "com.affine.editor.at-menu.journal": "Journal", "com.affine.editor.at-menu.date-picker": "Select a specific date", "com.affine.editor.bi-directional-link-panel.show": "Show", - "com.affine.editor.bi-directional-link-panel.hide": "Hide" + "com.affine.editor.bi-directional-link-panel.hide": "Hide", + "com.affine.upgrade-to-team-page.title": "Empower Your Team with Seamless Collaboration", + "com.affine.upgrade-to-team-page.workspace-selector.placeholder": "Select an existing workspace or create a new one", + "com.affine.upgrade-to-team-page.workspace-selector.create-workspace": "Create Workspace", + "com.affine.upgrade-to-team-page.upgrade-button": "Upgrade to Team Workspace", + "com.affine.upgrade-to-team-page.benefit.title": "Team Workspace gives you everything you need for seamless team collaboration:", + "com.affine.upgrade-to-team-page.benefit.g1": "Invite unlimited members to your workspace", + "com.affine.upgrade-to-team-page.benefit.g2": "Set custom roles and permissions for better control", + "com.affine.upgrade-to-team-page.benefit.g3": "Access advanced team management features", + "com.affine.upgrade-to-team-page.benefit.g4": "Get priority customer support", + "com.affine.upgrade-to-team-page.benefit.description": "Perfect for growing teams and organizations that need professional collaboration tools.", + "com.affine.upgrade-to-team-page.upgrade-confirm.title": "Upgrade to Team Workspace", + "com.affine.upgrade-to-team-page.upgrade-confirm.description": "Are you sure you want to upgrade <1>{{workspaceName}} to a Team Workspace? This will allow unlimited members to collaborate in this workspace.", + "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title": "Name Your Workspace", + "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description": "A workspace is your virtual space to capture, create and plan as just one person or together as a team.", + "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder": "Set a workspace name", + "com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm": "Continue to Pricing" }