Skip to content

Commit 98c5a13

Browse files
committed
feat(core): add self host team plan
1 parent e35d930 commit 98c5a13

File tree

11 files changed

+319
-5
lines changed

11 files changed

+319
-5
lines changed

packages/backend/server/src/plugins/payment/resolver.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ export class SubscriptionResolver {
278278
if (input.plan === SubscriptionPlan.SelfHostedTeam) {
279279
session = await this.service.checkout(input, {
280280
plan: input.plan as any,
281-
quantity: input.args.quantity ?? 10,
281+
quantity: input.args?.quantity ?? 10,
282282
});
283283
} else {
284284
if (!user) {

packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx

+10-4
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,21 @@ export const generateSubscriptionCallbackLink = (
4949
recurring: SubscriptionRecurring,
5050
workspaceId?: string
5151
) => {
52-
if (account === null) {
53-
throw new Error('Account is required');
54-
}
5552
const baseUrl =
5653
plan === SubscriptionPlan.AI
5754
? '/ai-upgrade-success'
5855
: plan === SubscriptionPlan.Team
5956
? '/upgrade-success/team'
60-
: '/upgrade-success';
57+
: plan === SubscriptionPlan.SelfHostedTeam
58+
? '/upgrade-success/self-hosted-team'
59+
: '/upgrade-success';
60+
61+
if (plan === SubscriptionPlan.SelfHostedTeam) {
62+
return baseUrl;
63+
}
64+
if (account === null) {
65+
throw new Error('Account is required');
66+
}
6167

6268
let name = account?.info?.name ?? '';
6369
if (name.includes(separator)) {

packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx

+34
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,40 @@ export function getPlanDetail(t: T) {
171171
benefits: teamBenefits(t),
172172
},
173173
],
174+
[
175+
SubscriptionPlan.SelfHostedTeam,
176+
{
177+
type: 'fixed',
178+
plan: SubscriptionPlan.SelfHostedTeam,
179+
price: '12',
180+
yearlyPrice: '10',
181+
name: 'Self-hosted Team',
182+
description: 'Self-hosted Team description',
183+
titleRenderer: (recurring, detail) => {
184+
const price =
185+
recurring === SubscriptionRecurring.Yearly
186+
? detail.yearlyPrice
187+
: detail.price;
188+
return (
189+
<>
190+
{t['com.affine.payment.cloud.team-workspace.title.price-monthly'](
191+
{
192+
price: '$' + price,
193+
}
194+
)}
195+
{recurring === SubscriptionRecurring.Yearly ? (
196+
<span className={planTitleTitleCaption}>
197+
{t[
198+
'com.affine.payment.cloud.team-workspace.title.billed-yearly'
199+
]()}
200+
</span>
201+
) : null}
202+
</>
203+
);
204+
},
205+
benefits: teamBenefits(t),
206+
},
207+
],
174208
]);
175209
}
176210

packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx

+62
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
import { GlobalDialogService } from '@affine/core/modules/dialogs';
1111
import {
1212
type CreateCheckoutSessionInput,
13+
ServerDeploymentType,
1314
SubscriptionPlan,
1415
SubscriptionRecurring,
1516
SubscriptionStatus,
@@ -121,15 +122,24 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
121122
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
122123
const isFree = detail.plan === SubscriptionPlan.Free;
123124

125+
const serverService = useService(ServerService);
126+
const isSelfHosted = useLiveData(
127+
serverService.server.config$.selector(
128+
c => c.type === ServerDeploymentType.Selfhosted
129+
)
130+
);
131+
124132
const signUpText = useMemo(
125133
() => getSignUpText(detail.plan, t),
126134
[detail.plan, t]
127135
);
128136

129137
// branches:
130138
// if contact => 'Contact Sales'
139+
// if self-hosted team => 'Upgrade'
131140
// if not signed in:
132141
// if free => 'Sign up free'
142+
// if team => 'Upgrade'
133143
// else => 'Buy Pro'
134144
// else
135145
// if team => 'Start 14-day free trial'
@@ -144,6 +154,13 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
144154
// if currentRecurring !== recurring => 'Change to {recurring} Billing'
145155
// else => 'Upgrade'
146156

157+
// self-hosted team
158+
if (isSelfHosted || detail.plan === SubscriptionPlan.SelfHostedTeam) {
159+
return (
160+
<UpgradeToSelfHostTeam recurring={recurring as SubscriptionRecurring} />
161+
);
162+
}
163+
147164
// not signed in
148165
if (!loggedIn) {
149166
return <SignUpAction>{signUpText}</SignUpAction>;
@@ -267,6 +284,51 @@ const UpgradeToTeam = ({ recurring }: { recurring: SubscriptionRecurring }) => {
267284
</a>
268285
);
269286
};
287+
const UpgradeToSelfHostTeam = ({
288+
recurring,
289+
}: {
290+
recurring: SubscriptionRecurring;
291+
}) => {
292+
const t = useI18n();
293+
294+
const handleBeforeCheckout = useCallback(() => {
295+
track.$.settingsPanel.plans.checkout({
296+
plan: SubscriptionPlan.SelfHostedTeam,
297+
recurring: recurring,
298+
});
299+
}, [recurring]);
300+
301+
const checkoutOptions = useMemo(
302+
() => ({
303+
recurring,
304+
plan: SubscriptionPlan.SelfHostedTeam,
305+
variant: null,
306+
coupon: null,
307+
successCallbackLink: generateSubscriptionCallbackLink(
308+
null,
309+
SubscriptionPlan.SelfHostedTeam,
310+
recurring
311+
),
312+
}),
313+
[recurring]
314+
);
315+
316+
return (
317+
<CheckoutSlot
318+
onBeforeCheckout={handleBeforeCheckout}
319+
checkoutOptions={checkoutOptions}
320+
renderer={props => (
321+
<Button
322+
className={clsx(styles.planAction)}
323+
variant="primary"
324+
{...props}
325+
>
326+
{t['com.affine.payment.upgrade']()}
327+
</Button>
328+
)}
329+
/>
330+
);
331+
};
270332

271333
export const Upgrade = ({
272334
className,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { Button, IconButton, notify } from '@affine/component';
2+
import { AuthPageContainer } from '@affine/component/auth-components';
3+
import { useMutation } from '@affine/core/components/hooks/use-mutation';
4+
import { OpenInAppService } from '@affine/core/modules/open-in-app';
5+
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
6+
import { generateLicenseKeyMutation, UserFriendlyError } from '@affine/graphql';
7+
import { Trans, useI18n } from '@affine/i18n';
8+
import { CopyIcon } from '@blocksuite/icons/rc';
9+
import { useService } from '@toeverything/infra';
10+
import { useCallback, useEffect, useState } from 'react';
11+
import { useSearchParams } from 'react-router-dom';
12+
13+
import { PageNotFound } from '../../404';
14+
import * as styles from './styles.css';
15+
16+
/**
17+
* /upgrade-success/self-hosted-team page
18+
*
19+
* only on web
20+
*/
21+
export const Component = () => {
22+
const [params] = useSearchParams();
23+
const [key, setKey] = useState<string | null>(null);
24+
const sessionId = params.get('session_id');
25+
const { trigger: generateLicenseKey } = useMutation({
26+
mutation: generateLicenseKeyMutation,
27+
});
28+
29+
useEffect(() => {
30+
if (sessionId && !key) {
31+
generateLicenseKey({ sessionId })
32+
.then(({ generateLicenseKey }) => {
33+
setKey(generateLicenseKey);
34+
})
35+
.catch(e => {
36+
const error = UserFriendlyError.fromAnyError(e);
37+
console.error(error);
38+
39+
notify.error({
40+
title: error.name,
41+
message: error.message,
42+
});
43+
});
44+
}
45+
}, [generateLicenseKey, key, sessionId]);
46+
47+
if (!sessionId) {
48+
return <PageNotFound noPermission />;
49+
}
50+
51+
if (key) {
52+
return <Success licenseKey={key} />;
53+
} else {
54+
return (
55+
<AuthPageContainer
56+
title={'Failed to generate the license key'}
57+
subtitle={
58+
<span>
59+
Failed to generate the license key, please contact our {''}
60+
<a href="mailto:[email protected]" className={styles.mail}>
61+
customer support
62+
</a>
63+
.
64+
</span>
65+
}
66+
></AuthPageContainer>
67+
);
68+
}
69+
};
70+
71+
const Success = ({ licenseKey }: { licenseKey: string }) => {
72+
const t = useI18n();
73+
const openInAppService = useService(OpenInAppService);
74+
75+
const openAFFiNE = useCallback(() => {
76+
openInAppService.showOpenInAppPage();
77+
}, [openInAppService]);
78+
79+
const onCopy = useCallback(() => {
80+
copyTextToClipboard(licenseKey)
81+
.then(success => {
82+
if (success) {
83+
notify.success({
84+
title: t['com.affine.payment.license-success.copy'](),
85+
});
86+
}
87+
})
88+
.catch(err => {
89+
console.error(err);
90+
notify.error({ title: 'Copy failed, please try again later' });
91+
});
92+
}, [licenseKey, t]);
93+
94+
const subtitle = (
95+
<span className={styles.leftContentText}>
96+
<span>{t['com.affine.payment.license-success.text-1']()}</span>
97+
<span>
98+
<Trans
99+
i18nKey={'com.affine.payment.license-success.text-2'}
100+
components={{
101+
1: (
102+
<a
103+
href="mailto:[email protected]"
104+
className={styles.mail}
105+
/>
106+
),
107+
}}
108+
/>
109+
</span>
110+
</span>
111+
);
112+
return (
113+
<AuthPageContainer
114+
title={t['com.affine.payment.license-success.title']()}
115+
subtitle={subtitle}
116+
>
117+
<div className={styles.content}>
118+
<div className={styles.licenseKeyContainer}>
119+
{licenseKey}
120+
<IconButton
121+
icon={<CopyIcon />}
122+
className={styles.icon}
123+
size="20"
124+
tooltip={t['Copy']()}
125+
onClick={onCopy}
126+
/>
127+
</div>
128+
<div>{t['com.affine.payment.license-success.hint']()}</div>
129+
<div>
130+
<Button variant="primary" size="extraLarge" onClick={openAFFiNE}>
131+
{t['com.affine.payment.license-success.open-affine']()}
132+
</Button>
133+
</div>
134+
</div>
135+
</AuthPageContainer>
136+
);
137+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { cssVar } from '@toeverything/theme';
2+
import { cssVarV2 } from '@toeverything/theme/v2';
3+
import { style } from '@vanilla-extract/css';
4+
export const leftContentText = style({
5+
fontSize: cssVar('fontBase'),
6+
fontWeight: 400,
7+
lineHeight: '1.6',
8+
maxWidth: '548px',
9+
});
10+
export const mail = style({
11+
color: cssVar('linkColor'),
12+
textDecoration: 'none',
13+
':visited': {
14+
color: cssVar('linkColor'),
15+
},
16+
});
17+
export const content = style({
18+
display: 'flex',
19+
flexDirection: 'column',
20+
gap: '28px',
21+
});
22+
23+
export const licenseKeyContainer = style({
24+
width: '100%',
25+
display: 'flex',
26+
justifyContent: 'space-between',
27+
alignItems: 'center',
28+
backgroundColor: cssVarV2('layer/background/secondary'),
29+
borderRadius: '4px',
30+
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
31+
padding: '8px 10px',
32+
gap: '8px',
33+
});
34+
35+
export const icon = style({
36+
color: cssVarV2('icon/primary'),
37+
});

packages/frontend/core/src/desktop/router.tsx

+4
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export const topLevelRoutes = [
6868
path: '/upgrade-success/team',
6969
lazy: () => import('./pages/upgrade-success/team'),
7070
},
71+
{
72+
path: '/upgrade-success/self-hosted-team',
73+
lazy: () => import('./pages/upgrade-success/self-host-team'),
74+
},
7175
{
7276
path: '/ai-upgrade-success',
7377
lazy: () => import('./pages/ai-upgrade-success'),
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
mutation generateLicenseKey($sessionId: String!) {
2+
generateLicenseKey(sessionId: $sessionId)
3+
}

packages/frontend/graphql/src/graphql/index.ts

+11
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,17 @@ mutation forkCopilotSession($options: ForkChatSessionInput!) {
293293
}`,
294294
};
295295

296+
export const generateLicenseKeyMutation = {
297+
id: 'generateLicenseKeyMutation' as const,
298+
operationName: 'generateLicenseKey',
299+
definitionName: 'generateLicenseKey',
300+
containsFile: false,
301+
query: `
302+
mutation generateLicenseKey($sessionId: String!) {
303+
generateLicenseKey(sessionId: $sessionId)
304+
}`,
305+
};
306+
296307
export const getCopilotHistoriesQuery = {
297308
id: 'getCopilotHistoriesQuery' as const,
298309
operationName: 'getCopilotHistories',

0 commit comments

Comments
 (0)