fix(core): free cloud and ai onetime payment adaptation (#8558)

close AF-1515, AF-1516
This commit is contained in:
CatsJuice
2024-10-22 02:18:04 +00:00
parent 054c0ef9f1
commit 64f97806bb
9 changed files with 232 additions and 131 deletions

View File

@@ -39,6 +39,7 @@ import {
} from '../../../../atoms'; } from '../../../../atoms';
import { CancelAction, ResumeAction } from '../plans/actions'; import { CancelAction, ResumeAction } from '../plans/actions';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions'; import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
import { AIRedeemCodeButton } from '../plans/ai/actions/redeem';
import { BelieverCard } from '../plans/lifetime/believer-card'; import { BelieverCard } from '../plans/lifetime/believer-card';
import { BelieverBenefits } from '../plans/lifetime/benefits'; import { BelieverBenefits } from '../plans/lifetime/benefits';
import * as styles from './style.css'; import * as styles from './style.css';
@@ -94,7 +95,7 @@ const SubscriptionSettings = () => {
const proSubscription = useLiveData(subscriptionService.subscription.pro$); const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const proPrice = useLiveData(subscriptionService.prices.proPrice$); const proPrice = useLiveData(subscriptionService.prices.proPrice$);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const [openCancelModal, setOpenCancelModal] = useState(false); const [openCancelModal, setOpenCancelModal] = useState(false);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
@@ -347,6 +348,7 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
}, [subscriptionService]); }, [subscriptionService]);
const price = useLiveData(subscriptionService.prices.aiPrice$); const price = useLiveData(subscriptionService.prices.aiPrice$);
const subscription = useLiveData(subscriptionService.subscription.ai$); const subscription = useLiveData(subscriptionService.subscription.ai$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const priceReadable = price?.yearlyAmount const priceReadable = price?.yearlyAmount
? `$${(price.yearlyAmount / 100).toFixed(2)}` ? `$${(price.yearlyAmount / 100).toFixed(2)}`
@@ -389,7 +391,9 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
/> />
{price?.yearlyAmount ? ( {price?.yearlyAmount ? (
subscription ? ( subscription ? (
subscription.canceledAt ? ( isOnetime ? (
<AIRedeemCodeButton className={styles.planAction} />
) : subscription.canceledAt ? (
<AIResume className={styles.planAction} /> <AIResume className={styles.planAction} />
) : ( ) : (
<AICancel className={styles.planAction} /> <AICancel className={styles.planAction} />

View File

@@ -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>
)}
/>
);
};

View File

@@ -1,79 +1,49 @@
import { Button, type ButtonProps, Skeleton } from '@affine/component'; import { Button, type ButtonProps, Skeleton } from '@affine/component';
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; 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 { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { popupWindow } from '@affine/core/utils';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n'; import { useI18n } from '@affine/i18n';
import { track } from '@affine/track'; import { track } from '@affine/track';
import { useLiveData, useService } from '@toeverything/infra'; import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid'; import { useCallback, useMemo } from 'react';
import { useEffect, useState } from 'react';
import { CheckoutSlot } from '../../checkout-slot';
export interface AISubscribeProps extends ButtonProps { export interface AISubscribeProps extends ButtonProps {
displayedFrequency?: 'yearly' | 'monthly'; displayedFrequency?: 'yearly' | 'monthly' | null;
} }
export const AISubscribe = ({ export const AISubscribe = ({
displayedFrequency = 'yearly', displayedFrequency = 'yearly',
...btnProps ...btnProps
}: AISubscribeProps) => { }: AISubscribeProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
const authService = useService(AuthService); const authService = useService(AuthService);
const subscriptionService = useService(SubscriptionService); const subscriptionService = useService(SubscriptionService);
const price = useLiveData(subscriptionService.prices.aiPrice$); const price = useLiveData(subscriptionService.prices.aiPrice$);
useEffect(() => {
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const t = useI18n(); const t = useI18n();
useEffect(() => { const onBeforeCheckout = useCallback(() => {
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);
track.$.settingsPanel.plans.checkout({ track.$.settingsPanel.plans.checkout({
plan: SubscriptionPlan.AI, plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly, recurring: SubscriptionRecurring.Yearly,
}); });
try { }, []);
const session = await subscriptionService.createCheckoutSession({ const checkoutOptions = useMemo(
recurring: SubscriptionRecurring.Yearly, () => ({
idempotencyKey, recurring: SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.AI, plan: SubscriptionPlan.AI,
variant: null, variant: null,
coupon: null, coupon: null,
successCallbackLink: generateSubscriptionCallbackLink( successCallbackLink: generateSubscriptionCallbackLink(
authService.session.account$.value, authService.session.account$.value,
SubscriptionPlan.AI, SubscriptionPlan.AI,
SubscriptionRecurring.Yearly SubscriptionRecurring.Yearly
), ),
}); }),
popupWindow(session); [authService.session.account$.value]
setOpenedExternalWindow(true); );
setIdempotencyKey(nanoid());
} finally {
setMutating(false);
}
}, [authService, idempotencyKey, subscriptionService]);
if (!price || !price.yearlyAmount) { if (!price || !price.yearlyAmount) {
return ( return (
@@ -100,25 +70,26 @@ export const AISubscribe = ({
: t['com.affine.payment.billing-setting.month'](); : t['com.affine.payment.billing-setting.month']();
return ( return (
<Button <CheckoutSlot
loading={isMutating} onBeforeCheckout={onBeforeCheckout}
onClick={subscribe} checkoutOptions={checkoutOptions}
variant="primary" renderer={props => (
{...btnProps} <Button variant="primary" {...props} {...btnProps}>
> {btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`} {displayedFrequency === 'monthly' ? (
{displayedFrequency === 'monthly' ? ( <span
<span style={{
style={{ fontSize: 10,
fontSize: 10, opacity: 0.75,
opacity: 0.75, letterSpacing: -0.2,
letterSpacing: -0.2, paddingLeft: 4,
paddingLeft: 4, }}
}} >
> {t['com.affine.payment.ai.subscribe.billed-annually']()}
{t['com.affine.payment.ai.subscribe.billed-annually']()} </span>
</span> ) : null}
) : null} </Button>
</Button> )}
/>
); );
}; };

View File

@@ -5,6 +5,7 @@ import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { AICancel, AILogin, AIResume, AISubscribe } from './actions'; import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
import { AIRedeemCodeButton } from './actions/redeem';
import * as styles from './ai-plan.css'; import * as styles from './ai-plan.css';
import { AIPlanLayout } from './layout'; import { AIPlanLayout } from './layout';
@@ -17,6 +18,7 @@ export const AIPlan = () => {
const price = useLiveData(subscriptionService.prices.aiPrice$); const price = useLiveData(subscriptionService.prices.aiPrice$);
const isLoggedIn = const isLoggedIn =
useLiveData(authService.session.status$) === 'authenticated'; useLiveData(authService.session.status$) === 'authenticated';
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
useEffect(() => { useEffect(() => {
subscriptionService.subscription.revalidate(); subscriptionService.subscription.revalidate();
@@ -52,7 +54,9 @@ export const AIPlan = () => {
actionButtons={ actionButtons={
isLoggedIn ? ( isLoggedIn ? (
subscription ? ( subscription ? (
subscription.canceledAt ? ( isOnetime ? (
<AIRedeemCodeButton className={styles.purchaseButton} />
) : subscription.canceledAt ? (
<AIResume className={styles.purchaseButton} /> <AIResume className={styles.purchaseButton} />
) : ( ) : (
<AICancel className={styles.purchaseButton} /> <AICancel className={styles.purchaseButton} />

View File

@@ -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} />;
};

View File

@@ -17,7 +17,7 @@ export const LifetimePlan = () => {
subscriptionService.prices.readableLifetimePrice$ subscriptionService.prices.readableLifetimePrice$
); );
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
if (!readableLifetimePrice) return null; if (!readableLifetimePrice) return null;

View File

@@ -3,7 +3,6 @@ import { Tooltip } from '@affine/component/ui/tooltip';
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { popupWindow } from '@affine/core/utils';
import { import {
type CreateCheckoutSessionInput, type CreateCheckoutSessionInput,
SubscriptionRecurring, SubscriptionRecurring,
@@ -21,10 +20,11 @@ import clsx from 'clsx';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { authAtom } from '../../../../atoms/index'; import { authAtom } from '../../../../atoms/index';
import { CancelAction, ResumeAction } from './actions'; import { CancelAction, ResumeAction } from './actions';
import { CheckoutSlot } from './checkout-slot';
import type { DynamicPrice, FixedPrice } from './cloud-plans'; import type { DynamicPrice, FixedPrice } from './cloud-plans';
import { ConfirmLoadingModal } from './modals'; import { ConfirmLoadingModal } from './modals';
import * as styles from './style.css'; import * as styles from './style.css';
@@ -103,7 +103,8 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
); );
const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free; const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = primarySubscription?.recurring; const currentRecurring = primarySubscription?.recurring;
const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$); const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
const isFree = detail.plan === SubscriptionPlan.Free;
// branches: // branches:
// if contact => 'Contact Sales' // if contact => 'Contact Sales'
@@ -112,7 +113,9 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
// else => 'Buy Pro' // else => 'Buy Pro'
// else // else
// if isBeliever => 'Included in Lifetime' // if isBeliever => 'Included in Lifetime'
// if onetime => 'Redeem Code' // if onetime
// if free => 'Included in Pro'
// else => 'Redeem Code'
// if isCurrent // if isCurrent
// if canceled => 'Resume' // if canceled => 'Resume'
// else => 'Current Plan' // else => 'Current Plan'
@@ -147,11 +150,16 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
// onetime // onetime
if (isOnetime) { 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 isCanceled = !!primarySubscription?.canceledAt;
const isFree = detail.plan === SubscriptionPlan.Free;
const isCurrent = const isCurrent =
detail.plan === currentPlan && detail.plan === currentPlan &&
(isFree (isFree
@@ -261,42 +269,20 @@ export const Upgrade = ({
recurring: SubscriptionRecurring; recurring: SubscriptionRecurring;
checkoutInput?: Partial<CreateCheckoutSessionInput>; checkoutInput?: Partial<CreateCheckoutSessionInput>;
}) => { }) => {
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
const t = useI18n(); const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService); const authService = useService(AuthService);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); const onBeforeCheckout = useCallback(() => {
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);
track.$.settingsPanel.plans.checkout({ track.$.settingsPanel.plans.checkout({
plan: SubscriptionPlan.Pro, plan: SubscriptionPlan.Pro,
recurring: recurring, recurring: recurring,
}); });
const link = await subscriptionService.createCheckoutSession({ }, [recurring]);
const checkoutOptions = useMemo(
() => ({
recurring, recurring,
idempotencyKey, plan: SubscriptionPlan.Pro,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
variant: null, variant: null,
coupon: null, coupon: null,
successCallbackLink: generateSubscriptionCallbackLink( successCallbackLink: generateSubscriptionCallbackLink(
@@ -305,30 +291,25 @@ export const Upgrade = ({
recurring recurring
), ),
...checkoutInput, ...checkoutInput,
}); }),
setMutating(false); [authService.session.account$.value, checkoutInput, recurring]
setIdempotencyKey(nanoid()); );
popupWindow(link);
setOpenedExternalWindow(true);
}, [
recurring,
authService.session.account$.value,
subscriptionService,
idempotencyKey,
checkoutInput,
]);
return ( return (
<Button <CheckoutSlot
className={clsx(styles.planAction, className)} onBeforeCheckout={onBeforeCheckout}
variant="primary" checkoutOptions={checkoutOptions}
onClick={upgrade} renderer={props => (
disabled={isMutating} <Button
loading={isMutating} className={clsx(styles.planAction, className)}
{...btnProps} variant="primary"
> {...props}
{children ?? t['com.affine.payment.upgrade']()} {...btnProps}
</Button> >
{children ?? t['com.affine.payment.upgrade']()}
</Button>
)}
/>
); );
}; };

View File

@@ -45,7 +45,10 @@ export class Subscription extends Entity {
isBeliever$ = this.pro$.map( isBeliever$ = this.pro$.map(
sub => sub?.recurring === SubscriptionRecurring.Lifetime 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 sub => sub?.variant === SubscriptionVariant.Onetime
); );

View File

@@ -789,6 +789,7 @@
"com.affine.payment.cloud.free.description": "Open-source under MIT license.", "com.affine.payment.cloud.free.description": "Open-source under MIT license.",
"com.affine.payment.cloud.free.name": "FOSS + Basic", "com.affine.payment.cloud.free.name": "FOSS + Basic",
"com.affine.payment.cloud.free.title": "Free forever", "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.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.caption": "We host, no technical setup required.",
"com.affine.payment.cloud.pricing-plan.select.title": "Hosted by AFFiNE.Pro", "com.affine.payment.cloud.pricing-plan.select.title": "Hosted by AFFiNE.Pro",