diff --git a/packages/backend/server/src/plugins/payment/resolver.ts b/packages/backend/server/src/plugins/payment/resolver.ts index 45c7344e027c1..b233f8fcc2a4c 100644 --- a/packages/backend/server/src/plugins/payment/resolver.ts +++ b/packages/backend/server/src/plugins/payment/resolver.ts @@ -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) { diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index 196baee8f1f0e..28171edfaf5e0 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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 @@ -337,6 +337,10 @@ type InvalidHistoryTimestampDataType { timestamp: String! } +type InvalidLicenseUpdateParamsDataType { + reason: String! +} + type InvalidPasswordLengthDataType { max: Int! min: Int! @@ -976,6 +980,10 @@ enum WorkspaceMemberStatus { UnderReview } +type WorkspaceMembersExceedLimitToDowngradeDataType { + limit: Int! +} + type WorkspacePage { id: String! mode: PublicPageMode! diff --git a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx index 9c48d62df267a..a8cc253eb223f 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx +++ b/packages/frontend/core/src/components/hooks/affine/use-subscription-notify.tsx @@ -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)) { diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx index db88da3939f71..b679d8f865031 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/cloud-plans.tsx @@ -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 ? ( + + {t[ + 'com.affine.payment.cloud.team-workspace.title.billed-yearly' + ]()} + + ) : null} + + ); + }, + benefits: teamBenefits(t), + }, + ], ]); } diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx index ae65910b0faa8..518a5f6c23f50 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/plan-card.tsx @@ -10,6 +10,7 @@ import { import { GlobalDialogService } from '@affine/core/modules/dialogs'; import { type CreateCheckoutSessionInput, + ServerDeploymentType, SubscriptionPlan, SubscriptionRecurring, SubscriptionStatus, @@ -121,6 +122,13 @@ 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] @@ -128,8 +136,10 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // 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' @@ -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 ( + + ); + } + // not signed in if (!loggedIn) { return {signUpText}; @@ -267,6 +284,51 @@ const UpgradeToTeam = ({ recurring }: { recurring: SubscriptionRecurring }) => { ); }; +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 ( + ( + + )} + /> + ); +}; export const Upgrade = ({ className, diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx new file mode 100644 index 0000000000000..b9521f9f5b40b --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/index.tsx @@ -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(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 ; + } + + if (key) { + return ; + } else { + return ( + + Failed to generate the license key, please contact our {''} + + customer support + + . + + } + > + ); + } +}; + +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 = ( + + {t['com.affine.payment.license-success.text-1']()} + + + ), + }} + /> + + + ); + return ( + +
+
+ {licenseKey} + } + className={styles.icon} + size="20" + tooltip={t['Copy']()} + onClick={onCopy} + /> +
+
{t['com.affine.payment.license-success.hint']()}
+
+ +
+
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts new file mode 100644 index 0000000000000..474b38e0ebb1e --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/upgrade-success/self-host-team/styles.css.ts @@ -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'), +}); diff --git a/packages/frontend/core/src/desktop/router.tsx b/packages/frontend/core/src/desktop/router.tsx index d311ade62c0ab..b0173c75a36fe 100644 --- a/packages/frontend/core/src/desktop/router.tsx +++ b/packages/frontend/core/src/desktop/router.tsx @@ -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'), diff --git a/packages/frontend/graphql/src/graphql/generate-license-key.gql b/packages/frontend/graphql/src/graphql/generate-license-key.gql new file mode 100644 index 0000000000000..43f66dbfabd1a --- /dev/null +++ b/packages/frontend/graphql/src/graphql/generate-license-key.gql @@ -0,0 +1,3 @@ +mutation generateLicenseKey($sessionId: String!) { + generateLicenseKey(sessionId: $sessionId) +} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 90fa4e658f019..0614546451688 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -293,6 +293,17 @@ mutation forkCopilotSession($options: ForkChatSessionInput!) { }`, }; +export const generateLicenseKeyMutation = { + id: 'generateLicenseKeyMutation' as const, + operationName: 'generateLicenseKey', + definitionName: 'generateLicenseKey', + containsFile: false, + query: ` +mutation generateLicenseKey($sessionId: String!) { + generateLicenseKey(sessionId: $sessionId) +}`, +}; + export const getCopilotHistoriesQuery = { id: 'getCopilotHistoriesQuery' as const, operationName: 'getCopilotHistories', diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index 84791f356523d..6d1dad11b7d87 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -1700,6 +1700,15 @@ export type PasswordLimitsFragment = { maxLength: number; }; +export type GenerateLicenseKeyMutationVariables = Exact<{ + sessionId: Scalars['String']['input']; +}>; + +export type GenerateLicenseKeyMutation = { + __typename?: 'Mutation'; + generateLicenseKey: string; +}; + export type GetCopilotHistoriesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; docId?: InputMaybe; @@ -3130,6 +3139,11 @@ export type Mutations = variables: ForkCopilotSessionMutationVariables; response: ForkCopilotSessionMutation; } + | { + name: 'generateLicenseKeyMutation'; + variables: GenerateLicenseKeyMutationVariables; + response: GenerateLicenseKeyMutation; + } | { name: 'leaveWorkspaceMutation'; variables: LeaveWorkspaceMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index fc3c770c9aed2..84bdcb6e6c420 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1033,6 +1033,12 @@ "com.affine.payment.upgrade-success-page.title": "Upgrade successful!", "com.affine.payment.upgrade-success-page.team.text-1": "Congratulations! Your workspace has been successfully upgraded to a Team Workspace. Now you can invite unlimited members to collaborate in this workspace.", "com.affine.payment.upgrade-success-page.team.text-2": "If you have any questions, please contact our <1>customer support.", + "com.affine.payment.license-success.title": "Thank you for your purchase!", + "com.affine.payment.license-success.text-1": "Thank you for purchasing the AFFiNE self-hosted license.", + "com.affine.payment.license-success.text-2": "If you have any questions, please contact our <1>customer support.", + "com.affine.payment.license-success.hint": "You can use this key to upgrade in Settings > Workspace > Billing > Upgrade", + "com.affine.payment.license-success.open-affine": "Open AFFiNE", + "com.affine.payment.license-success.copy": "Copied key to clipboard", "com.affine.peek-view-controls.close": "Close", "com.affine.peek-view-controls.open-doc": "Open this doc", "com.affine.peek-view-controls.open-doc-in-new-tab": "Open in new tab",