From 13a2562282b09d33c8cbd657f4d8e968399c06e4 Mon Sep 17 00:00:00 2001 From: CatsJuice Date: Mon, 8 Jul 2024 08:31:21 +0000 Subject: [PATCH] feat(core): believer subscription UI (#7431) feat(core): switch ai and cloud plans position feat(core): impl lifetime subscription ui feat(core): adapt ui for lifetime status feat(core): add believer card in billing page --- .../components/card/workspace-card/index.tsx | 7 +- .../src/ui/avatar/avatar.stories.tsx | 11 + .../component/src/ui/avatar/avatar.tsx | 8 + .../component/src/ui/avatar/style.css.ts | 4 +- packages/frontend/core/src/atoms/index.ts | 20 +- .../src/components/affine/auth/style.css.ts | 7 + .../affine/auth/user-plan-button.tsx | 10 +- .../page-history-modal/history-modal.tsx | 1 + .../quota-reached-modal/cloud-quota-modal.tsx | 1 + .../setting-modal/account-setting/index.tsx | 1 + .../general-setting/billing/index.tsx | 138 ++++++++---- .../general-setting/billing/style.css.ts | 40 ++++ .../plans/ai/actions/subscribe.tsx | 47 +++- .../general-setting/plans/ai/ai-plan.css.ts | 21 +- .../general-setting/plans/ai/ai-plan.tsx | 78 +++---- .../general-setting/plans/ai/benefits.tsx | 1 - .../general-setting/plans/ai/layout.tsx | 47 ++++ .../general-setting/plans/cloud-plans.tsx | 200 +++++++++++++++++- .../general-setting/plans/index.tsx | 187 +--------------- .../general-setting/plans/layout.css.ts | 41 +--- .../general-setting/plans/layout.tsx | 123 +++++------ .../general-setting/plans/lifetime/assets.ts | 67 ++++++ .../plans/lifetime/believer-card.css.ts | 119 +++++++++++ .../plans/lifetime/believer-card.tsx | 24 +++ .../plans/lifetime/benefits.css.ts | 21 ++ .../plans/lifetime/benefits.tsx | 41 ++++ .../plans/lifetime/lifetime-plan.tsx | 60 ++++++ .../plans/lifetime/style.css.ts | 42 ++++ .../general-setting/plans/plan-card.tsx | 30 ++- .../general-setting/plans/skeleton.tsx | 29 +++ .../general-setting/plans/style.css.ts | 2 + .../setting-modal/setting-sidebar/index.tsx | 6 +- .../new-workspace-setting-detail/members.tsx | 1 + .../new-workspace-setting-detail/profile.tsx | 6 +- .../block-suite-editor/ai/setup-provider.tsx | 1 - .../user-account/index.css.ts | 2 +- .../workspace-card/index.tsx | 8 +- .../cloud/entities/subscription-prices.ts | 6 + .../modules/cloud/entities/subscription.ts | 5 +- .../frontend/graphql/src/graphql/index.ts | 1 + .../frontend/graphql/src/graphql/prices.gql | 1 + packages/frontend/graphql/src/schema.ts | 1 + packages/frontend/i18n/src/resources/en.json | 21 +- 43 files changed, 1048 insertions(+), 439 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/layout.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/assets.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.css.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.css.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/style.css.ts diff --git a/packages/frontend/component/src/components/card/workspace-card/index.tsx b/packages/frontend/component/src/components/card/workspace-card/index.tsx index e6cb52729b..b64d1593ad 100644 --- a/packages/frontend/component/src/components/card/workspace-card/index.tsx +++ b/packages/frontend/component/src/components/card/workspace-card/index.tsx @@ -6,7 +6,6 @@ import type { WorkspaceMetadata } from '@toeverything/infra'; import clsx from 'clsx'; import { type MouseEvent, useCallback } from 'react'; -import { type AvatarProps } from '../../../ui/avatar'; import { Button } from '../../../ui/button'; import { Skeleton } from '../../../ui/skeleton'; import * as styles from './styles.css'; @@ -44,9 +43,6 @@ export const WorkspaceCardSkeleton = () => { ); }; -const avatarImageProps = { - style: { borderRadius: 3, overflow: 'hidden' }, -} satisfies AvatarProps['imageProps']; export const WorkspaceCard = ({ onClick, onSettingClick, @@ -80,8 +76,7 @@ export const WorkspaceCard = ({ = args => ( +
+ + + +
+); diff --git a/packages/frontend/component/src/ui/avatar/avatar.tsx b/packages/frontend/component/src/ui/avatar/avatar.tsx index 66adb927b6..b5a9be8317 100644 --- a/packages/frontend/component/src/ui/avatar/avatar.tsx +++ b/packages/frontend/component/src/ui/avatar/avatar.tsx @@ -25,6 +25,7 @@ import { useState, } from 'react'; +import { withUnit } from '../../utils/with-unit'; import { IconButton } from '../button'; import type { TooltipProps } from '../tooltip'; import { Tooltip } from '../tooltip'; @@ -44,6 +45,11 @@ export type AvatarProps = { onRemove?: (e: MouseEvent) => void; avatarTooltipOptions?: Omit; removeTooltipOptions?: Omit; + /** + * Same as `CSS.borderRadius`, number in px or string with unit + * @default '50%' + */ + rounded?: number | string; fallbackProps?: AvatarFallbackProps; imageProps?: Omit< @@ -92,6 +98,7 @@ export const Avatar = forwardRef( fallbackProps: { className: fallbackClassName, ...fallbackProps } = {}, imageProps, avatarProps, + rounded = '50%', onRemove, hoverWrapperProps: { className: hoverWrapperClassName, @@ -144,6 +151,7 @@ export const Avatar = forwardRef( ...assignInlineVars({ [sizeVar]: size ? `${size}px` : '20px', [blurVar]: `${size * 0.3}px`, + borderRadius: withUnit(rounded, 'px'), }), ...propsStyles, }} diff --git a/packages/frontend/component/src/ui/avatar/style.css.ts b/packages/frontend/component/src/ui/avatar/style.css.ts index 877159bce6..a7def125e9 100644 --- a/packages/frontend/component/src/ui/avatar/style.css.ts +++ b/packages/frontend/component/src/ui/avatar/style.css.ts @@ -144,17 +144,16 @@ export const avatarWrapper = style({ verticalAlign: 'middle', userSelect: 'none', position: 'relative', + overflow: 'hidden', }); export const avatarImage = style({ width: '100%', height: '100%', objectFit: 'cover', - borderRadius: '50%', }); export const avatarFallback = style({ width: '100%', height: '100%', - borderRadius: '50%', overflow: 'hidden', display: 'flex', alignItems: 'center', @@ -167,7 +166,6 @@ export const avatarFallback = style({ export const hoverWrapper = style({ width: '100%', height: '100%', - borderRadius: '50%', position: 'absolute', display: 'flex', justifyContent: 'center', diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 5f107830e0..55f3b132eb 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -3,6 +3,7 @@ import { atom } from 'jotai'; import type { AuthProps } from '../components/affine/auth'; import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal'; import type { SettingProps } from '../components/affine/setting-modal'; +import type { ActiveTab } from '../components/affine/setting-modal/types'; // modal atoms export const openWorkspacesModalAtom = atom(false); export const openCreateWorkspaceModalAtom = atom(false); @@ -15,13 +16,20 @@ export const openHistoryTipsModalAtom = atom(false); export const rightSidebarWidthAtom = atom(320); -export type SettingAtom = Pick< - SettingProps, - 'activeTab' | 'workspaceMetadata' -> & { +export type PlansScrollAnchor = + | 'aiPricingPlan' + | 'cloudPricingPlan' + | 'lifetimePricingPlan'; +export type SettingAtom = { open: boolean; - scrollAnchor?: string; -}; + workspaceMetadata?: SettingProps['workspaceMetadata']; +} & ( + | { + activeTab: 'plans'; + scrollAnchor?: PlansScrollAnchor; + } + | { activeTab: Exclude } +); export const openSettingModalAtom = atom({ activeTab: 'appearance', diff --git a/packages/frontend/core/src/components/affine/auth/style.css.ts b/packages/frontend/core/src/components/affine/auth/style.css.ts index 3c9bb5c2e3..cd7695528f 100644 --- a/packages/frontend/core/src/components/affine/auth/style.css.ts +++ b/packages/frontend/core/src/components/affine/auth/style.css.ts @@ -97,4 +97,11 @@ export const userPlanButton = style({ borderRadius: 4, justifyContent: 'center', alignItems: 'center', + + selectors: { + '&[data-is-believer="true"]': { + // TODO(@CatsJuice): this color is new `Figma token` value without dark mode support. + backgroundColor: '#374151', + }, + }, }); diff --git a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx index 1e921cffa0..2cee13303b 100644 --- a/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx +++ b/packages/frontend/core/src/components/affine/auth/user-plan-button.tsx @@ -27,6 +27,7 @@ export const UserPlanButton = () => { subscription !== null ? subscription?.plan : null ) ); + const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); const isLoading = plan === null; useEffect(() => { @@ -41,6 +42,7 @@ export const UserPlanButton = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); mixpanel.track('PlansViewed', { segment: 'settings panel', @@ -62,11 +64,15 @@ export const UserPlanButton = () => { return; } - const planLabel = plan ?? SubscriptionPlan.Free; + const planLabel = isBeliever ? 'Believer' : plan ?? SubscriptionPlan.Free; return ( -
+
{planLabel}
diff --git a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx index e8fad15adb..a4929c1e3f 100644 --- a/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx +++ b/packages/frontend/core/src/components/affine/page-history-modal/history-modal.tsx @@ -227,6 +227,7 @@ const PlanPrompt = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); mixpanel.track('PlansViewed', { segment: 'doc history', diff --git a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx index 8531acbde3..cd8f4211c9 100644 --- a/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx +++ b/packages/frontend/core/src/components/affine/quota-reached-modal/cloud-quota-modal.tsx @@ -47,6 +47,7 @@ export const CloudQuotaModal = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); mixpanel.track('PlansViewed', { diff --git a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx index eabc66e450..d2eb0f4d23 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/account-setting/index.tsx @@ -171,6 +171,7 @@ const StoragePanel = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); }, [setSettingModalAtom]); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx index e48e9c4da3..011f0d27b1 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/billing/index.tsx @@ -24,7 +24,10 @@ import { useLiveData, useService } from '@toeverything/infra'; import { useSetAtom } from 'jotai'; import { Suspense, useCallback, useEffect, useState } from 'react'; -import { openSettingModalAtom } from '../../../../../atoms'; +import { + openSettingModalAtom, + type PlansScrollAnchor, +} from '../../../../../atoms'; import { useMutation } from '../../../../../hooks/use-mutation'; import { useQuery } from '../../../../../hooks/use-query'; import { SubscriptionService } from '../../../../../modules/cloud'; @@ -32,6 +35,8 @@ import { mixpanel, popupWindow } from '../../../../../utils'; import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary'; import { CancelAction, ResumeAction } from '../plans/actions'; import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions'; +import { BelieverCard } from '../plans/lifetime/believer-card'; +import { BelieverBenefits } from '../plans/lifetime/benefits'; import * as styles from './style.css'; enum DescriptionI18NKey { @@ -94,6 +99,7 @@ const SubscriptionSettings = () => { const proSubscription = useLiveData(subscriptionService.subscription.pro$); const proPrice = useLiveData(subscriptionService.prices.proPrice$); + const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); const [openCancelModal, setOpenCancelModal] = useState(false); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); @@ -103,7 +109,7 @@ const SubscriptionSettings = () => { proSubscription?.recurring ?? SubscriptionRecurring.Monthly; const openPlans = useCallback( - (scrollAnchor?: string) => { + (scrollAnchor?: PlansScrollAnchor) => { mixpanel.track('PlansViewed', { type: proSubscription?.plan, category: proSubscription?.recurring, @@ -121,7 +127,10 @@ const SubscriptionSettings = () => { }, [proSubscription?.plan, proSubscription?.recurring, setOpenSettingModalAtom] ); - const gotoCloudPlansSetting = useCallback(() => openPlans(), [openPlans]); + const gotoCloudPlansSetting = useCallback( + () => openPlans('cloudPricingPlan'), + [openPlans] + ); const gotoAiPlanSetting = useCallback( () => openPlans('aiPricingPlan'), [openPlans] @@ -137,50 +146,54 @@ const SubscriptionSettings = () => { return (
+ {/* loaded */} {proSubscription !== null ? ( -
-
- - ), - }} - /> - } - /> - + isBeliever ? ( + + ) : ( +
+
+ + ), + }} + /> + } + /> + +
+

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

-

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

-
+ ) ) : ( )} - {proSubscription !== null ? ( proSubscription?.status === SubscriptionStatus.Active && ( <> @@ -256,6 +269,45 @@ const SubscriptionSettings = () => { ); }; +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); @@ -298,7 +350,7 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => { ) : null; return ( -
+
a`, { + color: cssVar('brandColor'), + fontWeight: 500, +}); +export const believerPriceWrapper = style({ + display: 'flex', + flexDirection: 'column', + alignItems: 'end', +}); +export const believerPrice = style({ + fontSize: '18px', + fontWeight: 600, + lineHeight: '26px', + color: cssVar('textPrimaryColor'), +}); +export const believerPriceCaption = style({ + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 500, + color: cssVar('textSecondaryColor'), +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx index fc0cbab559..4a2832a0df 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/actions/subscribe.tsx @@ -1,4 +1,4 @@ -import { Button, type ButtonProps } from '@affine/component'; +import { Button, type ButtonProps, Skeleton } from '@affine/component'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { SubscriptionService } from '@affine/core/modules/cloud'; import { mixpanel, popupWindow } from '@affine/core/utils'; @@ -8,9 +8,14 @@ import { useLiveData, useService } from '@toeverything/infra'; import { nanoid } from 'nanoid'; import { useEffect, useState } from 'react'; -export interface AISubscribeProps extends ButtonProps {} +export interface AISubscribeProps extends ButtonProps { + displayedFrequency?: 'yearly' | 'monthly'; +} -export const AISubscribe = ({ ...btnProps }: AISubscribeProps) => { +export const AISubscribe = ({ + displayedFrequency = 'yearly', + ...btnProps +}: AISubscribeProps) => { const [idempotencyKey, setIdempotencyKey] = useState(nanoid()); const [isMutating, setMutating] = useState(false); const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false); @@ -63,12 +68,28 @@ export const AISubscribe = ({ ...btnProps }: AISubscribeProps) => { }, [idempotencyKey, subscriptionService]); if (!price || !price.yearlyAmount) { - // TODO(@catsjuice): loading UI - return null; + return ( + + ); } - const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`; - const priceFrequency = t['com.affine.payment.billing-setting.year'](); + const priceReadable = `$${( + price.yearlyAmount / + 100 / + (displayedFrequency === 'yearly' ? 1 : 12) + ).toFixed(2)}`; + const priceFrequency = + displayedFrequency === 'yearly' + ? t['com.affine.payment.billing-setting.year']() + : t['com.affine.payment.billing-setting.month'](); return ( ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.css.ts index 335b0014cf..2c5449f209 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.css.ts @@ -11,26 +11,25 @@ export const titleBlock = style({ display: 'flex', flexDirection: 'column', gap: 8, - marginBottom: 24, + marginBottom: 12, }); export const titleCaption1 = style({ fontWeight: 500, - fontSize: cssVar('fontSm'), - lineHeight: '14px', + fontSize: cssVar('fontXs'), + lineHeight: '20px', color: cssVar('brandColor'), }); export const titleCaption2 = style({ fontWeight: 500, fontSize: cssVar('fontSm'), - lineHeight: '20px', + lineHeight: '22px', color: cssVar('textPrimaryColor'), - letterSpacing: '-2%', }); export const title = style({ + color: cssVar('textPrimaryColor'), fontWeight: 600, - fontSize: '30px', + fontSize: '28px', lineHeight: '36px', - letterSpacing: '-2%', }); // action button @@ -89,22 +88,24 @@ export const benefitTitle = style({ fontSize: cssVar('fontSm'), lineHeight: '20px', color: cssVar('textPrimaryColor'), - letterSpacing: '-2%', display: 'flex', alignItems: 'center', - gap: 8, + gap: 4, }); globalStyle(`.${benefitTitle} > svg`, { color: cssVar('brandColor'), + width: 20, + height: 20, }); export const benefitList = style({ display: 'flex', flexDirection: 'column', - gap: 8, + gap: 4, }); export const benefitItem = style({ fontWeight: 400, fontSize: cssVar('fontXs'), + color: cssVar('textSecondaryColor'), lineHeight: '24px', paddingLeft: 22, position: 'relative', diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx index 35bc4e80ab..d7679864dd 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/ai-plan.tsx @@ -4,10 +4,9 @@ import { i18nTime, useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; import { useEffect } from 'react'; -import { AIPlanLayout } from '../layout'; import { AICancel, AILogin, AIResume, AISubscribe } from './actions'; import * as styles from './ai-plan.css'; -import { AIBenefits } from './benefits'; +import { AIPlanLayout } from './layout'; export const AIPlan = () => { const t = useI18n(); @@ -45,60 +44,37 @@ export const AIPlan = () => { return ( -
-
-
- {t['com.affine.payment.ai.pricing-plan.title-caption-1']()} -
-
- {t['com.affine.payment.ai.pricing-plan.title']()} -
-
- {t['com.affine.payment.ai.pricing-plan.title-caption-2']()} -
-
- -
-
- {isLoggedIn ? ( - subscription ? ( - subscription.canceledAt ? ( - - ) : ( - - ) - ) : ( - <> - - - - - - ) + actionButtons={ + isLoggedIn ? ( + subscription ? ( + subscription.canceledAt ? ( + ) : ( - - )} -
- {billingTip ? ( -
{billingTip}
- ) : null} -
- - -
-
+ + ) + ) : ( + <> + + + + + + ) + ) : ( + + ) + } + billingTip={billingTip} + /> ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/benefits.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/benefits.tsx index a866afa9bd..e9c7105e98 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/benefits.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/benefits.tsx @@ -41,7 +41,6 @@ const benefitsGetter = (t: ReturnType) => [ export const AIBenefits = () => { const t = useI18n(); const benefits = useMemo(() => benefitsGetter(t), [t]); - // TODO(@catsjuice): responsive return (
{benefits.map(({ name, icon, items }) => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/layout.tsx new file mode 100644 index 0000000000..b0196b1081 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/ai/layout.tsx @@ -0,0 +1,47 @@ +import { useI18n } from '@affine/i18n'; +import type { ReactNode } from 'react'; + +import { PricingCollapsible } from '../layout'; +import * as styles from './ai-plan.css'; +import { AIBenefits } from './benefits'; + +export interface AIPlanLayoutProps { + caption?: ReactNode; + actionButtons?: ReactNode; + billingTip?: ReactNode; +} +export const AIPlanLayout = ({ + caption, + actionButtons, + billingTip, +}: AIPlanLayoutProps) => { + const t = useI18n(); + const title = t['com.affine.payment.ai.pricing-plan.title'](); + + return ( + +
+
+
+ {t['com.affine.payment.ai.pricing-plan.title-caption-1']()} +
+
+ {t['com.affine.payment.ai.pricing-plan.title']()} +
+
+ {t['com.affine.payment.ai.pricing-plan.title-caption-2']()} +
+
+ +
+
{actionButtons}
+ {billingTip ? ( +
{billingTip}
+ ) : null} +
+ + +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/cloud-plans.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/cloud-plans.tsx index 345183c66c..312d680d0a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/cloud-plans.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/cloud-plans.tsx @@ -1,9 +1,16 @@ +import { Switch } from '@affine/component'; +import { AuthService, SubscriptionService } from '@affine/core/modules/cloud'; import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; -import type { useI18n } from '@affine/i18n'; +import { Trans, useI18n } from '@affine/i18n'; import { AfFiNeIcon } from '@blocksuite/icons/rc'; -import type { ReactNode } from 'react'; +import { useLiveData, useServices } from '@toeverything/infra'; +import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { CloudPlanLayout } from './layout'; +import { LifetimePlan } from './lifetime/lifetime-plan'; +import { PlanCard } from './plan-card'; import { planTitleTitleCaption } from './style.css'; +import * as styles from './style.css'; type T = ReturnType; @@ -142,3 +149,192 @@ export function getPlanDetail(t: T) { ], ]); } + +const getRecurringLabel = ({ + recurring, + t, +}: { + recurring: SubscriptionRecurring; + t: ReturnType; +}) => { + return recurring === SubscriptionRecurring.Monthly + ? t['com.affine.payment.recurring-monthly']() + : t['com.affine.payment.recurring-yearly'](); +}; + +export const CloudPlans = () => { + const t = useI18n(); + const scrollWrapper = useRef(null); + + const { authService, subscriptionService } = useServices({ + AuthService, + SubscriptionService, + }); + + const prices = useLiveData(subscriptionService.prices.prices$); + const loggedIn = useLiveData(authService.session.status$) === 'authenticated'; + const proSubscription = useLiveData(subscriptionService.subscription.pro$); + + const [recurring, setRecurring] = useState( + proSubscription?.recurring ?? SubscriptionRecurring.Yearly + ); + + const planDetail = useMemo(() => { + const rawMap = getPlanDetail(t); + const clonedMap = new Map(); + + rawMap.forEach((detail, plan) => { + clonedMap.set(plan, { ...detail }); + }); + + prices?.forEach(price => { + const detail = clonedMap.get(price.plan); + + if (detail?.type === 'fixed') { + detail.price = ((price.amount ?? 0) / 100).toFixed(2); + detail.yearlyPrice = ((price.yearlyAmount ?? 0) / 100 / 12).toFixed(2); + detail.discount = + price.yearlyAmount && price.amount + ? Math.floor( + (1 - price.yearlyAmount / 12 / price.amount) * 100 + ).toString() + : undefined; + } + }); + + return clonedMap; + }, [prices, t]); + + const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free; + const isCanceled = !!proSubscription?.canceledAt; + const currentRecurring = + proSubscription?.recurring ?? SubscriptionRecurring.Monthly; + const yearlyDiscount = ( + planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined + )?.discount; + + // auto scroll to current plan card + useEffect(() => { + if (!scrollWrapper.current) return; + const currentPlanCard = scrollWrapper.current?.querySelector( + '[data-current="true"]' + ); + const wrapperComputedStyle = getComputedStyle(scrollWrapper.current); + const left = currentPlanCard + ? currentPlanCard.getBoundingClientRect().left - + scrollWrapper.current.getBoundingClientRect().left - + parseInt(wrapperComputedStyle.paddingLeft) + : 0; + const appeared = scrollWrapper.current.dataset.appeared === 'true'; + const animationFrameId = requestAnimationFrame(() => { + scrollWrapper.current?.scrollTo({ + behavior: appeared ? 'smooth' : 'instant', + left, + }); + scrollWrapper.current?.setAttribute('data-appeared', 'true'); + }); + return () => { + cancelAnimationFrame(animationFrameId); + }; + }, [recurring]); + + // caption + const cloudCaption = loggedIn ? ( + isCanceled ? ( +

+ {t['com.affine.payment.subtitle-canceled']({ + plan: `${getRecurringLabel({ + recurring: currentRecurring, + t, + })} ${currentPlan}`, + })} +

+ ) : ( +

+ + You are currently on the {{ currentPlan }} plan. If you have any + questions, please contact our  + + customer support + + . + +

+ ) + ) : ( +

{t['com.affine.payment.subtitle-not-signed-in']()}

+ ); + + // toggle + const cloudToggle = ( +
+
+ {recurring === SubscriptionRecurring.Yearly ? ( +
+ {t['com.affine.payment.cloud.pricing-plan.toggle-yearly']()} +
+ ) : ( + <> +
+ + {t[ + 'com.affine.payment.cloud.pricing-plan.toggle-billed-yearly' + ]()} + +
+ {yearlyDiscount ? ( +
+ {t['com.affine.payment.cloud.pricing-plan.toggle-discount']({ + discount: yearlyDiscount, + })} +
+ ) : null} + + )} +
+ + setRecurring( + checked + ? SubscriptionRecurring.Yearly + : SubscriptionRecurring.Monthly + ) + } + /> +
+ ); + + const cloudScroll = ( +
+ {Array.from(planDetail.values()).map(detail => { + return ; + })} +
+ ); + + const cloudSelect = ( +
+ {t['com.affine.payment.cloud.pricing-plan.select.title']()} + {t['com.affine.payment.cloud.pricing-plan.select.caption']()} +
+ ); + + return ( + } + /> + ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx index b0c6bbc56b..adab1ea611 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/index.tsx @@ -1,41 +1,18 @@ -import { Switch } from '@affine/component'; -import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql'; -import { Trans, useI18n } from '@affine/i18n'; +import { useI18n } from '@affine/i18n'; import { useLiveData, useService } from '@toeverything/infra'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect } from 'react'; import type { FallbackProps } from 'react-error-boundary'; import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary'; -import { AuthService, SubscriptionService } from '../../../../../modules/cloud'; +import { SubscriptionService } from '../../../../../modules/cloud'; import { AIPlan } from './ai/ai-plan'; -import { type FixedPrice, getPlanDetail } from './cloud-plans'; +import { CloudPlans } from './cloud-plans'; import { CloudPlanLayout, PlanLayout } from './layout'; -import { PlanCard } from './plan-card'; import { PlansSkeleton } from './skeleton'; import * as styles from './style.css'; -const getRecurringLabel = ({ - recurring, - t, -}: { - recurring: SubscriptionRecurring; - t: ReturnType; -}) => { - return recurring === SubscriptionRecurring.Monthly - ? t['com.affine.payment.recurring-monthly']() - : t['com.affine.payment.recurring-yearly'](); -}; - const Settings = () => { - const t = useI18n(); - - const loggedIn = - useLiveData(useService(AuthService).session.status$) === 'authenticated'; - const planDetail = useMemo(() => getPlanDetail(t), [t]); - const scrollWrapper = useRef(null); - const subscriptionService = useService(SubscriptionService); - const proSubscription = useLiveData(subscriptionService.subscription.pro$); const prices = useLiveData(subscriptionService.prices.prices$); useEffect(() => { @@ -43,165 +20,11 @@ const Settings = () => { subscriptionService.prices.revalidate(); }, [subscriptionService]); - prices?.forEach(price => { - const detail = planDetail.get(price.plan); - - if (detail?.type === 'fixed') { - detail.price = ((price.amount ?? 0) / 100).toFixed(2); - detail.yearlyPrice = ((price.yearlyAmount ?? 0) / 100 / 12).toFixed(2); - detail.discount = - price.yearlyAmount && price.amount - ? Math.floor( - (1 - price.yearlyAmount / 12 / price.amount) * 100 - ).toString() - : undefined; - } - }); - - const [recurring, setRecurring] = useState( - proSubscription?.recurring ?? SubscriptionRecurring.Yearly - ); - - const currentPlan = proSubscription?.plan ?? SubscriptionPlan.Free; - const isCanceled = !!proSubscription?.canceledAt; - const currentRecurring = - proSubscription?.recurring ?? SubscriptionRecurring.Monthly; - - const yearlyDiscount = ( - planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined - )?.discount; - - // auto scroll to current plan card - useEffect(() => { - if (!scrollWrapper.current) return; - const currentPlanCard = scrollWrapper.current?.querySelector( - '[data-current="true"]' - ); - const wrapperComputedStyle = getComputedStyle(scrollWrapper.current); - const left = currentPlanCard - ? currentPlanCard.getBoundingClientRect().left - - scrollWrapper.current.getBoundingClientRect().left - - parseInt(wrapperComputedStyle.paddingLeft) - : 0; - const appeared = scrollWrapper.current.dataset.appeared === 'true'; - const animationFrameId = requestAnimationFrame(() => { - scrollWrapper.current?.scrollTo({ - behavior: appeared ? 'smooth' : 'instant', - left, - }); - scrollWrapper.current?.setAttribute('data-appeared', 'true'); - }); - return () => { - cancelAnimationFrame(animationFrameId); - }; - }, [recurring]); - - const cloudCaption = loggedIn ? ( - isCanceled ? ( -

- {t['com.affine.payment.subtitle-canceled']({ - plan: `${getRecurringLabel({ - recurring: currentRecurring, - t, - })} ${currentPlan}`, - })} -

- ) : ( -

- - You are currently on the {{ currentPlan }} plan. If you have any - questions, please contact our  - - customer support - - . - -

- ) - ) : ( -

{t['com.affine.payment.subtitle-not-signed-in']()}

- ); - - const cloudToggle = ( -
-
- {recurring === SubscriptionRecurring.Yearly ? ( -
- {t['com.affine.payment.cloud.pricing-plan.toggle-yearly']()} -
- ) : ( - <> -
- - {t[ - 'com.affine.payment.cloud.pricing-plan.toggle-billed-yearly' - ]()} - -
- {yearlyDiscount ? ( -
- {t['com.affine.payment.cloud.pricing-plan.toggle-discount']({ - discount: yearlyDiscount, - })} -
- ) : null} - - )} -
- - setRecurring( - checked - ? SubscriptionRecurring.Yearly - : SubscriptionRecurring.Monthly - ) - } - /> -
- ); - - const cloudScroll = ( -
- {Array.from(planDetail.values()).map(detail => { - return ; - })} -
- ); - - const cloudSelect = ( -
- {t['com.affine.payment.cloud.pricing-plan.select.title']()} - {t['com.affine.payment.cloud.pricing-plan.select.caption']()} -
- ); - if (prices === null) { return ; } - return ( - - } - ai={} - /> - ); + return } ai={} />; }; export const AFFiNEPricingPlans = () => { diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts index cce52eb514..c2f471a526 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.css.ts @@ -10,7 +10,7 @@ export const scrollArea = style({ paddingLeft: 'var(--setting-modal-gap-x)', width: 'var(--setting-modal-width)', overflowX: 'auto', - scrollSnapType: 'x mandatory', + // scrollSnapType: 'x mandatory', paddingBottom: '21px', /** Avoid box-shadow clipping */ paddingTop: '21px', @@ -73,7 +73,7 @@ export const affineCloudHeader = style({ export const aiDivider = style({ opacity: 0, selectors: { - '[data-ai-visible] &': { + '[data-cloud-visible] &': { opacity: 1, }, }, @@ -105,7 +105,7 @@ export const aiScrollTip = style({ animation: `${slideInBottom} 0.3s ease 0.5s forwards`, selectors: { - '[data-ai-visible] &': { + '[data-cloud-visible] &': { transform: 'translateY(100px)', opacity: 0, }, @@ -115,38 +115,15 @@ export const aiScrollTip = style({ globalStyle(`div.${aiScrollTip}`, { display: 'flex !important', }); -export const aiScrollTipLabel = style({ - display: 'flex', - alignItems: 'center', -}); -export const aiScrollTipText = style({ - padding: '0px 10px 0px 8px', +export const cloudScrollTipTitle = style({ fontSize: cssVar('fontSm'), fontWeight: 600, lineHeight: '22px', color: cssVar('textPrimaryColor'), }); -export const aiScrollTipTag = style({ - background: 'linear-gradient(180deg, #41B0FF 0%, #0873BE 100%)', - borderRadius: 3, - fontWeight: 600, - fontSize: 10, - lineHeight: '12px', - letterSpacing: '-1%', - color: cssVar('pureWhite'), - boxShadow: - '0px 0px 1px 0px #45474926, 1px 2px 2px 0px #45474921, 2px 4px 3px 0px #45474914, 4px 6px 3px 0px #45474905, 6px 10px 3px 0px #45474900', - padding: 1, -}); - -export const aiScrollTipTagInner = style({ - borderRadius: 2, - padding: '2px 3px', - fontWeight: 'inherit', - fontSize: 'inherit', - lineHeight: 'inherit', - content: 'var(--content, "")', - letterSpacing: 'inherit', - background: - 'linear-gradient(180deg, #56B9FF 0%, #23A4FF 37.88%, #1E96EB 75%)', +export const cloudScrollTipCaption = style({ + fontSize: cssVar('fontXs'), + fontWeight: 400, + lineHeight: '20px', + color: cssVar('textSecondaryColor'), }); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx index ef4bedb830..5cbe322d75 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/layout.tsx @@ -1,19 +1,16 @@ import { Button, Divider, IconButton } from '@affine/component'; import { SettingHeader } from '@affine/component/setting-components'; -import { openSettingModalAtom } from '@affine/core/atoms'; -import { useI18n } from '@affine/i18n'; import { - ArrowDownBigIcon, - ArrowRightBigIcon, - ArrowUpSmallIcon, -} from '@blocksuite/icons/rc'; + openSettingModalAtom, + type PlansScrollAnchor, +} from '@affine/core/atoms'; +import { useI18n } from '@affine/i18n'; +import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc'; import * as Collapsible from '@radix-ui/react-collapsible'; import * as ScrollArea from '@radix-ui/react-scroll-area'; -import { cssVar } from '@toeverything/theme'; import { useAtom, useAtomValue } from 'jotai'; import { type HtmlHTMLAttributes, - type PropsWithChildren, type ReactNode, useCallback, useEffect, @@ -21,7 +18,7 @@ import { useRef, useState, } from 'react'; -import { createPortal } from 'react-dom'; +import { createPortal, flushSync } from 'react-dom'; import { settingModalScrollContainerAtom } from '../../atoms'; import * as styles from './layout.css'; @@ -47,7 +44,7 @@ interface PricingCollapsibleProps title?: ReactNode; caption?: ReactNode; } -const PricingCollapsible = ({ +export const PricingCollapsible = ({ title, caption, children, @@ -78,97 +75,96 @@ const PricingCollapsible = ({ export interface PlanLayoutProps { cloud?: ReactNode; ai?: ReactNode; - aiTip?: boolean; + cloudTip?: boolean; } -export const PlanLayout = ({ cloud, ai, aiTip }: PlanLayoutProps) => { +export const PlanLayout = ({ cloud, ai, cloudTip }: PlanLayoutProps) => { const t = useI18n(); - const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom); - const aiPricingPlanRef = useRef(null); - const aiScrollTipRef = useRef(null); + const [modal, setOpenSettingModal] = useAtom(openSettingModalAtom); + const scrollAnchor = modal.activeTab === 'plans' ? modal.scrollAnchor : null; + const plansRootRef = useRef(null); + const cloudScrollTipRef = useRef(null); const settingModalScrollContainer = useAtomValue( settingModalScrollContainerAtom ); - const updateAiTipState = useCallback(() => { - if (!aiTip) return; - const aiContainer = aiPricingPlanRef.current; - if (!settingModalScrollContainer || !aiContainer) return; + const updateCloudTipState = useCallback(() => { + if (!cloudTip) return; + const cloudContainer = + plansRootRef.current?.querySelector('#cloudPricingPlan'); + if (!settingModalScrollContainer || !cloudContainer) return; const minVisibleHeight = 30; const containerRect = settingModalScrollContainer.getBoundingClientRect(); - const aiTop = aiContainer.getBoundingClientRect().top - containerRect.top; - const aiIntoView = aiTop < containerRect.height - minVisibleHeight; - if (aiIntoView) { - settingModalScrollContainer.dataset.aiVisible = ''; + const cloudTop = + cloudContainer.getBoundingClientRect().top - containerRect.top; + const cloudIntoView = cloudTop < containerRect.height - minVisibleHeight; + if (cloudIntoView) { + settingModalScrollContainer.dataset.cloudVisible = ''; } - }, [aiTip, settingModalScrollContainer]); + }, [cloudTip, settingModalScrollContainer]); // TODO(@catsjuice): Need a better solution to handle this situation useLayoutEffect(() => { if (!scrollAnchor) return; - setTimeout(() => { - if (scrollAnchor === 'aiPricingPlan' && aiPricingPlanRef.current) { - aiPricingPlanRef.current.scrollIntoView(); + flushSync(() => { + const target = plansRootRef.current?.querySelector(`#${scrollAnchor}`); + if (target) { + target.scrollIntoView(); setOpenSettingModal(prev => ({ ...prev, scrollAnchor: undefined })); } }); }, [scrollAnchor, setOpenSettingModal]); useEffect(() => { - if (!settingModalScrollContainer || !aiScrollTipRef.current) return; + if (!settingModalScrollContainer || !cloudScrollTipRef.current) return; - settingModalScrollContainer.addEventListener('scroll', updateAiTipState); - updateAiTipState(); + settingModalScrollContainer.addEventListener('scroll', updateCloudTipState); + updateCloudTipState(); return () => { settingModalScrollContainer.removeEventListener( 'scroll', - updateAiTipState + updateCloudTipState ); }; - }, [settingModalScrollContainer, updateAiTipState]); + }, [settingModalScrollContainer, updateCloudTipState]); - const scrollAiIntoView = useCallback(() => { - aiPricingPlanRef.current?.scrollIntoView({ behavior: 'smooth' }); + const scrollToAnchor = useCallback((anchor: PlansScrollAnchor) => { + const target = plansRootRef.current?.querySelector(`#${anchor}`); + target && target.scrollIntoView({ behavior: 'smooth' }); }, []); return ( -
+
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */} - {cloud} {ai ? ( <> +
{ai}
-
- {ai} -
) : null} +
{cloud}
- {aiTip && settingModalScrollContainer + {cloudTip && settingModalScrollContainer ? createPortal( -
-
- -
- {t['com.affine.ai-scroll-tip.title']()} +
+
+
+ {t['com.affine.cloud-scroll-tip.title']()}
-
-
- {t['com.affine.ai-scroll-tip.tag']()} -
+
+ {t['com.affine.cloud-scroll-tip.caption']()}
-
, @@ -186,6 +182,7 @@ export interface PlanCardProps { select?: ReactNode; toggle?: ReactNode; scroll?: ReactNode; + lifetime?: ReactNode; scrollRef?: React.RefObject; } export const CloudPlanLayout = ({ @@ -194,6 +191,7 @@ export const CloudPlanLayout = ({ select, toggle, scroll, + lifetime, scrollRef, }: PlanCardProps) => { return ( @@ -214,22 +212,7 @@ export const CloudPlanLayout = ({ - - ); -}; - -export interface AIPlanLayoutProps { - title?: ReactNode; - caption?: ReactNode; -} -export const AIPlanLayout = ({ - title = 'AFFiNE AI', - caption, - children, -}: PropsWithChildren) => { - return ( - - {children} + {lifetime ?
{lifetime}
: null}
); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/assets.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/assets.ts new file mode 100644 index 0000000000..cd4856228e --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/assets.ts @@ -0,0 +1,67 @@ +export const bgAFFiNERaw = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +export const bgIconsRaw = ` + + + + + + + + + + + + +`; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.css.ts new file mode 100644 index 0000000000..def0d586c6 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.css.ts @@ -0,0 +1,119 @@ +import { cssVar } from '@toeverything/theme'; +import { globalStyle, style } from '@vanilla-extract/css'; + +const colorSchemes = { + light: { + dot: '#E0E0E0', + affine: '#fff', + icon: 'rgba(0,0,0,0.1)', + }, + dark: { + dot: '#333', + affine: cssVar('backgroundPrimaryColor'), + icon: 'rgba(255,255,255,0.1)', + }, +}; + +export const card = style({ + position: 'relative', + width: '100%', + minHeight: 200, + borderRadius: 16, + padding: '20px 24px', + border: `1px solid ${cssVar('borderColor')}`, + overflow: 'hidden', +}); + +export const content = style({ + position: 'relative', + zIndex: 3, +}); + +export const bg = style({ + vars: { + '--dot': colorSchemes.light.dot, + }, + width: '100%', + height: '100%', + maxHeight: 320, + position: 'absolute', + top: 0, + left: 0, + backgroundImage: + 'radial-gradient(circle, var(--dot) 1.2px, transparent 1.2px)', + backgroundSize: '12px 12px', + backgroundRepeat: 'repeat', + + selectors: { + '[data-theme="dark"] &': { + vars: { + '--dot': colorSchemes.dark.dot, + }, + }, + + [`${card}[data-type="1"] &::after`]: { + background: `linear-gradient(231deg, transparent 0%, ${cssVar('backgroundOverlayPanelColor')} 80%)`, + }, + [`${card}[data-type="2"] &::after`]: { + background: `linear-gradient(290deg, transparent 0%, ${cssVar('backgroundOverlayPanelColor')} 30%)`, + }, + }, + + // Overlay + '::after': { + content: '""', + position: 'absolute', + width: '100%', + height: '100%', + top: 0, + left: 0, + zIndex: 1, + }, +}); +globalStyle(`.${bg} > svg.affine-svg`, { + color: colorSchemes.light.affine, + position: 'absolute', + zIndex: 0, +}); +globalStyle(`[data-theme='dark'].${bg} > svg.affine-svg`, { + color: colorSchemes.dark.affine, +}); +globalStyle(` .${bg} > svg.icons-svg`, { + color: colorSchemes.light.icon, + position: 'absolute', + zIndex: 2, +}); +globalStyle(`[data-theme='dark'] .${bg} > svg.icons-svg`, { + color: colorSchemes.dark.icon, +}); + +// --------- style1 --------- +globalStyle(`.${card}[data-type="1"] .${bg} > svg.affine-svg`, { + right: -150, + top: -100, +}); + +globalStyle(`.${card}[data-type="1"] .${bg} > svg.icons-svg`, { + right: -20, + top: 130, + opacity: 0.5, +}); + +// --------- style2 --------- +globalStyle(`.${card}[data-type="2"] .${bg} > svg.affine-svg`, { + position: 'absolute', + right: -140, + bottom: -130, + transform: 'scale(0.58)', +}); + +globalStyle(`.${card}[data-type="2"] .${bg} > svg.icons-svg`, { + position: 'absolute', + right: 148, + bottom: 16, + opacity: 0.5, +}); + +globalStyle(`.${card}[data-type="2"] .${bg} > svg.icons-svg .star`, { + display: 'none', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.tsx new file mode 100644 index 0000000000..c7264997af --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/believer-card.tsx @@ -0,0 +1,24 @@ +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; + +import { bgAFFiNERaw, bgIconsRaw } from './assets'; +import { bg, card, content } from './believer-card.css'; + +export const BelieverCard = ({ + children, + type, + className, + ...attrs +}: HTMLAttributes & { + type: 1 | 2; +}) => { + return ( +
+
+
{children}
+
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.css.ts new file mode 100644 index 0000000000..73707b792c --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.css.ts @@ -0,0 +1,21 @@ +import { cssVar } from '@toeverything/theme'; +import { globalStyle, style } from '@vanilla-extract/css'; + +export const benefits = style({ + display: 'flex', + flexDirection: 'column', + gap: 8, +}); +export const li = style({ + display: 'flex', + gap: 8, + alignItems: 'start', + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 400, +}); +globalStyle(`.${li} svg`, { + width: 20, + height: 20, + color: cssVar('brandColor'), +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.tsx new file mode 100644 index 0000000000..1011bb2954 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/benefits.tsx @@ -0,0 +1,41 @@ +import { useI18n } from '@affine/i18n'; +import { AfFiNeIcon, DoneIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import type { HTMLAttributes } from 'react'; + +import { benefits, li } from './benefits.css'; + +export const BelieverBenefits = ({ + className, + ...attrs +}: HTMLAttributes) => { + const t = useI18n(); + + return ( +
    +
  • + + {t['com.affine.payment.lifetime.benefit-1']()} +
  • + +
  • + + {t['com.affine.payment.lifetime.benefit-2']()} +
  • + +
  • + + + {t['com.affine.payment.lifetime.benefit-3']({ + capacity: '1T', + })} + +
  • + +
  • + + {t['com.affine.payment.lifetime.benefit-4']()} +
  • +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx new file mode 100644 index 0000000000..23fa5528f8 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/lifetime-plan.tsx @@ -0,0 +1,60 @@ +import { Button } from '@affine/component'; +import { SubscriptionService } from '@affine/core/modules/cloud'; +import { SubscriptionRecurring } from '@affine/graphql'; +import { Trans, useI18n } from '@affine/i18n'; +import { useLiveData, useService } from '@toeverything/infra'; + +import { Upgrade } from '../plan-card'; +import { BelieverCard } from './believer-card'; +import { BelieverBenefits } from './benefits'; +import * as styles from './style.css'; + +export const LifetimePlan = () => { + const t = useI18n(); + const subscriptionService = useService(SubscriptionService); + + const readableLifetimePrice = useLiveData( + subscriptionService.prices.readableLifetimePrice$ + ); + const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); + + if (!readableLifetimePrice) return null; + + return ( + +
+ {t['com.affine.payment.lifetime.caption-1']()} +
+ +
+ {t['com.affine.payment.lifetime.title']()} +
+ +
{readableLifetimePrice}
+ + {isBeliever ? ( + + ) : ( + + {t['com.affine.payment.lifetime.purchase']()} + + )} + +
+ , + }} + /> +
+ + +
+ ); +}; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/style.css.ts b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/style.css.ts new file mode 100644 index 0000000000..fd47ef11f8 --- /dev/null +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/lifetime/style.css.ts @@ -0,0 +1,42 @@ +import { cssVar } from '@toeverything/theme'; +import { style } from '@vanilla-extract/css'; + +export const caption1 = style({ + fontSize: cssVar('fontSm'), + fontWeight: 500, + lineHeight: '22px', + color: cssVar('textSecondaryColor'), + marginBottom: 8, +}); +export const title = style({ + fontSize: 18, + fontWeight: 600, + lineHeight: '26px', + color: cssVar('textPrimaryColor'), + marginBottom: 4, +}); +export const price = style({ + fontSize: 30, + fontWeight: 700, + lineHeight: 'normal', + color: cssVar('brandColor'), + marginBottom: 24, +}); +export const purchase = style({ + width: 'auto', + height: 36, + marginBottom: 8, + padding: '8px 18px', +}); +export const caption2 = style({ + color: cssVar('textSecondaryColor'), + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 400, + marginBottom: 16, + maxWidth: 324, +}); +export const usePolicyLink = style({ + color: cssVar('textPrimaryColor'), + textDecoration: 'underline', +}); diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx index 823779959b..13a7260c64 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/plan-card.tsx @@ -8,9 +8,10 @@ import { SubscriptionPlan, SubscriptionStatus } from '@affine/graphql'; import { Trans, useI18n } from '@affine/i18n'; import { DoneIcon } from '@blocksuite/icons/rc'; import { useLiveData, useService } from '@toeverything/infra'; +import clsx from 'clsx'; import { useAtom, useSetAtom } from 'jotai'; import { nanoid } from 'nanoid'; -import type { PropsWithChildren } from 'react'; +import type { HTMLAttributes, PropsWithChildren } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { openPaymentDisableAtom } from '../../../../../atoms'; @@ -89,6 +90,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { const loggedIn = useLiveData(useService(AuthService).session.status$) === 'authenticated'; const subscriptionService = useService(SubscriptionService); + const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$); const primarySubscription = useLiveData( subscriptionService.subscription.pro$ ); @@ -101,6 +103,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { // if free => 'Sign up free' // else => 'Buy Pro' // else + // if isBeliever => 'Included in Lifetime' // if isCurrent // if canceled => 'Resume' // else => 'Current Plan' @@ -125,6 +128,15 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => { ); } + // lifetime + if (isBeliever) { + return ( + + ); + } + const isCanceled = !!primarySubscription?.canceledAt; const isFree = detail.plan === SubscriptionPlan.Free; const isCurrent = @@ -229,13 +241,20 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => { }} > ); }; -const Upgrade = ({ recurring }: { recurring: SubscriptionRecurring }) => { +export const Upgrade = ({ + className, + recurring, + children, + ...attrs +}: HTMLAttributes & { + recurring: SubscriptionRecurring; +}) => { const [isMutating, setMutating] = useState(false); const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false); const t = useI18n(); @@ -291,13 +310,14 @@ const Upgrade = ({ recurring }: { recurring: SubscriptionRecurring }) => { return ( ); }; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx index 4e8c7fb735..906d48c5a8 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/plans/skeleton.tsx @@ -1,5 +1,6 @@ import { Skeleton } from '@affine/component'; +import { AIPlanLayout } from './ai/layout'; import { CloudPlanLayout, PlanLayout } from './layout'; import * as styles from './skeleton.css'; @@ -48,6 +49,34 @@ const ScrollSkeleton = () => ( export const PlansSkeleton = () => { return ( + } + actionButtons={ + <> + + + + } + /> + } cloud={ { > ; }[]; -const avatarImageProps = { style: { borderRadius: 2 } }; const WorkspaceListItem = ({ activeSubTab, meta, @@ -326,9 +326,7 @@ const WorkspaceListItem = ({ style={{ marginRight: '10px', }} - imageProps={avatarImageProps} - fallbackProps={avatarImageProps} - hoverWrapperProps={avatarImageProps} + rounded={2} /> {name} {isCurrent ? ( diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx index 8633bed6d8..dfaadd322c 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/members.tsx @@ -144,6 +144,7 @@ export const CloudWorkspaceMembersPanel = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); mixpanel.track('PlansViewed', { // page: diff --git a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx index d4de4a74bd..b8ba21b250 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/workspace-setting/new-workspace-setting-detail/profile.tsx @@ -14,8 +14,6 @@ import { useCallback, useEffect, useState } from 'react'; import * as style from './style.css'; -const avatarImageProps = { style: { borderRadius: 8 } }; - export const ProfilePanel = () => { const t = useI18n(); @@ -146,9 +144,7 @@ export const ProfilePanel = () => { meta={workspace.meta} size={56} name={name} - imageProps={avatarImageProps} - fallbackProps={avatarImageProps} - hoverWrapperProps={avatarImageProps} + rounded={8} colorfulFallback hoverIcon={isOwner ? : undefined} onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined} diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx index 1f8ad78e01..55b5ea97e0 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/ai/setup-provider.tsx @@ -405,7 +405,6 @@ Could you make a new website based on these notes and send back just the html fi getCurrentStore().set(openSettingModalAtom, { activeTab: 'billing', open: true, - scrollAnchor: 'aiPricingPlan', }); mixpanel.track('PlansViewed', { segment: 'payment wall', diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts index 8378d573db..bc79f5e8a3 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/user-with-workspace-list/user-account/index.css.ts @@ -2,7 +2,7 @@ import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; export const userAccountContainer = style({ display: 'flex', - padding: '4px 0px 4px 12px', + padding: '4px 4px 4px 12px', gap: '8px', alignItems: 'center', justifyContent: 'space-between', diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx index c7d77caffc..e1b71c690f 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/workspace-card/index.tsx @@ -1,5 +1,4 @@ import { notify, Tooltip } from '@affine/component'; -import { type AvatarProps } from '@affine/component/ui/avatar'; import { Loading } from '@affine/component/ui/loading'; import { WorkspaceAvatar } from '@affine/component/workspace-avatar'; import { openSettingModalAtom } from '@affine/core/atoms'; @@ -96,6 +95,7 @@ const useSyncEngineSyncProgress = () => { setSettingModalAtom({ open: true, activeTab: 'plans', + scrollAnchor: 'cloudPricingPlan', }); }, [setSettingModalAtom]); @@ -276,9 +276,6 @@ const WorkspaceInfo = ({ name }: { name: string }) => { ); }; -const avatarImageProps = { - style: { borderRadius: 3 }, -} satisfies AvatarProps['imageProps']; export const WorkspaceCard = forwardRef< HTMLDivElement, HTMLAttributes @@ -302,8 +299,7 @@ export const WorkspaceCard = forwardRef< price.plan === 'AI') : null ); + readableLifetimePrice$ = this.proPrice$.map(price => + price?.lifetimeAmount + ? `$${(price.lifetimeAmount / 100).toFixed(2).replace(/\.0+$/, '')}` + : '' + ); + constructor( private readonly serverConfigService: ServerConfigService, private readonly store: SubscriptionStore diff --git a/packages/frontend/core/src/modules/cloud/entities/subscription.ts b/packages/frontend/core/src/modules/cloud/entities/subscription.ts index f667b3d92b..c512a95ee9 100644 --- a/packages/frontend/core/src/modules/cloud/entities/subscription.ts +++ b/packages/frontend/core/src/modules/cloud/entities/subscription.ts @@ -1,4 +1,4 @@ -import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql'; +import { type SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql'; import { SubscriptionPlan } from '@affine/graphql'; import { backoffRetry, @@ -38,6 +38,9 @@ export class Subscription extends Entity { ? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI) : null ); + isBeliever$ = this.pro$.map( + sub => sub?.recurring === SubscriptionRecurring.Lifetime + ); constructor( private readonly authService: AuthService, diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index a52dde498d..fbf138b19e 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -668,6 +668,7 @@ query prices { currency amount yearlyAmount + lifetimeAmount } }`, }; diff --git a/packages/frontend/graphql/src/graphql/prices.gql b/packages/frontend/graphql/src/graphql/prices.gql index eb4a75e7bd..9a1659ddf4 100644 --- a/packages/frontend/graphql/src/graphql/prices.gql +++ b/packages/frontend/graphql/src/graphql/prices.gql @@ -5,5 +5,6 @@ query prices { currency amount yearlyAmount + lifetimeAmount } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index ecd75d4497..5c780013e4 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -1765,6 +1765,7 @@ export type PricesQuery = { currency: string; amount: number | null; yearlyAmount: number | null; + lifetimeAmount: number | null; }>; }; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 32351f58b1..729c45b219 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -398,6 +398,8 @@ "com.affine.ai-scroll-tip.tag": "New", "com.affine.ai-scroll-tip.title": "Meet AFFiNE AI", "com.affine.ai-scroll-tip.view": "View", + "com.affine.cloud-scroll-tip.title": "AFFiNE Cloud", + "com.affine.cloud-scroll-tip.caption": "Host by AFFiNE.Pro, Save, sync, and backup all your data.", "com.affine.ai.action.edgeless-only.dialog-title": "Please switch to edgeless mode", "com.affine.ai.login-required.dialog-cancel": "Cancel", "com.affine.ai.login-required.dialog-confirm": "Sign in", @@ -912,7 +914,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.ai.pricing-plan.caption-free": "You are currently on the Basic 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.learn": "Learn About AFFiNE AI", "com.affine.payment.ai.pricing-plan.title": "AFFiNE AI", @@ -924,6 +926,7 @@ "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.ai.subscribe.billed-annually": "Billed annually", "com.affine.payment.benefit-1": "Unlimited local workspaces", "com.affine.payment.benefit-2": "Unlimited login devices", "com.affine.payment.benefit-3": "Unlimited blocks", @@ -960,12 +963,16 @@ "com.affine.payment.billing-setting.upgrade": "Upgrade", "com.affine.payment.billing-setting.view-invoice": "View Invoice", "com.affine.payment.billing-setting.year": "year", + "com.affine.payment.billing-setting.believer.title": "AFFiNE Cloud", + "com.affine.payment.billing-setting.believer.description": "You have purchased Believer Plan. Enjoy with your benefits!", + "com.affine.payment.billing-setting.believer.price-caption": "One-time Payment", "com.affine.payment.blob-limit.description.local": "The maximum file upload size for local workspaces is {{quota}}.", "com.affine.payment.blob-limit.description.member": "The maximum file upload size for this joined workspace is {{quota}}. You can contact the owner of this workspace.", "com.affine.payment.blob-limit.description.owner.free": "{{planName}} users can upload files with a maximum size of {{currentQuota}}. You can upgrade your account to unlock a maximum file size of {{upgradeQuota}}.", "com.affine.payment.blob-limit.description.owner.pro": "{{planName}} users can upload files with a maximum size of {{quota}}.", "com.affine.payment.blob-limit.title": "You have reached the limit", "com.affine.payment.book-a-demo": "Book a Demo", + "com.affine.payment.tell-us-use-case": "Tell Us Your Use Case", "com.affine.payment.buy-pro": "Buy Pro", "com.affine.payment.change-to": "Change to {{to}} Billing", "com.affine.payment.cloud.free.benefit.g1": "Include in FOSS", @@ -1010,7 +1017,17 @@ "com.affine.payment.cloud.team.benefit.g2-3": "Embed-able & Integrations with IT support.", "com.affine.payment.cloud.team.description": "Best for scalable teams.", "com.affine.payment.cloud.team.name": "Team / Enterprise", - "com.affine.payment.cloud.team.title": "Contact Sales", + "com.affine.payment.cloud.team.title": "Coming Soon", + "com.affine.payment.cloud.lifetime.included": "Included In Believer Plan", + "com.affine.payment.lifetime.caption-1": "Become a Life-time supporter?", + "com.affine.payment.lifetime.title": "Believer Plan", + "com.affine.payment.lifetime.purchase": "Purchase", + "com.affine.payment.lifetime.purchased": "Purchased", + "com.affine.payment.lifetime.caption-2": "One-time Purchase. Personal use rights for up to 150 years. Fair Use Policies may apply.", + "com.affine.payment.lifetime.benefit-1": "Everything in AFFiNE Pro", + "com.affine.payment.lifetime.benefit-2": "Life-time Personal usage", + "com.affine.payment.lifetime.benefit-3": "{{capacity}} Cloud Storage", + "com.affine.payment.lifetime.benefit-4": "Dedicated Discord support with AFFiNE makers", "com.affine.payment.contact-sales": "Contact Sales", "com.affine.payment.current-plan": "Current Plan", "com.affine.payment.disable-payment.description": "This is a special testing(Canary) version of AFFiNE. Account upgrades are not supported in this version. If you want to experience the full service, please download the stable version from our website.",