mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
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  
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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']()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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`
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user