From e13b6db443e2acf622588b58ef4d910abd762f32 Mon Sep 17 00:00:00 2001 From: Cats Juice Date: Mon, 8 Apr 2024 13:56:15 +0800 Subject: [PATCH] feat(core): ai subscription in billing page --- .../general-setting/billing/index.tsx | 47 ++++++++++++++++++- .../plans/ai/{ => actions}/cancel.tsx | 25 +++++----- .../general-setting/plans/ai/actions/index.ts | 4 ++ .../plans/ai/{ => actions}/login.tsx | 12 +++-- .../plans/ai/{ => actions}/resume.tsx | 30 ++++++------ .../plans/ai/{ => actions}/subscribe.tsx | 24 ++++++---- .../general-setting/plans/ai/ai-plan.tsx | 35 +++----------- .../general-setting/plans/ai/types.ts | 7 +-- .../plans/ai/use-affine-ai-price.ts | 14 ++++++ .../plans/ai/use-affine-ai-subscription.ts | 40 ++++++++++++++++ packages/frontend/i18n/src/resources/en.json | 3 +- 11 files changed, 163 insertions(+), 78 deletions(-) rename packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/{ => actions}/cancel.tsx (68%) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/index.ts rename packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/{ => actions}/login.tsx (50%) rename packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/{ => actions}/resume.tsx (73%) rename packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/{ => actions}/subscribe.tsx (72%) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index c9a8c0089a34..c67858abc124 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -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 = () => {

+ +
+
+ + {aiPrice?.yearlyAmount ? ( + + {aiActionType === 'subscribe' ? 'Purchase' : null} + + ) : null} +
+

+ {aiPriceReadable} + /{aiPriceFrequency} +

+
{subscription?.status === SubscriptionStatus.Active && ( <> ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/index.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/index.ts new file mode 100644 index 000000000000..cb6a77e20945 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/index.ts @@ -0,0 +1,4 @@ +export * from './cancel'; +export * from './login'; +export * from './resume'; +export * from './subscribe'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/login.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/login.tsx similarity index 50% rename from packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/login.tsx rename to packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/login.tsx index 37471f1b088f..8ed8c648415e 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/login.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/login.tsx @@ -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 ; + return ( + + ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/resume.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx similarity index 73% rename from packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/resume.tsx rename to packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx index 69d8fa5e5ae5..1259160c2072 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/resume.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/resume.tsx @@ -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 ( - ); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/subscribe.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx similarity index 72% rename from packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/subscribe.tsx rename to packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx index f29abbb64949..0a9dc2689e2f 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/subscribe.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx @@ -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(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 = ({ ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx index 715aafb35597..5d4f7fa92e57 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx @@ -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 ( {
- - {subscription?.nextBillAt ? ( - + + {billingTip ? ( +
{billingTip}
) : null}
@@ -81,9 +64,3 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
); }; - -const PurchasedTip = ({ due }: { due: string }) => ( -
- You have purchased AFFiNE AI. The next payment date is {due}. -
-); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts index 68668635c790..e23573c5aa9d 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/types.ts @@ -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; } diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts new file mode 100644 index 000000000000..7d766ac45bed --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-price.ts @@ -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 }; +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts new file mode 100644 index 000000000000..c1b975e58997 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/use-affine-ai-subscription.ts @@ -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 }; +}; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index eddf6da245d9..e506a808d0a4 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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.", "com.affine.payment.billing-setting.current-plan.description.monthly": "You are currently on the monthly <1>{{planName}} plan.", "com.affine.payment.billing-setting.current-plan.description.yearly": "You are currently on the yearly <1>{{planName}} plan.", + "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",