mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-11-27 06:33:32 +03:00
feat(core): ai subscription in billing page (#6476)
This commit is contained in:
parent
97c4ae48b5
commit
4a93582799
@ -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']()}
|
||||
|
@ -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>
|
||||
);
|
@ -0,0 +1,4 @@
|
||||
export * from './cancel';
|
||||
export * from './login';
|
||||
export * from './resume';
|
||||
export * from './subscribe';
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 };
|
||||
};
|
@ -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 };
|
||||
};
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user