Skip to content

Commit

Permalink
feat(core): add self host team plan
Browse files Browse the repository at this point in the history
  • Loading branch information
JimmFly committed Jan 7, 2025
1 parent 39c4981 commit 9142705
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 6 deletions.
2 changes: 1 addition & 1 deletion packages/backend/server/src/plugins/payment/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ export class SubscriptionResolver {
if (input.plan === SubscriptionPlan.SelfHostedTeam) {
session = await this.service.checkout(input, {
plan: input.plan as any,
quantity: input.args.quantity ?? 10,
quantity: input.args?.quantity ?? 10,
});
} else {
if (!user) {
Expand Down
10 changes: 9 additions & 1 deletion packages/backend/server/src/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ type EditorType {
name: String!
}

union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WrongSignInCredentialsDataType
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocAccessDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WrongSignInCredentialsDataType

enum ErrorNames {
ACCESS_DENIED
Expand Down Expand Up @@ -337,6 +337,10 @@ type InvalidHistoryTimestampDataType {
timestamp: String!
}

type InvalidLicenseUpdateParamsDataType {
reason: String!
}

type InvalidPasswordLengthDataType {
max: Int!
min: Int!
Expand Down Expand Up @@ -976,6 +980,10 @@ enum WorkspaceMemberStatus {
UnderReview
}

type WorkspaceMembersExceedLimitToDowngradeDataType {
limit: Int!
}

type WorkspacePage {
id: String!
mode: PublicPageMode!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,21 @@ export const generateSubscriptionCallbackLink = (
recurring: SubscriptionRecurring,
workspaceId?: string
) => {
if (account === null) {
throw new Error('Account is required');
}
const baseUrl =
plan === SubscriptionPlan.AI
? '/ai-upgrade-success'
: plan === SubscriptionPlan.Team
? '/upgrade-success/team'
: '/upgrade-success';
: plan === SubscriptionPlan.SelfHostedTeam
? '/upgrade-success/self-hosted-team'
: '/upgrade-success';

if (plan === SubscriptionPlan.SelfHostedTeam) {
return baseUrl;
}
if (account === null) {
throw new Error('Account is required');
}

let name = account?.info?.name ?? '';
if (name.includes(separator)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,40 @@ export function getPlanDetail(t: T) {
benefits: teamBenefits(t),
},
],
[
SubscriptionPlan.SelfHostedTeam,
{
type: 'fixed',
plan: SubscriptionPlan.SelfHostedTeam,
price: '12',
yearlyPrice: '10',
name: 'Self-hosted Team',
description: 'Self-hosted Team 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),
},
],
]);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import {
type CreateCheckoutSessionInput,
ServerDeploymentType,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
Expand Down Expand Up @@ -121,15 +122,24 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
const isFree = detail.plan === SubscriptionPlan.Free;

const serverService = useService(ServerService);
const isSelfHosted = useLiveData(
serverService.server.config$.selector(
c => c.type === ServerDeploymentType.Selfhosted
)
);

const signUpText = useMemo(
() => getSignUpText(detail.plan, t),
[detail.plan, t]
);

// branches:
// if contact => 'Contact Sales'
// if self-hosted team => 'Upgrade'
// if not signed in:
// if free => 'Sign up free'
// if team => 'Upgrade'
// else => 'Buy Pro'
// else
// if team => 'Start 14-day free trial'
Expand All @@ -144,6 +154,13 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
// if currentRecurring !== recurring => 'Change to {recurring} Billing'
// else => 'Upgrade'

// self-hosted team
if (isSelfHosted || detail.plan === SubscriptionPlan.SelfHostedTeam) {
return (
<UpgradeToSelfHostTeam recurring={recurring as SubscriptionRecurring} />
);
}

// not signed in
if (!loggedIn) {
return <SignUpAction>{signUpText}</SignUpAction>;
Expand Down Expand Up @@ -267,6 +284,51 @@ const UpgradeToTeam = ({ recurring }: { recurring: SubscriptionRecurring }) => {
</a>
);
};
const UpgradeToSelfHostTeam = ({
recurring,
}: {
recurring: SubscriptionRecurring;
}) => {
const t = useI18n();

const handleBeforeCheckout = useCallback(() => {
track.$.settingsPanel.plans.checkout({
plan: SubscriptionPlan.SelfHostedTeam,
recurring: recurring,
});
}, [recurring]);

const checkoutOptions = useMemo(
() => ({
recurring,
plan: SubscriptionPlan.SelfHostedTeam,
variant: null,
coupon: null,
successCallbackLink: generateSubscriptionCallbackLink(
null,
SubscriptionPlan.SelfHostedTeam,
recurring
),
}),
[recurring]
);

return (
<CheckoutSlot
onBeforeCheckout={handleBeforeCheckout}
checkoutOptions={checkoutOptions}
renderer={props => (
<Button
className={clsx(styles.planAction)}
variant="primary"
{...props}
>
{t['com.affine.payment.upgrade']()}
</Button>
)}
/>
);
};

export const Upgrade = ({
className,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { Button, IconButton, notify } from '@affine/component';
import { AuthPageContainer } from '@affine/component/auth-components';
import { useMutation } from '@affine/core/components/hooks/use-mutation';
import { OpenInAppService } from '@affine/core/modules/open-in-app';
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
import { generateLicenseKeyMutation, UserFriendlyError } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { CopyIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useSearchParams } from 'react-router-dom';

import { PageNotFound } from '../../404';
import * as styles from './styles.css';

/**
* /upgrade-success/self-hosted-team page
*
* only on web
*/
export const Component = () => {
const [params] = useSearchParams();
const [key, setKey] = useState<string | null>(null);
const sessionId = params.get('session_id');
const { trigger: generateLicenseKey } = useMutation({
mutation: generateLicenseKeyMutation,
});

useEffect(() => {
if (sessionId && !key) {
generateLicenseKey({ sessionId })
.then(({ generateLicenseKey }) => {
setKey(generateLicenseKey);
})
.catch(e => {
const error = UserFriendlyError.fromAnyError(e);
console.error(error);

notify.error({
title: error.name,
message: error.message,
});
});
}
}, [generateLicenseKey, key, sessionId]);

if (!sessionId) {
return <PageNotFound noPermission />;
}

if (key) {
return <Success licenseKey={key} />;
} else {
return (
<AuthPageContainer
title={'Failed to generate the license key'}
subtitle={
<span>
Failed to generate the license key, please contact our {''}
<a href="mailto:[email protected]" className={styles.mail}>
customer support
</a>
.
</span>
}
></AuthPageContainer>
);
}
};

const Success = ({ licenseKey }: { licenseKey: string }) => {
const t = useI18n();
const openInAppService = useService(OpenInAppService);

const openAFFiNE = useCallback(() => {
openInAppService.showOpenInAppPage();
}, [openInAppService]);

const onCopy = useCallback(() => {
copyTextToClipboard(licenseKey)
.then(success => {
if (success) {
notify.success({
title: t['com.affine.payment.license-success.copy'](),
});
}
})
.catch(err => {
console.error(err);
notify.error({ title: 'Copy failed, please try again later' });
});
}, [licenseKey, t]);

const subtitle = (
<span className={styles.leftContentText}>
<span>{t['com.affine.payment.license-success.text-1']()}</span>
<span>
<Trans
i18nKey={'com.affine.payment.license-success.text-2'}
components={{
1: (
<a
href="mailto:[email protected]"
className={styles.mail}
/>
),
}}
/>
</span>
</span>
);
return (
<AuthPageContainer
title={t['com.affine.payment.license-success.title']()}
subtitle={subtitle}
>
<div className={styles.content}>
<div className={styles.licenseKeyContainer}>
{licenseKey}
<IconButton
icon={<CopyIcon />}
className={styles.icon}
size="20"
tooltip={t['Copy']()}
onClick={onCopy}
/>
</div>
<div>{t['com.affine.payment.license-success.hint']()}</div>
<div>
<Button variant="primary" size="extraLarge" onClick={openAFFiNE}>
{t['com.affine.payment.license-success.open-affine']()}
</Button>
</div>
</div>
</AuthPageContainer>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
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'),
},
});
export const content = style({
display: 'flex',
flexDirection: 'column',
gap: '28px',
});

export const licenseKeyContainer = style({
width: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
backgroundColor: cssVarV2('layer/background/secondary'),
borderRadius: '4px',
border: `1px solid ${cssVarV2('layer/insideBorder/blackBorder')}`,
padding: '8px 10px',
gap: '8px',
});

export const icon = style({
color: cssVarV2('icon/primary'),
});
4 changes: 4 additions & 0 deletions packages/frontend/core/src/desktop/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export const topLevelRoutes = [
path: '/upgrade-success/team',
lazy: () => import('./pages/upgrade-success/team'),
},
{
path: '/upgrade-success/self-hosted-team',
lazy: () => import('./pages/upgrade-success/self-host-team'),
},
{
path: '/ai-upgrade-success',
lazy: () => import('./pages/ai-upgrade-success'),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
mutation generateLicenseKey($sessionId: String!) {
generateLicenseKey(sessionId: $sessionId)
}
Loading

0 comments on commit 9142705

Please sign in to comment.