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",