feat(core): impl subscription plans setting

This commit is contained in:
forehalo 2023-10-19 10:08:16 +08:00
parent df054ac7f6
commit 1d62133f4f
No known key found for this signature in database
11 changed files with 829 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import type { ReactElement, SVGProps } from 'react';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
import { AFFiNECloudPlans } from './plans';
import { Plugins } from './plugins';
import { Shortcuts } from './shortcuts';
@ -16,7 +17,9 @@ export type GeneralSettingKeys =
| 'shortcuts'
| 'appearance'
| 'plugins'
| 'about';
| 'about'
| 'plans'
| 'billing';
interface GeneralSettingListItem {
key: GeneralSettingKeys;
@ -43,6 +46,22 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon,
testId: 'shortcuts-panel-trigger',
},
{
key: 'plans',
// TODO: i18n
title: 'AFFiNE Cloud Plans',
// TODO: icon
icon: KeyboardIcon,
testId: 'plans-panel-trigger',
},
{
key: 'billing',
// TODO: i18n
title: 'Billing',
// TODO: icon
icon: KeyboardIcon,
testId: 'billing-panel-trigger',
},
{
key: 'plugins',
title: 'Plugins',
@ -72,6 +91,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
return <Plugins />;
case 'about':
return <AboutAffine />;
case 'plans':
return <AFFiNECloudPlans />;
default:
return null;
}

View File

