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

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

View File

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

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$
);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetime$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimePro$);
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 { 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>
)}
/>
);
};

View File

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

View File

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