From 0cb06668cdb3ea28dfc7dfed694515fc35146bb7 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Tue, 18 Mar 2025 10:05:44 +0000 Subject: [PATCH] refactor(core): split the billing component into a separate file (#10924) refactor(core): split the billing component into a separate file feat(core): show subscription status in billing settings ![CleanShot 2025-03-17 at 17 02 59@2x](https://github.com/user-attachments/assets/4b3ee6e7-45ad-4d50-b9a5-55d658611e07) ![CleanShot 2025-03-17 at 17 00 33@2x](https://github.com/user-attachments/assets/995fd1d6-de1c-4df2-b66e-4823721adf14) --- .../general-setting/billing/ai-plan-card.tsx | 110 ++++ .../billing/biliever-identifier.tsx | 50 ++ .../billing/billing-history.tsx | 120 ++++ .../billing/card-name-label-row.tsx | 78 +++ .../setting/general-setting/billing/index.tsx | 536 +----------------- .../billing/payment-method.tsx | 147 +++++ .../general-setting/billing/pro-plan-card.tsx | 187 ++++++ .../general-setting/billing/style.css.ts | 48 +- .../general-setting/billing/typeform-link.tsx | 46 ++ .../plans/ai/actions/cancel.tsx | 2 +- .../billing/billing-history.tsx | 113 ++++ .../billing/card-name-label-row.tsx | 78 +++ .../workspace-setting/billing/index.tsx | 322 +---------- .../billing/payment-method.tsx | 40 ++ .../workspace-setting/billing/styles.css.ts | 41 +- .../workspace-setting/billing/team-card.tsx | 142 +++++ .../billing/typeform-link.tsx | 45 ++ packages/frontend/i18n/src/i18n.gen.ts | 18 + packages/frontend/i18n/src/resources/en.json | 4 + 19 files changed, 1280 insertions(+), 847 deletions(-) create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/ai-plan-card.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/biliever-identifier.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/billing-history.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/card-name-label-row.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/payment-method.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/pro-plan-card.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/typeform-link.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/billing-history.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/card-name-label-row.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/payment-method.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/team-card.tsx create mode 100644 packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/typeform-link.tsx diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/ai-plan-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/ai-plan-card.tsx new file mode 100644 index 0000000000..fcc4748449 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/ai-plan-card.tsx @@ -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 ( + , + }} + /> + ); + } + 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 ; + } + + return ( +
+
+ + } + desc={billingTip} + /> + {price?.yearlyAmount ? ( + subscription ? ( + isOnetime ? ( + + ) : subscription.canceledAt ? ( + + ) : ( + + ) + ) : ( + + {t['com.affine.payment.billing-setting.ai.purchase']()} + + ) + ) : null} + {subscription?.status === SubscriptionStatus.PastDue ? ( + + ) : null} +
+

+ {subscription ? priceReadable : '$0'} + /{priceFrequency} +

