feat(core): ai subscription in billing page (#6476)

This commit is contained in:
CatsJuice 2024-04-09 08:54:53 +00:00
parent 97c4ae48b5
commit 4a93582799
No known key found for this signature in database
GPG Key ID: 1C1E76924FAFDDE4
11 changed files with 163 additions and 78 deletions

View File

@ -21,6 +21,7 @@ import {
} from '@affine/graphql'; } from '@affine/graphql';
import { Trans } from '@affine/i18n'; import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { ArrowRightSmallIcon } from '@blocksuite/icons'; import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useMemo, useState } from 'react'; import { Suspense, useCallback, useMemo, useState } from 'react';
@ -34,6 +35,8 @@ import { useUserSubscription } from '../../../../../hooks/use-subscription';
import { mixpanel, popupWindow } from '../../../../../utils'; import { mixpanel, popupWindow } from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary'; import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
import { CancelAction, ResumeAction } from '../plans/actions'; import { CancelAction, ResumeAction } from '../plans/actions';
import { useAffineAIPrice } from '../plans/ai/use-affine-ai-price';
import { useAffineAISubscription } from '../plans/ai/use-affine-ai-subscription';
import * as styles from './style.css'; import * as styles from './style.css';
enum DescriptionI18NKey { enum DescriptionI18NKey {
@ -93,6 +96,11 @@ export const BillingSettings = () => {
const SubscriptionSettings = () => { const SubscriptionSettings = () => {
const [subscription, mutateSubscription] = useUserSubscription(); const [subscription, mutateSubscription] = useUserSubscription();
const [openCancelModal, setOpenCancelModal] = useState(false); const [openCancelModal, setOpenCancelModal] = useState(false);
const {
actionType: aiActionType,
Action: AIAction,
billingTip,
} = useAffineAISubscription();
const { data: pricesQueryResult } = useQuery({ const { data: pricesQueryResult } = useQuery({
query: pricesQuery, query: pricesQuery,
@ -102,6 +110,10 @@ const SubscriptionSettings = () => {
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly; const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
const price = pricesQueryResult.prices.find(price => price.plan === plan); const price = pricesQueryResult.prices.find(price => price.plan === plan);
const aiPrice = pricesQueryResult.prices.find(
price => price.plan === SubscriptionPlan.AI
);
assertExists(aiPrice);
const amount = const amount =
plan === SubscriptionPlan.Free plan === SubscriptionPlan.Free
? '0' ? '0'
@ -111,6 +123,8 @@ const SubscriptionSettings = () => {
: String((price.yearlyAmount ?? 0) / 100) : String((price.yearlyAmount ?? 0) / 100)
: '?'; : '?';
const { priceReadable: aiPriceReadable, priceFrequency: aiPriceFrequency } =
useAffineAIPrice(aiPrice);
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
@ -167,6 +181,30 @@ const SubscriptionSettings = () => {
</span> </span>
</p> </p>
</div> </div>
<div className={styles.planCard} style={{ marginTop: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.ai-plan']()}
desc={billingTip}
/>
{aiPrice?.yearlyAmount ? (
<AIAction
className={styles.planAction}
price={aiPrice}
recurring={SubscriptionRecurring.Yearly}
onSubscriptionUpdate={mutateSubscription}
>
{aiActionType === 'subscribe' ? 'Purchase' : null}
</AIAction>
) : null}
</div>
<p className={styles.planPrice}>
{aiPriceReadable}
<span className={styles.billingFrequency}>/{aiPriceFrequency}</span>
</p>
</div>
{subscription?.status === SubscriptionStatus.Active && ( {subscription?.status === SubscriptionStatus.Active && (
<> <>
<SettingRow <SettingRow
@ -365,6 +403,13 @@ const InvoiceLine = ({
} }
}, [invoice.link]); }, [invoice.link]);
const planText =
invoice.plan === SubscriptionPlan.AI
? 'AFFiNE AI'
: invoice.plan === SubscriptionPlan.Pro
? 'AFFiNE Cloud'
: null;
return ( return (
<SettingRow <SettingRow
key={invoice.id} key={invoice.id}
@ -374,7 +419,7 @@ const InvoiceLine = ({
invoice.status === InvoiceStatus.Paid invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']() ? t['com.affine.payment.billing-setting.paid']()
: '' : ''
} $${invoice.amount / 100}`} } $${invoice.amount / 100} - ${planText}`}
> >
<Button className={styles.button} onClick={open}> <Button className={styles.button} onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()} {t['com.affine.payment.billing-setting.view-invoice']()}

View File

@ -1,15 +1,17 @@
import { Button, useConfirmModal } from '@affine/component'; import { Button, type ButtonProps, useConfirmModal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation'; import { useMutation } from '@affine/core/hooks/use-mutation';
import { cancelSubscriptionMutation } from '@affine/graphql'; import { cancelSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useState } from 'react'; import { useState } from 'react';
import { purchaseButton } from './ai-plan.css'; import type { BaseActionProps } from '../types';
import type { BaseActionProps } from './types';
interface AICancelProps extends BaseActionProps {} export interface AICancelProps extends BaseActionProps, ButtonProps {}
export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => { export const AICancel = ({
onSubscriptionUpdate,
...btnProps
}: AICancelProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { trigger, isMutating } = useMutation({ const { trigger, isMutating } = useMutation({
mutation: cancelSubscriptionMutation, mutation: cancelSubscriptionMutation,
@ -32,7 +34,7 @@ export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
}, },
onConfirm: async () => { onConfirm: async () => {
await trigger( await trigger(
{ idempotencyKey, plan }, { idempotencyKey, plan: SubscriptionPlan.AI },
{ {
onSuccess: data => { onSuccess: data => {
// refresh idempotency key // refresh idempotency key
@ -43,15 +45,10 @@ export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
); );
}, },
}); });
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]); }, [openConfirmModal, trigger, idempotencyKey, onSubscriptionUpdate]);
return ( return (
<Button <Button onClick={cancel} loading={isMutating} type="primary" {...btnProps}>
onClick={cancel}
loading={isMutating}
className={purchaseButton}
type="primary"
>
Cancel subscription Cancel subscription
</Button> </Button>
); );

View File

@ -0,0 +1,4 @@
export * from './cancel';
export * from './login';
export * from './resume';
export * from './subscribe';

View File

@ -1,9 +1,11 @@
import { Button } from '@affine/component'; import { Button, type ButtonProps } from '@affine/component';
import { authAtom } from '@affine/core/atoms'; import { authAtom } from '@affine/core/atoms';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { useCallback } from 'react'; import { useCallback } from 'react';
export const AILogin = () => { import type { BaseActionProps } from '../types';
export const AILogin = (btnProps: BaseActionProps & ButtonProps) => {
const setOpen = useSetAtom(authAtom); const setOpen = useSetAtom(authAtom);
const onClickSignIn = useCallback(() => { const onClickSignIn = useCallback(() => {
@ -13,5 +15,9 @@ export const AILogin = () => {
})); }));
}, [setOpen]); }, [setOpen]);
return <Button onClick={onClickSignIn}>Login</Button>; return (
<Button onClick={onClickSignIn} type="primary" {...btnProps}>
Login
</Button>
);
}; };

View File

@ -1,18 +1,25 @@
import { Button, notify, useConfirmModal } from '@affine/component'; import {
Button,
type ButtonProps,
notify,
useConfirmModal,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation'; import { useMutation } from '@affine/core/hooks/use-mutation';
import { resumeSubscriptionMutation } from '@affine/graphql'; import { resumeSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons'; import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import { cssVar } from '@toeverything/theme'; import { cssVar } from '@toeverything/theme';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useState } from 'react'; import { useState } from 'react';
import { purchaseButton } from './ai-plan.css'; import type { BaseActionProps } from '../types';
import type { BaseActionProps } from './types';
interface AIResumeProps extends BaseActionProps {} export interface AIResumeProps extends BaseActionProps, ButtonProps {}
export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => { export const AIResume = ({
onSubscriptionUpdate,
...btnProps
}: AIResumeProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { isMutating, trigger } = useMutation({ const { isMutating, trigger } = useMutation({
@ -31,7 +38,7 @@ export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => {
}, },
onConfirm: async () => { onConfirm: async () => {
await trigger( await trigger(
{ idempotencyKey, plan }, { idempotencyKey, plan: SubscriptionPlan.AI },
{ {
onSuccess: data => { onSuccess: data => {
// refresh idempotency key // refresh idempotency key
@ -51,15 +58,10 @@ export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => {
); );
}, },
}); });
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]); }, [openConfirmModal, trigger, idempotencyKey, onSubscriptionUpdate]);
return ( return (
<Button <Button loading={isMutating} onClick={resume} type="primary" {...btnProps}>
loading={isMutating}
onClick={resume}
className={purchaseButton}
type="primary"
>
Resume Resume
</Button> </Button>
); );

View File

@ -1,23 +1,27 @@
import { Button } from '@affine/component'; import { Button, type ButtonProps } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation'; import { useMutation } from '@affine/core/hooks/use-mutation';
import { popupWindow } from '@affine/core/utils'; import { popupWindow } from '@affine/core/utils';
import { createCheckoutSessionMutation } from '@affine/graphql'; import {
createCheckoutSessionMutation,
SubscriptionPlan,
} from '@affine/graphql';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useCallback, useEffect, useMemo, useRef } from 'react';
import { purchaseButton } from './ai-plan.css'; import type { BaseActionProps } from '../types';
import type { BaseActionProps } from './types'; import { useAffineAIPrice } from '../use-affine-ai-price';
interface AISubscribeProps extends BaseActionProps {} export interface AISubscribeProps extends BaseActionProps, ButtonProps {}
export const AISubscribe = ({ export const AISubscribe = ({
price, price,
plan,
recurring, recurring,
onSubscriptionUpdate, onSubscriptionUpdate,
...btnProps
}: AISubscribeProps) => { }: AISubscribeProps) => {
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]); const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
const { priceReadable, priceFrequency } = useAffineAIPrice(price);
const newTabRef = useRef<Window | null>(null); const newTabRef = useRef<Window | null>(null);
@ -45,7 +49,7 @@ export const AISubscribe = ({
input: { input: {
recurring, recurring,
idempotencyKey, idempotencyKey,
plan, plan: SubscriptionPlan.AI,
coupon: null, coupon: null,
successCallbackLink: null, successCallbackLink: null,
}, },
@ -60,7 +64,7 @@ export const AISubscribe = ({
}, },
} }
); );
}, [idempotencyKey, onClose, plan, recurring, trigger]); }, [idempotencyKey, onClose, recurring, trigger]);
if (!price.yearlyAmount) return null; if (!price.yearlyAmount) return null;
@ -68,10 +72,10 @@ export const AISubscribe = ({
<Button <Button
loading={isMutating} loading={isMutating}
onClick={subscribe} onClick={subscribe}
className={purchaseButton}
type="primary" type="primary"
{...btnProps}
> >
${(price.yearlyAmount / 100).toFixed(2)} / Year {btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
</Button> </Button>
); );
}; };

View File

@ -1,9 +1,7 @@
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import { import {
type SubscriptionMutator, type SubscriptionMutator,
useUserSubscription, useUserSubscription,
} from '@affine/core/hooks/use-subscription'; } from '@affine/core/hooks/use-subscription';
import { timestampToLocalDate } from '@affine/core/utils';
import { import {
type PricesQuery, type PricesQuery,
SubscriptionPlan, SubscriptionPlan,
@ -13,42 +11,27 @@ import {
import { AIPlanLayout } from '../layout'; import { AIPlanLayout } from '../layout';
import * as styles from './ai-plan.css'; import * as styles from './ai-plan.css';
import { AIBenefits } from './benefits'; import { AIBenefits } from './benefits';
import { AICancel } from './cancel';
import { AILogin } from './login';
import { AIResume } from './resume';
import { AISubscribe } from './subscribe';
import type { BaseActionProps } from './types'; import type { BaseActionProps } from './types';
import { useAffineAISubscription } from './use-affine-ai-subscription';
interface AIPlanProps { interface AIPlanProps {
price?: PricesQuery['prices'][number]; price?: PricesQuery['prices'][number];
onSubscriptionUpdate: SubscriptionMutator; onSubscriptionUpdate: SubscriptionMutator;
} }
export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => { export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
const plan = SubscriptionPlan.AI;
const recurring = SubscriptionRecurring.Yearly; const recurring = SubscriptionRecurring.Yearly;
const loggedIn = useCurrentLoginStatus() === 'authenticated'; const { Action, billingTip } = useAffineAISubscription();
const [subscription] = useUserSubscription(SubscriptionPlan.AI);
const [subscription] = useUserSubscription(plan);
// yearly subscription should always be available // yearly subscription should always be available
if (!price?.yearlyAmount) return null; if (!price?.yearlyAmount) return null;
const baseActionProps: BaseActionProps = { const baseActionProps: BaseActionProps = {
plan,
price, price,
recurring, recurring,
onSubscriptionUpdate, onSubscriptionUpdate,
}; };
const isCancelled = !!subscription?.canceledAt;
const Action = !loggedIn
? AILogin
: !subscription
? AISubscribe
: isCancelled
? AIResume
: AICancel;
return ( return (
<AIPlanLayout <AIPlanLayout
@ -70,9 +53,9 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
</div> </div>
<div className={styles.actionBlock}> <div className={styles.actionBlock}>
<Action {...baseActionProps} /> <Action {...baseActionProps} className={styles.purchaseButton} />
{subscription?.nextBillAt ? ( {billingTip ? (
<PurchasedTip due={timestampToLocalDate(subscription.nextBillAt)} /> <div className={styles.agreement}>{billingTip}</div>
) : null} ) : null}
</div> </div>
@ -81,9 +64,3 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
</AIPlanLayout> </AIPlanLayout>
); );
}; };
const PurchasedTip = ({ due }: { due: string }) => (
<div className={styles.agreement}>
You have purchased AFFiNE AI. The next payment date is {due}.
</div>
);

View File

@ -1,13 +1,8 @@
import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription'; import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
import type { import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql';
PricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
export interface BaseActionProps { export interface BaseActionProps {
price: PricesQuery['prices'][number]; price: PricesQuery['prices'][number];
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
plan: SubscriptionPlan;
onSubscriptionUpdate: SubscriptionMutator; onSubscriptionUpdate: SubscriptionMutator;
} }

View File

@ -0,0 +1,14 @@
import type { PricesQuery } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
export const useAffineAIPrice = (price: PricesQuery['prices'][number]) => {
const t = useAFFiNEI18N();
assertExists(price.yearlyAmount, 'AFFiNE AI yearly price is missing');
const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`;
const priceFrequency = t['com.affine.payment.billing-setting.year']();
return { priceReadable, priceFrequency };
};

View File

@ -0,0 +1,40 @@
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { timestampToLocalDate } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
const plan = SubscriptionPlan.AI;
export type ActionType = 'login' | 'subscribe' | 'resume' | 'cancel';
export const useAffineAISubscription = () => {
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const [subscription] = useUserSubscription(plan);
const isCancelled = !!subscription?.canceledAt;
const actionType: ActionType = !loggedIn
? 'login'
: !subscription
? 'subscribe'
: isCancelled
? 'resume'
: 'cancel';
const Action = {
login: AILogin,
subscribe: AISubscribe,
resume: AIResume,
cancel: AICancel,
}[actionType];
const billingTip = subscription?.nextBillAt
? `You have purchased AFFiNE AI. The next payment date is ${timestampToLocalDate(subscription.nextBillAt)}.`
: subscription?.canceledAt && subscription.end
? `You have purchased AFFiNE AI. The expiration date is ${timestampToLocalDate(subscription.end)}.`
: null;
return { actionType, Action, billingTip };
};

View File

@ -844,10 +844,11 @@
"com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription", "com.affine.payment.billing-setting.cancel-subscription": "Cancel Subscription",
"com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}", "com.affine.payment.billing-setting.cancel-subscription.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
"com.affine.payment.billing-setting.change-plan": "Change Plan", "com.affine.payment.billing-setting.change-plan": "Change Plan",
"com.affine.payment.billing-setting.current-plan": "Current Plan", "com.affine.payment.billing-setting.current-plan": "AFFiNE Cloud",
"com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description": "You are currently on the <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.", "com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan</1>.",
"com.affine.payment.billing-setting.ai-plan": "AFFiNE AI",
"com.affine.payment.billing-setting.expiration-date": "Expiration Date", "com.affine.payment.billing-setting.expiration-date": "Expiration Date",
"com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}", "com.affine.payment.billing-setting.expiration-date.description": "Your subscription is valid until {{expirationDate}}",
"com.affine.payment.billing-setting.history": "Billing history", "com.affine.payment.billing-setting.history": "Billing history",