mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 14:27:02 +08: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 { Skeleton } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
Pagination,
|
|
||||||
SettingHeader,
|
SettingHeader,
|
||||||
SettingRow,
|
|
||||||
SettingWrapper,
|
SettingWrapper,
|
||||||
} from '@affine/component/setting-components';
|
} from '@affine/component/setting-components';
|
||||||
import { Button, IconButton } from '@affine/component/ui/button';
|
import { SubscriptionService } from '@affine/core/modules/cloud';
|
||||||
import { Loading } from '@affine/component/ui/loading';
|
import { SubscriptionStatus } from '@affine/graphql';
|
||||||
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
import { useI18n } from '@affine/i18n';
|
||||||
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 { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { cssVar } from '@toeverything/theme';
|
import { useCallback, useEffect } from 'react';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { useMutation } from '../../../../../components/hooks/use-mutation';
|
|
||||||
import type { SettingState } from '../../types';
|
import type { SettingState } from '../../types';
|
||||||
import { CancelAction, ResumeAction } from '../plans/actions';
|
import { AIPlanCard } from './ai-plan-card';
|
||||||
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
|
import { BelieverIdentifier } from './biliever-identifier';
|
||||||
import { AIRedeemCodeButton } from '../plans/ai/actions/redeem';
|
import { BillingHistory } from './billing-history';
|
||||||
import { BelieverCard } from '../plans/lifetime/believer-card';
|
import { PaymentMethod } from './payment-method';
|
||||||
import { BelieverBenefits } from '../plans/lifetime/benefits';
|
import { ProPlanCard } from './pro-plan-card';
|
||||||
import { RedeemCode } from '../plans/plan-card';
|
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
import { TypeformLink } from './typeform-link';
|
||||||
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 BillingSettings = ({
|
export const BillingSettings = ({
|
||||||
onChangeSettingState,
|
onChangeSettingState,
|
||||||
@@ -90,7 +49,6 @@ const SubscriptionSettings = ({
|
|||||||
}: {
|
}: {
|
||||||
onChangeSettingState: (state: SettingState) => void;
|
onChangeSettingState: (state: SettingState) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
|
||||||
const subscriptionService = useService(SubscriptionService);
|
const subscriptionService = useService(SubscriptionService);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
subscriptionService.subscription.revalidate();
|
subscriptionService.subscription.revalidate();
|
||||||
@@ -98,15 +56,7 @@ const SubscriptionSettings = ({
|
|||||||
}, [subscriptionService]);
|
}, [subscriptionService]);
|
||||||
|
|
||||||
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
|
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
|
||||||
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
|
|
||||||
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
|
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(
|
const openPlans = useCallback(
|
||||||
(scrollAnchor?: string) => {
|
(scrollAnchor?: string) => {
|
||||||
@@ -127,14 +77,6 @@ const SubscriptionSettings = ({
|
|||||||
[openPlans]
|
[openPlans]
|
||||||
);
|
);
|
||||||
|
|
||||||
const amount = proSubscription
|
|
||||||
? proPrice
|
|
||||||
? proSubscription.recurring === SubscriptionRecurring.Monthly
|
|
||||||
? String((proPrice.amount ?? 0) / 100)
|
|
||||||
: String((proPrice.yearlyAmount ?? 0) / 100)
|
|
||||||
: '?'
|
|
||||||
: '0';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.subscription}>
|
<div className={styles.subscription}>
|
||||||
<AIPlanCard onClick={gotoAiPlanSetting} />
|
<AIPlanCard onClick={gotoAiPlanSetting} />
|
||||||
@@ -143,102 +85,17 @@ const SubscriptionSettings = ({
|
|||||||
isBeliever ? (
|
isBeliever ? (
|
||||||
<BelieverIdentifier onOpenPlans={gotoCloudPlansSetting} />
|
<BelieverIdentifier onOpenPlans={gotoCloudPlansSetting} />
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.planCard}>
|
<ProPlanCard gotoCloudPlansSetting={gotoCloudPlansSetting} />
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<SubscriptionSettingSkeleton />
|
<SubscriptionSettingSkeleton />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<TypeFormLink />
|
<TypeformLink />
|
||||||
|
|
||||||
{proSubscription !== null ? (
|
{proSubscription !== null ? (
|
||||||
proSubscription?.status === SubscriptionStatus.Active && (
|
proSubscription?.status === SubscriptionStatus.Active && (
|
||||||
<>
|
<PaymentMethod />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<SubscriptionSettingSkeleton />
|
<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 SubscriptionSettingSkeleton = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
return (
|
return (
|
||||||
@@ -616,14 +117,3 @@ const SubscriptionSettingSkeleton = () => {
|
|||||||
</SettingWrapper>
|
</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 { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
export const subscription = style({});
|
export const subscription = style({});
|
||||||
export const history = style({
|
export const history = style({
|
||||||
@@ -14,7 +15,7 @@ export const planCard = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
border: `1px solid ${cssVar('borderColor')}`,
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
});
|
});
|
||||||
export const currentPlan = style({
|
export const currentPlan = style({
|
||||||
@@ -35,10 +36,10 @@ export const paymentMethod = style({
|
|||||||
marginTop: '24px',
|
marginTop: '24px',
|
||||||
});
|
});
|
||||||
globalStyle('.dangerous-setting .name', {
|
globalStyle('.dangerous-setting .name', {
|
||||||
color: cssVar('errorColor'),
|
color: cssVarV2('status/error'),
|
||||||
});
|
});
|
||||||
export const noInvoice = style({
|
export const noInvoice = style({
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVarV2('text/secondary'),
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
});
|
});
|
||||||
export const currentPlanName = style({
|
export const currentPlanName = style({
|
||||||
@@ -70,13 +71,13 @@ export const believerTitle = style({
|
|||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVarV2('text/primary'),
|
||||||
});
|
});
|
||||||
export const believerSubtitle = style({
|
export const believerSubtitle = style({
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVarV2('text/secondary'),
|
||||||
});
|
});
|
||||||
globalStyle(`.${believerSubtitle} > a`, {
|
globalStyle(`.${believerSubtitle} > a`, {
|
||||||
color: cssVar('brandColor'),
|
color: cssVar('brandColor'),
|
||||||
@@ -91,11 +92,44 @@ export const believerPrice = style({
|
|||||||
fontSize: '18px',
|
fontSize: '18px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: '26px',
|
lineHeight: '26px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVarV2('text/primary'),
|
||||||
});
|
});
|
||||||
export const believerPriceCaption = style({
|
export const believerPriceCaption = style({
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
fontWeight: 500,
|
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
|
<Button
|
||||||
onClick={cancel}
|
onClick={cancel}
|
||||||
loading={isMutating}
|
loading={isMutating}
|
||||||
variant="primary"
|
variant="secondary"
|
||||||
{...btnProps}
|
{...btnProps}
|
||||||
>
|
>
|
||||||
{t['com.affine.payment.ai.action.cancel.button-label']()}
|
{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 { Button, Loading } from '@affine/component';
|
||||||
import {
|
import {
|
||||||
Pagination,
|
|
||||||
SettingHeader,
|
SettingHeader,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
SettingWrapper,
|
SettingWrapper,
|
||||||
} from '@affine/component/setting-components';
|
} from '@affine/component/setting-components';
|
||||||
import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
|
import { WorkspaceSubscriptionService } from '@affine/core/modules/cloud';
|
||||||
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 { WorkspaceService } from '@affine/core/modules/workspace';
|
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 { useI18n } from '@affine/i18n';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { cssVar } from '@toeverything/theme';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
import {
|
import { TeamResumeAction } from '../../general-setting/plans/actions';
|
||||||
CancelTeamAction,
|
import { BillingHistory } from './billing-history';
|
||||||
TeamResumeAction,
|
import { PaymentMethodUpdater } from './payment-method';
|
||||||
} from '../../general-setting/plans/actions';
|
import { TeamCard } from './team-card';
|
||||||
import * as styles from './styles.css';
|
import { TypeformLink } from './typeform-link';
|
||||||
|
|
||||||
export const WorkspaceSettingBilling = () => {
|
export const WorkspaceSettingBilling = () => {
|
||||||
const workspace = useService(WorkspaceService).workspace;
|
const workspace = useService(WorkspaceService).workspace;
|
||||||
@@ -70,7 +50,7 @@ export const WorkspaceSettingBilling = () => {
|
|||||||
title={t['com.affine.payment.billing-setting.information']()}
|
title={t['com.affine.payment.billing-setting.information']()}
|
||||||
>
|
>
|
||||||
<TeamCard />
|
<TeamCard />
|
||||||
<TypeFormLink />
|
<TypeformLink />
|
||||||
<PaymentMethodUpdater />
|
<PaymentMethodUpdater />
|
||||||
{subscription?.end && subscription.canceledAt ? (
|
{subscription?.end && subscription.canceledAt ? (
|
||||||
<ResumeSubscription expirationDate={subscription.end} />
|
<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 ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -221,174 +81,10 @@ const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<TeamResumeAction open={open} onOpenChange={setOpen}>
|
<TeamResumeAction open={open} onOpenChange={setOpen}>
|
||||||
<Button onClick={handleClick}>
|
<Button onClick={handleClick} variant="primary">
|
||||||
{t['com.affine.payment.billing-setting.resume-subscription']()}
|
{t['com.affine.payment.billing-setting.resume-subscription']()}
|
||||||
</Button>
|
</Button>
|
||||||
</TeamResumeAction>
|
</TeamResumeAction>
|
||||||
</SettingRow>
|
</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 { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const paymentMethod = style({
|
export const paymentMethod = style({
|
||||||
@@ -16,7 +17,7 @@ export const historyContent = style({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const noInvoice = style({
|
export const noInvoice = style({
|
||||||
color: cssVar('textSecondaryColor'),
|
color: cssVarV2('text/secondary'),
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export const planCard = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
padding: '12px',
|
padding: '12px',
|
||||||
border: `1px solid ${cssVar('borderColor')}`,
|
border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -58,10 +59,44 @@ export const billingFrequency = style({
|
|||||||
export const currentPlanName = style({
|
export const currentPlanName = style({
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
color: cssVar('textEmphasisColor'),
|
color: cssVarV2('text/emphasis'),
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const cancelPlanButton = style({
|
export const cancelPlanButton = style({
|
||||||
marginTop: '8px',
|
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: {
|
["com.affine.payment.ai.billing-tip.next-bill-at"](options: {
|
||||||
readonly due: string;
|
readonly due: string;
|
||||||
}): 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.`
|
* `You are currently on the Free plan.`
|
||||||
*/
|
*/
|
||||||
@@ -3310,6 +3316,18 @@ export function useAFFiNEI18N(): {
|
|||||||
used: string;
|
used: string;
|
||||||
limit: string;
|
limit: string;
|
||||||
}>): 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`
|
* `Unlimited local workspaces`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -811,6 +811,7 @@
|
|||||||
"com.affine.payment.ai.benefit.g3-3": "Open source & Privacy ensured",
|
"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.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.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-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.caption-purchased": "You have purchased AFFiNE AI",
|
||||||
"com.affine.payment.ai.pricing-plan.learn": "Learn about 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.purchase-button-label": "Purchase",
|
||||||
"com.affine.payment.ai.usage.used-caption": "Times used",
|
"com.affine.payment.ai.usage.used-caption": "Times used",
|
||||||
"com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} times",
|
"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-1": "Unlimited local workspaces",
|
||||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||||
|
|||||||
Reference in New Issue
Block a user