mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
fix(core): free cloud and ai onetime payment adaptation (#8558)
close AF-1515, AF-1516
This commit is contained in:
@@ -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 ? (
|
||||
<AIRedeemCodeButton className={styles.planAction} />
|
||||
) : subscription.canceledAt ? (
|
||||
<AIResume className={styles.planAction} />
|
||||
) : (
|
||||
<AICancel className={styles.planAction} />
|
||||
|
||||
@@ -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 (
|
||||
<CheckoutSlot
|
||||
onBeforeCheckout={onBeforeCheckout}
|
||||
checkoutOptions={checkoutOptions}
|
||||
renderer={props => (
|
||||
<Button variant="primary" {...btnProps} {...props}>
|
||||
{t['com.affine.payment.redeem-code']()}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<Button
|
||||
loading={isMutating}
|
||||
onClick={subscribe}
|
||||
variant="primary"
|
||||
{...btnProps}
|
||||
>
|
||||
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
|
||||
{displayedFrequency === 'monthly' ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
opacity: 0.75,
|
||||
letterSpacing: -0.2,
|
||||
paddingLeft: 4,
|
||||
}}
|
||||
>
|
||||
{t['com.affine.payment.ai.subscribe.billed-annually']()}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
<CheckoutSlot
|
||||
onBeforeCheckout={onBeforeCheckout}
|
||||
checkoutOptions={checkoutOptions}
|
||||
renderer={props => (
|
||||
<Button variant="primary" {...props} {...btnProps}>
|
||||
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
|
||||
{displayedFrequency === 'monthly' ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
opacity: 0.75,
|
||||
letterSpacing: -0.2,
|
||||
paddingLeft: 4,
|
||||
}}
|
||||
>
|
||||
{t['com.affine.payment.ai.subscribe.billed-annually']()}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 ? (
|
||||
<AIRedeemCodeButton className={styles.purchaseButton} />
|
||||
) : subscription.canceledAt ? (
|
||||
<AIResume className={styles.purchaseButton} />
|
||||
) : (
|
||||
<AICancel className={styles.purchaseButton} />
|
||||
|
||||
@@ -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<CreateCheckoutSessionInput, 'idempotencyKey'>;
|
||||
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 <Renderer onClick={subscribe} loading={isMutating} />;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 <RedeemCode recurring={recurring} />;
|
||||
return isFree ? (
|
||||
<Button className={styles.planAction} disabled>
|
||||
{t['com.affine.payment.cloud.onetime.included']()}
|
||||
</Button>
|
||||
) : (
|
||||
<RedeemCode recurring={recurring} />
|
||||
);
|
||||
}
|
||||
|
||||
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<CreateCheckoutSessionInput>;
|
||||
}) => {
|
||||
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 (
|
||||
<Button
|
||||
className={clsx(styles.planAction, className)}
|
||||
variant="primary"
|
||||
onClick={upgrade}
|
||||
disabled={isMutating}
|
||||
loading={isMutating}
|
||||
{...btnProps}
|
||||
>
|
||||
{children ?? t['com.affine.payment.upgrade']()}
|
||||
</Button>
|
||||
<CheckoutSlot
|
||||
onBeforeCheckout={onBeforeCheckout}
|
||||
checkoutOptions={checkoutOptions}
|
||||
renderer={props => (
|
||||
<Button
|
||||
className={clsx(styles.planAction, className)}
|
||||
variant="primary"
|
||||
{...props}
|
||||
{...btnProps}
|
||||
>
|
||||
{children ?? t['com.affine.payment.upgrade']()}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user