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

AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877
UI for team workspaces :
- add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`)
- update team plans on pricing page ( settings —> pricing plans )
- update reaching the usage/member limit modal
- update invite member modal
- update member CRUD options
This commit is contained in:
JimmFly
2024-12-10 06:31:35 +00:00
parent 5d25580eff
commit 612310bc26
77 changed files with 3788 additions and 1044 deletions

View File

@@ -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}
<DowngradeModal
open={open}
onCancel={downgrade}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};
/**
* 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);

View File

@@ -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: <AfFiNeIcon />,
},
...([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 ? (
<span className={planTitleTitleCaption}>
{t[
'com.affine.payment.cloud.team-workspace.title.billed-yearly'
]()}
</span>
) : null}
</>
);
},
benefits: teamBenefits(t),
},
],

View File

@@ -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 = () => {
<Upgrade
className={styles.purchase}
recurring={SubscriptionRecurring.Lifetime}
plan={SubscriptionPlan.Pro}
>
{t['com.affine.payment.lifetime.purchase']()}
</Upgrade>

View File

@@ -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<typeof useI18n>
) => {
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 <BookDemo plan={detail.plan} />;
}
// not signed in
if (!loggedIn) {
return (
<SignUpAction>
{detail.plan === SubscriptionPlan.Free
? t['com.affine.payment.sign-up-free']()
: t['com.affine.payment.buy-pro']()}
</SignUpAction>
);
return <SignUpAction>{signUpText}</SignUpAction>;
}
// team
if (detail.plan === SubscriptionPlan.Team) {
return <UpgradeToTeam />;
}
// lifetime
@@ -183,7 +201,10 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
disabled={isCanceled}
/>
) : (
<Upgrade recurring={recurring as SubscriptionRecurring} />
<Upgrade
recurring={recurring as SubscriptionRecurring}
plan={SubscriptionPlan.Pro}
/>
);
};
@@ -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 (
<a
className={styles.planAction}
@@ -249,10 +261,9 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
<Button
className={styles.planAction}
variant="primary"
data-event-props="$.settingsPanel.billing.bookDemo"
data-event-args-url={url}
>
{t['com.affine.payment.tell-us-use-case']()}
{t['com.affine.payment.start-free-trial']()}
</Button>
</a>
);
@@ -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<CreateCheckoutSessionInput>;
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 (
<CheckoutSlot
onBeforeCheckout={onBeforeCheckout}
onBeforeCheckout={handleBeforeCheckout}
checkoutOptions={checkoutOptions}
onCheckoutSuccess={onCheckoutSuccess}
renderer={props => (
<Button
className={clsx(styles.planAction, className)}
@@ -437,9 +456,13 @@ const redeemCodeCheckoutInput = { variant: SubscriptionVariant.Onetime };
export const RedeemCode = ({
className,
recurring = SubscriptionRecurring.Yearly,
plan,
children,
...btnProps
}: ButtonProps & { recurring?: SubscriptionRecurring }) => {
}: ButtonProps & {
recurring?: SubscriptionRecurring;
plan?: SubscriptionPlan;
}) => {
const t = useI18n();
return (
@@ -447,6 +470,7 @@ export const RedeemCode = ({
recurring={recurring}
className={className}
checkoutInput={redeemCodeCheckoutInput}
plan={plan ?? SubscriptionPlan.Pro}
{...btnProps}
>
{children ?? t['com.affine.payment.redeem-code']()}

View File

@@ -251,20 +251,6 @@ export const WorkspaceList = ({
);
};
const subTabConfigs = [
{
key: 'workspace:preference',
title: 'com.affine.settings.workspace.preferences',
},
{
key: 'workspace:properties',
title: 'com.affine.settings.workspace.properties',
},
] satisfies {
key: SettingTab;
title: keyof ReturnType<typeof useI18n>;
}[];
const WorkspaceListItem = ({
activeTab,
meta,
@@ -294,7 +280,30 @@ const WorkspaceListItem = ({
onClick('workspace:preference');
}, [onClick]);
const showBilling = information?.isTeam && information?.isOwner;
const subTabs = useMemo(() => {
const subTabConfigs = [
{
key: 'workspace:preference',
title: 'com.affine.settings.workspace.preferences',
},
{
key: 'workspace:properties',
title: 'com.affine.settings.workspace.properties',
},
...(showBilling
? [
{
key: 'workspace:billing' as SettingTab,
title: 'com.affine.settings.workspace.billing',
},
]
: []),
] satisfies {
key: SettingTab;
title: keyof ReturnType<typeof useI18n>;
}[];
return subTabConfigs.map(({ key, title }) => {
return (
<div
@@ -311,7 +320,7 @@ const WorkspaceListItem = ({
</div>
);
});
}, [activeTab, onClick, t]);
}, [activeTab, onClick, showBilling, t]);
return (
<>

View File

@@ -0,0 +1,362 @@
import { Button, Loading } from '@affine/component';
import { Pagination } from '@affine/component/member-components';
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/components/hooks/use-mutation';
import {
AuthService,
InvoicesService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import {
createCustomerPortalMutation,
type InvoicesQuery,
InvoiceStatus,
SubscriptionPlan,
SubscriptionRecurring,
UserFriendlyError,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import {
FrameworkScope,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
CancelTeamAction,
ResumeAction,
} from '../../general-setting/plans/actions';
import * as styles from './styles.css';
export const WorkspaceSettingBilling = () => {
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const subscriptionService = useService(SubscriptionService);
const team = useLiveData(subscriptionService.subscription.team$);
const title = useLiveData(workspace.name$) || 'untitled';
if (workspace === null) {
console.log('workspace is null', title);
return null;
}
if (!team) {
return <Loading />;
}
return (
<FrameworkScope scope={workspace.scope}>
<SettingHeader
title={t['com.affine.payment.billing-setting.title']()}
subtitle={t['com.affine.payment.billing-setting.subtitle']()}
/>
<SettingWrapper
title={t['com.affine.payment.billing-setting.information']()}
>
<TeamCard />
<TypeFormLink />
<PaymentMethodUpdater />
{team.end && team.canceledAt ? (
<ResumeSubscription expirationDate={team.end} />
) : null}
</SettingWrapper>
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<BillingHistory />
</SettingWrapper>
</FrameworkScope>
);
};
const TeamCard = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const teamSubscription = useLiveData(subscriptionService.subscription.team$);
const teamPrices = useLiveData(subscriptionService.prices.teamPrice$);
const [openCancelModal, setOpenCancelModal] = useState(false);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const expiration = teamSubscription?.end;
const nextBillingDate = teamSubscription?.nextBillAt;
const recurring = teamSubscription?.recurring;
const description = useMemo(() => {
if (recurring === SubscriptionRecurring.Yearly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.annually'
]();
}
if (recurring === SubscriptionRecurring.Monthly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.monthly'
]();
}
return t['com.affine.payment.billing-setting.free-trial']();
}, [recurring, t]);
const expirationDate = useMemo(() => {
if (expiration) {
return t[
'com.affine.settings.workspace.billing.team-workspace.not-renewed'
]({
date: new Date(expiration).toLocaleDateString(),
});
}
if (nextBillingDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.next-billing-date'
]({
date: new Date(nextBillingDate).toLocaleDateString(),
});
}
return '';
}, [expiration, nextBillingDate, t]);
const amount = teamSubscription
? teamPrices
? teamSubscription.recurring === SubscriptionRecurring.Monthly
? String((teamPrices.amount ?? 0) / 100)
: String((teamPrices.yearlyAmount ?? 0) / 100)
: '?'
: '0';
return (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.settings.workspace.billing.team-workspace']()}
desc={
<>
<div>{description}</div>
<div>{expirationDate}</div>
</>
}
/>
<CancelTeamAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<Button variant="primary" className={styles.cancelPlanButton}>
{t[
'com.affine.settings.workspace.billing.team-workspace.cancel-plan'
]()}
</Button>
</CancelTeamAction>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{teamSubscription?.recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
);
};
const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
const t = useI18n();
const [open, setOpen] = useState(false);
const handleClick = useCallback(() => {
setOpen(true);
}, []);
return (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t['com.affine.payment.billing-setting.expiration-date.description'](
{
expirationDate: new Date(expirationDate).toLocaleDateString(),
}
)}
>
<ResumeAction open={open} onOpenChange={setOpen}>
<Button onClick={handleClick}>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
</SettingRow>
);
};
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 (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};
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 (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
</SettingRow>
);
};
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 <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const BillingHistorySkeleton = () => {
return (
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
);
};

View File

@@ -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',
});

View File

@@ -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 (
<WorkspaceSettingProperties workspaceMetadata={workspaceMetadata} />
);
case 'workspace:billing':
return <WorkspaceSettingBilling />;
}
return null;
};

View File

@@ -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 (
<SettingWrapper
title={t['com.affine.settings.workspace.affine-ai.title']()}
>
<SettingRow
name={t['com.affine.settings.workspace.affine-ai.label']()}
desc={t['com.affine.settings.workspace.affine-ai.description']()}
>
<Switch
checked={!!workspaceEnableAi}
onChange={toggleAi}
disabled={loading}
/>
</SettingRow>
</SettingWrapper>
);
};

View File

@@ -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 = ({
<EnableCloudPanel onCloseSetting={onCloseSetting} />
<MembersPanel onChangeSettingState={onChangeSettingState} />
</SettingWrapper>
<AiSetting />
<SharingPanel />
{BUILD_CONFIG.isElectron && (
<SettingWrapper title={t['Storage and Export']()}>

View File

@@ -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<typeof useI18n>): 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 (

View File

@@ -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 (
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
<div className={style.fakeWrapper}>
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
<Button>{t['Invite Members']()}</Button>
</SettingRow>
</div>
</Tooltip>
);
};
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<InviteModalProps['onConfirm']>(
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<OnRevoke>(
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 (
<span>
{t['com.affine.payment.member.description2']()}
{hasPaymentFeature ? (
<div
className={style.goUpgradeWrapper}
onClick={handleUpgradeConfirm}
>
<span className={style.goUpgrade}>
{t['com.affine.payment.member.description.choose-plan']()}
</span>
</div>
) : null}
</span>
);
}, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]);
if (workspaceQuota === null) {
if (isLoading) {
return <MembersPanelFallback />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
}
return (
<>
<SettingRow
name={`${t['Members']()} (${workspaceQuota.memberCount}/${workspaceQuota.humanReadable.memberLimit})`}
desc={desc}
spreadCol={!!isOwner}
>
{isOwner ? (
<>
<Button onClick={openModal}>{t['Invite Members']()}</Button>
{isLimited ? (
<MemberLimitModal
isFreePlan={!!plan}
open={open}
plan={workspaceQuota.humanReadable.name ?? ''}
quota={workspaceQuota.humanReadable.memberLimit ?? ''}
setOpen={setOpen}
onConfirm={handleUpgradeConfirm}
/>
) : (
<InviteModal
open={open}
setOpen={setOpen}
onConfirm={onInviteConfirm}
isMutating={isMutating}
/>
)}
</>
) : null}
</SettingRow>
<div className={style.membersPanel}>
<MemberList isOwner={!!isOwner} onRevoke={onRevoke} />
</div>
</>
);
};
export const MembersPanelFallback = () => {
const t = useI18n();
return (
<>
<SettingRow
name={t['Members']()}
desc={t['com.affine.payment.member.description2']()}
/>
<div className={style.membersPanel}>
<MemberListFallback memberCount={1} />
</div>
</>
);
};
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 (
<div
style={{
height,
}}
className={style.membersFallback}
>
<Loading size={20} />
<span>{t['com.affine.settings.member.loading']()}</span>
</div>
);
};
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 (
<div className={style.memberList}>
{pageMembers === undefined ? (
isLoading ? (
<MemberListFallback
memberCount={
memberCount
? clamp(
memberCount - pageNum * membersService.members.PAGE_SIZE,
1,
membersService.members.PAGE_SIZE
)
: 1
}
/>
) : (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
)
) : (
pageMembers?.map(member => (
<MemberItem
currentAccount={account}
key={member.id}
member={member}
isOwner={isOwner}
onRevoke={onRevoke}
/>
))
)}
{memberCount !== undefined &&
memberCount > membersService.members.PAGE_SIZE && (
<Pagination
totalCount={memberCount}
countPerPage={membersService.members.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
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 (
<div
key={member.id}
className={style.memberListItem}
data-testid="member-item"
>
<Avatar
size={36}
url={member.avatarUrl}
name={(member.name ? member.name : member.email) as string}
/>
<div className={style.memberContainer}>
{member.name ? (
<>
<div className={style.memberName}>{member.name}</div>
<div className={style.memberEmail}>{member.email}</div>
</>
) : (
<div className={style.memberName}>{member.email}</div>
)}
</div>
<div
className={clsx(style.roleOrStatus, {
pending: !member.accepted,
})}
>
{member.accepted
? member.permission === Permission.Owner
? 'Workspace Owner'
: 'Member'
: 'Pending'}
</div>
<Menu
items={
<MenuItem data-member-id={member.id} onClick={handleRevoke}>
{operationButtonInfo.leaveOrRevokeText}
</MenuItem>
}
>
<IconButton
disabled={!operationButtonInfo.show}
style={{
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
flexShrink: 0,
}}
>
<MoreVerticalIcon />
</IconButton>
</Menu>
</div>
);
};
export const MembersPanel = ({
onChangeSettingState,
}: {
onChangeSettingState: (settingState: SettingState) => void;
}): ReactElement | null => {
const workspace = useService(WorkspaceService).workspace;
if (workspace.flavour === 'local') {
return <MembersPanelLocal />;
}
return (
<AffineErrorBoundary>
<CloudWorkspaceMembersPanel onChangeSettingState={onChangeSettingState} />
</AffineErrorBoundary>
);
};

View File

@@ -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<string[]> => {
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<InviteModalProps['onConfirm']>(
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 <span>{t['com.affine.payment.member.team.description']()}</span>;
}
return (
<span>
{t['com.affine.payment.member.description2']()}
{hasPaymentFeature ? (
<div
className={styles.goUpgradeWrapper}
onClick={handleUpgradeConfirm}
>
<span className={styles.goUpgrade}>
{t['com.affine.payment.member.description.choose-plan']()}
</span>
</div>
) : null}
</span>
);
}, [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 <MembersPanelFallback />;
} else {
return (
<span className={styles.errorStyle}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
);
}
}
return (
<>
<SettingRow name={title} desc={desc} spreadCol={!!isOwner}>
{isOwner ? (
<>
<Button onClick={openModal}>{t['Invite Members']()}</Button>
{isTeam ? (
<InviteTeamMemberModal
open={open}
setOpen={setOpen}
onConfirm={onInviteBatchConfirm}
isMutating={isMutating}
copyTextToClipboard={copyTextToClipboard}
onGenerateInviteLink={onGenerateInviteLink}
onRevokeInviteLink={onRevokeInviteLink}
importCSV={<ImportCSV onImport={onImportCSV} />}
/>
) : isLimited ? (
<MemberLimitModal
isFreePlan={!plan}
open={open}
plan={workspaceQuota.humanReadable.name ?? ''}
quota={workspaceQuota.humanReadable.memberLimit ?? ''}
setOpen={setOpen}
onConfirm={handleUpgradeConfirm}
/>
) : (
<InviteModal
open={open}
setOpen={setOpen}
onConfirm={onInviteConfirm}
isMutating={isMutating}
/>
)}
</>
) : null}
</SettingRow>
<div className={styles.membersPanel}>
<MemberList isOwner={!!isOwner} isAdmin={!!isAdmin} />
</div>
</>
);
};
export const MembersPanelFallback = () => {
const t = useI18n();
return (
<>
<SettingRow
name={t['Members']()}
desc={t['com.affine.payment.member.description2']()}
/>
<div className={styles.membersPanel}>
<MemberListFallback memberCount={1} />
</div>
</>
);
};
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 (
<div
style={{
height,
}}
className={styles.membersFallback}
>
<Loading size={20} />
<span>{t['com.affine.settings.member.loading']()}</span>
</div>
);
};
const ImportCSV = ({ onImport }: { onImport: (file: File) => void }) => {
const t = useI18n();
return (
<Upload accept="text/csv" fileChange={onImport}>
<Button className={styles.importButton} prefix={<ExportIcon />}>
{t['com.affine.payment.member.team.invite.import-csv']()}
</Button>
</Upload>
);
};

View File

@@ -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 (
<ConfirmModal
childrenContentClassName={styles.confirmAssignModalContent}
open={open}
onOpenChange={setOpen}
title={t['com.affine.payment.member.team.assign.confirm.title']()}
confirmText={t['com.affine.payment.member.team.assign.confirm.button']()}
onConfirm={onConfirm}
confirmButtonOptions={{ disabled: !isEquals, variant: 'error' }}
>
<div className={styles.confirmAssignModalContent}>
<div>
<p>
{t['com.affine.payment.member.team.assign.confirm.description']({
name: member.name || member.email || member.id,
})}
</p>
<p>
{t['com.affine.payment.member.team.assign.confirm.description-1']()}
</p>
<p>
{t['com.affine.payment.member.team.assign.confirm.description-2']()}
</p>
<p>
{t['com.affine.payment.member.team.assign.confirm.description-3']()}
</p>
</div>
<div className={styles.confirmInputContainer}>
{t['com.affine.payment.member.team.assign.confirm.description-4']()}
<Input
value={inputValue}
inputStyle={{ fontSize: cssVar('fontSm') }}
onChange={setInputValue}
placeholder={t.t(
'com.affine.payment.member.team.assign.confirm.placeholder'
)}
/>
</div>
</div>
</ConfirmModal>
);
};

View File

@@ -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 <MembersPanelLocal />;
}
return (
<AffineErrorBoundary>
<CloudWorkspaceMembersPanel
onChangeSettingState={onChangeSettingState}
isTeam={isTeam}
/>
</AffineErrorBoundary>
);
};
const MembersPanelLocal = () => {
const t = useI18n();
return (
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
<div className={styles.fakeWrapper}>
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
<Button>{t['Invite Members']()}</Button>
</SettingRow>
</div>
</Tooltip>
);
};

View File

@@ -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 (
<div>
{pageMembers === undefined ? (
isLoading ? (
<MemberListFallback
memberCount={
memberCount
? clamp(
memberCount - pageNum * membersService.members.PAGE_SIZE,
1,
membersService.members.PAGE_SIZE
)
: 1
}
/>
) : (
<span className={styles.errorStyle}>
{error
? UserFriendlyError.fromAnyError(error).message
: 'Failed to load members'}
</span>
)
) : (
pageMembers?.map(member => (
<MemberItem
currentAccount={account}
key={member.id}
member={member}
isOwner={isOwner}
isAdmin={isAdmin}
/>
))
)}
{memberCount !== undefined &&
memberCount > membersService.members.PAGE_SIZE && (
<Pagination
totalCount={memberCount}
countPerPage={membersService.members.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
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 (
<div
key={member.id}
className={styles.memberListItem}
data-testid="member-item"
>
<Avatar
size={36}
url={member.avatarUrl}
name={(member.name ? member.name : member.email) as string}
/>
<div className={styles.memberContainer}>
{member.name ? (
<>
<div className={styles.memberName}>{member.name}</div>
<div className={styles.memberEmail}>{member.email}</div>
</>
) : (
<div className={styles.memberName}>{member.email}</div>
)}
</div>
<div
className={clsx(styles.roleOrStatus, {
pending: !member.accepted,
})}
>
{t.t(memberStatus)}
</div>
<Menu
items={
<MemberOptions
member={member}
openAssignModal={handleOpenAssignModal}
isAdmin={isAdmin}
isOwner={isOwner}
/>
}
>
<IconButton
disabled={!show}
style={{
visibility: show ? 'visible' : 'hidden',
flexShrink: 0,
}}
>
<MoreVerticalIcon />
</IconButton>
</Menu>
<ConfirmAssignModal
open={open}
setOpen={setOpen}
member={member}
inputValue={inputValue}
setInputValue={setInputValue}
isEquals={isEquals}
onConfirm={confirmAssign}
/>
</div>
);
};
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 (
<div
style={{
height,
}}
className={styles.membersFallback}
>
<Loading size={20} />
<span>{t['com.affine.settings.member.loading']()}</span>
</div>
);
};

View File

@@ -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 ? (
<MenuItem key={item.label} onSelect={item.onClick}>
{item.label}
</MenuItem>
) : null
)}
</>
);
};

View File

@@ -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',
});

View File

@@ -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',
});

View File

@@ -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,

View File

@@ -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 = (
<div className={styles.leftContentText}>
<div>{t['com.affine.payment.upgrade-success-page.team.text-1']()}</div>
<div>
<Trans
i18nKey={'com.affine.payment.upgrade-success-page.team.text-2'}
components={{
1: (
<a
href="mailto:support@toeverything.info"
className={styles.mail}
/>
),
}}
/>
</div>
</div>
);
return (
<AuthPageContainer
title={t['com.affine.payment.upgrade-success-page.title']()}
subtitle={subtitle}
>
<Button variant="primary" size="extraLarge" onClick={openAffine}>
{t['com.affine.other-page.nav.open-affine']()}
</Button>
</AuthPageContainer>
);
};

View File

@@ -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'),
},
});

View File

@@ -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<WorkspaceMetadata | null>(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 (
<AuthPageContainer title={t['com.affine.upgrade-to-team-page.title']()}>
<div className={styles.root}>
<Menu
items={
<WorkspaceSelector
metas={workspaces}
onSelect={setSelectedWorkspace}
onClickCreateWorkspace={onClickCreateWorkspace}
/>
}
contentOptions={{
style: {
width: '410px',
},
}}
>
<MenuTrigger className={styles.menuTrigger} tooltip={menuTriggerText}>
{menuTriggerText}
</MenuTrigger>
</Menu>
<div className={styles.upgradeButton}>
<Button
variant="primary"
size="extraLarge"
onClick={onUpgradeButtonClick}
disabled={!selectedWorkspace}
>
{t['com.affine.upgrade-to-team-page.upgrade-button']()}
</Button>
</div>
<div className={styles.contentContainer}>
<div>{t['com.affine.upgrade-to-team-page.benefit.title']()}</div>
<ul>
{benefitList.map((benefit, index) => (
<li key={`${benefit}:${index}`} className={styles.liStyle}>
<DoneIcon className={styles.doneIcon} />
{t.t(benefit)}
</li>
))}
</ul>
<div>
{t['com.affine.upgrade-to-team-page.benefit.description']()}
</div>
<UpgradeDialog
open={openUpgrade}
onOpenChange={setOpenUpgrade}
workspaceName={name}
workspaceId={selectedWorkspace?.id ?? ''}
/>
<CreateWorkspaceDialog
open={openCreate}
onOpenChange={setOpenCreate}
onSelect={setSelectedWorkspace}
/>
</div>
</div>
</AuthPageContainer>
);
};
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 (
<Modal width={480} open={open} onOpenChange={onOpenChange}>
<div className={styles.dialogTitle}>
{t['com.affine.upgrade-to-team-page.upgrade-confirm.title']()}
</div>
<div className={styles.dialogMessage}>
<Trans
i18nKey="com.affine.upgrade-to-team-page.upgrade-confirm.description"
components={{
1: <span style={{ fontWeight: 600 }} />,
}}
values={{
workspaceName,
}}
/>
</div>
<div className={styles.dialogFooter}>
<Button onClick={onClose}>{t['Cancel']()}</Button>
<Upgrade
className={styles.upgradeButtonInDialog}
recurring={SubscriptionRecurring.Monthly}
plan={SubscriptionPlan.Team}
onCheckoutSuccess={onClose}
checkoutInput={{
args: {
workspaceId,
},
}}
>
{t['com.affine.payment.upgrade']()}
</Upgrade>
</div>
</Modal>
);
};
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 (
<div>
{cloudWorkspaces.length > 0 &&
cloudWorkspaces.map(workspace => (
<WorkspaceItem
key={workspace.id}
meta={workspace}
onSelect={handleSelect}
/>
))}
{cloudWorkspaces.length > 0 && <Divider size="thinner" />}
<MenuItem
className={styles.createWorkspaceItem}
prefix={<NewPageIcon className={styles.itemIcon} fontSize={28} />}
onClick={onClickCreateWorkspace}
>
<div className={styles.itemContent}>
{t[
'com.affine.upgrade-to-team-page.workspace-selector.create-workspace'
]()}
</div>
</MenuItem>
</div>
);
};
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 (
<MenuItem className={styles.plainMenuItem} onClick={onClick}>
<PureWorkspaceCard
className={styles.workspaceItem}
workspaceMetadata={meta}
avatarSize={28}
/>
</MenuItem>
);
};
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 (
<Modal width={480} open={open} onOpenChange={onOpenChange}>
<div className={styles.dialogTitle}>
{t[
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.title'
]()}
</div>
<div className={styles.createConfirmContent}>
<div>
{t[
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.description'
]()}
</div>
<Input
placeholder={t[
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.placeholder'
]()}
value={name}
onChange={setName}
/>
</div>
<div className={styles.dialogFooter}>
<Button onClick={onClose}>{t['Cancel']()}</Button>
<Button
variant="primary"
className={styles.upgradeButtonInDialog}
onClick={onBeforeCheckout}
>
{t[
'com.affine.upgrade-to-team-page.create-and-upgrade-confirm.confirm'
]()}
</Button>
</div>
</Modal>
);
};

View File

@@ -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',
});

View File

@@ -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 = <SharePageNotFoundError />;
// TODO(@JimmFly): handle error
element = <PageNotFound />;
} else {
element = <PageNotFound noPermission />;
}

View File

@@ -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: () => {