+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/biliever-identifier.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/biliever-identifier.tsx new file mode 100644 index 0000000000..ce2cd8c743 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/biliever-identifier.tsx @@ -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 ( + +
+
+
+ {t['com.affine.payment.billing-setting.believer.title']()} +
+
+ , + }} + /> +
+
+
+
{readableLifetimePrice}
+
+ {t['com.affine.payment.billing-setting.believer.price-caption']()} +
+
+
+ +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/billing-history.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/billing-history.tsx new file mode 100644 index 0000000000..11e822dca6 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/billing-history.tsx @@ -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 ; + } else { + return ( + + {error + ? UserFriendlyError.fromAny(error).message + : 'Failed to load invoices'} + + ); + } + } + + return ( +
+
+ {invoiceCount === 0 ? ( +

+ {t['com.affine.payment.billing-setting.no-invoice']()} +

+ ) : ( + pageInvoices?.map(invoice => ( + + )) + )} +
+ + {invoiceCount > invoicesService.invoices.PAGE_SIZE && ( + + )} +
+ ); +}; + +const InvoiceLine = ({ + invoice, +}: { + invoice: NonNullable['invoices'][0]; +}) => { + const t = useI18n(); + const urlService = useService(UrlService); + + const open = useCallback(() => { + if (invoice.link) { + urlService.openPopupWindow(invoice.link); + } + }, [invoice.link, urlService]); + + return ( + + + + ); +}; + +const BillingHistorySkeleton = () => { + const t = useI18n(); + return ( + +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/card-name-label-row.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/card-name-label-row.tsx new file mode 100644 index 0000000000..163111e0b4 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/card-name-label-row.tsx @@ -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 ; + case SubscriptionStatus.PastDue: + return ; + case SubscriptionStatus.Trialing: + return ; + default: + return null; + } +}; + +export const CardNameLabelRow = ({ + cardName, + status, +}: { + cardName: string; + status?: SubscriptionStatus; +}) => { + const statusLabel = useMemo(() => getStatusLabel(status), [status]); + return ( +
+
{cardName}
+ {statusLabel} +
+ ); +}; + +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 ; + case SubscriptionStatus.PastDue: + return ; + default: + return null; + } + }, [status]); + + return ( +
+
{icon}
+
{label}
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/index.tsx index 97d9c3f4ff..01f45a8e0f 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/index.tsx @@ -1,64 +1,23 @@ import { Skeleton } from '@affine/component'; import { - Pagination, SettingHeader, - SettingRow, SettingWrapper, } from '@affine/component/setting-components'; -import { Button, IconButton } from '@affine/component/ui/button'; -import { Loading } from '@affine/component/ui/loading'; -import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { - AuthService, - InvoicesService, - SubscriptionService, -} from '@affine/core/modules/cloud'; -import { UrlService } from '@affine/core/modules/url'; -import { UserFriendlyError } from '@affine/error'; -import type { InvoicesQuery } from '@affine/graphql'; -import { - createCustomerPortalMutation, - InvoiceStatus, - SubscriptionPlan, - SubscriptionRecurring, - SubscriptionStatus, -} from '@affine/graphql'; -import { type I18nString, i18nTime, Trans, useI18n } from '@affine/i18n'; +import { SubscriptionService } from '@affine/core/modules/cloud'; +import { SubscriptionStatus } from '@affine/graphql'; +import { useI18n } from '@affine/i18n'; import { track } from '@affine/track'; -import { ArrowRightSmallIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; -import { cssVar } from '@toeverything/theme'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; -import { useMutation } from '../../../../../components/hooks/use-mutation'; import type { SettingState } from '../../types'; -import { CancelAction, ResumeAction } from '../plans/actions'; -import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions'; -import { AIRedeemCodeButton } from '../plans/ai/actions/redeem'; -import { BelieverCard } from '../plans/lifetime/believer-card'; -import { BelieverBenefits } from '../plans/lifetime/benefits'; -import { RedeemCode } from '../plans/plan-card'; +import { AIPlanCard } from './ai-plan-card'; +import { BelieverIdentifier } from './biliever-identifier'; +import { BillingHistory } from './billing-history'; +import { PaymentMethod } from './payment-method'; +import { ProPlanCard } from './pro-plan-card'; import * as styles from './style.css'; - -const DescriptionI18NKey = { - Basic: 'com.affine.payment.billing-setting.current-plan.description', - Monthly: - 'com.affine.payment.billing-setting.current-plan.description.monthly', - Yearly: 'com.affine.payment.billing-setting.current-plan.description.yearly', - Lifetime: - 'com.affine.payment.billing-setting.current-plan.description.lifetime', -} as const satisfies { [key: string]: I18nString }; - -const getMessageKey = ( - plan: SubscriptionPlan, - recurring: SubscriptionRecurring -) => { - if (plan !== SubscriptionPlan.Pro) { - return DescriptionI18NKey.Basic; - } - return DescriptionI18NKey[recurring]; -}; +import { TypeformLink } from './typeform-link'; export const BillingSettings = ({ onChangeSettingState, @@ -90,7 +49,6 @@ const SubscriptionSettings = ({ }: { onChangeSettingState: (state: SettingState) => void; }) => { - const t = useI18n(); const subscriptionService = useService(SubscriptionService); useEffect(() => { subscriptionService.subscription.revalidate(); @@ -98,15 +56,7 @@ const SubscriptionSettings = ({ }, [subscriptionService]); const proSubscription = useLiveData(subscriptionService.subscription.pro$); - const proPrice = useLiveData(subscriptionService.prices.proPrice$); const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); - const isOnetime = useLiveData(subscriptionService.subscription.isOnetimeAI$); - - const [openCancelModal, setOpenCancelModal] = useState(false); - - const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free; - const currentRecurring = - proSubscription?.recurring ?? SubscriptionRecurring.Monthly; const openPlans = useCallback( (scrollAnchor?: string) => { @@ -127,14 +77,6 @@ const SubscriptionSettings = ({ [openPlans] ); - const amount = proSubscription - ? proPrice - ? proSubscription.recurring === SubscriptionRecurring.Monthly - ? String((proPrice.amount ?? 0) / 100) - : String((proPrice.yearlyAmount ?? 0) / 100) - : '?' - : '0'; - return (
@@ -143,102 +85,17 @@ const SubscriptionSettings = ({ isBeliever ? ( ) : ( -
-
- - - ), - }} - /> - - - } - /> - -
-

- ${amount} - - / - {currentRecurring === SubscriptionRecurring.Monthly - ? t['com.affine.payment.billing-setting.month']() - : t['com.affine.payment.billing-setting.year']()} - -

-
+ ) ) : ( )} - + {proSubscription !== null ? ( proSubscription?.status === SubscriptionStatus.Active && ( - <> - - - - {isBeliever || isOnetime ? null : proSubscription.end && - proSubscription.canceledAt ? ( - - - - ) : ( - - { - setOpenCancelModal(true); - }} - className="dangerous-setting" - name={t[ - 'com.affine.payment.billing-setting.cancel-subscription' - ]()} - desc={t[ - 'com.affine.payment.billing-setting.cancel-subscription.description' - ]()} - > - - - - )} - + ) ) : ( @@ -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 ? ( - <> -
- {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 ( - - - - - - ); -}; - -const BelieverIdentifier = ({ onOpenPlans }: { onOpenPlans?: () => void }) => { - const t = useI18n(); - const subscriptionService = useService(SubscriptionService); - const readableLifetimePrice = useLiveData( - subscriptionService.prices.readableLifetimePrice$ - ); - - if (!readableLifetimePrice) return null; - - return ( - -
-
-
- {t['com.affine.payment.billing-setting.believer.title']()} -
-
- , - }} - /> -
-
-
-
{readableLifetimePrice}
-
- {t['com.affine.payment.billing-setting.believer.price-caption']()} -
-
-
- -
- ); -}; - -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 ; - } - - const billingTip = - subscription === undefined ? ( - - ), - }} - /> - ) : 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 ( -
-
- - {price?.yearlyAmount ? ( - subscription ? ( - isOnetime ? ( - - ) : subscription.canceledAt ? ( - - ) : ( - - ) - ) : ( - - {t['com.affine.payment.billing-setting.ai.purchase']()} - - ) - ) : null} -
-

- {subscription ? priceReadable : '$0'} - /{priceFrequency} -

-
- ); -}; - -const PlanAction = ({ - plan, - gotoPlansSetting, -}: { - plan: string; - gotoPlansSetting: () => void; -}) => { - const t = useI18n(); - - const subscription = useService(SubscriptionService).subscription; - const isOnetimePro = useLiveData(subscription.isOnetimePro$); - - if (isOnetimePro) { - return ; - } - - return ( - - ); -}; - -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 ( - - ); -}; - -const ResumeSubscription = () => { - const t = useI18n(); - const [open, setOpen] = useState(false); - const subscription = useService(SubscriptionService).subscription; - const handleClick = useCallback(() => { - setOpen(true); - }, []); - - return ( - - - - ); -}; - -const CancelSubscription = ({ loading }: { loading?: boolean }) => { - return ( - - - - ); -}; - -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 ; - } else { - return ( - - {error - ? UserFriendlyError.fromAny(error).message - : 'Failed to load invoices'} - - ); - } - } - - return ( -
-
- {invoiceCount === 0 ? ( -

- {t['com.affine.payment.billing-setting.no-invoice']()} -

- ) : ( - pageInvoices?.map(invoice => ( - - )) - )} -
- - {invoiceCount > invoicesService.invoices.PAGE_SIZE && ( - - )} -
- ); -}; - -const InvoiceLine = ({ - invoice, -}: { - invoice: NonNullable['invoices'][0]; -}) => { - const t = useI18n(); - const urlService = useService(UrlService); - - const open = useCallback(() => { - if (invoice.link) { - urlService.openPopupWindow(invoice.link); - } - }, [invoice.link, urlService]); - - return ( - - - - ); -}; - const SubscriptionSettingSkeleton = () => { const t = useI18n(); return ( @@ -616,14 +117,3 @@ const SubscriptionSettingSkeleton = () => { ); }; - -const BillingHistorySkeleton = () => { - const t = useI18n(); - return ( - -
- -
-
- ); -}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/payment-method.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/payment-method.tsx new file mode 100644 index 0000000000..ba6ab9c9e6 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/payment-method.tsx @@ -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 ( + <> + + + + {isBeliever || isOnetime ? null : proSubscription?.end && + proSubscription?.canceledAt ? ( + + + + ) : ( + + { + setOpenCancelModal(true); + }} + className="dangerous-setting" + name={t['com.affine.payment.billing-setting.cancel-subscription']()} + desc={t[ + 'com.affine.payment.billing-setting.cancel-subscription.description' + ]()} + > + + + + )} + + ); +}; + +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 ( + + ); +}; + +const ResumeSubscription = () => { + const t = useI18n(); + const [open, setOpen] = useState(false); + const subscription = useService(SubscriptionService).subscription; + const handleClick = useCallback(() => { + setOpen(true); + }, []); + + return ( + + + + ); +}; + +const CancelSubscription = ({ loading }: { loading?: boolean }) => { + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/pro-plan-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/pro-plan-card.tsx new file mode 100644 index 0000000000..72e20ae987 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/pro-plan-card.tsx @@ -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 ( +
+
+ + } + desc={ + <> + + ), + }} + /> + + + } + /> + +
+

