fix(core): billing cancel confirm dialog (#4795)

This commit is contained in:
Cats Juice
2023-11-01 17:38:43 +08:00
committed by GitHub
parent e5be570f54
commit e5c86a9249
4 changed files with 171 additions and 131 deletions

View File

@@ -5,14 +5,12 @@ import {
SettingWrapper, SettingWrapper,
} from '@affine/component/setting-components'; } from '@affine/component/setting-components';
import { import {
cancelSubscriptionMutation,
createCustomerPortalMutation, createCustomerPortalMutation,
getInvoicesCountQuery, getInvoicesCountQuery,
type InvoicesQuery, type InvoicesQuery,
invoicesQuery, invoicesQuery,
InvoiceStatus, InvoiceStatus,
pricesQuery, pricesQuery,
resumeSubscriptionMutation,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
SubscriptionStatus, SubscriptionStatus,
@@ -25,7 +23,6 @@ import { Skeleton } from '@mui/material';
import { Button, IconButton } from '@toeverything/components/button'; import { Button, IconButton } from '@toeverything/components/button';
import { Loading } from '@toeverything/components/loading'; import { Loading } from '@toeverything/components/loading';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { nanoid } from 'nanoid';
import { Suspense, useCallback, useMemo, useState } from 'react'; import { Suspense, useCallback, useMemo, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms'; import { openSettingModalAtom } from '../../../../../atoms';
@@ -34,7 +31,7 @@ import {
type SubscriptionMutator, type SubscriptionMutator,
useUserSubscription, useUserSubscription,
} from '../../../../../hooks/use-subscription'; } from '../../../../../hooks/use-subscription';
import { DowngradeModal } from '../plans/modals'; import { CancelAction, ResumeAction } from '../plans/actions';
import * as styles from './style.css'; import * as styles from './style.css';
enum DescriptionI18NKey { enum DescriptionI18NKey {
@@ -89,25 +86,8 @@ export const BillingSettings = () => {
const SubscriptionSettings = () => { const SubscriptionSettings = () => {
const [subscription, mutateSubscription] = useUserSubscription(); const [subscription, mutateSubscription] = useUserSubscription();
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const [openCancelModal, setOpenCancelModal] = useState(false); const [openCancelModal, setOpenCancelModal] = useState(false);
// allow replay request on network error until component unmount
const idempotencyKey = useMemo(() => nanoid(), []);
const cancel = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
mutateSubscription(data.cancelSubscription);
},
}
);
}, [trigger, idempotencyKey, mutateSubscription]);
const { data: pricesQueryResult } = useQuery({ const { data: pricesQueryResult } = useQuery({
query: pricesQuery, query: pricesQuery,
}); });
@@ -213,27 +193,28 @@ const SubscriptionSettings = () => {
<ResumeSubscription onSubscriptionUpdate={mutateSubscription} /> <ResumeSubscription onSubscriptionUpdate={mutateSubscription} />
</SettingRow> </SettingRow>
) : ( ) : (
<SettingRow <CancelAction
style={{ cursor: 'pointer' }} open={openCancelModal}
onClick={() => (isMutating ? null : setOpenCancelModal(true))} onOpenChange={setOpenCancelModal}
className="dangerous-setting" onSubscriptionUpdate={mutateSubscription}
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
]()}
desc={t[
'com.affine.payment.billing-setting.cancel-subscription.description'
]({
cancelDate: new Date(subscription.end).toLocaleDateString(),
})}
> >
<CancelSubscription loading={isMutating} /> <SettingRow
</SettingRow> style={{ cursor: 'pointer' }}
onClick={() => setOpenCancelModal(true)}
className="dangerous-setting"
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
]()}
desc={t[
'com.affine.payment.billing-setting.cancel-subscription.description'
]({
cancelDate: new Date(subscription.end).toLocaleDateString(),
})}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)} )}
<DowngradeModal
open={openCancelModal}
onCancel={() => (isMutating ? null : cancel())}
onOpenChange={setOpenCancelModal}
/>
</> </>
)} )}
</div> </div>
@@ -295,33 +276,18 @@ const ResumeSubscription = ({
onSubscriptionUpdate: SubscriptionMutator; onSubscriptionUpdate: SubscriptionMutator;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({ const [open, setOpen] = useState(false);
mutation: resumeSubscriptionMutation,
});
// allow replay request on network error until component unmount
const idempotencyKey = useMemo(() => nanoid(), []);
const resume = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
onSubscriptionUpdate(data.resumeSubscription);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
return ( return (
<Button <ResumeAction
className={styles.button} open={open}
onClick={resume} onOpenChange={setOpen}
loading={isMutating} onSubscriptionUpdate={onSubscriptionUpdate}
disabled={isMutating}
> >
{t['com.affine.payment.billing-setting.resume-subscription']()} <Button className={styles.button} onClick={() => setOpen(true)}>
</Button> {t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
); );
}; };

View File

@@ -0,0 +1,107 @@
import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
import {
cancelSubscriptionMutation,
resumeSubscriptionMutation,
} from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useCallback, useState } from 'react';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
/**
* Cancel action with modal & request
* @param param0
* @returns
*/
export const CancelAction = ({
children,
open,
onOpenChange,
onSubscriptionUpdate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubscriptionUpdate: SubscriptionMutator;
} & PropsWithChildren) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { trigger, isMutating } = useMutation({
mutation: cancelSubscriptionMutation,
});
const downgrade = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.cancelSubscription);
onOpenChange(false);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]);
return (
<>
{children}
<DowngradeModal
open={open}
onCancel={downgrade}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};
/**
* Resume payment action with modal & request
* @param param0
* @returns
*/
export const ResumeAction = ({
children,
open,
onOpenChange,
onSubscriptionUpdate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubscriptionUpdate: SubscriptionMutator;
} & PropsWithChildren) => {
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const resume = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.resumeSubscription);
onOpenChange(false);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]);
return (
<>
{children}
<ConfirmLoadingModal
type={'resume'}
open={open}
onConfirm={resume}
onOpenChange={onOpenChange}
loading={isMutating}
/>
</>
);
};

