refactor(core): split the billing component into a separate file (#10924)

refactor(core): split the billing component into a separate file
feat(core): show subscription status in billing settings
![CleanShot 2025-03-17 at 17 02 59@2x](https://github.com/user-attachments/assets/4b3ee6e7-45ad-4d50-b9a5-55d658611e07)
![CleanShot 2025-03-17 at 17 00 33@2x](https://github.com/user-attachments/assets/995fd1d6-de1c-4df2-b66e-4823721adf14)
This commit is contained in:
JimmFly
2025-03-18 10:05:44 +00:00
parent 4fc3e92205
commit 0cb06668cd
19 changed files with 1280 additions and 847 deletions

View File

@@ -0,0 +1,110 @@
import { Skeleton } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionStatus } from '@affine/graphql';
import { i18nTime, Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
import { AIRedeemCodeButton } from '../plans/ai/actions/redeem';
import { CardNameLabelRow } from './card-name-label-row';
import { PaymentMethodUpdater } from './payment-method';
import * as styles from './style.css';
export const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [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)}`
: '?';
const priceFrequency = t['com.affine.payment.billing-setting.year']();
const billingTip = useMemo(() => {
if (subscription === undefined) {
return (
<Trans
i18nKey={'com.affine.payment.billing-setting.ai.free-desc'}
components={{
a: <span onClick={onClick} className={styles.currentPlanName} />,
}}
/>
);
}
if (subscription?.status === SubscriptionStatus.PastDue) {
return t['com.affine.payment.billing-tip.past-due']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
}
if (subscription?.nextBillAt) {
return t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
}
if ((isOnetime || subscription?.canceledAt) && subscription?.end) {
return t['com.affine.payment.ai.billing-tip.end-at']({
end: i18nTime(subscription.end, { absolute: { accuracy: 'day' } }),
});
}
return null;
}, [subscription, isOnetime, onClick, t]);
if (subscription === null) {
return <Skeleton height={100} />;
}
return (
<div className={styles.planCard} style={{ marginBottom: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={
<CardNameLabelRow
cardName={t['com.affine.payment.billing-setting.ai-plan']()}
status={subscription?.status}
/>
}
desc={billingTip}
/>
{price?.yearlyAmount ? (
subscription ? (
isOnetime ? (
<AIRedeemCodeButton className={styles.planAction} />
) : subscription.canceledAt ? (
<AIResume className={styles.planAction} />
) : (
<AICancel className={styles.planAction} />
)
) : (
<AISubscribe className={styles.planAction}>
{t['com.affine.payment.billing-setting.ai.purchase']()}
</AISubscribe>
)
) : null}
{subscription?.status === SubscriptionStatus.PastDue ? (
<PaymentMethodUpdater
inCardView
className={styles.manageMentInCard}
variant="primary"
/>
) : null}
</div>
<p className={styles.planPrice}>
{subscription ? priceReadable : '$0'}
<span className={styles.billingFrequency}>/{priceFrequency}</span>
</p>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { SubscriptionService } from '@affine/core/modules/cloud';
import { Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { BelieverCard } from '../plans/lifetime/believer-card';
import { BelieverBenefits } from '../plans/lifetime/benefits';
import * as styles from './style.css';
export const BelieverIdentifier = ({
onOpenPlans,
}: {
onOpenPlans?: () => void;
}) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const readableLifetimePrice = useLiveData(
subscriptionService.prices.readableLifetimePrice$
);
if (!readableLifetimePrice) return null;
return (
<BelieverCard type={2} style={{ borderRadius: 8, padding: 12 }}>
<header className={styles.believerHeader}>
<div>
<div className={styles.believerTitle}>
{t['com.affine.payment.billing-setting.believer.title']()}
</div>
<div className={styles.believerSubtitle}>
<Trans
i18nKey={
'com.affine.payment.billing-setting.believer.description'
}
components={{
a: <a href="#" onClick={onOpenPlans} />,
}}
/>
</div>
</div>
<div className={styles.believerPriceWrapper}>
<div className={styles.believerPrice}>{readableLifetimePrice}</div>
<div className={styles.believerPriceCaption}>
{t['com.affine.payment.billing-setting.believer.price-caption']()}
</div>
</div>
</header>
<BelieverBenefits />
</BelieverCard>
);
};

View File

@@ -0,0 +1,120 @@
import { Button, Loading } from '@affine/component';
import {
Pagination,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { InvoicesService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import { type InvoicesQuery, InvoiceStatus } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect } from 'react';
import * as styles from './style.css';
export const BillingHistory = () => {
const t = useI18n();
const invoicesService = useService(InvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load invoices'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const BillingHistorySkeleton = () => {
const t = useI18n();
return (
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
</SettingWrapper>
);
};

View File

@@ -0,0 +1,78 @@
import { SubscriptionStatus } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import {
InformationFillDuotoneIcon,
SingleSelectCheckSolidIcon,
} from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from './style.css';
const getStatusLabel = (status?: SubscriptionStatus) => {
switch (status) {
case SubscriptionStatus.Active:
return <StatusLabel status={status} />;
case SubscriptionStatus.PastDue:
return <StatusLabel status={status} />;
case SubscriptionStatus.Trialing:
return <StatusLabel status={status} />;
default:
return null;
}
};
export const CardNameLabelRow = ({
cardName,
status,
}: {
cardName: string;
status?: SubscriptionStatus;
}) => {
const statusLabel = useMemo(() => getStatusLabel(status), [status]);
return (
<div className={styles.cardNameLabelRow}>
<div className={styles.cardName}>{cardName}</div>
{statusLabel}
</div>
);
};
const StatusLabel = ({ status }: { status: SubscriptionStatus }) => {
const t = useI18n();
const label = useMemo(() => {
switch (status) {
case SubscriptionStatus.Active:
return t['com.affine.payment.subscription-status.active']();
case SubscriptionStatus.PastDue:
return t['com.affine.payment.subscription-status.past-due']();
case SubscriptionStatus.Trialing:
return t['com.affine.payment.subscription-status.trialing']();
default:
return '';
}
}, [status, t]);
const icon = useMemo(() => {
switch (status) {
case SubscriptionStatus.Active:
case SubscriptionStatus.Trialing:
return <SingleSelectCheckSolidIcon />;
case SubscriptionStatus.PastDue:
return <InformationFillDuotoneIcon />;
default:
return null;
}
}, [status]);
return (
<div
className={clsx(styles.cardLabelContainer, {
'past-due': status === SubscriptionStatus.PastDue,
})}
>
<div className={styles.cardLabelIcon}>{icon}</div>
<div className={styles.cardLabel}>{label}</div>
</div>
);
};

View File

@@ -1,64 +1,23 @@
import { Skeleton } from '@affine/component';
import {
Pagination,
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { Button, IconButton } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
AuthService,
InvoicesService,
SubscriptionService,
} from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import type { InvoicesQuery } from '@affine/graphql';
import {
createCustomerPortalMutation,
InvoiceStatus,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionStatus } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect } from 'react';
import { useMutation } from '../../../../../components/hooks/use-mutation';
import type { SettingState } from '../../types';
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 { RedeemCode } from '../plans/plan-card';
import { AIPlanCard } from './ai-plan-card';
import { BelieverIdentifier } from './biliever-identifier';
import { BillingHistory } from './billing-history';
import { PaymentMethod } from './payment-method';
import { ProPlanCard } from './pro-plan-card';
import * as styles from './style.css';
const DescriptionI18NKey = {
Basic: 'com.affine.payment.billing-setting.current-plan.description',
Monthly:
'com.affine.payment.billing-setting.current-plan.description.monthly',
Yearly: 'com.affine.payment.billing-setting.current-plan.description.yearly',
Lifetime:
'com.affine.payment.billing-setting.current-plan.description.lifetime',
} as const satisfies { [key: string]: I18nString };
const getMessageKey = (
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
) => {
if (plan !== SubscriptionPlan.Pro) {
return DescriptionI18NKey.Basic;
}
return DescriptionI18NKey[recurring];
};
import { TypeformLink } from './typeform-link';
export const BillingSettings = ({
onChangeSettingState,
@@ -90,7 +49,6 @@ const SubscriptionSettings = ({
}: {
onChangeSettingState: (state: SettingState) => void;
}) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
@@ -98,15 +56,7 @@ const SubscriptionSettings = ({
}, [subscriptionService]);
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const [openCancelModal, setOpenCancelModal] = useState(false);
const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring =
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
const openPlans = useCallback(
(scrollAnchor?: string) => {
@@ -127,14 +77,6 @@ const SubscriptionSettings = ({
[openPlans]
);
const amount = proSubscription
? proPrice
? proSubscription.recurring === SubscriptionRecurring.Monthly
? String((proPrice.amount ?? 0) / 100)
: String((proPrice.yearlyAmount ?? 0) / 100)
: '?'
: '0';
return (
<div className={styles.subscription}>
<AIPlanCard onClick={gotoAiPlanSetting} />
@@ -143,102 +85,17 @@ const SubscriptionSettings = ({
isBeliever ? (
<BelieverIdentifier onOpenPlans={gotoCloudPlansSetting} />
) : (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.current-plan']()}
desc={
<>
<Trans
i18nKey={getMessageKey(currentPlan, currentRecurring)}
values={{
planName: currentPlan,
}}
components={{
1: (
<span
onClick={gotoCloudPlansSetting}
className={styles.currentPlanName}
/>
),
}}
/>
<CloudExpirationInfo />
</>
}
/>
<PlanAction
plan={currentPlan}
gotoPlansSetting={gotoCloudPlansSetting}
/>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{currentRecurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
<ProPlanCard gotoCloudPlansSetting={gotoCloudPlansSetting} />
)
) : (
<SubscriptionSettingSkeleton />
)}
<TypeFormLink />
<TypeformLink />
{proSubscription !== null ? (
proSubscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<PaymentMethodUpdater />
</SettingRow>
{isBeliever || isOnetime ? null : proSubscription.end &&
proSubscription.canceledAt ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[
'com.affine.payment.billing-setting.expiration-date.description'
]({
expirationDate: new Date(
proSubscription.end
).toLocaleDateString(),
})}
>
<ResumeSubscription />
</SettingRow>
) : (
<CancelAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<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'
]()}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)}
</>
<PaymentMethod />
)
) : (
<SubscriptionSettingSkeleton />
@@ -247,362 +104,6 @@ const SubscriptionSettings = ({
);
};
const CloudExpirationInfo = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const subscription = useLiveData(subscriptionService.subscription.pro$);
let text = '';
if (subscription?.nextBillAt) {
text = t['com.affine.payment.billing-setting.renew-date.description']({
renewDate: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
} else if (subscription?.end) {
text = t['com.affine.payment.billing-setting.due-date.description']({
dueDate: i18nTime(subscription.end, {
absolute: { accuracy: 'day' },
}),
});
}
return text ? (
<>
<br />
{text}
</>
) : null;
};
const TypeFormLink = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService);
const pro = useLiveData(subscriptionService.subscription.pro$);
const ai = useLiveData(subscriptionService.subscription.ai$);
const account = useLiveData(authService.session.account$);
if (!account) return null;
if (!pro && !ai) return null;
const plan = [];
if (pro) plan.push(SubscriptionPlan.Pro);
if (ai) plan.push(SubscriptionPlan.AI);
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: pro?.recurring ?? ai?.recurring ?? SubscriptionRecurring.Yearly,
plan,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};
const BelieverIdentifier = ({ onOpenPlans }: { onOpenPlans?: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const readableLifetimePrice = useLiveData(
subscriptionService.prices.readableLifetimePrice$
);
if (!readableLifetimePrice) return null;
return (
<BelieverCard type={2} style={{ borderRadius: 8, padding: 12 }}>
<header className={styles.believerHeader}>
<div>
<div className={styles.believerTitle}>
{t['com.affine.payment.billing-setting.believer.title']()}
</div>
<div className={styles.believerSubtitle}>
<Trans
i18nKey={
'com.affine.payment.billing-setting.believer.description'
}
components={{
a: <a href="#" onClick={onOpenPlans} />,
}}
/>
</div>
</div>
<div className={styles.believerPriceWrapper}>
<div className={styles.believerPrice}>{readableLifetimePrice}</div>
<div className={styles.believerPriceCaption}>
{t['com.affine.payment.billing-setting.believer.price-caption']()}
</div>
</div>
</header>
<BelieverBenefits />
</BelieverCard>
);
};
const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [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)}`
: '?';
const priceFrequency = t['com.affine.payment.billing-setting.year']();
if (subscription === null) {
return <Skeleton height={100} />;
}
const billingTip =
subscription === undefined ? (
<Trans
i18nKey={'com.affine.payment.billing-setting.ai.free-desc'}
components={{
a: (
<a href="#" onClick={onClick} className={styles.currentPlanName} />
),
}}
/>
) : subscription?.nextBillAt ? (
t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
})
) : (isOnetime || subscription?.canceledAt) && subscription.end ? (
t['com.affine.payment.ai.billing-tip.end-at']({
end: i18nTime(subscription.end, { absolute: { accuracy: 'day' } }),
})
) : null;
return (
<div className={styles.planCard} style={{ marginBottom: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.ai-plan']()}
desc={billingTip}
/>
{price?.yearlyAmount ? (
subscription ? (
isOnetime ? (
<AIRedeemCodeButton className={styles.planAction} />
) : subscription.canceledAt ? (
<AIResume className={styles.planAction} />
) : (
<AICancel className={styles.planAction} />
)
) : (
<AISubscribe className={styles.planAction}>
{t['com.affine.payment.billing-setting.ai.purchase']()}
</AISubscribe>
)
) : null}
</div>
<p className={styles.planPrice}>
{subscription ? priceReadable : '$0'}
<span className={styles.billingFrequency}>/{priceFrequency}</span>
</p>
</div>
);
};
const PlanAction = ({
plan,
gotoPlansSetting,
}: {
plan: string;
gotoPlansSetting: () => void;
}) => {
const t = useI18n();
const subscription = useService(SubscriptionService).subscription;
const isOnetimePro = useLiveData(subscription.isOnetimePro$);
if (isOnetimePro) {
return <RedeemCode variant="primary" className={styles.planAction} />;
}
return (
<Button
className={styles.planAction}
variant="primary"
onClick={gotoPlansSetting}
>
{plan === SubscriptionPlan.Pro
? t['com.affine.payment.billing-setting.change-plan']()
: t['com.affine.payment.billing-setting.upgrade']()}
</Button>
);
};
const PaymentMethodUpdater = () => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const urlService = useService(UrlService);
const t = useI18n();
const update = useAsyncCallback(async () => {
await trigger(null, {
onSuccess: data => {
urlService.openPopupWindow(data.createCustomerPortal);
},
});
}, [trigger, urlService]);
return (
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
);
};
const ResumeSubscription = () => {
const t = useI18n();
const [open, setOpen] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const handleClick = useCallback(() => {
setOpen(true);
}, []);
return (
<ResumeAction open={open} onOpenChange={setOpen}>
<Button
onClick={handleClick}
data-event-props="$.settingsPanel.plans.resumeSubscription"
data-event-args-type={subscription.pro$.value?.plan}
data-event-args-category={subscription.pro$.value?.recurring}
>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
);
};
const CancelSubscription = ({ loading }: { loading?: boolean }) => {
return (
<IconButton
style={{ pointerEvents: 'none' }}
disabled={loading}
loading={loading}
>
<ArrowRightSmallIcon />
</IconButton>
);
};
const BillingHistory = () => {
const t = useI18n();
const invoicesService = useService(InvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load invoices'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const SubscriptionSettingSkeleton = () => {
const t = useI18n();
return (
@@ -616,14 +117,3 @@ const SubscriptionSettingSkeleton = () => {
</SettingWrapper>
);
};
const BillingHistorySkeleton = () => {
const t = useI18n();
return (
<SettingWrapper title={t['com.affine.payment.billing-setting.history']()}>
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
</SettingWrapper>
);
};

View File

@@ -0,0 +1,147 @@
import { SettingRow } from '@affine/component/setting-components';
import {
Button,
type ButtonProps,
IconButton,
} from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { createCustomerPortalMutation } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useMutation } from '../../../../../components/hooks/use-mutation';
import { CancelAction, ResumeAction } from '../plans/actions';
import * as styles from './style.css';
export const PaymentMethod = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$);
const [openCancelModal, setOpenCancelModal] = useState(false);
return (
<>
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<PaymentMethodUpdater />
</SettingRow>
{isBeliever || isOnetime ? null : proSubscription?.end &&
proSubscription?.canceledAt ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[
'com.affine.payment.billing-setting.expiration-date.description'
]({
expirationDate: new Date(proSubscription.end).toLocaleDateString(),
})}
>
<ResumeSubscription />
</SettingRow>
) : (
<CancelAction open={openCancelModal} onOpenChange={setOpenCancelModal}>
<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'
]()}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)}
</>
);
};
export const PaymentMethodUpdater = ({
inCardView,
className,
variant,
}: {
inCardView?: boolean;
className?: string;
variant?: ButtonProps['variant'];
}) => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const urlService = useService(UrlService);
const t = useI18n();
const update = useAsyncCallback(async () => {
await trigger(null, {
onSuccess: data => {
urlService.openPopupWindow(data.createCustomerPortal);
},
});
}, [trigger, urlService]);
return (
<Button
onClick={update}
loading={isMutating}
disabled={isMutating}
className={className}
variant={variant}
>
{inCardView
? t['com.affine.payment.billing-setting.payment-method']()
: t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
);
};
const ResumeSubscription = () => {
const t = useI18n();
const [open, setOpen] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const handleClick = useCallback(() => {
setOpen(true);
}, []);
return (
<ResumeAction open={open} onOpenChange={setOpen}>
<Button
onClick={handleClick}
data-event-props="$.settingsPanel.plans.resumeSubscription"
data-event-args-type={subscription.pro$.value?.plan}
data-event-args-category={subscription.pro$.value?.recurring}
>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</ResumeAction>
);
};
const CancelSubscription = ({ loading }: { loading?: boolean }) => {
return (
<IconButton
style={{ pointerEvents: 'none' }}
disabled={loading}
loading={loading}
>
<ArrowRightSmallIcon />
</IconButton>
);
};

