mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(core): impl billing settings (#4652)
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;
|
||||||
@@ -224,6 +224,8 @@ model UserInvoice {
|
|||||||
// billing reason
|
// billing reason
|
||||||
reason String @db.VarChar
|
reason String @db.VarChar
|
||||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||||
|
// stripe hosted invoice link
|
||||||
|
link String? @db.Text
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,9 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
|||||||
@Field(() => String, { nullable: true })
|
@Field(() => String, { nullable: true })
|
||||||
lastPaymentError?: string | null;
|
lastPaymentError?: string | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
link?: string | null;
|
||||||
|
|
||||||
@Field(() => Date)
|
@Field(() => Date)
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@@ -183,6 +186,13 @@ export class SubscriptionResolver {
|
|||||||
return session.url;
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String, {
|
||||||
|
description: 'Create a stripe customer portal to manage payment methods',
|
||||||
|
})
|
||||||
|
async createCustomerPortal(@CurrentUser() user: User) {
|
||||||
|
return this.service.createCustomerPortal(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => UserSubscriptionType)
|
@Mutation(() => UserSubscriptionType)
|
||||||
async cancelSubscription(@CurrentUser() user: User) {
|
async cancelSubscription(@CurrentUser() user: User) {
|
||||||
return this.service.cancelSubscription(user.id);
|
return this.service.cancelSubscription(user.id);
|
||||||
|
|||||||
@@ -296,6 +296,29 @@ export class SubscriptionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createCustomerPortal(id: string) {
|
||||||
|
const user = await this.db.userStripeCustomer.findUnique({
|
||||||
|
where: {
|
||||||
|
userId: id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('Unknown user');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const portal = await this.stripe.billingPortal.sessions.create({
|
||||||
|
customer: user.stripeCustomerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return portal.url;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error('Failed to create customer portal.', e);
|
||||||
|
throw new Error('Failed to create customer portal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent('customer.subscription.created')
|
@OnEvent('customer.subscription.created')
|
||||||
@OnEvent('customer.subscription.updated')
|
@OnEvent('customer.subscription.updated')
|
||||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||||
@@ -519,6 +542,7 @@ export class SubscriptionService {
|
|||||||
currency: stripeInvoice.currency,
|
currency: stripeInvoice.currency,
|
||||||
amount: stripeInvoice.total,
|
amount: stripeInvoice.total,
|
||||||
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
||||||
|
link: stripeInvoice.hosted_invoice_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
// handle payment error
|
// handle payment error
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ type UserInvoice {
|
|||||||
status: InvoiceStatus!
|
status: InvoiceStatus!
|
||||||
reason: String!
|
reason: String!
|
||||||
lastPaymentError: String
|
lastPaymentError: String
|
||||||
|
link: String
|
||||||
createdAt: DateTime!
|
createdAt: DateTime!
|
||||||
updatedAt: DateTime!
|
updatedAt: DateTime!
|
||||||
}
|
}
|
||||||
@@ -278,6 +279,9 @@ type Mutation {
|
|||||||
|
|
||||||
"""Create a subscription checkout link of stripe"""
|
"""Create a subscription checkout link of stripe"""
|
||||||
checkout(recurring: SubscriptionRecurring!): String!
|
checkout(recurring: SubscriptionRecurring!): String!
|
||||||
|
|
||||||
|
"""Create a stripe customer portal to manage payment methods"""
|
||||||
|
createCustomerPortal: String!
|
||||||
cancelSubscription: UserSubscription!
|
cancelSubscription: UserSubscription!
|
||||||
resumeSubscription: UserSubscription!
|
resumeSubscription: UserSubscription!
|
||||||
updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription!
|
updateSubscriptionRecurring(recurring: SubscriptionRecurring!): UserSubscription!
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type SettingRowProps = PropsWithChildren<{
|
|||||||
spreadCol?: boolean;
|
spreadCol?: boolean;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const SettingRow = ({
|
export const SettingRow = ({
|
||||||
@@ -21,14 +22,19 @@ export const SettingRow = ({
|
|||||||
style,
|
style,
|
||||||
spreadCol = true,
|
spreadCol = true,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
className,
|
||||||
...props
|
...props
|
||||||
}: PropsWithChildren<SettingRowProps>) => {
|
}: PropsWithChildren<SettingRowProps>) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={clsx(settingRow, {
|
className={clsx(
|
||||||
'two-col': spreadCol,
|
settingRow,
|
||||||
disabled,
|
{
|
||||||
})}
|
'two-col': spreadCol,
|
||||||
|
disabled,
|
||||||
|
},
|
||||||
|
className
|
||||||
|
)}
|
||||||
style={style}
|
style={style}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-testid={props['data-testid']}
|
data-testid={props['data-testid']}
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ globalStyle(`${settingRow} .desc`, {
|
|||||||
color: 'var(--affine-text-secondary-color)',
|
color: 'var(--affine-text-secondary-color)',
|
||||||
});
|
});
|
||||||
globalStyle(`${settingRow} .right-col`, {
|
globalStyle(`${settingRow} .right-col`, {
|
||||||
width: '250px',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
paddingLeft: '15px',
|
paddingLeft: '15px',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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)',
|
||||||
|
});
|
||||||
@@ -7,8 +7,10 @@ import {
|
|||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { ReactElement, SVGProps } from 'react';
|
import type { ReactElement, SVGProps } from 'react';
|
||||||
|
|
||||||
|
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
|
||||||
import { AboutAffine } from './about';
|
import { AboutAffine } from './about';
|
||||||
import { AppearanceSettings } from './appearance';
|
import { AppearanceSettings } from './appearance';
|
||||||
|
import { BillingSettings } from './billing';
|
||||||
import { AFFiNECloudPlans } from './plans';
|
import { AFFiNECloudPlans } from './plans';
|
||||||
import { Plugins } from './plugins';
|
import { Plugins } from './plugins';
|
||||||
import { Shortcuts } from './shortcuts';
|
import { Shortcuts } from './shortcuts';
|
||||||
@@ -32,8 +34,9 @@ export type GeneralSettingList = GeneralSettingListItem[];
|
|||||||
|
|
||||||
export const useGeneralSettingList = (): GeneralSettingList => {
|
export const useGeneralSettingList = (): GeneralSettingList => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
|
const status = useCurrentLoginStatus();
|
||||||
|
|
||||||
return [
|
const settings: GeneralSettingListItem[] = [
|
||||||
{
|
{
|
||||||
key: 'appearance',
|
key: 'appearance',
|
||||||
title: t['com.affine.settings.appearance'](),
|
title: t['com.affine.settings.appearance'](),
|
||||||
@@ -54,14 +57,7 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
|||||||
icon: KeyboardIcon,
|
icon: KeyboardIcon,
|
||||||
testId: 'plans-panel-trigger',
|
testId: 'plans-panel-trigger',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'billing',
|
|
||||||
// TODO: i18n
|
|
||||||
title: 'Billing',
|
|
||||||
// TODO: icon
|
|
||||||
icon: KeyboardIcon,
|
|
||||||
testId: 'billing-panel-trigger',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'plugins',
|
key: 'plugins',
|
||||||
title: 'Plugins',
|
title: 'Plugins',
|
||||||
@@ -75,6 +71,19 @@ export const useGeneralSettingList = (): GeneralSettingList => {
|
|||||||
testId: 'about-panel-trigger',
|
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 {
|
interface GeneralSettingProps {
|
||||||
@@ -93,6 +102,8 @@ export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
|
|||||||
return <AboutAffine />;
|
return <AboutAffine />;
|
||||||
case 'plans':
|
case 'plans':
|
||||||
return <AFFiNECloudPlans />;
|
return <AFFiNECloudPlans />;
|
||||||
|
case 'billing':
|
||||||
|
return <BillingSettings />;
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,12 +129,11 @@ const Settings = () => {
|
|||||||
const subscription = data.currentUser?.subscription;
|
const subscription = data.currentUser?.subscription;
|
||||||
|
|
||||||
const [recurring, setRecurring] = useState<string>(
|
const [recurring, setRecurring] = useState<string>(
|
||||||
subscription?.recurring ?? SubscriptionRecurring.Monthly
|
subscription?.recurring ?? SubscriptionRecurring.Yearly
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
|
||||||
const currentRecurring =
|
const currentRecurring = subscription?.recurring;
|
||||||
subscription?.recurring ?? SubscriptionRecurring.Monthly;
|
|
||||||
|
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
mutate();
|
mutate();
|
||||||
@@ -178,8 +177,6 @@ const Settings = () => {
|
|||||||
{/* TODO: may scroll current plan into view when first loading? */}
|
{/* TODO: may scroll current plan into view when first loading? */}
|
||||||
<div className={styles.planCardsWrapper}>
|
<div className={styles.planCardsWrapper}>
|
||||||
{Array.from(planDetail.values()).map(detail => {
|
{Array.from(planDetail.values()).map(detail => {
|
||||||
const isCurrent =
|
|
||||||
currentPlan === detail.plan && currentRecurring === recurring;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={detail.plan}
|
key={detail.plan}
|
||||||
@@ -192,11 +189,12 @@ const Settings = () => {
|
|||||||
<div className={styles.planTitle}>
|
<div className={styles.planTitle}>
|
||||||
<p>
|
<p>
|
||||||
{detail.plan}{' '}
|
{detail.plan}{' '}
|
||||||
{'discount' in detail && (
|
{'discount' in detail &&
|
||||||
<span className={styles.discountLabel}>
|
recurring === SubscriptionRecurring.Yearly && (
|
||||||
{detail.discount}% off
|
<span className={styles.discountLabel}>
|
||||||
</span>
|
{detail.discount}% off
|
||||||
)}
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className={styles.planPrice}>
|
<span className={styles.planPrice}>
|
||||||
@@ -224,18 +222,26 @@ const Settings = () => {
|
|||||||
detail.type === 'dynamic' ? (
|
detail.type === 'dynamic' ? (
|
||||||
<ContactSales />
|
<ContactSales />
|
||||||
) : loggedIn ? (
|
) : loggedIn ? (
|
||||||
isCurrent ? (
|
detail.plan === currentPlan &&
|
||||||
|
(currentRecurring === recurring ||
|
||||||
|
(!currentRecurring &&
|
||||||
|
detail.plan === SubscriptionPlan.Free)) ? (
|
||||||
<CurrentPlan />
|
<CurrentPlan />
|
||||||
) : detail.plan === SubscriptionPlan.Free ? (
|
) : detail.plan === SubscriptionPlan.Free ? (
|
||||||
<Downgrade onActionDone={refresh} />
|
<Downgrade onActionDone={refresh} />
|
||||||
) : currentRecurring !== recurring ? (
|
) : currentRecurring !== recurring &&
|
||||||
|
currentPlan === detail.plan ? (
|
||||||
<ChangeRecurring
|
<ChangeRecurring
|
||||||
|
// @ts-expect-error must exist
|
||||||
from={currentRecurring}
|
from={currentRecurring}
|
||||||
to={recurring as SubscriptionRecurring}
|
to={recurring as SubscriptionRecurring}
|
||||||
onActionDone={refresh}
|
onActionDone={refresh}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Upgrade recurring={recurring} onActionDone={refresh} />
|
<Upgrade
|
||||||
|
recurring={recurring as SubscriptionRecurring}
|
||||||
|
onActionDone={refresh}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<SignupAction>
|
<SignupAction>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
mutation createCustomerPortal {
|
||||||
|
createCustomerPortal
|
||||||
|
}
|
||||||
@@ -138,6 +138,17 @@ mutation checkout($recurring: SubscriptionRecurring!) {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createCustomerPortalMutation = {
|
||||||
|
id: 'createCustomerPortalMutation' as const,
|
||||||
|
operationName: 'createCustomerPortal',
|
||||||
|
definitionName: 'createCustomerPortal',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation createCustomerPortal {
|
||||||
|
createCustomerPortal
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const createWorkspaceMutation = {
|
export const createWorkspaceMutation = {
|
||||||
id: 'createWorkspaceMutation' as const,
|
id: 'createWorkspaceMutation' as const,
|
||||||
operationName: 'createWorkspace',
|
operationName: 'createWorkspace',
|
||||||
@@ -365,6 +376,7 @@ query invoices($take: Int!, $skip: Int!) {
|
|||||||
amount
|
amount
|
||||||
reason
|
reason
|
||||||
lastPaymentError
|
lastPaymentError
|
||||||
|
link
|
||||||
createdAt
|
createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -416,6 +428,23 @@ mutation removeAvatar {
|
|||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const resumeSubscriptionMutation = {
|
||||||
|
id: 'resumeSubscriptionMutation' as const,
|
||||||
|
operationName: 'resumeSubscription',
|
||||||
|
definitionName: 'resumeSubscription',
|
||||||
|
containsFile: false,
|
||||||
|
query: `
|
||||||
|
mutation resumeSubscription {
|
||||||
|
resumeSubscription {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
nextBillAt
|
||||||
|
start
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
|
||||||
export const revokeMemberPermissionMutation = {
|
export const revokeMemberPermissionMutation = {
|
||||||
id: 'revokeMemberPermissionMutation' as const,
|
id: 'revokeMemberPermissionMutation' as const,
|
||||||
operationName: 'revokeMemberPermission',
|
operationName: 'revokeMemberPermission',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ query invoices($take: Int!, $skip: Int!) {
|
|||||||
amount
|
amount
|
||||||
reason
|
reason
|
||||||
lastPaymentError
|
lastPaymentError
|
||||||
|
link
|
||||||
createdAt
|
createdAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
mutation resumeSubscription {
|
||||||
|
resumeSubscription {
|
||||||
|
id
|
||||||
|
status
|
||||||
|
nextBillAt
|
||||||
|
start
|
||||||
|
end
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -182,6 +182,15 @@ export type CheckoutMutationVariables = Exact<{
|
|||||||
|
|
||||||
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
|
export type CheckoutMutation = { __typename?: 'Mutation'; checkout: string };
|
||||||
|
|
||||||
|
export type CreateCustomerPortalMutationVariables = Exact<{
|
||||||
|
[key: string]: never;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CreateCustomerPortalMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
createCustomerPortal: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CreateWorkspaceMutationVariables = Exact<{
|
export type CreateWorkspaceMutationVariables = Exact<{
|
||||||
init: Scalars['Upload']['input'];
|
init: Scalars['Upload']['input'];
|
||||||
}>;
|
}>;
|
||||||
@@ -368,6 +377,7 @@ export type InvoicesQuery = {
|
|||||||
amount: number;
|
amount: number;
|
||||||
reason: string;
|
reason: string;
|
||||||
lastPaymentError: string | null;
|
lastPaymentError: string | null;
|
||||||
|
link: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}>;
|
}>;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -405,6 +415,22 @@ export type RemoveAvatarMutation = {
|
|||||||
removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean };
|
removeAvatar: { __typename?: 'RemoveAvatar'; success: boolean };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResumeSubscriptionMutationVariables = Exact<{
|
||||||
|
[key: string]: never;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type ResumeSubscriptionMutation = {
|
||||||
|
__typename?: 'Mutation';
|
||||||
|
resumeSubscription: {
|
||||||
|
__typename?: 'UserSubscription';
|
||||||
|
id: string;
|
||||||
|
status: SubscriptionStatus;
|
||||||
|
nextBillAt: string | null;
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type RevokeMemberPermissionMutationVariables = Exact<{
|
export type RevokeMemberPermissionMutationVariables = Exact<{
|
||||||
workspaceId: Scalars['String']['input'];
|
workspaceId: Scalars['String']['input'];
|
||||||
userId: Scalars['String']['input'];
|
userId: Scalars['String']['input'];
|
||||||
@@ -712,6 +738,11 @@ export type Mutations =
|
|||||||
variables: CheckoutMutationVariables;
|
variables: CheckoutMutationVariables;
|
||||||
response: CheckoutMutation;
|
response: CheckoutMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'createCustomerPortalMutation';
|
||||||
|
variables: CreateCustomerPortalMutationVariables;
|
||||||
|
response: CreateCustomerPortalMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'createWorkspaceMutation';
|
name: 'createWorkspaceMutation';
|
||||||
variables: CreateWorkspaceMutationVariables;
|
variables: CreateWorkspaceMutationVariables;
|
||||||
@@ -737,6 +768,11 @@ export type Mutations =
|
|||||||
variables: RemoveAvatarMutationVariables;
|
variables: RemoveAvatarMutationVariables;
|
||||||
response: RemoveAvatarMutation;
|
response: RemoveAvatarMutation;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
name: 'resumeSubscriptionMutation';
|
||||||
|
variables: ResumeSubscriptionMutationVariables;
|
||||||
|
response: ResumeSubscriptionMutation;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
name: 'revokeMemberPermissionMutation';
|
name: 'revokeMemberPermissionMutation';
|
||||||
variables: RevokeMemberPermissionMutationVariables;
|
variables: RevokeMemberPermissionMutationVariables;
|
||||||
|
|||||||
Reference in New Issue
Block a user