From 64f97806bb14440d1242baca32ad51ae7c49c51e Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Tue, 22 Oct 2024 02:18:04 +0000 Subject: [PATCH] fix(core): free cloud and ai onetime payment adaptation (#8558) close AF-1515, AF-1516 --- .../general-setting/billing/index.tsx | 8 +- .../plans/ai/actions/redeem.tsx | 52 ++++++++ .../plans/ai/actions/subscribe.tsx | 111 +++++++----------- .../general-setting/plans/ai/ai-plan.tsx | 6 +- .../general-setting/plans/checkout-slot.tsx | 85 ++++++++++++++ .../plans/lifetime/lifetime-plan.tsx | 2 +- .../general-setting/plans/plan-card.tsx | 93 ++++++--------- .../modules/cloud/entities/subscription.ts | 5 +- packages/frontend/i18n/src/resources/en.json | 1 + 9 files changed, 232 insertions(+), 131 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/redeem.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/checkout-slot.tsx 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 69a0bbe441..e30b01abf7 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 @@ -39,6 +39,7 @@ import { } from '../../../../atoms'; import { CancelAction, ResumeAction } from '../plans/actions'; import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions'; +import { AIRedeemCodeButton } from '../plans/ai/actions/redeem'; import { BelieverCard } from '../plans/lifetime/believer-card'; import { BelieverBenefits } from '../plans/lifetime/benefits'; import * as styles from './style.css'; @@ -94,7 +95,7 @@ const SubscriptionSettings = () => { const proSubscription = useLiveData(subscriptionService.subscription.pro$); const proPrice = useLiveData(subscriptionService.prices.proPrice$); const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); - const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); + const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$); const [openCancelModal, setOpenCancelModal] = useState(false); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); @@ -347,6 +348,7 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => { }, [subscriptionService]); const price = useLiveData(subscriptionService.prices.aiPrice$); const subscription = useLiveData(subscriptionService.subscription.ai$); + const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$); const priceReadable = price?.yearlyAmount ? `$${(price.yearlyAmount / 100).toFixed(2)}` @@ -389,7 +391,9 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => { /> {price?.yearlyAmount ? ( subscription ? ( - subscription.canceledAt ? ( + isOnetime ? ( + + ) : subscription.canceledAt ? ( ) : ( diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/redeem.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/redeem.tsx new file mode 100644 index 0000000000..542c299bad --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/redeem.tsx @@ -0,0 +1,52 @@ +import { Button, type ButtonProps } from '@affine/component'; +import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; +import { AuthService } from '@affine/core/modules/cloud'; +import { + SubscriptionPlan, + SubscriptionRecurring, + SubscriptionVariant, +} from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; +import track from '@affine/track'; +import { useService } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import { CheckoutSlot } from '../../checkout-slot'; + +export const AIRedeemCodeButton = (btnProps: ButtonProps) => { + const t = useI18n(); + const authService = useService(AuthService); + + const onBeforeCheckout = useCallback(() => { + track.$.settingsPanel.plans.checkout({ + plan: SubscriptionPlan.AI, + recurring: SubscriptionRecurring.Yearly, + }); + }, []); + const checkoutOptions = useMemo( + () => ({ + recurring: SubscriptionRecurring.Yearly, + plan: SubscriptionPlan.AI, + variant: SubscriptionVariant.Onetime, + coupon: null, + successCallbackLink: generateSubscriptionCallbackLink( + authService.session.account$.value, + SubscriptionPlan.AI, + SubscriptionRecurring.Yearly + ), + }), + [authService.session.account$.value] + ); + + return ( + ( + + )} + /> + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx index 31c3af17b7..59da7eca76 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx @@ -1,79 +1,49 @@ import { Button, type ButtonProps, Skeleton } from '@affine/component'; import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; -import { popupWindow } from '@affine/core/utils'; import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; import { useLiveData, useService } from '@toeverything/infra'; -import { nanoid } from 'nanoid'; -import { useEffect, useState } from 'react'; +import { useCallback, useMemo } from 'react'; + +import { CheckoutSlot } from '../../checkout-slot'; export interface AISubscribeProps extends ButtonProps { - displayedFrequency?: 'yearly' | 'monthly'; + displayedFrequency?: 'yearly' | 'monthly' | null; } export const AISubscribe = ({ displayedFrequency = 'yearly', ...btnProps }: AISubscribeProps) => { - const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); - const [isMutating, setMutating] = useState(false); - const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false); const authService = useService(AuthService); const subscriptionService = useService(SubscriptionService); const price = useLiveData(subscriptionService.prices.aiPrice$); - useEffect(() => { - subscriptionService.prices.revalidate(); - }, [subscriptionService]); const t = useI18n(); - useEffect(() => { - if (isOpenedExternalWindow) { - // when the external window is opened, revalidate the subscription when window get focus - window.addEventListener( - 'focus', - subscriptionService.subscription.revalidate - ); - return () => { - window.removeEventListener( - 'focus', - subscriptionService.subscription.revalidate - ); - }; - } - return; - }, [isOpenedExternalWindow, subscriptionService]); - - const subscribe = useAsyncCallback(async () => { - setMutating(true); + const onBeforeCheckout = useCallback(() => { track.$.settingsPanel.plans.checkout({ plan: SubscriptionPlan.AI, recurring: SubscriptionRecurring.Yearly, }); - try { - const session = await subscriptionService.createCheckoutSession({ - recurring: SubscriptionRecurring.Yearly, - idempotencyKey, - plan: SubscriptionPlan.AI, - variant: null, - coupon: null, - successCallbackLink: generateSubscriptionCallbackLink( - authService.session.account$.value, - SubscriptionPlan.AI, - SubscriptionRecurring.Yearly - ), - }); - popupWindow(session); - setOpenedExternalWindow(true); - setIdempotencyKey(nanoid()); - } finally { - setMutating(false); - } - }, [authService, idempotencyKey, subscriptionService]); + }, []); + const checkoutOptions = useMemo( + () => ({ + recurring: SubscriptionRecurring.Yearly, + plan: SubscriptionPlan.AI, + variant: null, + coupon: null, + successCallbackLink: generateSubscriptionCallbackLink( + authService.session.account$.value, + SubscriptionPlan.AI, + SubscriptionRecurring.Yearly + ), + }), + [authService.session.account$.value] + ); if (!price || !price.yearlyAmount) { return ( @@ -100,25 +70,26 @@ export const AISubscribe = ({ : t['com.affine.payment.billing-setting.month'](); return ( - + ( + + )} + /> ); }; 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 6b1afe5134..aac3392559 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 @@ -5,6 +5,7 @@ import { useLiveData, useService } from '@toeverything/infra'; import { useEffect } from 'react'; import { AICancel, AILogin, AIResume, AISubscribe } from './actions'; +import { AIRedeemCodeButton } from './actions/redeem'; import * as styles from './ai-plan.css'; import { AIPlanLayout } from './layout'; @@ -17,6 +18,7 @@ export const AIPlan = () => { const price = useLiveData(subscriptionService.prices.aiPrice$); const isLoggedIn = useLiveData(authService.session.status$) === 'authenticated'; + const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$); useEffect(() => { subscriptionService.subscription.revalidate(); @@ -52,7 +54,9 @@ export const AIPlan = () => { actionButtons={ isLoggedIn ? ( subscription ? ( - subscription.canceledAt ? ( + isOnetime ? ( + + ) : subscription.canceledAt ? ( ) : ( diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/checkout-slot.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/checkout-slot.tsx new file mode 100644 index 0000000000..13777894e5 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/checkout-slot.tsx @@ -0,0 +1,85 @@ +import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; +import { SubscriptionService } from '@affine/core/modules/cloud'; +import { popupWindow } from '@affine/core/utils'; +import type { CreateCheckoutSessionInput } from '@affine/graphql'; +import { useService } from '@toeverything/infra'; +import { nanoid } from 'nanoid'; +import { + type PropsWithChildren, + type ReactNode, + useEffect, + useState, +} from 'react'; + +export interface CheckoutSlotProps extends PropsWithChildren { + checkoutOptions: Omit; + onBeforeCheckout?: () => void; + onCheckoutError?: (error: any) => void; + onCheckoutSuccess?: () => void; + renderer: (props: { onClick: () => void; loading: boolean }) => ReactNode; +} + +/** + * A wrapper component for checkout action + */ +export const CheckoutSlot = ({ + checkoutOptions, + onBeforeCheckout, + onCheckoutError, + onCheckoutSuccess, + renderer: Renderer, +}: CheckoutSlotProps) => { + const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); + const [isMutating, setMutating] = useState(false); + const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false); + + const subscriptionService = useService(SubscriptionService); + + useEffect(() => { + subscriptionService.prices.revalidate(); + }, [subscriptionService]); + useEffect(() => { + if (isOpenedExternalWindow) { + // when the external window is opened, revalidate the subscription when window get focus + window.addEventListener( + 'focus', + subscriptionService.subscription.revalidate + ); + return () => { + window.removeEventListener( + 'focus', + subscriptionService.subscription.revalidate + ); + }; + } + return; + }, [isOpenedExternalWindow, subscriptionService]); + + const subscribe = useAsyncCallback(async () => { + setMutating(true); + onBeforeCheckout?.(); + try { + const session = await subscriptionService.createCheckoutSession({ + idempotencyKey, + ...checkoutOptions, + }); + popupWindow(session); + setOpenedExternalWindow(true); + setIdempotencyKey(nanoid()); + onCheckoutSuccess?.(); + } catch (e) { + onCheckoutError?.(e); + } finally { + setMutating(false); + } + }, [ + checkoutOptions, + idempotencyKey, + onBeforeCheckout, + onCheckoutError, + onCheckoutSuccess, + subscriptionService, + ]); + + return ; +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx index a8eeb6820d..4420dce357 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx @@ -17,7 +17,7 @@ export const LifetimePlan = () => { subscriptionService.prices.readableLifetimePrice$ ); const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); - const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); + const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$); if (!readableLifetimePrice) return null; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index b746bd0c3d..3a6c0652f1 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -3,7 +3,6 @@ import { Tooltip } from '@affine/component/ui/tooltip'; import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; -import { popupWindow } from '@affine/core/utils'; import { type CreateCheckoutSessionInput, SubscriptionRecurring, @@ -21,10 +20,11 @@ import clsx from 'clsx'; import { useSetAtom } from 'jotai'; import { nanoid } from 'nanoid'; import type { PropsWithChildren } from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { authAtom } from '../../../../atoms/index'; import { CancelAction, ResumeAction } from './actions'; +import { CheckoutSlot } from './checkout-slot'; import type { DynamicPrice, FixedPrice } from './cloud-plans'; import { ConfirmLoadingModal } from './modals'; import * as styles from './style.css'; @@ -103,7 +103,8 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { ); const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free; const currentRecurring = primarySubscription?.recurring; - const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); + const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$); + const isFree = detail.plan === SubscriptionPlan.Free; // branches: // if contact => 'Contact Sales' @@ -112,7 +113,9 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // else => 'Buy Pro' // else // if isBeliever => 'Included in Lifetime' - // if onetime => 'Redeem Code' + // if onetime + // if free => 'Included in Pro' + // else => 'Redeem Code' // if isCurrent // if canceled => 'Resume' // else => 'Current Plan' @@ -147,11 +150,16 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // onetime if (isOnetime) { - return ; + return isFree ? ( + + ) : ( + + ); } const isCanceled = !!primarySubscription?.canceledAt; - const isFree = detail.plan === SubscriptionPlan.Free; const isCurrent = detail.plan === currentPlan && (isFree @@ -261,42 +269,20 @@ export const Upgrade = ({ recurring: SubscriptionRecurring; checkoutInput?: Partial; }) => { - const [isMutating, setMutating] = useState(false); - const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false); const t = useI18n(); - - const subscriptionService = useService(SubscriptionService); const authService = useService(AuthService); - const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); - - useEffect(() => { - if (isOpenedExternalWindow) { - // when the external window is opened, revalidate the subscription when window get focus - window.addEventListener( - 'focus', - subscriptionService.subscription.revalidate - ); - return () => { - window.removeEventListener( - 'focus', - subscriptionService.subscription.revalidate - ); - }; - } - return; - }, [isOpenedExternalWindow, subscriptionService]); - - const upgrade = useAsyncCallback(async () => { - setMutating(true); + const onBeforeCheckout = useCallback(() => { track.$.settingsPanel.plans.checkout({ plan: SubscriptionPlan.Pro, recurring: recurring, }); - const link = await subscriptionService.createCheckoutSession({ + }, [recurring]); + + const checkoutOptions = useMemo( + () => ({ recurring, - idempotencyKey, - plan: SubscriptionPlan.Pro, // Only support prod plan now. + plan: SubscriptionPlan.Pro, variant: null, coupon: null, successCallbackLink: generateSubscriptionCallbackLink( @@ -305,30 +291,25 @@ export const Upgrade = ({ recurring ), ...checkoutInput, - }); - setMutating(false); - setIdempotencyKey(nanoid()); - popupWindow(link); - setOpenedExternalWindow(true); - }, [ - recurring, - authService.session.account$.value, - subscriptionService, - idempotencyKey, - checkoutInput, - ]); + }), + [authService.session.account$.value, checkoutInput, recurring] + ); return ( - + ( + + )} + /> ); }; diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts index 550ea18408..3f5f68df3e 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts @@ -45,7 +45,10 @@ export class Subscription extends Entity { isBeliever$ = this.pro$.map( sub => sub?.recurring === SubscriptionRecurring.Lifetime ); - isOnetime$ = this.pro$.map( + isOnetimePro$ = this.pro$.map( + sub => sub?.variant === SubscriptionVariant.Onetime + ); + isOnetimeAI$ = this.ai$.map( sub => sub?.variant === SubscriptionVariant.Onetime ); diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 43f00535ac..8dada9665b 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -789,6 +789,7 @@ "com.affine.payment.cloud.free.description": "Open-source under MIT license.", "com.affine.payment.cloud.free.name": "FOSS + Basic", "com.affine.payment.cloud.free.title": "Free forever", + "com.affine.payment.cloud.onetime.included": "Included in Pro plan", "com.affine.payment.cloud.lifetime.included": "Included in Believer plan", "com.affine.payment.cloud.pricing-plan.select.caption": "We host, no technical setup required.", "com.affine.payment.cloud.pricing-plan.select.title": "Hosted by AFFiNE.Pro",