View File

@@ -0,0 +1,187 @@
import { Button } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { SubscriptionService } from '@affine/core/modules/cloud';
import {
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { RedeemCode } from '../plans/plan-card';
import { CardNameLabelRow } from './card-name-label-row';
import { PaymentMethodUpdater } from './payment-method';
import * as styles from './style.css';
const DescriptionI18NKey = {
Basic: 'com.affine.payment.billing-setting.current-plan.description',
Monthly:
'com.affine.payment.billing-setting.current-plan.description.monthly',
Yearly: 'com.affine.payment.billing-setting.current-plan.description.yearly',
Lifetime:
'com.affine.payment.billing-setting.current-plan.description.lifetime',
} as const satisfies { [key: string]: I18nString };
const getMessageKey = (
plan: SubscriptionPlan,
recurring: SubscriptionRecurring
) => {
if (plan !== SubscriptionPlan.Pro) {
return DescriptionI18NKey.Basic;
}
return DescriptionI18NKey[recurring];
};
export const ProPlanCard = ({
gotoCloudPlansSetting,
}: {
gotoCloudPlansSetting: () => void;
}) => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring =
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
const amount = proSubscription
? proPrice
? proSubscription.recurring === SubscriptionRecurring.Monthly
? String((proPrice.amount ?? 0) / 100)
: String((proPrice.yearlyAmount ?? 0) / 100)
: '?'
: '0';
return (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={
<CardNameLabelRow
cardName={t['com.affine.payment.billing-setting.current-plan']()}
status={proSubscription?.status}
/>
}
desc={
<>
<Trans
i18nKey={getMessageKey(currentPlan, currentRecurring)}
values={{
planName: currentPlan,
}}
components={{
1: (
<span
onClick={gotoCloudPlansSetting}
className={styles.currentPlanName}
/>
),
}}
/>
<CloudExpirationInfo />
</>
}
/>
<PlanAction
plan={currentPlan}
subscriptionStatus={proSubscription?.status}
gotoPlansSetting={gotoCloudPlansSetting}
/>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{currentRecurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
);
};
const CloudExpirationInfo = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const subscription = useLiveData(subscriptionService.subscription.pro$);
let text = '';
if (subscription?.status === SubscriptionStatus.PastDue) {
text = t['com.affine.payment.billing-tip.past-due']({
due: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
} else if (subscription?.nextBillAt) {
text = t['com.affine.payment.billing-setting.renew-date.description']({
renewDate: i18nTime(subscription.nextBillAt, {
absolute: { accuracy: 'day' },
}),
});
} else if (subscription?.end) {
text = t['com.affine.payment.billing-setting.due-date.description']({
dueDate: i18nTime(subscription.end, {
absolute: { accuracy: 'day' },
}),
});
}
return text ? (
<>
<br />
{text}
</>
) : null;
};
const PlanAction = ({
plan,
subscriptionStatus,
gotoPlansSetting,
}: {
plan: string;
gotoPlansSetting: () => void;
subscriptionStatus?: SubscriptionStatus;
}) => {
const t = useI18n();
const subscription = useService(SubscriptionService).subscription;
const isOnetimePro = useLiveData(subscription.isOnetimePro$);
if (isOnetimePro) {
return <RedeemCode variant="primary" className={styles.planAction} />;
}
return (
<>
<Button
className={styles.planAction}
variant="primary"
onClick={gotoPlansSetting}
>
{plan === SubscriptionPlan.Pro
? t['com.affine.payment.billing-setting.change-plan']()
: t['com.affine.payment.billing-setting.upgrade']()}
</Button>
{subscriptionStatus === SubscriptionStatus.PastDue ? (
<PaymentMethodUpdater
inCardView
className={styles.manageMentInCard}
variant="primary"
/>
) : null}
</>
);
};

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const subscription = style({});
export const history = style({
@@ -14,7 +15,7 @@ export const planCard = style({
display: 'flex',
justifyContent: 'space-between',
padding: '12px',
border: `1px solid ${cssVar('borderColor')}`,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: '8px',
});
export const currentPlan = style({
@@ -35,10 +36,10 @@ export const paymentMethod = style({
marginTop: '24px',
});
globalStyle('.dangerous-setting .name', {
color: cssVar('errorColor'),
color: cssVarV2('status/error'),
});
export const noInvoice = style({
color: cssVar('textSecondaryColor'),
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
});
export const currentPlanName = style({
@@ -70,13 +71,13 @@ export const believerTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
lineHeight: '22px',
color: cssVar('textPrimaryColor'),
color: cssVarV2('text/primary'),
});
export const believerSubtitle = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 400,
color: cssVar('textSecondaryColor'),
color: cssVarV2('text/secondary'),
});
globalStyle(`.${believerSubtitle} > a`, {
color: cssVar('brandColor'),
@@ -91,11 +92,44 @@ export const believerPrice = style({
fontSize: '18px',
fontWeight: 600,
lineHeight: '26px',
color: cssVar('textPrimaryColor'),
color: cssVarV2('text/primary'),
});
export const believerPriceCaption = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 500,
color: cssVar('textSecondaryColor'),
color: cssVarV2('text/secondary'),
});
export const cardNameLabelRow = style({
display: 'flex',
alignItems: 'center',
gap: '16px',
});
export const cardName = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
color: cssVarV2('text/primary'),
lineHeight: '22px',
});
export const cardLabelContainer = style({
display: 'flex',
gap: '4px',
color: cssVarV2('button/primary'),
selectors: {
'&.past-due': {
color: cssVarV2('button/error'),
},
},
});
export const cardLabel = style({
fontSize: cssVar('fontXs'),
fontWeight: 500,
});
export const cardLabelIcon = style({
width: '14px',
height: '14px',
});
export const manageMentInCard = style({
marginTop: '8px',
marginLeft: '12px',
});

View File

@@ -0,0 +1,46 @@
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './style.css';
export const TypeformLink = () => {
const t = useI18n();
const subscriptionService = useService(SubscriptionService);
const authService = useService(AuthService);
const pro = useLiveData(subscriptionService.subscription.pro$);
const ai = useLiveData(subscriptionService.subscription.ai$);
const account = useLiveData(authService.session.account$);
if (!account) return null;
if (!pro && !ai) return null;
const plan = [];
if (pro) plan.push(SubscriptionPlan.Pro);
if (ai) plan.push(SubscriptionPlan.AI);
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: pro?.recurring ?? ai?.recurring ?? SubscriptionRecurring.Yearly,
plan,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};

View File

@@ -86,7 +86,7 @@ export const AICancel = (btnProps: ButtonProps) => {
<Button
onClick={cancel}
loading={isMutating}
variant="primary"
variant="secondary"
{...btnProps}
>
{t['com.affine.payment.ai.action.cancel.button-label']()}

View File

@@ -0,0 +1,113 @@
import { Button, Loading } from '@affine/component';
import { Pagination, SettingRow } from '@affine/component/setting-components';
import { WorkspaceInvoicesService } from '@affine/core/modules/cloud';
import { UrlService } from '@affine/core/modules/url';
import { UserFriendlyError } from '@affine/error';
import { type InvoicesQuery, InvoiceStatus } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect } from 'react';
import * as styles from './styles.css';
export const BillingHistory = () => {
const t = useI18n();
const invoicesService = useService(WorkspaceInvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load invoices'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const BillingHistorySkeleton = () => {
return (
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
);
};

View File

@@ -0,0 +1,78 @@
import { SubscriptionStatus } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import {
InformationFillDuotoneIcon,
SingleSelectCheckSolidIcon,
} from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { useMemo } from 'react';
import * as styles from './styles.css';
const getStatusLabel = (status?: SubscriptionStatus) => {
switch (status) {
case SubscriptionStatus.Active:
return <StatusLabel status={status} />;
case SubscriptionStatus.PastDue:
return <StatusLabel status={status} />;
case SubscriptionStatus.Trialing:
return <StatusLabel status={status} />;
default:
return null;
}
};
export const CardNameLabelRow = ({
cardName,
status,
}: {
cardName: string;
status?: SubscriptionStatus;
}) => {
const statusLabel = useMemo(() => getStatusLabel(status), [status]);
return (
<div className={styles.cardNameLabelRow}>
<div className={styles.cardName}>{cardName}</div>
{statusLabel}
</div>
);
};
const StatusLabel = ({ status }: { status: SubscriptionStatus }) => {
const t = useI18n();
const label = useMemo(() => {
switch (status) {
case SubscriptionStatus.Active:
return t['com.affine.payment.subscription-status.active']();
case SubscriptionStatus.PastDue:
return t['com.affine.payment.subscription-status.past-due']();
case SubscriptionStatus.Trialing:
return t['com.affine.payment.subscription-status.trialing']();
default:
return '';
}
}, [status, t]);
const icon = useMemo(() => {
switch (status) {
case SubscriptionStatus.Active:
case SubscriptionStatus.Trialing:
return <SingleSelectCheckSolidIcon />;
case SubscriptionStatus.PastDue:
return <InformationFillDuotoneIcon />;
default:
return null;
}
}, [status]);
return (
<div
className={clsx(styles.cardLabelContainer, {
'past-due': status === SubscriptionStatus.PastDue,
})}
>
<div className={styles.cardLabelIcon}>{icon}</div>
<div className={styles.cardLabel}>{label}</div>
</div>
);
};

View File

@@ -1,40 +1,20 @@
import { Button, Loading } from '@affine/component';
import {
Pagination,
SettingHeader,
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/components/hooks/use-mutation';
import {
AuthService,
SubscriptionService,
WorkspaceInvoicesService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { UrlService } from '@affine/core/modules/url';
import { WorkspaceSubscriptionService } from '@affine/core/modules/cloud';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { UserFriendlyError } from '@affine/error';
import {
createCustomerPortalMutation,
type InvoicesQuery,
InvoiceStatus,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
CancelTeamAction,
TeamResumeAction,
} from '../../general-setting/plans/actions';
import * as styles from './styles.css';
import { TeamResumeAction } from '../../general-setting/plans/actions';
import { BillingHistory } from './billing-history';
import { PaymentMethodUpdater } from './payment-method';
import { TeamCard } from './team-card';
import { TypeformLink } from './typeform-link';
export const WorkspaceSettingBilling = () => {
const workspace = useService(WorkspaceService).workspace;
@@ -70,7 +50,7 @@ export const WorkspaceSettingBilling = () => {
title={t['com.affine.payment.billing-setting.information']()}
>
<TeamCard />
<TypeFormLink />
<TypeformLink />
<PaymentMethodUpdater />
{subscription?.end && subscription.canceledAt ? (
<ResumeSubscription expirationDate={subscription.end} />
@@ -84,126 +64,6 @@ export const WorkspaceSettingBilling = () => {
);
};
const TeamCard = () => {
const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const workspaceQuotaService = useService(WorkspaceQuotaService);
const subscriptionService = useService(SubscriptionService);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const workspaceMemberCount = workspaceQuota?.memberCount;
const teamSubscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
const teamPrices = useLiveData(subscriptionService.prices.teamPrice$);
const [openCancelModal, setOpenCancelModal] = useState(false);
useEffect(() => {
workspaceSubscriptionService.subscription.revalidate();
workspaceQuotaService.quota.revalidate();
subscriptionService.prices.revalidate();
}, [
subscriptionService,
workspaceQuotaService,
workspaceSubscriptionService,
]);
const expiration = teamSubscription?.canceledAt;
const nextBillingDate = teamSubscription?.nextBillAt;
const recurring = teamSubscription?.recurring;
const endDate = teamSubscription?.end;
const description = useMemo(() => {
if (recurring === SubscriptionRecurring.Yearly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.annually'
]();
}
if (recurring === SubscriptionRecurring.Monthly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.monthly'
]();
}
return t['com.affine.payment.billing-setting.free-trial']();
}, [recurring, t]);
const expirationDate = useMemo(() => {
if (expiration && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.not-renewed'
]({
date: new Date(endDate).toLocaleDateString(),
});
}
if (nextBillingDate && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.next-billing-date'
]({
date: new Date(endDate).toLocaleDateString(),
});
}
return '';
}, [endDate, expiration, nextBillingDate, t]);
const amount = teamSubscription
? teamPrices && workspaceMemberCount
? teamSubscription.recurring === SubscriptionRecurring.Monthly
? String(
(teamPrices.amount ? teamPrices.amount * workspaceMemberCount : 0) /
100
)
: String(
(teamPrices.yearlyAmount
? teamPrices.yearlyAmount * workspaceMemberCount
: 0) / 100
)
: '?'
: '0';
const handleClick = useCallback(() => {
setOpenCancelModal(true);
}, []);
return (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.settings.workspace.billing.team-workspace']()}
desc={
<>
<div>{description}</div>
<div>{expirationDate}</div>
</>
}
/>
<CancelTeamAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<Button
variant="primary"
className={styles.cancelPlanButton}
onClick={handleClick}
>
{t[
'com.affine.settings.workspace.billing.team-workspace.cancel-plan'
]()}
</Button>
</CancelTeamAction>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{teamSubscription?.recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
);
};
const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
const t = useI18n();
const [open, setOpen] = useState(false);
@@ -221,174 +81,10 @@ const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
)}
>
<TeamResumeAction open={open} onOpenChange={setOpen}>
<Button onClick={handleClick}>
<Button onClick={handleClick} variant="primary">
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
</TeamResumeAction>
</SettingRow>
);
};
const TypeFormLink = () => {
const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const authService = useService(AuthService);
const workspaceSubscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
const account = useLiveData(authService.session.account$);
if (!account) return null;
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: workspaceSubscription?.recurring ?? SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.Team,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};
const PaymentMethodUpdater = () => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const urlService = useService(UrlService);
const t = useI18n();
const update = useAsyncCallback(async () => {
await trigger(null, {
onSuccess: data => {
urlService.openPopupWindow(data.createCustomerPortal);
},
});
}, [trigger, urlService]);
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
</SettingRow>
);
};
const BillingHistory = () => {
const t = useI18n();
const invoicesService = useService(WorkspaceInvoicesService);
const pageInvoices = useLiveData(invoicesService.invoices.pageInvoices$);
const invoiceCount = useLiveData(invoicesService.invoices.invoiceCount$);
const isLoading = useLiveData(invoicesService.invoices.isLoading$);
const error = useLiveData(invoicesService.invoices.error$);
const pageNum = useLiveData(invoicesService.invoices.pageNum$);
useEffect(() => {
invoicesService.invoices.revalidate();
}, [invoicesService]);
const handlePageChange = useCallback(
(_: number, pageNum: number) => {
invoicesService.invoices.setPageNum(pageNum);
invoicesService.invoices.revalidate();
},
[invoicesService]
);
if (invoiceCount === undefined) {
if (isLoading) {
return <BillingHistorySkeleton />;
} else {
return (
<span style={{ color: cssVar('errorColor') }}>
{error
? UserFriendlyError.fromAny(error).message
: 'Failed to load invoices'}
</span>
);
}
}
return (
<div className={styles.history}>
<div className={styles.historyContent}>
{invoiceCount === 0 ? (
<p className={styles.noInvoice}>
{t['com.affine.payment.billing-setting.no-invoice']()}
</p>
) : (
pageInvoices?.map(invoice => (
<InvoiceLine key={invoice.id} invoice={invoice} />
))
)}
</div>
{invoiceCount > invoicesService.invoices.PAGE_SIZE && (
<Pagination
totalCount={invoiceCount}
countPerPage={invoicesService.invoices.PAGE_SIZE}
pageNum={pageNum}
onPageChange={handlePageChange}
/>
)}
</div>
);
};
const InvoiceLine = ({
invoice,
}: {
invoice: NonNullable<InvoicesQuery['currentUser']>['invoices'][0];
}) => {
const t = useI18n();
const urlService = useService(UrlService);
const open = useCallback(() => {
if (invoice.link) {
urlService.openPopupWindow(invoice.link);
}
}, [invoice.link, urlService]);
return (
<SettingRow
key={invoice.id}
name={new Date(invoice.createdAt).toLocaleDateString()}
desc={`${
invoice.status === InvoiceStatus.Paid
? t['com.affine.payment.billing-setting.paid']()
: ''
} $${invoice.amount / 100}`}
>
<Button onClick={open}>
{t['com.affine.payment.billing-setting.view-invoice']()}
</Button>
</SettingRow>
);
};
const BillingHistorySkeleton = () => {
return (
<div className={styles.billingHistorySkeleton}>
<Loading />
</div>
);
};

