feat(core): impl billing settings (#4652)

This commit is contained in:
liuyi
2023-10-20 09:42:33 +08:00
committed by forehalo
parent 1d62133f4f
commit 858a1da35f
16 changed files with 480 additions and 27 deletions

View File

@@ -0,0 +1,272 @@
import {
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import {
cancelSubscriptionMutation,
createCustomerPortalMutation,
type InvoicesQuery,
invoicesQuery,
InvoiceStatus,
pricesQuery,
resumeSubscriptionMutation,
SubscriptionPlan,
subscriptionQuery,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useEffect } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import * as styles from './style.css';
export const BillingSettings = () => {
const status = useCurrentLoginStatus();
if (status !== 'authenticated') {
return null;
}
return (
<>
<SettingHeader
title="Billing"
subtitle="Manage your billing information and invoices."
/>
{/* TODO: loading fallback */}
<Suspense>
<SettingWrapper title="information">
<SubscriptionSettings />
</SettingWrapper>
</Suspense>
{/* TODO: loading fallback */}
<Suspense>
<SettingWrapper title="Billing history">
<BillingHistory />
</SettingWrapper>
</Suspense>
</>
);
};
const SubscriptionSettings = () => {
const { data: subscriptionQueryResult } = useQuery({
query: subscriptionQuery,
});
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
});
const subscription = subscriptionQueryResult.currentUser?.subscription;
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
const price = pricesQueryResult.prices.find(price => price.plan === plan);
const amount =
plan === SubscriptionPlan.Free
? '0'
: price
? recurring === SubscriptionRecurring.Monthly
? String(price.amount / 100)
: (price.yearlyAmount / 100 / 12).toFixed(2)
: '?';
return (
<div className={styles.subscription}>
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name="Current Plan"
desc={
<p>
You are current on the{' '}
<a>
{/* TODO: Action */}
{plan} plan
</a>
.
</p>
}
/>
<PlanAction plan={plan} />
</div>
<p className={styles.planPrice}>${amount}/month</p>
</div>
{subscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name="Payment Method"
desc="Provided by Stripe."
>
<PaymentMethodUpdater />
</SettingRow>
{subscription.nextBillAt && (
<SettingRow
name="Renew Date"
desc={`Next billing date: ${new Date(
subscription.nextBillAt
).toLocaleDateString()}`}
/>
)}
{subscription.canceledAt ? (
<SettingRow
name="Expiration Date"
desc={`Your subscription is valid until ${new Date(
subscription.end
).toLocaleDateString()}`}
>
<ResumeSubscription />
</SettingRow>
) : (
<SettingRow
className="dangerous-setting"
name="Cancel Subscription"
desc={`Subscription cancelled, your pro account will expire on ${new Date(
subscription.end
).toLocaleDateString()}`}
>
<CancelSubscription />
</SettingRow>
)}
</>
)}
</div>
);
};
const PlanAction = ({ plan }: { plan: string }) => {
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const gotoPlansSetting = useCallback(() => {
setOpenSettingModalAtom({
open: true,
activeTab: 'plans',
workspaceId: null,
});
}, [setOpenSettingModalAtom]);
return (
<Button
className={styles.planAction}
type="primary"
onClick={gotoPlansSetting}
>
{plan === SubscriptionPlan.Free ? 'Upgrade' : 'Change Plan'}
</Button>
);
};
const PaymentMethodUpdater = () => {
// TODO: open stripe customer portal
const { isMutating, trigger, data } = useMutation({
mutation: createCustomerPortalMutation,
});
const update = useCallback(() => {
trigger();
}, [trigger]);
useEffect(() => {
if (data?.createCustomerPortal) {
window.open(data.createCustomerPortal, '_blank', 'noopener noreferrer');
}
}, [data]);
return (
<Button onClick={update} loading={isMutating} disabled={isMutating}>
Update
</Button>
);
};
const ResumeSubscription = () => {
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const resume = useCallback(() => {
trigger();
}, [trigger]);
return (
<Button onClick={resume} loading={isMutating} disabled={isMutating}>
Resume
</Button>
);
};
const CancelSubscription = () => {
const { isMutating, trigger } = useMutation({
mutation: cancelSubscriptionMutation,
});
const cancel = useCallback(() => {
trigger();
}, [trigger]);
return (
<IconButton
icon={<ArrowRightSmallIcon />}
disabled={isMutating}
loading={isMutating}
onClick={cancel /* TODO: popup confirmation modal instead */}
/>
);
};
const BillingHistory = () => {
const { data: invoicesQueryResult } = useQuery({
query: invoicesQuery,
variables: {
skip: 0,
take: 12,
},
});
const invoices = invoicesQueryResult.currentUser?.invoices ?? [];
return (
<div className={styles.billingHistory}>
{invoices.length === 0 ? (
<p className={styles.noInvoice}>There are no invoices to display.</p>
) : (
// TODO: pagination
invoices.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const open = useCallback(() => {
if (invoice.link) {
window.open(invoice.link, '_blank', 'noopener noreferrer');
}
}, [invoice.link]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
// TODO: currency to format: usd => $, cny => ¥
desc={`${invoice.status === InvoiceStatus.Paid ? 'Paid' : ''} $${
invoice.amount / 100
}`}
>
<Button onClick={open}>View Invoice</Button>
</SettingRow>
);
};

View File

@@ -0,0 +1,39 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const subscription = style({});
export const billingHistory = style({});
export const planCard = style({
display: 'flex',
justifyContent: 'space-between',
padding: '12px',
border: '1px solid var(--affine-border-color)',
borderRadius: '8px',
});
export const currentPlan = style({
flex: '1 0 0',
});
export const planAction = style({
marginTop: '8px',
});
export const planPrice = style({
fontSize: 'var(--affine-font-h-6)',
fontWeight: 600,
});
export const paymentMethod = style({
marginTop: '24px',
});
globalStyle('.dangerous-setting .name', {
color: 'var(--affine-error-color)',
});
export const noInvoice = style({
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
});

View File

@@ -7,8 +7,10 @@ import {
} from '@blocksuite/icons';
import type { ReactElement, SVGProps } from 'react';
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
import { BillingSettings } from './billing';
import { AFFiNECloudPlans } from './plans';
import { Plugins } from './plugins';
import { Shortcuts } from './shortcuts';
@@ -32,8 +34,9 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useAFFiNEI18N();
const status = useCurrentLoginStatus();
return [
const settings: GeneralSettingListItem[] = [
{
key: 'appearance',
title: t['com.affine.settings.appearance'](),
@@ -54,14 +57,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
icon: KeyboardIcon,
testId: 'plans-panel-trigger',
},
{
key: 'billing',
// TODO: i18n
title: 'Billing',
// TODO: icon
icon: KeyboardIcon,
testId: 'billing-panel-trigger',
},
{
key: 'plugins',
title: 'Plugins',
@@ -75,6 +71,19 @@ export const useGeneralSettingList = (): GeneralSettingList => {
testId: 'about-panel-trigger',
},
];
if (status === 'authenticated') {
settings.splice(3, 0, {
key: 'billing',
// TODO: i18n
title: 'Billing',
// TODO: icon
icon: KeyboardIcon,
testId: 'billing-panel-trigger',
});
}
return settings;
};
interface GeneralSettingProps {
@@ -93,6 +102,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
return <AboutAffine />;
case 'plans':
return <AFFiNECloudPlans />;
case 'billing':
return <BillingSettings />;
default:
return null;
}

View File

@@ -129,12 +129,11 @@ const Settings = () => {
const subscription = data.currentUser?.subscription;
const [recurring, setRecurring] = useState<string>(
subscription?.recurring ?? SubscriptionRecurring.Monthly
subscription?.recurring ?? SubscriptionRecurring.Yearly
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring =
subscription?.recurring ?? SubscriptionRecurring.Monthly;
const currentRecurring = subscription?.recurring;
const refresh = useCallback(() => {
mutate();
@@ -178,8 +177,6 @@ const Settings = () => {
{/* TODO: may scroll current plan into view when first loading? */}
<div className={styles.planCardsWrapper}>
{Array.from(planDetail.values()).map(detail => {
const isCurrent =
currentPlan === detail.plan && currentRecurring === recurring;
return (
<div
key={detail.plan}
@@ -192,11 +189,12 @@ const Settings = () => {
<div className={styles.planTitle}>
<p>
{detail.plan}{' '}
{'discount' in detail && (
<span className={styles.discountLabel}>
{detail.discount}% off
</span>
)}
{'discount' in detail &&
recurring === SubscriptionRecurring.Yearly && (
<span className={styles.discountLabel}>
{detail.discount}% off
</span>
)}
</p>
<p>
<span className={styles.planPrice}>
@@ -224,18 +222,26 @@ const Settings = () => {
detail.type === 'dynamic' ? (
<ContactSales />
) : loggedIn ? (
isCurrent ? (
detail.plan === currentPlan &&
(currentRecurring === recurring ||
(!currentRecurring &&
detail.plan === SubscriptionPlan.Free)) ? (
<CurrentPlan />
) : detail.plan === SubscriptionPlan.Free ? (
<Downgrade onActionDone={refresh} />
) : currentRecurring !== recurring ? (
) : currentRecurring !== recurring &&
currentPlan === detail.plan ? (
<ChangeRecurring
// @ts-expect-error must exist
from={currentRecurring}
to={recurring as SubscriptionRecurring}
onActionDone={refresh}
/>
) : (
<Upgrade recurring={recurring} onActionDone={refresh} />
<Upgrade
recurring={recurring as SubscriptionRecurring}
onActionDone={refresh}
/>
)
) : (
<SignupAction>