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

View File

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

View File

@@ -30,6 +30,7 @@ export enum SubscriptionPlan {
Pro = 'pro',
Team = 'team',
Enterprise = 'enterprise',
SelfHosted = 'selfhosted',
}
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
Team
Enterprise
SelfHosted
}
type UserSubscription {

View File

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

View File

@@ -28,6 +28,11 @@ const UserPlanButtonWithData = () => {
const t = useAFFiNEI18N();
if (plan === SubscriptionPlan.SelfHosted) {
// Self hosted version doesn't have a payment apis.
return <div className={styles.userPlanButton}>{plan}</div>;
}
return (
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
<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 { useMemo } from 'react';
import { useSelfHosted } from '../../../hooks/affine/use-server-flavor';
import { useWorkspace } from '../../../hooks/use-workspace';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { ExportPanel } from './export';
@@ -20,6 +21,7 @@ import type { WorkspaceSettingDetailProps } from './types';
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
const { workspaceId } = props;
const t = useAFFiNEI18N();
const isSelfHosted = useSelfHosted();
const workspace = useWorkspace(workspaceId);
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
@@ -56,7 +58,11 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
</SettingWrapper>
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<PublishPanel workspace={workspace} {...props} />
<MembersPanel workspace={workspace} {...props} />
<MembersPanel
workspace={workspace}
upgradable={!isSelfHosted}
{...props}
/>
</SettingWrapper>
{storageAndExportSetting}
<SettingWrapper>

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
import type { ReactElement, SVGProps } from 'react';
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { useSelfHosted } from '../../../../hooks/affine/use-server-flavor';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
import { BillingSettings } from './billing';
@@ -36,6 +37,7 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useAFFiNEI18N();
const status = useCurrentLoginStatus();
const isSelfHosted = useSelfHosted();
const settings: GeneralSettingListItem[] = [
{
@@ -50,13 +52,6 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon,
testId: 'shortcuts-panel-trigger',
},
{
key: 'plans',
title: t['com.affine.payment.title'](),
icon: UpgradeIcon,
testId: 'plans-panel-trigger',
},
{
key: 'plugins',
title: 'Plugins',
@@ -71,13 +66,21 @@ export const useGeneralSettingList = (): GeneralSettingList => {
},
];
if (status === 'authenticated') {
if (!isSelfHosted) {
settings.splice(3, 0, {
key: 'billing',
title: t['com.affine.payment.billing-setting.title'](),
icon: PaymentIcon,
testId: 'billing-panel-trigger',
key: 'plans',
title: t['com.affine.payment.title'](),
icon: UpgradeIcon,
testId: 'plans-panel-trigger',
});
if (status === 'authenticated') {
settings.splice(3, 0, {
key: 'billing',
title: t['com.affine.payment.billing-setting.title'](),
icon: PaymentIcon,
testId: 'billing-panel-trigger',
});
}
}
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 { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
import { useSelfHosted } from './affine/use-server-flavor';
export type Subscription = NonNullable<
NonNullable<SubscriptionQuery['currentUser']>['subscription']
>;
@@ -12,6 +14,7 @@ const selector = (data: SubscriptionQuery) =>
data.currentUser?.subscription ?? null;
export const useUserSubscription = () => {
const isSelfHosted = useSelfHosted();
const { data, mutate } = useQuery({
query: subscriptionQuery,
});
@@ -36,5 +39,9 @@ export const useUserSubscription = () => {
[mutate]
);
if (isSelfHosted) {
return [selector(data), () => {}] as const;
}
return [selector(data), set] as const;
};

View File

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