feat: optional payment for frontend (#5056)

This commit is contained in:
DarkSky
2023-11-25 23:15:44 +08:00
committed by GitHub
parent 13e712158c
commit f04ec50d12
14 changed files with 146 additions and 38 deletions

View File

@@ -6,6 +6,7 @@ import { GqlModule } from '../graphql.module';
import { ServerConfigModule } from './config'; import { ServerConfigModule } from './config';
import { DocModule } from './doc'; import { DocModule } from './doc';
import { PaymentModule } from './payment'; import { PaymentModule } from './payment';
import { SelfHostedModule } from './self-hosted';
import { SyncModule } from './sync'; import { SyncModule } from './sync';
import { UsersModule } from './users'; import { UsersModule } from './users';
import { WorkspaceModule } from './workspaces'; import { WorkspaceModule } from './workspaces';
@@ -25,10 +26,12 @@ switch (SERVER_FLAVOR) {
case 'selfhosted': case 'selfhosted':
BusinessModules.push( BusinessModules.push(
ServerConfigModule, ServerConfigModule,
SelfHostedModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
GqlModule, GqlModule,
WorkspaceModule, WorkspaceModule,
UsersModule, UsersModule,
SyncModule,
DocModule.forRoot() DocModule.forRoot()
); );
break; break;

View File

@@ -52,7 +52,7 @@ class SubscriptionPrice {
} }
@ObjectType('UserSubscription') @ObjectType('UserSubscription')
class UserSubscriptionType implements Partial<UserSubscription> { export class UserSubscriptionType implements Partial<UserSubscription> {
@Field({ name: 'id' }) @Field({ name: 'id' })
stripeSubscriptionId!: string; stripeSubscriptionId!: string;

View File

@@ -30,6 +30,7 @@ export enum SubscriptionPlan {
Pro = 'pro', Pro = 'pro',
Team = 'team', Team = 'team',
Enterprise = 'enterprise', Enterprise = 'enterprise',
SelfHosted = 'selfhosted',
} }
export function encodeLookupKey( export function encodeLookupKey(

View File

@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { ResolveField, Resolver } from '@nestjs/graphql';
import { UserSubscriptionType } from './payment/resolver';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from './payment/service';
import { UserType } from './users';
const YEAR = 1000 * 60 * 60 * 24 * 30 * 12;
@Resolver(() => UserType)
export class SelfHostedDummyResolver {
private readonly start = new Date();
private readonly end = new Date(Number(this.start) + YEAR);
constructor() {}
@ResolveField(() => UserSubscriptionType)
async subscription() {
return {
stripeSubscriptionId: 'dummy',
plan: SubscriptionPlan.SelfHosted,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: this.start,
end: this.end,
createdAt: this.start,
updatedAt: this.start,
};
}
}
@Module({
providers: [SelfHostedDummyResolver],
})
export class SelfHostedModule {}

View File

@@ -81,6 +81,7 @@ enum SubscriptionPlan {
Pro Pro
Team Team
Enterprise Enterprise
SelfHosted
} }
type UserSubscription { type UserSubscription {

View File

@@ -11,6 +11,7 @@ import * as styles from './share.css';
export interface StorageProgressProgress { export interface StorageProgressProgress {
max: number; max: number;
value: number; value: number;
upgradable?: boolean;
onUpgrade: () => void; onUpgrade: () => void;
plan: SubscriptionPlan; plan: SubscriptionPlan;
} }
@@ -23,6 +24,7 @@ enum ButtonType {
export const StorageProgress = ({ export const StorageProgress = ({
max: upperLimit, max: upperLimit,
value, value,
upgradable = true,
onUpgrade, onUpgrade,
plan, plan,
}: StorageProgressProgress) => { }: StorageProgressProgress) => {
@@ -63,6 +65,7 @@ export const StorageProgress = ({
</div> </div>
</div> </div>
{upgradable ? (
<Tooltip <Tooltip
options={{ hidden: percent < 100 }} options={{ hidden: percent < 100 }}
content={t['com.affine.storage.maximum-tips']()} content={t['com.affine.storage.maximum-tips']()}
@@ -79,6 +82,7 @@ export const StorageProgress = ({
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
) : null}
</div> </div>
); );
}; };

View File

@@ -28,6 +28,11 @@ const UserPlanButtonWithData = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
if (plan === SubscriptionPlan.SelfHosted) {
// Self hosted version doesn't have a payment apis.
return <div className={styles.userPlanButton}>{plan}</div>;
}
return ( return (
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top"> <Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
<div className={styles.userPlanButton} onClick={handleClick}> <div className={styles.userPlanButton} onClick={handleClick}>

View File

@@ -7,6 +7,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name'; import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSelfHosted } from '../../../hooks/affine/use-server-flavor';
import { useWorkspace } from '../../../hooks/use-workspace'; import { useWorkspace } from '../../../hooks/use-workspace';
import { DeleteLeaveWorkspace } from './delete-leave-workspace'; import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { ExportPanel } from './export'; import { ExportPanel } from './export';
@@ -20,6 +21,7 @@ import type { WorkspaceSettingDetailProps } from './types';
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => { export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
const { workspaceId } = props; const { workspaceId } = props;
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const isSelfHosted = useSelfHosted();
const workspace = useWorkspace(workspaceId); const workspace = useWorkspace(workspaceId);
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace); const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
@@ -56,7 +58,11 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
</SettingWrapper> </SettingWrapper>
<SettingWrapper title={t['com.affine.brand.affineCloud']()}> <SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<PublishPanel workspace={workspace} {...props} /> <PublishPanel workspace={workspace} {...props} />
<MembersPanel workspace={workspace} {...props} /> <MembersPanel
workspace={workspace}
upgradable={!isSelfHosted}
{...props}
/>
</SettingWrapper> </SettingWrapper>
{storageAndExportSetting} {storageAndExportSetting}
<SettingWrapper> <SettingWrapper>

View File

@@ -51,6 +51,7 @@ enum MemberLimitCount {
const COUNT_PER_PAGE = 8; const COUNT_PER_PAGE = 8;
export interface MembersPanelProps extends WorkspaceSettingDetailProps { export interface MembersPanelProps extends WorkspaceSettingDetailProps {
upgradable: boolean;
workspace: AffineOfficialWorkspace; workspace: AffineOfficialWorkspace;
} }
type OnRevoke = (memberId: string) => void; type OnRevoke = (memberId: string) => void;
@@ -70,6 +71,7 @@ const MembersPanelLocal = () => {
export const CloudWorkspaceMembersPanel = ({ export const CloudWorkspaceMembersPanel = ({
workspace, workspace,
isOwner, isOwner,
upgradable,
}: MembersPanelProps) => { }: MembersPanelProps) => {
const workspaceId = workspace.id; const workspaceId = workspace.id;
const memberCount = useMemberCount(workspaceId); const memberCount = useMemberCount(workspaceId);
@@ -165,6 +167,8 @@ export const CloudWorkspaceMembersPanel = ({
planName: plan, planName: plan,
memberLimit, memberLimit,
})} })}
{upgradable ? (
<>
, ,
<div className={style.goUpgradeWrapper} onClick={handleUpgrade}> <div className={style.goUpgradeWrapper} onClick={handleUpgrade}>
<span className={style.goUpgrade}> <span className={style.goUpgrade}>
@@ -172,9 +176,11 @@ export const CloudWorkspaceMembersPanel = ({
</span> </span>
<ArrowRightBigIcon className={style.arrowRight} /> <ArrowRightBigIcon className={style.arrowRight} />
</div> </div>
</>
) : null}
</span> </span>
); );
}, [handleUpgrade, memberLimit, plan, t]); }, [handleUpgrade, memberLimit, plan, t, upgradable]);
return ( return (
<> <>

View File

@@ -35,6 +35,7 @@ import {
openSignOutModalAtom, openSignOutModalAtom,
} from '../../../../atoms'; } from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user'; import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useSelfHosted } from '../../../../hooks/affine/use-server-flavor';
import { useUserSubscription } from '../../../../hooks/use-subscription'; import { useUserSubscription } from '../../../../hooks/use-subscription';
import { Upload } from '../../../pure/file-upload'; import { Upload } from '../../../pure/file-upload';
import * as style from './style.css'; import * as style from './style.css';
@@ -167,6 +168,7 @@ export const AvatarAndName = () => {
const StoragePanel = () => { const StoragePanel = () => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const isSelfHosted = useSelfHosted();
const { data } = useQuery({ const { data } = useQuery({
query: allBlobSizesQuery, query: allBlobSizesQuery,
@@ -175,6 +177,7 @@ const StoragePanel = () => {
const [subscription] = useUserSubscription(); const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free; const plan = subscription?.plan ?? SubscriptionPlan.Free;
// TODO(@JimmFly): get limit from user usage query directly after #4720 is merged
const maxLimit = useMemo(() => { const maxLimit = useMemo(() => {
return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB'); return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB');
}, [plan]); }, [plan]);
@@ -199,6 +202,7 @@ const StoragePanel = () => {
plan={plan} plan={plan}
value={data.collectAllBlobSizes.size} value={data.collectAllBlobSizes.size}
onUpgrade={onUpgrade} onUpgrade={onUpgrade}
upgradable={!isSelfHosted}
/> />
</SettingRow> </SettingRow>
); );

View File

@@ -8,6 +8,7 @@ import {
import type { ReactElement, SVGProps } from 'react'; import type { ReactElement, SVGProps } from 'react';
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { useSelfHosted } from '../../../../hooks/affine/use-server-flavor';
import { AboutAffine } from './about'; import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance'; import { AppearanceSettings } from './appearance';
import { BillingSettings } from './billing'; import { BillingSettings } from './billing';
@@ -36,6 +37,7 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => { export const useGeneralSettingList = (): GeneralSettingList => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const status = useCurrentLoginStatus(); const status = useCurrentLoginStatus();
const isSelfHosted = useSelfHosted();
const settings: GeneralSettingListItem[] = [ const settings: GeneralSettingListItem[] = [
{ {
@@ -50,13 +52,6 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon, icon: KeyboardIcon,
testId: 'shortcuts-panel-trigger', testId: 'shortcuts-panel-trigger',
}, },
{
key: 'plans',
title: t['com.affine.payment.title'](),
icon: UpgradeIcon,
testId: 'plans-panel-trigger',
},
{ {
key: 'plugins', key: 'plugins',
title: 'Plugins', title: 'Plugins',
@@ -71,6 +66,13 @@ export const useGeneralSettingList = (): GeneralSettingList => {
}, },
]; ];
if (!isSelfHosted) {
settings.splice(3, 0, {
key: 'plans',
title: t['com.affine.payment.title'](),
icon: UpgradeIcon,
testId: 'plans-panel-trigger',
});
if (status === 'authenticated') { if (status === 'authenticated') {
settings.splice(3, 0, { settings.splice(3, 0, {
key: 'billing', key: 'billing',
@@ -79,6 +81,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
testId: 'billing-panel-trigger', testId: 'billing-panel-trigger',
}); });
} }
}
return settings; return settings;
}; };

View File

@@ -0,0 +1,29 @@
import { serverConfigQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
import type { BareFetcher, Middleware } from 'swr';
const wrappedFetcher = (fetcher: BareFetcher<any> | null, ...args: any[]) =>
fetcher?.(...args).catch(() => null);
const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config);
};
export const useServerFlavor = () => {
const { data: config, error } = useQuery(
{ query: serverConfigQuery },
{ use: [errorHandler] }
);
if (error || !config) {
return 'local';
}
return config.serverConfig.flavor;
};
export const useSelfHosted = () => {
const serverFlavor = useServerFlavor();
return ['local', 'selfhosted'].includes(serverFlavor);
};

View File

@@ -2,6 +2,8 @@ import { type SubscriptionQuery, subscriptionQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql'; import { useQuery } from '@affine/workspace/affine/gql';
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSelfHosted } from './affine/use-server-flavor';
export type Subscription = NonNullable< export type Subscription = NonNullable<
NonNullable<SubscriptionQuery['currentUser']>['subscription'] NonNullable<SubscriptionQuery['currentUser']>['subscription']
>; >;
@@ -12,6 +14,7 @@ const selector = (data: SubscriptionQuery) =>
data.currentUser?.subscription ?? null; data.currentUser?.subscription ?? null;
export const useUserSubscription = () => { export const useUserSubscription = () => {
const isSelfHosted = useSelfHosted();
const { data, mutate } = useQuery({ const { data, mutate } = useQuery({
query: subscriptionQuery, query: subscriptionQuery,
}); });
@@ -36,5 +39,9 @@ export const useUserSubscription = () => {
[mutate] [mutate]
); );
if (isSelfHosted) {
return [selector(data), () => {}] as const;
}
return [selector(data), set] as const; return [selector(data), set] as const;
}; };

View File

@@ -62,6 +62,7 @@ export enum SubscriptionPlan {
Enterprise = 'Enterprise', Enterprise = 'Enterprise',
Free = 'Free', Free = 'Free',
Pro = 'Pro', Pro = 'Pro',
SelfHosted = 'SelfHosted',
Team = 'Team', Team = 'Team',
} }