+ ${amount} + + / + {currentRecurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()} + +

+
+ ); +}; + +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 ? ( + <> +
+ {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 ; + } + + return ( + <> + + {subscriptionStatus === SubscriptionStatus.PastDue ? ( + + ) : null} + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/style.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/style.css.ts index c145251a5a..b9cb852e96 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/style.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/style.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { globalStyle, style } from '@vanilla-extract/css'; export const subscription = style({}); export const history = style({ @@ -14,7 +15,7 @@ export const planCard = style({ display: 'flex', justifyContent: 'space-between', padding: '12px', - border: `1px solid ${cssVar('borderColor')}`, + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, borderRadius: '8px', }); export const currentPlan = style({ @@ -35,10 +36,10 @@ export const paymentMethod = style({ marginTop: '24px', }); globalStyle('.dangerous-setting .name', { - color: cssVar('errorColor'), + color: cssVarV2('status/error'), }); export const noInvoice = style({ - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), fontSize: cssVar('fontXs'), }); export const currentPlanName = style({ @@ -70,13 +71,13 @@ export const believerTitle = style({ fontSize: cssVar('fontSm'), fontWeight: 600, lineHeight: '22px', - color: cssVar('textPrimaryColor'), + color: cssVarV2('text/primary'), }); export const believerSubtitle = style({ fontSize: cssVar('fontXs'), lineHeight: '20px', fontWeight: 400, - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), }); globalStyle(`.${believerSubtitle} > a`, { color: cssVar('brandColor'), @@ -91,11 +92,44 @@ export const believerPrice = style({ fontSize: '18px', fontWeight: 600, lineHeight: '26px', - color: cssVar('textPrimaryColor'), + color: cssVarV2('text/primary'), }); export const believerPriceCaption = style({ fontSize: cssVar('fontXs'), lineHeight: '20px', fontWeight: 500, - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), +}); +export const cardNameLabelRow = style({ + display: 'flex', + alignItems: 'center', + gap: '16px', +}); +export const cardName = style({ + fontSize: cssVar('fontSm'), + fontWeight: 600, + color: cssVarV2('text/primary'), + lineHeight: '22px', +}); +export const cardLabelContainer = style({ + display: 'flex', + gap: '4px', + color: cssVarV2('button/primary'), + selectors: { + '&.past-due': { + color: cssVarV2('button/error'), + }, + }, +}); +export const cardLabel = style({ + fontSize: cssVar('fontXs'), + fontWeight: 500, +}); +export const cardLabelIcon = style({ + width: '14px', + height: '14px', +}); +export const manageMentInCard = style({ + marginTop: '8px', + marginLeft: '12px', }); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/typeform-link.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/typeform-link.tsx new file mode 100644 index 0000000000..2770c756fa --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/billing/typeform-link.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/actions/cancel.tsx b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/actions/cancel.tsx index 7e18b648c6..a76ee587d2 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/actions/cancel.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/general-setting/plans/ai/actions/cancel.tsx @@ -86,7 +86,7 @@ export const AICancel = (btnProps: ButtonProps) => { + + ); +}; + +const BillingHistorySkeleton = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/card-name-label-row.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/card-name-label-row.tsx new file mode 100644 index 0000000000..f61f2c8d93 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/card-name-label-row.tsx @@ -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 ; + case SubscriptionStatus.PastDue: + return ; + case SubscriptionStatus.Trialing: + return ; + default: + return null; + } +}; + +export const CardNameLabelRow = ({ + cardName, + status, +}: { + cardName: string; + status?: SubscriptionStatus; +}) => { + const statusLabel = useMemo(() => getStatusLabel(status), [status]); + return ( +
+
{cardName}
+ {statusLabel} +
+ ); +}; + +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 ; + case SubscriptionStatus.PastDue: + return ; + default: + return null; + } + }, [status]); + + return ( +
+
{icon}
+
{label}
+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx index 43d6ca98c8..1236ee8ba1 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/index.tsx @@ -1,40 +1,20 @@ import { Button, Loading } from '@affine/component'; import { - Pagination, SettingHeader, SettingRow, SettingWrapper, } from '@affine/component/setting-components'; -import { getUpgradeQuestionnaireLink } from '@affine/core/components/hooks/affine/use-subscription-notify'; -import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; -import { useMutation } from '@affine/core/components/hooks/use-mutation'; -import { - AuthService, - SubscriptionService, - WorkspaceInvoicesService, - WorkspaceSubscriptionService, -} from '@affine/core/modules/cloud'; -import { WorkspaceQuotaService } from '@affine/core/modules/quota'; -import { UrlService } from '@affine/core/modules/url'; +import { WorkspaceSubscriptionService } from '@affine/core/modules/cloud'; import { WorkspaceService } from '@affine/core/modules/workspace'; -import { UserFriendlyError } from '@affine/error'; -import { - createCustomerPortalMutation, - type InvoicesQuery, - InvoiceStatus, - SubscriptionPlan, - SubscriptionRecurring, -} from '@affine/graphql'; import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { cssVar } from '@toeverything/theme'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; -import { - CancelTeamAction, - TeamResumeAction, -} from '../../general-setting/plans/actions'; -import * as styles from './styles.css'; +import { TeamResumeAction } from '../../general-setting/plans/actions'; +import { BillingHistory } from './billing-history'; +import { PaymentMethodUpdater } from './payment-method'; +import { TeamCard } from './team-card'; +import { TypeformLink } from './typeform-link'; export const WorkspaceSettingBilling = () => { const workspace = useService(WorkspaceService).workspace; @@ -70,7 +50,7 @@ export const WorkspaceSettingBilling = () => { title={t['com.affine.payment.billing-setting.information']()} > - + {subscription?.end && subscription.canceledAt ? ( @@ -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 ( -
-
- -
{description}
-
{expirationDate}
- - } - /> - - - -
-

- ${amount} - - / - {teamSubscription?.recurring === SubscriptionRecurring.Monthly - ? t['com.affine.payment.billing-setting.month']() - : t['com.affine.payment.billing-setting.year']()} - -

-
- ); -}; - const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => { const t = useI18n(); const [open, setOpen] = useState(false); @@ -221,174 +81,10 @@ const ResumeSubscription = ({ expirationDate }: { expirationDate: string }) => { )} > - ); }; - -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 ( - - - - - - ); -}; - -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 ( - - - - ); -}; - -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 ; - } else { - return ( - - {error - ? UserFriendlyError.fromAny(error).message - : 'Failed to load invoices'} - - ); - } - } - - return ( -
-
- {invoiceCount === 0 ? ( -

- {t['com.affine.payment.billing-setting.no-invoice']()} -

- ) : ( - pageInvoices?.map(invoice => ( - - )) - )} -
- - {invoiceCount > invoicesService.invoices.PAGE_SIZE && ( - - )} -
- ); -}; - -const InvoiceLine = ({ - invoice, -}: { - invoice: NonNullable['invoices'][0]; -}) => { - const t = useI18n(); - const urlService = useService(UrlService); - - const open = useCallback(() => { - if (invoice.link) { - urlService.openPopupWindow(invoice.link); - } - }, [invoice.link, urlService]); - - return ( - - - - ); -}; - -const BillingHistorySkeleton = () => { - return ( -
- -
- ); -}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/payment-method.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/payment-method.tsx new file mode 100644 index 0000000000..1df6f3f987 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/payment-method.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts index 74ddd960bb..473a8af27d 100644 --- a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/styles.css.ts @@ -1,4 +1,5 @@ import { cssVar } from '@toeverything/theme'; +import { cssVarV2 } from '@toeverything/theme/v2'; import { style } from '@vanilla-extract/css'; export const paymentMethod = style({ @@ -16,7 +17,7 @@ export const historyContent = style({ }); export const noInvoice = style({ - color: cssVar('textSecondaryColor'), + color: cssVarV2('text/secondary'), fontSize: cssVar('fontXs'), }); @@ -38,7 +39,7 @@ export const planCard = style({ display: 'flex', justifyContent: 'space-between', padding: '12px', - border: `1px solid ${cssVar('borderColor')}`, + border: `1px solid ${cssVarV2('layer/insideBorder/border')}`, borderRadius: '8px', }); @@ -58,10 +59,44 @@ export const billingFrequency = style({ export const currentPlanName = style({ fontSize: cssVar('fontXs'), fontWeight: 500, - color: cssVar('textEmphasisColor'), + color: cssVarV2('text/emphasis'), cursor: 'pointer', }); export const cancelPlanButton = style({ marginTop: '8px', }); + +export const cardNameLabelRow = style({ + display: 'flex', + alignItems: 'center', + gap: '16px', +}); + +export const cardName = style({ + fontSize: cssVar('fontSm'), + fontWeight: 600, + color: cssVarV2('text/primary'), + lineHeight: '22px', +}); + +export const cardLabelContainer = style({ + display: 'flex', + gap: '4px', + color: cssVarV2('button/primary'), + selectors: { + '&.past-due': { + color: cssVarV2('button/error'), + }, + }, +}); + +export const cardLabel = style({ + fontSize: cssVar('fontXs'), + fontWeight: 500, +}); + +export const cardLabelIcon = style({ + width: '14px', + height: '14px', +}); diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/team-card.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/team-card.tsx new file mode 100644 index 0000000000..cae4812339 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/team-card.tsx @@ -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 ( +
+
+ + } + desc={ + <> +
{description}
+
{expirationDate}
+ + } + /> + + + +
+

+ ${amount} + + / + {teamSubscription?.recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.billing-setting.month']() + : t['com.affine.payment.billing-setting.year']()} + +

+
+ ); +}; diff --git a/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/typeform-link.tsx b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/typeform-link.tsx new file mode 100644 index 0000000000..af535cc021 --- /dev/null +++ b/packages/frontend/core/src/desktop/dialogs/setting/workspace-setting/billing/typeform-link.tsx @@ -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 ( + + + + + + ); +}; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 48f7e95665..b931d18888 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -3255,6 +3255,12 @@ export function useAFFiNEI18N(): { ["com.affine.payment.ai.billing-tip.next-bill-at"](options: { readonly due: string; }): string; + /** + * `Your recent payment failed, the next payment date is {{due}}.` + */ + ["com.affine.payment.billing-tip.past-due"](options: { + readonly due: string; + }): string; /** * `You are currently on the Free plan.` */ @@ -3310,6 +3316,18 @@ export function useAFFiNEI18N(): { used: string; limit: string; }>): string; + /** + * `Active` + */ + ["com.affine.payment.subscription-status.active"](): string; + /** + * `Past-due bill` + */ + ["com.affine.payment.subscription-status.past-due"](): string; + /** + * `Trialing` + */ + ["com.affine.payment.subscription-status.trialing"](): string; /** * `Unlimited local workspaces` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index c8bc7a370d..8ad9c9c0e7 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -811,6 +811,7 @@ "com.affine.payment.ai.benefit.g3-3": "Open source & Privacy ensured", "com.affine.payment.ai.billing-tip.end-at": "You have purchased AFFiNE AI. The expiration date is {{end}}.", "com.affine.payment.ai.billing-tip.next-bill-at": "You have purchased AFFiNE AI. The next payment date is {{due}}.", + "com.affine.payment.billing-tip.past-due": "Your recent payment failed, the next payment date is {{due}}.", "com.affine.payment.ai.pricing-plan.caption-free": "You are currently on the Free plan.", "com.affine.payment.ai.pricing-plan.caption-purchased": "You have purchased AFFiNE AI", "com.affine.payment.ai.pricing-plan.learn": "Learn about AFFiNE AI", @@ -824,6 +825,9 @@ "com.affine.payment.ai.usage.purchase-button-label": "Purchase", "com.affine.payment.ai.usage.used-caption": "Times used", "com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} times", + "com.affine.payment.subscription-status.active": "Active", + "com.affine.payment.subscription-status.past-due": "Past-due bill", + "com.affine.payment.subscription-status.trialing": "Trialing", "com.affine.payment.benefit-1": "Unlimited local workspaces", "com.affine.payment.benefit-2": "Unlimited login devices", "com.affine.payment.benefit-3": "Unlimited blocks",