feat: optional payment for frontend (#5056)
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
38
packages/backend/server/src/modules/self-hosted.ts
Normal file
38
packages/backend/server/src/modules/self-hosted.ts
Normal 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 {}
|
||||||
@@ -81,6 +81,7 @@ enum SubscriptionPlan {
|
|||||||
Pro
|
Pro
|
||||||
Team
|
Team
|
||||||
Enterprise
|
Enterprise
|
||||||
|
SelfHosted
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserSubscription {
|
type UserSubscription {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
29
packages/frontend/core/src/hooks/affine/use-server-flavor.ts
Normal file
29
packages/frontend/core/src/hooks/affine/use-server-flavor.ts
Normal 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);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user