@ -0,0 +1,417 @@
import { RadioButton, RadioButtonGroup } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import {
cancelSubscriptionMutation,
checkoutMutation,
pricesQuery,
SubscriptionPlan,
subscriptionQuery,
SubscriptionRecurring,
updateSubscriptionMutation,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { Button } from '@toeverything/components/button';
import {
type PropsWithChildren,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
interface FixedPrice {
type: 'fixed';
plan: SubscriptionPlan;
price: string;
yearlyPrice: string;
discount?: string;
benefits: string[];
}
interface DynamicPrice {
type: 'dynamic';
plan: SubscriptionPlan;
contact: boolean;
benefits: string[];
}
// TODO: i18n all things
const planDetail = new Map<SubscriptionPlan, FixedPrice | DynamicPrice>([
[
SubscriptionPlan.Free,
{
type: 'fixed',
plan: SubscriptionPlan.Free,
price: '0',
yearlyPrice: '0',
benefits: [
'Unlimited local workspace',
'Unlimited login devices',
'Unlimited blocks',
'AFFiNE Cloud Storage 10GB',
'The maximum file size is 10M',
'Number of members per Workspace ≤ 3',
],
},
],
[
SubscriptionPlan.Pro,
{
type: 'fixed',
plan: SubscriptionPlan.Pro,
price: '1',
yearlyPrice: '1',
benefits: [
'Unlimited local workspace',
'Unlimited login devices',
'Unlimited blocks',
'AFFiNE Cloud Storage 100GB',
'The maximum file size is 500M',
'Number of members per Workspace ≤ 10',
],
},
],
[
SubscriptionPlan.Team,
{
type: 'dynamic',
plan: SubscriptionPlan.Team,
contact: true,
benefits: [
'Best team workspace for collaboration and knowledge distilling.',
'Focusing on what really matters with team project management and automation.',
'Pay for seats, fits all team size.',
],
},
],
[
SubscriptionPlan.Enterprise,
{
type: 'dynamic',
plan: SubscriptionPlan.Enterprise,
contact: true,
benefits: [
'Solutions & best practices for dedicated needs.',
'Embedable & interrogations with IT support.',
],
},
],
]);
const Settings = () => {
const { data, mutate } = useQuery({
query: subscriptionQuery,
});
const {
data: { prices },
} = useQuery({
query: pricesQuery,
});
prices.forEach(price => {
const detail = planDetail.get(price.plan);
if (detail?.type === 'fixed') {
detail.price = (price.amount / 100).toFixed(2);
detail.yearlyPrice = (price.yearlyAmount / 100 / 12).toFixed(2);
detail.discount = (
(1 - price.yearlyAmount / 12 / price.amount) *
100
).toFixed(2);
}
});
const loggedIn = !!data.currentUser;
const subscription = data.currentUser?.subscription;
const [recurring, setRecurring] = useState<string>(
subscription?.recurring ?? SubscriptionRecurring.Monthly
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring =
subscription?.recurring ?? SubscriptionRecurring.Monthly;
const refresh = useCallback(() => {
mutate();
}, [mutate]);
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
)?.discount;
return (
<>
<SettingHeader
title="Plans"
subtitle={
// TODO: different subtitle for un-logged user
<p>
You are current on the {currentPlan} plan. If you have any
questions, please contact our{' '}
<span>{/*TODO: add action*/}customer support</span>.
</p>
}
/>
<div className={styles.wrapper}>
<RadioButtonGroup
className={styles.recurringRadioGroup}
value={recurring}
onValueChange={setRecurring}
>
{Object.values(SubscriptionRecurring).map(plan => (
<RadioButton key={plan} value={plan}>
{plan}
{plan === SubscriptionRecurring.Yearly && yearlyDiscount && (
<span className={styles.radioButtonDiscount}>
{yearlyDiscount}% off
</span>
)}
</RadioButton>
))}
</RadioButtonGroup>
{/* TODO: plan cards horizontal scroll behavior is not the same as design */}
{/* TODO: may scroll current plan into view when first loading? */}
<div className={styles.planCardsWrapper}>
{Array.from(planDetail.values()).map(detail => {
const isCurrent =
currentPlan === detail.plan && currentRecurring === recurring;
return (
<div
key={detail.plan}
className={
loggedIn && currentPlan === detail.plan
? styles.currentPlanCard
: styles.planCard
}
>
<div className={styles.planTitle}>
<p>
{detail.plan}{' '}
{'discount' in detail && (
<span className={styles.discountLabel}>
{detail.discount}% off
</span>
)}
</p>
<p>
<span className={styles.planPrice}>
$
{detail.type === 'dynamic'
? '?'
: recurring === SubscriptionRecurring.Monthly
? detail.price
: detail.yearlyPrice}
</span>
<span className={styles.planPriceDesc}>per month</span>
</p>
{
// branches:
// if contact => 'Contact Sales'
// if not signed in:
// if free => 'Sign up free'
// else => 'Buy Pro'
// else
// if isCurrent => 'Current Plan'
// else if free => 'Downgrade'
// else if currentRecurring !== recurring => 'Change to {recurring} Billing'
// else => 'Upgrade'
// TODO: should replace with components with proper actions
detail.type === 'dynamic' ? (
<ContactSales />
) : loggedIn ? (
isCurrent ? (
<CurrentPlan />
) : detail.plan === SubscriptionPlan.Free ? (
<Downgrade onActionDone={refresh} />
) : currentRecurring !== recurring ? (
<ChangeRecurring
from={currentRecurring}
to={recurring as SubscriptionRecurring}
onActionDone={refresh}
/>
) : (
<Upgrade recurring={recurring} onActionDone={refresh} />
)
) : (
<SignupAction>
{detail.plan === SubscriptionPlan.Free
? 'Sign up free'
: 'Buy Pro'}
</SignupAction>
)
}
</div>
<div className={styles.planBenefits}>
{detail.benefits.map((content, i) => (
<div key={i} className={styles.planBenefit}>
<div className={styles.planBenefitIcon}>
{/* TODO: icons */}
{detail.type == 'dynamic' ? '·' : '✅'}
</div>
{content}
</div>
))}
</div>
</div>
);
})}
</div>
<a
className={styles.allPlansLink}
href="https://affine.pro/pricing"
target="_blank"
rel="noopener noreferrer"
>
See all plans {/* TODO: icon */}
</a>
</div>
</>
);
};
const Downgrade = ({ onActionDone }: { onActionDone: () => void }) => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const downgrade = useCallback(() => {
trigger(null, { onSuccess: onActionDone });
}, [trigger, onActionDone]);
return (
<Button
className={styles.planAction}
type="primary"
onClick={downgrade /* TODO: poppup confirmation modal instead */}
disabled={isMutating}
loading={isMutating}
>
Downgrade
</Button>
);
};
const Upgrade = ({
recurring,
onActionDone,
}: {
recurring: SubscriptionRecurring;
onActionDone: () => void;
}) => {
const { isMutating, trigger, data } = useMutation({
mutation: checkoutMutation,
});
const upgrade = useCallback(() => {
trigger({ recurring });
}, [trigger, recurring]);
const newTabRef = useRef<Window | null>(null);
useEffect(() => {
if (data?.checkout) {
if (newTabRef.current) {
newTabRef.current.focus();
} else {
// FIXME: safari prevents from opening new tab by window api
// TODO(@xp): what if electron?
const newTab = window.open(
data.checkout,
'_blank',
'noopener noreferrer'
);
if (newTab) {
newTabRef.current = newTab;
const update = () => {
onActionDone();
};
newTab.addEventListener('close', update);
return () => newTab.removeEventListener('close', update);
}
}
}
return;
}, [data?.checkout, onActionDone]);
return (
<Button
className={styles.planAction}
type="primary"
onClick={upgrade}
disabled={isMutating}
loading={isMutating}
>
Upgrade
</Button>
);
};
const ChangeRecurring = ({
from: _from /* TODO: from can be useful when showing confirmation modal */,
to,
onActionDone,
}: {
from: SubscriptionRecurring;
to: SubscriptionRecurring;
onActionDone: () => void;
}) => {
const { isMutating, trigger } = useMutation({
mutation: updateSubscriptionMutation,
});
const change = useCallback(() => {
trigger({ recurring: to }, { onSuccess: onActionDone });
}, [trigger, onActionDone, to]);
return (
<Button
className={styles.planAction}
type="primary"
onClick={change /* TODO: popup confirmation modal instead */}
disabled={isMutating}
loading={isMutating}
>
Change to {to} Billing
</Button>
);
};
const ContactSales = () => {
return (
// TODO: add action
<Button className={styles.planAction} type="primary">
Contact Sales
</Button>
);
};
const CurrentPlan = () => {
return <Button className={styles.planAction}>Current Plan</Button>;
};
const SignupAction = ({ children }: PropsWithChildren) => {
// TODO: add login action
return (
<Button className={styles.planAction} type="primary">
{children}
</Button>
);
};
export const AFFiNECloudPlans = () => {
return (
// TODO: loading skeleton
// TODO: Error Boundary
<Suspense>
<Settings />
</Suspense>
);
};

View File

@ -0,0 +1,104 @@
import { style } from '@vanilla-extract/css';
export const wrapper = style({
width: '100%',
});
export const recurringRadioGroup = style({
width: '256px',
});
export const radioButtonDiscount = style({
marginLeft: '4px',
color: 'var(--affine-primary-color)',
});
export const planCardsWrapper = style({
marginTop: '24px',
display: 'flex',
overflowX: 'auto',
});
export const planCard = style({
minHeight: '426px',
minWidth: '258px',
borderRadius: '16px',
padding: '20px',
border: '1px solid var(--affine-border-color)',
selectors: {
'&:not(:last-child)': {
marginRight: '16px',
},
},
});
export const currentPlanCard = style([
planCard,
{
borderWidth: '2px',
borderColor: 'var(--affine-primary-color)',
},
]);
export const discountLabel = style({
color: 'var(--affine-primary-color)',
marginLeft: '8px',
lineHeight: '20px',
fontSize: 'var(--affine-font-xs)',
fontWeight: 500,
padding: '0 4px',
backgroundColor: 'var(--affine-blue-50)',
borderRadius: '4px',
display: 'inline-block',
height: '100%',
});
export const planTitle = style({
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '10px',
fontWeight: 600,
});
export const planPrice = style({
fontSize: 'var(--affine-font-h-5)',
marginRight: '8px',
});
export const planPriceDesc = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-sm)',
});
export const planAction = style({
width: '100%',
});
export const planBenefits = style({
marginTop: '20px',
fontSize: 'var(--affine-font-xs)',
});
export const planBenefit = style({
display: 'flex',
selectors: {
'&:not(:last-child)': {
marginBottom: '8px',
},
},
});
export const planBenefitIcon = style({
display: 'inline-block',
marginRight: '8px',
});
export const allPlansLink = style({
display: 'block',
marginTop: '36px',
color: 'var(--affine-primary-color)',
background: 'transparent',
borderColor: 'transparent',
fontSize: 'var(--affine-font-xs)',
});

