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';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useMemo, useState } from 'react';
@ -34,6 +35,8 @@ import { useUserSubscription } from '../../../../../hooks/use-subscription';
import { mixpanel, popupWindow } from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
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';
enum DescriptionI18NKey {
@ -93,6 +96,11 @@ export const BillingSettings = () => {
const SubscriptionSettings = () => {
const [subscription, mutateSubscription] = useUserSubscription();
const [openCancelModal, setOpenCancelModal] = useState(false);
const {
actionType: aiActionType,
Action: AIAction,
billingTip,
} = useAffineAISubscription();
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
@ -102,6 +110,10 @@ const SubscriptionSettings = () => {
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
const price = pricesQueryResult.prices.find(price => price.plan === plan);
const aiPrice = pricesQueryResult.prices.find(
price => price.plan === SubscriptionPlan.AI
);
assertExists(aiPrice);
const amount =
plan === SubscriptionPlan.Free
? '0'
@ -111,6 +123,8 @@ const SubscriptionSettings = () => {
: String((price.yearlyAmount ?? 0) / 100)
: '?';
const { priceReadable: aiPriceReadable, priceFrequency: aiPriceFrequency } =
useAffineAIPrice(aiPrice);
const t = useAFFiNEI18N();
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
@ -167,6 +181,30 @@ const SubscriptionSettings = () => {
</span>
</p>
</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 && (
<>
<SettingRow
@ -365,6 +403,13 @@ const InvoiceLine = ({
}
}, [invoice.link]);
const planText =
invoice.plan === SubscriptionPlan.AI
? 'AFFiNE AI'
: invoice.plan === SubscriptionPlan.Pro
? 'AFFiNE Cloud'
: null;
return (
<SettingRow
key={invoice.id}
@ -374,7 +419,7 @@ const InvoiceLine = ({
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
} $${invoice.amount / 100} - ${planText}`}
>
<Button className={styles.button} onClick={open}>
{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 { useMutation } from '@affine/core/hooks/use-mutation';
import { cancelSubscriptionMutation } from '@affine/graphql';
import { cancelSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { nanoid } from 'nanoid';
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 const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
export interface AICancelProps extends BaseActionProps, ButtonProps {}
export const AICancel = ({
onSubscriptionUpdate,
...btnProps
}: AICancelProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { trigger, isMutating } = useMutation({
mutation: cancelSubscriptionMutation,
@ -32,7 +34,7 @@ export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
},
onConfirm: async () => {
await trigger(
{ idempotencyKey, plan },
{ idempotencyKey, plan: SubscriptionPlan.AI },
{
onSuccess: data => {
// refresh idempotency key
@ -43,15 +45,10 @@ export const AICancel = ({ plan, onSubscriptionUpdate }: AICancelProps) => {
);
},
});
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]);
}, [openConfirmModal, trigger, idempotencyKey, onSubscriptionUpdate]);
return (
<Button
onClick={cancel}
loading={isMutating}
className={purchaseButton}
type="primary"
>
<Button onClick={cancel} loading={isMutating} type="primary" {...btnProps}>
Cancel subscription
</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 { useSetAtom } from 'jotai';
import { useCallback } from 'react';
export const AILogin = () => {
import type { BaseActionProps } from '../types';
export const AILogin = (btnProps: BaseActionProps & ButtonProps) => {
const setOpen = useSetAtom(authAtom);
const onClickSignIn = useCallback(() => {
@ -13,5 +15,9 @@ export const AILogin = () => {
}));
}, [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 { useMutation } from '@affine/core/hooks/use-mutation';
import { resumeSubscriptionMutation } from '@affine/graphql';
import { resumeSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import { cssVar } from '@toeverything/theme';
import { nanoid } from 'nanoid';
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 { isMutating, trigger } = useMutation({
@ -31,7 +38,7 @@ export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => {
},
onConfirm: async () => {
await trigger(
{ idempotencyKey, plan },
{ idempotencyKey, plan: SubscriptionPlan.AI },
{
onSuccess: data => {
// refresh idempotency key
@ -51,15 +58,10 @@ export const AIResume = ({ plan, onSubscriptionUpdate }: AIResumeProps) => {
);
},
});
}, [openConfirmModal, trigger, idempotencyKey, plan, onSubscriptionUpdate]);
}, [openConfirmModal, trigger, idempotencyKey, onSubscriptionUpdate]);
return (
<Button
loading={isMutating}
onClick={resume}
className={purchaseButton}
type="primary"
>
<Button loading={isMutating} onClick={resume} type="primary" {...btnProps}>
Resume
</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 { useMutation } from '@affine/core/hooks/use-mutation';
import { popupWindow } from '@affine/core/utils';
import { createCheckoutSessionMutation } from '@affine/graphql';
import {
createCheckoutSessionMutation,
SubscriptionPlan,
} from '@affine/graphql';
import { nanoid } from 'nanoid';
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 = ({
price,
plan,
recurring,
onSubscriptionUpdate,
...btnProps
}: AISubscribeProps) => {
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
const { priceReadable, priceFrequency } = useAffineAIPrice(price);
const newTabRef = useRef<Window | null>(null);
@ -45,7 +49,7 @@ export const AISubscribe = ({
input: {
recurring,
idempotencyKey,
plan,
plan: SubscriptionPlan.AI,
coupon: null,
successCallbackLink: null,
},
@ -60,7 +64,7 @@ export const AISubscribe = ({
},
}
);
}, [idempotencyKey, onClose, plan, recurring, trigger]);
}, [idempotencyKey, onClose, recurring, trigger]);
if (!price.yearlyAmount) return null;
@ -68,10 +72,10 @@ export const AISubscribe = ({
<Button
loading={isMutating}
onClick={subscribe}
className={purchaseButton}
type="primary"
{...btnProps}
>
${(price.yearlyAmount / 100).toFixed(2)} / Year
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
</Button>
);
};

View File

@ -1,9 +1,7 @@
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import {
type SubscriptionMutator,
useUserSubscription,
} from '@affine/core/hooks/use-subscription';
import { timestampToLocalDate } from '@affine/core/utils';
import {
type PricesQuery,
SubscriptionPlan,
@ -13,42 +11,27 @@ import {
import { AIPlanLayout } from '../layout';
import * as styles from './ai-plan.css';
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 { useAffineAISubscription } from './use-affine-ai-subscription';
interface AIPlanProps {
price?: PricesQuery['prices'][number];
onSubscriptionUpdate: SubscriptionMutator;
}
export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
const plan = SubscriptionPlan.AI;
const recurring = SubscriptionRecurring.Yearly;
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const [subscription] = useUserSubscription(plan);
const { Action, billingTip } = useAffineAISubscription();
const [subscription] = useUserSubscription(SubscriptionPlan.AI);
// yearly subscription should always be available
if (!price?.yearlyAmount) return null;
const baseActionProps: BaseActionProps = {
plan,
price,
recurring,
onSubscriptionUpdate,
};
const isCancelled = !!subscription?.canceledAt;
const Action = !loggedIn
? AILogin
: !subscription
? AISubscribe
: isCancelled
? AIResume
: AICancel;
return (
<AIPlanLayout
@ -70,9 +53,9 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
</div>
<div className={styles.actionBlock}>
<Action {...baseActionProps} />
{subscription?.nextBillAt ? (
<PurchasedTip due={timestampToLocalDate(subscription.nextBillAt)} />
<Action {...baseActionProps} className={styles.purchaseButton} />
{billingTip ? (
<div className={styles.agreement}>{billingTip}</div>
) : null}
</div>
@ -81,9 +64,3 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
</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 {
PricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql';
export interface BaseActionProps {
price: PricesQuery['prices'][number];
recurring: SubscriptionRecurring;
plan: SubscriptionPlan;
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.description": "Subscription cancelled, your pro account will expire on {{cancelDate}}",
"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.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.ai-plan": "AFFiNE AI",
"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.history": "Billing history",