View File

@@ -0,0 +1,40 @@
import { Button } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/components/hooks/use-mutation';
import { UrlService } from '@affine/core/modules/url';
import { createCustomerPortalMutation } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useService } from '@toeverything/infra';
import * as styles from './styles.css';
export const PaymentMethodUpdater = () => {
const { isMutating, trigger } = useMutation({
mutation: createCustomerPortalMutation,
});
const urlService = useService(UrlService);
const t = useI18n();
const update = useAsyncCallback(async () => {
await trigger(null, {
onSuccess: data => {
urlService.openPopupWindow(data.createCustomerPortal);
},
});
}, [trigger, urlService]);
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<Button onClick={update} loading={isMutating} disabled={isMutating}>
{t['com.affine.payment.billing-setting.payment-method.go']()}
</Button>
</SettingRow>
);
};

View File

@@ -1,4 +1,5 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const paymentMethod = style({
@@ -16,7 +17,7 @@ export const historyContent = style({
});
export const noInvoice = style({
color: cssVar('textSecondaryColor'),
color: cssVarV2('text/secondary'),
fontSize: cssVar('fontXs'),
});
@@ -38,7 +39,7 @@ export const planCard = style({
display: 'flex',
justifyContent: 'space-between',
padding: '12px',
border: `1px solid ${cssVar('borderColor')}`,
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
borderRadius: '8px',
});
@@ -58,10 +59,44 @@ export const billingFrequency = style({
export const currentPlanName = style({
fontSize: cssVar('fontXs'),
fontWeight: 500,
color: cssVar('textEmphasisColor'),
color: cssVarV2('text/emphasis'),
cursor: 'pointer',
});
export const cancelPlanButton = style({
marginTop: '8px',
});
export const cardNameLabelRow = style({
display: 'flex',
alignItems: 'center',
gap: '16px',
});
export const cardName = style({
fontSize: cssVar('fontSm'),
fontWeight: 600,
color: cssVarV2('text/primary'),
lineHeight: '22px',
});
export const cardLabelContainer = style({
display: 'flex',
gap: '4px',
color: cssVarV2('button/primary'),
selectors: {
'&.past-due': {
color: cssVarV2('button/error'),
},
},
});
export const cardLabel = style({
fontSize: cssVar('fontXs'),
fontWeight: 500,
});
export const cardLabelIcon = style({
width: '14px',
height: '14px',
});

View File

@@ -0,0 +1,142 @@
import { Button } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import {
SubscriptionService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CancelTeamAction } from '../../general-setting/plans/actions';
import { CardNameLabelRow } from './card-name-label-row';
import * as styles from './styles.css';
export const TeamCard = () => {
const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const workspaceQuotaService = useService(WorkspaceQuotaService);
const subscriptionService = useService(SubscriptionService);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const workspaceMemberCount = workspaceQuota?.memberCount;
const teamSubscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
const teamPrices = useLiveData(subscriptionService.prices.teamPrice$);
const [openCancelModal, setOpenCancelModal] = useState(false);
useEffect(() => {
workspaceSubscriptionService.subscription.revalidate();
workspaceQuotaService.quota.revalidate();
subscriptionService.prices.revalidate();
}, [
subscriptionService,
workspaceQuotaService,
workspaceSubscriptionService,
]);
const expiration = teamSubscription?.canceledAt;
const nextBillingDate = teamSubscription?.nextBillAt;
const recurring = teamSubscription?.recurring;
const endDate = teamSubscription?.end;
const description = useMemo(() => {
if (recurring === SubscriptionRecurring.Yearly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.annually'
]();
}
if (recurring === SubscriptionRecurring.Monthly) {
return t[
'com.affine.settings.workspace.billing.team-workspace.description.billed.monthly'
]();
}
return t['com.affine.payment.billing-setting.free-trial']();
}, [recurring, t]);
const expirationDate = useMemo(() => {
if (expiration && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.not-renewed'
]({
date: new Date(endDate).toLocaleDateString(),
});
}
if (nextBillingDate && endDate) {
return t[
'com.affine.settings.workspace.billing.team-workspace.next-billing-date'
]({
date: new Date(endDate).toLocaleDateString(),
});
}
return '';
}, [endDate, expiration, nextBillingDate, t]);
const amount = teamSubscription
? teamPrices && workspaceMemberCount
? teamSubscription.recurring === SubscriptionRecurring.Monthly
? String(
(teamPrices.amount ? teamPrices.amount * workspaceMemberCount : 0) /
100
)
: String(
(teamPrices.yearlyAmount
? teamPrices.yearlyAmount * workspaceMemberCount
: 0) / 100
)
: '?'
: '0';
const handleClick = useCallback(() => {
setOpenCancelModal(true);
}, []);
return (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={
<CardNameLabelRow
cardName={t[
'com.affine.settings.workspace.billing.team-workspace'
]()}
status={teamSubscription?.status}
/>
}
desc={
<>
<div>{description}</div>
<div>{expirationDate}</div>
</>
}
/>
<CancelTeamAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<Button
variant="secondary"
className={styles.cancelPlanButton}
onClick={handleClick}
>
{t[
'com.affine.settings.workspace.billing.team-workspace.cancel-plan'
]()}
</Button>
</CancelTeamAction>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{teamSubscription?.recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { Button } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import {
AuthService,
WorkspaceSubscriptionService,
} from '@affine/core/modules/cloud';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import * as styles from './styles.css';
export const TypeformLink = () => {
const t = useI18n();
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
const authService = useService(AuthService);
const workspaceSubscription = useLiveData(
workspaceSubscriptionService.subscription.subscription$
);
const account = useLiveData(authService.session.account$);
if (!account) return null;
const link = getUpgradeQuestionnaireLink({
name: account.info?.name,
id: account.id,
email: account.email,
recurring: workspaceSubscription?.recurring ?? SubscriptionRecurring.Yearly,
plan: SubscriptionPlan.Team,
});
return (
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-type-form.title']()}
desc={t['com.affine.payment.billing-type-form.description']()}
>
<a target="_blank" href={link} rel="noreferrer">
<Button>{t['com.affine.payment.billing-type-form.go']()}</Button>
</a>
</SettingRow>
);
};

View File

@@ -3255,6 +3255,12 @@ export function useAFFiNEI18N(): {
["com.affine.payment.ai.billing-tip.next-bill-at"](options: {
readonly due: string;
}): string;
/**
* `Your recent payment failed, the next payment date is {{due}}.`
*/
["com.affine.payment.billing-tip.past-due"](options: {
readonly due: string;
}): string;
/**
* `You are currently on the Free plan.`
*/
@@ -3310,6 +3316,18 @@ export function useAFFiNEI18N(): {
used: string;
limit: string;
}>): string;
/**
* `Active`
*/
["com.affine.payment.subscription-status.active"](): string;
/**
* `Past-due bill`
*/
["com.affine.payment.subscription-status.past-due"](): string;
/**
* `Trialing`
*/
["com.affine.payment.subscription-status.trialing"](): string;
/**
* `Unlimited local workspaces`
*/

View File

@@ -811,6 +811,7 @@
"com.affine.payment.ai.benefit.g3-3": "Open source & Privacy ensured",
"com.affine.payment.ai.billing-tip.end-at": "You have purchased AFFiNE AI. The expiration date is {{end}}.",
"com.affine.payment.ai.billing-tip.next-bill-at": "You have purchased AFFiNE AI. The next payment date is {{due}}.",
"com.affine.payment.billing-tip.past-due": "Your recent payment failed, the next payment date is {{due}}.",
"com.affine.payment.ai.pricing-plan.caption-free": "You are currently on the Free plan.",
"com.affine.payment.ai.pricing-plan.caption-purchased": "You have purchased AFFiNE AI",
"com.affine.payment.ai.pricing-plan.learn": "Learn about AFFiNE AI",
@@ -824,6 +825,9 @@
"com.affine.payment.ai.usage.purchase-button-label": "Purchase",
"com.affine.payment.ai.usage.used-caption": "Times used",
"com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} times",
"com.affine.payment.subscription-status.active": "Active",
"com.affine.payment.subscription-status.past-due": "Past-due bill",
"com.affine.payment.subscription-status.trialing": "Trialing",
"com.affine.payment.benefit-1": "Unlimited local workspaces",
"com.affine.payment.benefit-2": "Unlimited login devices",
"com.affine.payment.benefit-3": "Unlimited blocks",