View File

@@ -72,6 +72,7 @@ export const ConfirmLoadingModal = ({
*/ */
export const DowngradeModal = ({ export const DowngradeModal = ({
open, open,
loading,
onOpenChange, onOpenChange,
onCancel, onCancel,
}: { }: {
@@ -81,6 +82,14 @@ export const DowngradeModal = ({
onCancel?: () => void; onCancel?: () => void;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const canceled = useRef(false);
useEffect(() => {
if (!loading && open && canceled.current) {
onOpenChange?.(false);
canceled.current = false;
}
}, [loading, open, onOpenChange]);
return ( return (
<Modal <Modal
@@ -102,14 +111,19 @@ export const DowngradeModal = ({
<footer className={styles.downgradeFooter}> <footer className={styles.downgradeFooter}>
<Button <Button
onClick={() => { onClick={() => {
onOpenChange?.(false); canceled.current = true;
onCancel?.(); onCancel?.();
}} }}
loading={loading}
> >
{t['com.affine.payment.modal.downgrade.cancel']()} {t['com.affine.payment.modal.downgrade.cancel']()}
</Button> </Button>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button onClick={() => onOpenChange?.(false)} type="primary"> <Button
disabled={loading}
onClick={() => onOpenChange?.(false)}
type="primary"
>
{t['com.affine.payment.modal.downgrade.confirm']()} {t['com.affine.payment.modal.downgrade.confirm']()}
</Button> </Button>
</DialogTrigger> </DialogTrigger>

View File

@@ -3,9 +3,7 @@ import type {
SubscriptionMutator, SubscriptionMutator,
} from '@affine/core/hooks/use-subscription'; } from '@affine/core/hooks/use-subscription';
import { import {
cancelSubscriptionMutation,
checkoutMutation, checkoutMutation,
resumeSubscriptionMutation,
SubscriptionPlan, SubscriptionPlan,
SubscriptionRecurring, SubscriptionRecurring,
updateSubscriptionMutation, updateSubscriptionMutation,
@@ -31,8 +29,9 @@ import {
import { openPaymentDisableAtom } from '../../../../../atoms'; import { openPaymentDisableAtom } from '../../../../../atoms';
import { authAtom } from '../../../../../atoms/index'; import { authAtom } from '../../../../../atoms/index';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status'; import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { CancelAction, ResumeAction } from './actions';
import { BulledListIcon } from './icons/bulled-list'; import { BulledListIcon } from './icons/bulled-list';
import { ConfirmLoadingModal, DowngradeModal } from './modals'; import { ConfirmLoadingModal } from './modals';
import * as styles from './style.css'; import * as styles from './style.css';
export interface FixedPrice { export interface FixedPrice {
@@ -130,14 +129,8 @@ export const PlanCard = (props: PlanCardProps) => {
const { detail, subscription, recurring } = props; const { detail, subscription, recurring } = props;
const loggedIn = useCurrentLoginStatus() === 'authenticated'; const loggedIn = useCurrentLoginStatus() === 'authenticated';
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free; const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = subscription?.recurring;
const isCurrent = const isCurrent = loggedIn && detail.plan === currentPlan;
loggedIn &&
detail.plan === currentPlan &&
(currentPlan === SubscriptionPlan.Free
? true
: currentRecurring === recurring);
return ( return (
<div <div
@@ -255,7 +248,7 @@ const ActionButton = ({
// is current // is current
if (isCurrent) { if (isCurrent) {
return isCanceled ? ( return isCanceled ? (
<ResumeAction onSubscriptionUpdate={mutateAndNotify} /> <ResumeButton onSubscriptionUpdate={mutateAndNotify} />
) : ( ) : (
<CurrentPlan /> <CurrentPlan />
); );
@@ -301,46 +294,30 @@ const Downgrade = ({
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const downgrade = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.cancelSubscription);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
const tooltipContent = disabled const tooltipContent = disabled
? t['com.affine.payment.downgraded-tooltip']() ? t['com.affine.payment.downgraded-tooltip']()
: null; : null;
return ( return (
<> <CancelAction
open={open}
onOpenChange={setOpen}
onSubscriptionUpdate={onSubscriptionUpdate}
>
<Tooltip content={tooltipContent} rootOptions={{ delayDuration: 0 }}> <Tooltip content={tooltipContent} rootOptions={{ delayDuration: 0 }}>
<div className={styles.planAction}> <div className={styles.planAction}>
<Button <Button
className={styles.planAction} className={styles.planAction}
type="primary" type="primary"
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
disabled={disabled || isMutating} disabled={disabled}
loading={isMutating}
> >
{t['com.affine.payment.downgrade']()} {t['com.affine.payment.downgrade']()}
</Button> </Button>
</div> </div>
</Tooltip> </Tooltip>
<DowngradeModal open={open} onCancel={downgrade} onOpenChange={setOpen} /> </CancelAction>
</>
); );
}; };
@@ -527,55 +504,31 @@ const SignUpAction = ({ children }: PropsWithChildren) => {
); );
}; };
const ResumeAction = ({ const ResumeButton = ({
onSubscriptionUpdate, onSubscriptionUpdate,
}: { }: {
onSubscriptionUpdate: SubscriptionMutator; onSubscriptionUpdate: SubscriptionMutator;
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const resume = useCallback(() => {
trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.resumeSubscription);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate]);
return ( return (
<> <ResumeAction
open={open}
onOpenChange={setOpen}
onSubscriptionUpdate={onSubscriptionUpdate}
>
<Button <Button
className={styles.planAction} className={styles.planAction}
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
loading={isMutating}
disabled={isMutating}
> >
{hovered {hovered
? t['com.affine.payment.resume-renewal']() ? t['com.affine.payment.resume-renewal']()
: t['com.affine.payment.current-plan']()} : t['com.affine.payment.current-plan']()}
</Button> </Button>
</ResumeAction>
<ConfirmLoadingModal
type={'resume'}
open={open}
onConfirm={resume}
onOpenChange={setOpen}
loading={isMutating}
/>
</>
); );
}; };