View File

@ -0,0 +1,8 @@
mutation cancelSubscription {
cancelSubscription {
id
status
nextBillAt
canceledAt
}
}

View File

@ -0,0 +1,3 @@
mutation checkout($recurring: SubscriptionRecurring!) {
checkout(recurring: $recurring)
}

View File

@ -79,6 +79,22 @@ query allBlobSizes {
}`,
};
export const cancelSubscriptionMutation = {
id: 'cancelSubscriptionMutation' as const,
operationName: 'cancelSubscription',
definitionName: 'cancelSubscription',
containsFile: false,
query: `
mutation cancelSubscription {
cancelSubscription {
id
status
nextBillAt
canceledAt
}
}`,
};
export const changeEmailMutation = {
id: 'changeEmailMutation' as const,
operationName: 'changeEmail',
@ -111,6 +127,17 @@ mutation changePassword($token: String!, $newPassword: String!) {
}`,
};
export const checkoutMutation = {
id: 'checkoutMutation' as const,
operationName: 'checkout',
definitionName: 'checkout',
containsFile: false,
query: `
mutation checkout($recurring: SubscriptionRecurring!) {
checkout(recurring: $recurring)
}`,
};
export const createWorkspaceMutation = {
id: 'createWorkspaceMutation' as const,
operationName: 'createWorkspace',
@ -321,6 +348,29 @@ query getWorkspaces {
}`,
};
export const invoicesQuery = {
id: 'invoicesQuery' as const,
operationName: 'invoices',
definitionName: 'currentUser',
containsFile: false,
query: `
query invoices($take: Int!, $skip: Int!) {
currentUser {
invoices(take: $take, skip: $skip) {
id
status
plan
recurring
currency
amount
reason
lastPaymentError
createdAt
}
}
}`,
};
export const leaveWorkspaceMutation = {
id: 'leaveWorkspaceMutation' as const,
operationName: 'leaveWorkspace',
@ -336,6 +386,23 @@ mutation leaveWorkspace($workspaceId: String!, $workspaceName: String!, $sendLea
}`,
};
export const pricesQuery = {
id: 'pricesQuery' as const,
operationName: 'prices',
definitionName: 'prices',
containsFile: false,
query: `
query prices {
prices {
type
plan
currency
amount
yearlyAmount
}
}`,
};
export const removeAvatarMutation = {
id: 'removeAvatarMutation' as const,
operationName: 'removeAvatar',
@ -469,6 +536,44 @@ mutation signUp($name: String!, $email: String!, $password: String!) {
}`,
};
export const subscriptionQuery = {
id: 'subscriptionQuery' as const,
operationName: 'subscription',
definitionName: 'currentUser',
containsFile: false,
query: `
query subscription {
currentUser {
subscription {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
}
}
}`,
};
export const updateSubscriptionMutation = {
id: 'updateSubscriptionMutation' as const,
operationName: 'updateSubscription',
definitionName: 'updateSubscriptionRecurring',
containsFile: false,
query: `
mutation updateSubscription($recurring: SubscriptionRecurring!) {
updateSubscriptionRecurring(recurring: $recurring) {
id
plan
recurring
nextBillAt
}
}`,
};
export const uploadAvatarMutation = {
id: 'uploadAvatarMutation' as const,
operationName: 'uploadAvatar',

View File

@ -0,0 +1,15 @@
query invoices($take: Int!, $skip: Int!) {
currentUser {
invoices(take: $take, skip: $skip) {
id
status
plan
recurring
currency
amount
reason
lastPaymentError
createdAt
}
}
}

View File

@ -0,0 +1,9 @@
query prices {
prices {
type
plan
currency
amount
yearlyAmount
}
}

View File

@ -0,0 +1,14 @@
query subscription {
currentUser {
subscription {
id
status
plan
recurring
start
end
nextBillAt
canceledAt
}
}
}

View File

@ -0,0 +1,8 @@
mutation updateSubscription($recurring: SubscriptionRecurring!) {
updateSubscriptionRecurring(recurring: $recurring) {
id
plan
recurring
nextBillAt
}
}

View File

@ -130,6 +130,21 @@ export type AllBlobSizesQuery = {
collectAllBlobSizes: { __typename?: 'WorkspaceBlobSizes'; size: number };
};
export type CancelSubscriptionMutationVariables = Exact<{
[key: string]: never;
}>;
export type CancelSubscriptionMutation = {
__typename?: 'Mutation';
cancelSubscription: {
__typename?: 'UserSubscription';
id: string;
status: SubscriptionStatus;
nextBillAt: string | null;
canceledAt: string | null;
};
};
export type ChangeEmailMutationVariables = Exact<{
token: Scalars['String']['input'];
}>;
@ -161,6 +176,12 @@ export type ChangePasswordMutation = {
};
};
export type CheckoutMutationVariables = Exact<{
recurring: SubscriptionRecurring;
}>;
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
export type CreateWorkspaceMutationVariables = Exact<{
init: Scalars['Upload']['input'];
}>;
@ -328,6 +349,30 @@ export type GetWorkspacesQuery = {
workspaces: Array<{ __typename?: 'WorkspaceType'; id: string }>;
};
export type InvoicesQueryVariables = Exact<{
take: Scalars['Int']['input'];
skip: Scalars['Int']['input'];
}>;
export type InvoicesQuery = {
__typename?: 'Query';
currentUser: {
__typename?: 'UserType';
invoices: Array<{
__typename?: 'UserInvoice';
id: string;
status: InvoiceStatus;
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
currency: string;
amount: number;
reason: string;
lastPaymentError: string | null;
createdAt: string;
}>;
} | null;
};
export type LeaveWorkspaceMutationVariables = Exact<{
workspaceId: Scalars['String']['input'];
workspaceName: Scalars['String']['input'];
@ -339,6 +384,20 @@ export type LeaveWorkspaceMutation = {
leaveWorkspace: boolean;
};
export type PricesQueryVariables = Exact<{ [key: string]: never }>;
export type PricesQuery = {
__typename?: 'Query';
prices: Array<{
__typename?: 'SubscriptionPrice';
type: string;
plan: SubscriptionPlan;
currency: string;
amount: number;
yearlyAmount: number;
}>;
};
export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>;
export type RemoveAvatarMutation = {
@ -451,6 +510,41 @@ export type SignUpMutation = {
};
};
export type SubscriptionQueryVariables = Exact<{ [key: string]: never }>;
export type SubscriptionQuery = {
__typename?: 'Query';
currentUser: {
__typename?: 'UserType';
subscription: {
__typename?: 'UserSubscription';
id: string;
status: SubscriptionStatus;
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
start: string;
end: string;
nextBillAt: string | null;
canceledAt: string | null;
} | null;
} | null;
};
export type UpdateSubscriptionMutationVariables = Exact<{
recurring: SubscriptionRecurring;
}>;
export type UpdateSubscriptionMutation = {
__typename?: 'Mutation';
updateSubscriptionRecurring: {
__typename?: 'UserSubscription';
id: string;
plan: SubscriptionPlan;
recurring: SubscriptionRecurring;
nextBillAt: string | null;
};
};
export type UploadAvatarMutationVariables = Exact<{
avatar: Scalars['Upload']['input'];
}>;
@ -570,6 +664,21 @@ export type Queries =
name: 'getWorkspacesQuery';
variables: GetWorkspacesQueryVariables;
response: GetWorkspacesQuery;
}
| {
name: 'invoicesQuery';
variables: InvoicesQueryVariables;
response: InvoicesQuery;
}
| {
name: 'pricesQuery';
variables: PricesQueryVariables;
response: PricesQuery;
}
| {
name: 'subscriptionQuery';
variables: SubscriptionQueryVariables;
response: SubscriptionQuery;
};
export type Mutations =
@ -583,6 +692,11 @@ export type Mutations =
variables: SetBlobMutationVariables;
response: SetBlobMutation;
}
| {
name: 'cancelSubscriptionMutation';
variables: CancelSubscriptionMutationVariables;
response: CancelSubscriptionMutation;
}
| {
name: 'changeEmailMutation';
variables: ChangeEmailMutationVariables;
@ -593,6 +707,11 @@ export type Mutations =
variables: ChangePasswordMutationVariables;
response: ChangePasswordMutation;
}
| {
name: 'checkoutMutation';
variables: CheckoutMutationVariables;
response: CheckoutMutation;
}
| {
name: 'createWorkspaceMutation';
variables: CreateWorkspaceMutationVariables;
@ -668,6 +787,11 @@ export type Mutations =
variables: SignUpMutationVariables;
response: SignUpMutation;
}
| {
name: 'updateSubscriptionMutation';
variables: UpdateSubscriptionMutationVariables;
response: UpdateSubscriptionMutation;
}
| {
name: 'uploadAvatarMutation';
variables: UploadAvatarMutationVariables;