mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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
This commit is contained in:
@@ -6,7 +6,6 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
|
|||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { type MouseEvent, useCallback } from 'react';
|
import { type MouseEvent, useCallback } from 'react';
|
||||||
|
|
||||||
import { type AvatarProps } from '../../../ui/avatar';
|
|
||||||
import { Button } from '../../../ui/button';
|
import { Button } from '../../../ui/button';
|
||||||
import { Skeleton } from '../../../ui/skeleton';
|
import { Skeleton } from '../../../ui/skeleton';
|
||||||
import * as styles from './styles.css';
|
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 = ({
|
export const WorkspaceCard = ({
|
||||||
onClick,
|
onClick,
|
||||||
onSettingClick,
|
onSettingClick,
|
||||||
@@ -80,8 +76,7 @@ export const WorkspaceCard = ({
|
|||||||
<WorkspaceAvatar
|
<WorkspaceAvatar
|
||||||
key={meta.id}
|
key={meta.id}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
imageProps={avatarImageProps}
|
rounded={3}
|
||||||
fallbackProps={avatarImageProps}
|
|
||||||
size={28}
|
size={28}
|
||||||
name={name}
|
name={name}
|
||||||
colorfulFallback
|
colorfulFallback
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -25,6 +25,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
|
||||||
|
import { withUnit } from '../../utils/with-unit';
|
||||||
import { IconButton } from '../button';
|
import { IconButton } from '../button';
|
||||||
import type { TooltipProps } from '../tooltip';
|
import type { TooltipProps } from '../tooltip';
|
||||||
import { Tooltip } from '../tooltip';
|
import { Tooltip } from '../tooltip';
|
||||||
@@ -44,6 +45,11 @@ export type AvatarProps = {
|
|||||||
onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
|
onRemove?: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||||
avatarTooltipOptions?: Omit<TooltipProps, 'children'>;
|
avatarTooltipOptions?: Omit<TooltipProps, 'children'>;
|
||||||
removeTooltipOptions?: Omit<TooltipProps, 'children'>;
|
removeTooltipOptions?: Omit<TooltipProps, 'children'>;
|
||||||
|
/**
|
||||||
|
* Same as `CSS.borderRadius`, number in px or string with unit
|
||||||
|
* @default '50%'
|
||||||
|
*/
|
||||||
|
rounded?: number | string;
|
||||||
|
|
||||||
fallbackProps?: AvatarFallbackProps;
|
fallbackProps?: AvatarFallbackProps;
|
||||||
imageProps?: Omit<
|
imageProps?: Omit<
|
||||||
@@ -92,6 +98,7 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
|
|||||||
fallbackProps: { className: fallbackClassName, ...fallbackProps } = {},
|
fallbackProps: { className: fallbackClassName, ...fallbackProps } = {},
|
||||||
imageProps,
|
imageProps,
|
||||||
avatarProps,
|
avatarProps,
|
||||||
|
rounded = '50%',
|
||||||
onRemove,
|
onRemove,
|
||||||
hoverWrapperProps: {
|
hoverWrapperProps: {
|
||||||
className: hoverWrapperClassName,
|
className: hoverWrapperClassName,
|
||||||
@@ -144,6 +151,7 @@ export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>(
|
|||||||
...assignInlineVars({
|
...assignInlineVars({
|
||||||
[sizeVar]: size ? `${size}px` : '20px',
|
[sizeVar]: size ? `${size}px` : '20px',
|
||||||
[blurVar]: `${size * 0.3}px`,
|
[blurVar]: `${size * 0.3}px`,
|
||||||
|
borderRadius: withUnit(rounded, 'px'),
|
||||||
}),
|
}),
|
||||||
...propsStyles,
|
...propsStyles,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -144,17 +144,16 @@ export const avatarWrapper = style({
|
|||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
export const avatarImage = style({
|
export const avatarImage = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
borderRadius: '50%',
|
|
||||||
});
|
});
|
||||||
export const avatarFallback = style({
|
export const avatarFallback = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '50%',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -167,7 +166,6 @@ export const avatarFallback = style({
|
|||||||
export const hoverWrapper = style({
|
export const hoverWrapper = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: '50%',
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { atom } from 'jotai';
|
|||||||
import type { AuthProps } from '../components/affine/auth';
|
import type { AuthProps } from '../components/affine/auth';
|
||||||
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
|
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
|
||||||
import type { SettingProps } from '../components/affine/setting-modal';
|
import type { SettingProps } from '../components/affine/setting-modal';
|
||||||
|
import type { ActiveTab } from '../components/affine/setting-modal/types';
|
||||||
// modal atoms
|
// modal atoms
|
||||||
export const openWorkspacesModalAtom = atom(false);
|
export const openWorkspacesModalAtom = atom(false);
|
||||||
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||||
@@ -15,13 +16,20 @@ export const openHistoryTipsModalAtom = atom(false);
|
|||||||
|
|
||||||
export const rightSidebarWidthAtom = atom(320);
|
export const rightSidebarWidthAtom = atom(320);
|
||||||
|
|
||||||
export type SettingAtom = Pick<
|
export type PlansScrollAnchor =
|
||||||
SettingProps,
|
| 'aiPricingPlan'
|
||||||
'activeTab' | 'workspaceMetadata'
|
| 'cloudPricingPlan'
|
||||||
> & {
|
| 'lifetimePricingPlan';
|
||||||
|
export type SettingAtom = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
scrollAnchor?: string;
|
workspaceMetadata?: SettingProps['workspaceMetadata'];
|
||||||
};
|
} & (
|
||||||
|
| {
|
||||||
|
activeTab: 'plans';
|
||||||
|
scrollAnchor?: PlansScrollAnchor;
|
||||||
|
}
|
||||||
|
| { activeTab: Exclude<ActiveTab, 'plans'> }
|
||||||
|
);
|
||||||
|
|
||||||
export const openSettingModalAtom = atom<SettingAtom>({
|
export const openSettingModalAtom = atom<SettingAtom>({
|
||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
|
|||||||
@@ -97,4 +97,11 @@ export const userPlanButton = style({
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'&[data-is-believer="true"]': {
|
||||||
|
// TODO(@CatsJuice): this color is new `Figma token` value without dark mode support.
|
||||||
|
backgroundColor: '#374151',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const UserPlanButton = () => {
|
|||||||
subscription !== null ? subscription?.plan : null
|
subscription !== null ? subscription?.plan : null
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
|
||||||
const isLoading = plan === null;
|
const isLoading = plan === null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,6 +42,7 @@ export const UserPlanButton = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
segment: 'settings panel',
|
segment: 'settings panel',
|
||||||
@@ -62,11 +64,15 @@ export const UserPlanButton = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const planLabel = plan ?? SubscriptionPlan.Free;
|
const planLabel = isBeliever ? 'Believer' : plan ?? SubscriptionPlan.Free;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
|
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
|
||||||
<div className={styles.userPlanButton} onClick={handleClick}>
|
<div
|
||||||
|
data-is-believer={isBeliever ? 'true' : undefined}
|
||||||
|
className={styles.userPlanButton}
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
{planLabel}
|
{planLabel}
|
||||||
</div>
|
</div>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ const PlanPrompt = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
segment: 'doc history',
|
segment: 'doc history',
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export const CloudQuotaModal = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
|
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ const StoragePanel = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
}, [setSettingModalAtom]);
|
}, [setSettingModalAtom]);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ import { useLiveData, useService } from '@toeverything/infra';
|
|||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Suspense, useCallback, useEffect, useState } from 'react';
|
import { Suspense, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { openSettingModalAtom } from '../../../../../atoms';
|
import {
|
||||||
|
openSettingModalAtom,
|
||||||
|
type PlansScrollAnchor,
|
||||||
|
} from '../../../../../atoms';
|
||||||
import { useMutation } from '../../../../../hooks/use-mutation';
|
import { useMutation } from '../../../../../hooks/use-mutation';
|
||||||
import { useQuery } from '../../../../../hooks/use-query';
|
import { useQuery } from '../../../../../hooks/use-query';
|
||||||
import { SubscriptionService } from '../../../../../modules/cloud';
|
import { SubscriptionService } from '../../../../../modules/cloud';
|
||||||
@@ -32,6 +35,8 @@ import { mixpanel, popupWindow } from '../../../../../utils';
|
|||||||
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
|
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
|
||||||
import { CancelAction, ResumeAction } from '../plans/actions';
|
import { CancelAction, ResumeAction } from '../plans/actions';
|
||||||
import { AICancel, AIResume, AISubscribe } from '../plans/ai/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';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
enum DescriptionI18NKey {
|
enum DescriptionI18NKey {
|
||||||
@@ -94,6 +99,7 @@ const SubscriptionSettings = () => {
|
|||||||
|
|
||||||
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
|
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
|
||||||
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
|
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
|
||||||
|
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
|
||||||
|
|
||||||
const [openCancelModal, setOpenCancelModal] = useState(false);
|
const [openCancelModal, setOpenCancelModal] = useState(false);
|
||||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||||
@@ -103,7 +109,7 @@ const SubscriptionSettings = () => {
|
|||||||
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
|
proSubscription?.recurring ?? SubscriptionRecurring.Monthly;
|
||||||
|
|
||||||
const openPlans = useCallback(
|
const openPlans = useCallback(
|
||||||
(scrollAnchor?: string) => {
|
(scrollAnchor?: PlansScrollAnchor) => {
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
type: proSubscription?.plan,
|
type: proSubscription?.plan,
|
||||||
category: proSubscription?.recurring,
|
category: proSubscription?.recurring,
|
||||||
@@ -121,7 +127,10 @@ const SubscriptionSettings = () => {
|
|||||||
},
|
},
|
||||||
[proSubscription?.plan, proSubscription?.recurring, setOpenSettingModalAtom]
|
[proSubscription?.plan, proSubscription?.recurring, setOpenSettingModalAtom]
|
||||||
);
|
);
|
||||||
const gotoCloudPlansSetting = useCallback(() => openPlans(), [openPlans]);
|
const gotoCloudPlansSetting = useCallback(
|
||||||
|
() => openPlans('cloudPricingPlan'),
|
||||||
|
[openPlans]
|
||||||
|
);
|
||||||
const gotoAiPlanSetting = useCallback(
|
const gotoAiPlanSetting = useCallback(
|
||||||
() => openPlans('aiPricingPlan'),
|
() => openPlans('aiPricingPlan'),
|
||||||
[openPlans]
|
[openPlans]
|
||||||
@@ -137,50 +146,54 @@ const SubscriptionSettings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.subscription}>
|
<div className={styles.subscription}>
|
||||||
|
<AIPlanCard onClick={gotoAiPlanSetting} />
|
||||||
{/* loaded */}
|
{/* loaded */}
|
||||||
{proSubscription !== null ? (
|
{proSubscription !== null ? (
|
||||||
<div className={styles.planCard}>
|
isBeliever ? (
|
||||||
<div className={styles.currentPlan}>
|
<BelieverIdentifier onOpenPlans={gotoCloudPlansSetting} />
|
||||||
<SettingRow
|
) : (
|
||||||
spreadCol={false}
|
<div className={styles.planCard}>
|
||||||
name={t['com.affine.payment.billing-setting.current-plan']()}
|
<div className={styles.currentPlan}>
|
||||||
desc={
|
<SettingRow
|
||||||
<Trans
|
spreadCol={false}
|
||||||
i18nKey={getMessageKey(currentPlan, currentRecurring)}
|
name={t['com.affine.payment.billing-setting.current-plan']()}
|
||||||
values={{
|
desc={
|
||||||
planName: currentPlan,
|
<Trans
|
||||||
}}
|
i18nKey={getMessageKey(currentPlan, currentRecurring)}
|
||||||
components={{
|
values={{
|
||||||
1: (
|
planName: currentPlan,
|
||||||
<span
|
}}
|
||||||
onClick={gotoCloudPlansSetting}
|
components={{
|
||||||
className={styles.currentPlanName}
|
1: (
|
||||||
/>
|
<span
|
||||||
),
|
onClick={gotoCloudPlansSetting}
|
||||||
}}
|
className={styles.currentPlanName}
|
||||||
/>
|
/>
|
||||||
}
|
),
|
||||||
/>
|
}}
|
||||||
<PlanAction
|
/>
|
||||||
plan={currentPlan}
|
}
|
||||||
gotoPlansSetting={gotoCloudPlansSetting}
|
/>
|
||||||
/>
|
<PlanAction
|
||||||
|
plan={currentPlan}
|
||||||
|
gotoPlansSetting={gotoCloudPlansSetting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={styles.planPrice}>
|
||||||
|
${amount}
|
||||||
|
<span className={styles.billingFrequency}>
|
||||||
|
/
|
||||||
|
{currentRecurring === SubscriptionRecurring.Monthly
|
||||||
|
? t['com.affine.payment.billing-setting.month']()
|
||||||
|
: t['com.affine.payment.billing-setting.year']()}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className={styles.planPrice}>
|
)
|
||||||
${amount}
|
|
||||||
<span className={styles.billingFrequency}>
|
|
||||||
/
|
|
||||||
{currentRecurring === SubscriptionRecurring.Monthly
|
|
||||||
? t['com.affine.payment.billing-setting.month']()
|
|
||||||
: t['com.affine.payment.billing-setting.year']()}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<SubscriptionSettingSkeleton />
|
<SubscriptionSettingSkeleton />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<AIPlanCard onClick={gotoAiPlanSetting} />
|
|
||||||
{proSubscription !== null ? (
|
{proSubscription !== null ? (
|
||||||
proSubscription?.status === SubscriptionStatus.Active && (
|
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 (
|
||||||
|
<BelieverCard type={2} style={{ borderRadius: 8, padding: 12 }}>
|
||||||
|
<header className={styles.believerHeader}>
|
||||||
|
<div>
|
||||||
|
<div className={styles.believerTitle}>
|
||||||
|
{t['com.affine.payment.billing-setting.believer.title']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.believerSubtitle}>
|
||||||
|
<Trans
|
||||||
|
i18nKey={
|
||||||
|
'com.affine.payment.billing-setting.believer.description'
|
||||||
|
}
|
||||||
|
components={{
|
||||||
|
a: <a href="#" onClick={onOpenPlans} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.believerPriceWrapper}>
|
||||||
|
<div className={styles.believerPrice}>{readableLifetimePrice}</div>
|
||||||
|
<div className={styles.believerPriceCaption}>
|
||||||
|
{t['com.affine.payment.billing-setting.believer.price-caption']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<BelieverBenefits />
|
||||||
|
</BelieverCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
|
const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const subscriptionService = useService(SubscriptionService);
|
const subscriptionService = useService(SubscriptionService);
|
||||||
@@ -298,7 +350,7 @@ const AIPlanCard = ({ onClick }: { onClick: () => void }) => {
|
|||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.planCard} style={{ marginTop: 24 }}>
|
<div className={styles.planCard} style={{ marginBottom: 24 }}>
|
||||||
<div className={styles.currentPlan}>
|
<div className={styles.currentPlan}>
|
||||||
<SettingRow
|
<SettingRow
|
||||||
spreadCol={false}
|
spreadCol={false}
|
||||||
@@ -468,7 +520,9 @@ const InvoiceLine = ({
|
|||||||
invoice.plan === SubscriptionPlan.AI
|
invoice.plan === SubscriptionPlan.AI
|
||||||
? 'AFFiNE AI'
|
? 'AFFiNE AI'
|
||||||
: invoice.plan === SubscriptionPlan.Pro
|
: invoice.plan === SubscriptionPlan.Pro
|
||||||
? 'AFFiNE Cloud'
|
? invoice.recurring === SubscriptionRecurring.Lifetime
|
||||||
|
? 'AFFiNE Cloud Believer'
|
||||||
|
: 'AFFiNE Cloud'
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -61,3 +61,43 @@ export const billingHistorySkeleton = style({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// believer-identification
|
||||||
|
export const believerHeader = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
});
|
||||||
|
export const believerTitle = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: '22px',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
});
|
||||||
|
export const believerSubtitle = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontWeight: 400,
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
|
});
|
||||||
|
globalStyle(`.${believerSubtitle} > 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'),
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import { SubscriptionService } from '@affine/core/modules/cloud';
|
import { SubscriptionService } from '@affine/core/modules/cloud';
|
||||||
import { mixpanel, popupWindow } from '@affine/core/utils';
|
import { mixpanel, popupWindow } from '@affine/core/utils';
|
||||||
@@ -8,9 +8,14 @@ import { useLiveData, useService } from '@toeverything/infra';
|
|||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { useEffect, useState } from 'react';
|
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 [idempotencyKey, setIdempotencyKey] = useState(nanoid());
|
||||||
const [isMutating, setMutating] = useState(false);
|
const [isMutating, setMutating] = useState(false);
|
||||||
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
|
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
|
||||||
@@ -63,12 +68,28 @@ export const AISubscribe = ({ ...btnProps }: AISubscribeProps) => {
|
|||||||
}, [idempotencyKey, subscriptionService]);
|
}, [idempotencyKey, subscriptionService]);
|
||||||
|
|
||||||
if (!price || !price.yearlyAmount) {
|
if (!price || !price.yearlyAmount) {
|
||||||
// TODO(@catsjuice): loading UI
|
return (
|
||||||
return null;
|
<Skeleton
|
||||||
|
className={btnProps.className}
|
||||||
|
width={160}
|
||||||
|
height={36}
|
||||||
|
style={{
|
||||||
|
borderRadius: 18,
|
||||||
|
...btnProps.style,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`;
|
const priceReadable = `$${(
|
||||||
const priceFrequency = t['com.affine.payment.billing-setting.year']();
|
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 (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -78,6 +99,18 @@ export const AISubscribe = ({ ...btnProps }: AISubscribeProps) => {
|
|||||||
{...btnProps}
|
{...btnProps}
|
||||||
>
|
>
|
||||||
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
|
{btnProps.children ?? `${priceReadable} / ${priceFrequency}`}
|
||||||
|
{displayedFrequency === 'monthly' ? (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
opacity: 0.75,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
paddingLeft: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t['com.affine.payment.ai.subscribe.billed-annually']()}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,26 +11,25 @@ export const titleBlock = style({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 8,
|
gap: 8,
|
||||||
marginBottom: 24,
|
marginBottom: 12,
|
||||||
});
|
});
|
||||||
export const titleCaption1 = style({
|
export const titleCaption1 = style({
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontXs'),
|
||||||
lineHeight: '14px',
|
lineHeight: '20px',
|
||||||
color: cssVar('brandColor'),
|
color: cssVar('brandColor'),
|
||||||
});
|
});
|
||||||
export const titleCaption2 = style({
|
export const titleCaption2 = style({
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
lineHeight: '20px',
|
lineHeight: '22px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
letterSpacing: '-2%',
|
|
||||||
});
|
});
|
||||||
export const title = style({
|
export const title = style({
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: '30px',
|
fontSize: '28px',
|
||||||
lineHeight: '36px',
|
lineHeight: '36px',
|
||||||
letterSpacing: '-2%',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// action button
|
// action button
|
||||||
@@ -89,22 +88,24 @@ export const benefitTitle = style({
|
|||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
letterSpacing: '-2%',
|
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 8,
|
gap: 4,
|
||||||
});
|
});
|
||||||
globalStyle(`.${benefitTitle} > svg`, {
|
globalStyle(`.${benefitTitle} > svg`, {
|
||||||
color: cssVar('brandColor'),
|
color: cssVar('brandColor'),
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
});
|
});
|
||||||
export const benefitList = style({
|
export const benefitList = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 8,
|
gap: 4,
|
||||||
});
|
});
|
||||||
export const benefitItem = style({
|
export const benefitItem = style({
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVar('textSecondaryColor'),
|
||||||
lineHeight: '24px',
|
lineHeight: '24px',
|
||||||
paddingLeft: 22,
|
paddingLeft: 22,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
|||||||
@@ -4,10 +4,9 @@ import { i18nTime, useI18n } from '@affine/i18n';
|
|||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import { AIPlanLayout } from '../layout';
|
|
||||||
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
|
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
|
||||||
import * as styles from './ai-plan.css';
|
import * as styles from './ai-plan.css';
|
||||||
import { AIBenefits } from './benefits';
|
import { AIPlanLayout } from './layout';
|
||||||
|
|
||||||
export const AIPlan = () => {
|
export const AIPlan = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -45,60 +44,37 @@ export const AIPlan = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AIPlanLayout
|
<AIPlanLayout
|
||||||
title={t['com.affine.payment.ai.pricing-plan.title']()}
|
|
||||||
caption={
|
caption={
|
||||||
subscription
|
subscription
|
||||||
? t['com.affine.payment.ai.pricing-plan.caption-purchased']()
|
? t['com.affine.payment.ai.pricing-plan.caption-purchased']()
|
||||||
: t['com.affine.payment.ai.pricing-plan.caption-free']()
|
: t['com.affine.payment.ai.pricing-plan.caption-free']()
|
||||||
}
|
}
|
||||||
>
|
actionButtons={
|
||||||
<div className={styles.card}>
|
isLoggedIn ? (
|
||||||
<div className={styles.titleBlock}>
|
subscription ? (
|
||||||
<section className={styles.titleCaption1}>
|
subscription.canceledAt ? (
|
||||||
{t['com.affine.payment.ai.pricing-plan.title-caption-1']()}
|
<AIResume className={styles.purchaseButton} />
|
||||||
</section>
|
|
||||||
<section className={styles.title}>
|
|
||||||
{t['com.affine.payment.ai.pricing-plan.title']()}
|
|
||||||
</section>
|
|
||||||
<section className={styles.titleCaption2}>
|
|
||||||
{t['com.affine.payment.ai.pricing-plan.title-caption-2']()}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.actionBlock}>
|
|
||||||
<div className={styles.actionButtons}>
|
|
||||||
{isLoggedIn ? (
|
|
||||||
subscription ? (
|
|
||||||
subscription.canceledAt ? (
|
|
||||||
<AIResume className={styles.purchaseButton} />
|
|
||||||
) : (
|
|
||||||
<AICancel className={styles.purchaseButton} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<AISubscribe className={styles.learnAIButton} />
|
|
||||||
<a
|
|
||||||
href="https://ai.affine.pro"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Button className={styles.learnAIButton}>
|
|
||||||
{t['com.affine.payment.ai.pricing-plan.learn']()}
|
|
||||||
</Button>
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<AILogin className={styles.purchaseButton} />
|
<AICancel className={styles.purchaseButton} />
|
||||||
)}
|
)
|
||||||
</div>
|
) : (
|
||||||
{billingTip ? (
|
<>
|
||||||
<div className={styles.agreement}>{billingTip}</div>
|
<AISubscribe
|
||||||
) : null}
|
className={styles.learnAIButton}
|
||||||
</div>
|
displayedFrequency="monthly"
|
||||||
|
/>
|
||||||
<AIBenefits />
|
<a href="https://ai.affine.pro" target="_blank" rel="noreferrer">
|
||||||
</div>
|
<Button className={styles.learnAIButton}>
|
||||||
</AIPlanLayout>
|
{t['com.affine.payment.ai.pricing-plan.learn']()}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<AILogin className={styles.purchaseButton} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
billingTip={billingTip}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const benefitsGetter = (t: ReturnType<typeof useI18n>) => [
|
|||||||
export const AIBenefits = () => {
|
export const AIBenefits = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const benefits = useMemo(() => benefitsGetter(t), [t]);
|
const benefits = useMemo(() => benefitsGetter(t), [t]);
|
||||||
// TODO(@catsjuice): responsive
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.benefits}>
|
<div className={styles.benefits}>
|
||||||
{benefits.map(({ name, icon, items }) => {
|
{benefits.map(({ name, icon, items }) => {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<PricingCollapsible title={title} caption={caption}>
|
||||||
|
<div className={styles.card}>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<section className={styles.titleCaption1}>
|
||||||
|
{t['com.affine.payment.ai.pricing-plan.title-caption-1']()}
|
||||||
|
</section>
|
||||||
|
<section className={styles.title}>
|
||||||
|
{t['com.affine.payment.ai.pricing-plan.title']()}
|
||||||
|
</section>
|
||||||
|
<section className={styles.titleCaption2}>
|
||||||
|
{t['com.affine.payment.ai.pricing-plan.title-caption-2']()}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actionBlock}>
|
||||||
|
<div className={styles.actionButtons}>{actionButtons}</div>
|
||||||
|
{billingTip ? (
|
||||||
|
<div className={styles.agreement}>{billingTip}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AIBenefits />
|
||||||
|
</div>
|
||||||
|
</PricingCollapsible>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
|
import { Switch } from '@affine/component';
|
||||||
|
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
|
||||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
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 { 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 { planTitleTitleCaption } from './style.css';
|
||||||
|
import * as styles from './style.css';
|
||||||
|
|
||||||
type T = ReturnType<typeof useI18n>;
|
type T = ReturnType<typeof useI18n>;
|
||||||
|
|
||||||
@@ -142,3 +149,192 @@ export function getPlanDetail(t: T) {
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getRecurringLabel = ({
|
||||||
|
recurring,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
|
t: ReturnType<typeof useI18n>;
|
||||||
|
}) => {
|
||||||
|
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<HTMLDivElement>(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<SubscriptionRecurring>(
|
||||||
|
proSubscription?.recurring ?? SubscriptionRecurring.Yearly
|
||||||
|
);
|
||||||
|
|
||||||
|
const planDetail = useMemo(() => {
|
||||||
|
const rawMap = getPlanDetail(t);
|
||||||
|
const clonedMap = new Map<SubscriptionPlan, FixedPrice | DynamicPrice>();
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<p>
|
||||||
|
{t['com.affine.payment.subtitle-canceled']({
|
||||||
|
plan: `${getRecurringLabel({
|
||||||
|
recurring: currentRecurring,
|
||||||
|
t,
|
||||||
|
})} ${currentPlan}`,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
<Trans
|
||||||
|
plan={currentPlan}
|
||||||
|
i18nKey="com.affine.payment.subtitle-active"
|
||||||
|
values={{ currentPlan }}
|
||||||
|
>
|
||||||
|
You are currently on the {{ currentPlan }} plan. If you have any
|
||||||
|
questions, please contact our
|
||||||
|
<a
|
||||||
|
href="mailto:support@toeverything.info"
|
||||||
|
style={{ color: 'var(--affine-link-color)' }}
|
||||||
|
>
|
||||||
|
customer support
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
// toggle
|
||||||
|
const cloudToggle = (
|
||||||
|
<div className={styles.recurringToggleWrapper}>
|
||||||
|
<div>
|
||||||
|
{recurring === SubscriptionRecurring.Yearly ? (
|
||||||
|
<div className={styles.recurringToggleRecurring}>
|
||||||
|
{t['com.affine.payment.cloud.pricing-plan.toggle-yearly']()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={styles.recurringToggleRecurring}>
|
||||||
|
<span>
|
||||||
|
{t[
|
||||||
|
'com.affine.payment.cloud.pricing-plan.toggle-billed-yearly'
|
||||||
|
]()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{yearlyDiscount ? (
|
||||||
|
<div className={styles.recurringToggleDiscount}>
|
||||||
|
{t['com.affine.payment.cloud.pricing-plan.toggle-discount']({
|
||||||
|
discount: yearlyDiscount,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={recurring === SubscriptionRecurring.Yearly}
|
||||||
|
onChange={checked =>
|
||||||
|
setRecurring(
|
||||||
|
checked
|
||||||
|
? SubscriptionRecurring.Yearly
|
||||||
|
: SubscriptionRecurring.Monthly
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloudScroll = (
|
||||||
|
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
||||||
|
{Array.from(planDetail.values()).map(detail => {
|
||||||
|
return <PlanCard key={detail.plan} {...{ detail, recurring }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const cloudSelect = (
|
||||||
|
<div className={styles.cloudSelect}>
|
||||||
|
<b>{t['com.affine.payment.cloud.pricing-plan.select.title']()}</b>
|
||||||
|
<span>{t['com.affine.payment.cloud.pricing-plan.select.caption']()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CloudPlanLayout
|
||||||
|
caption={cloudCaption}
|
||||||
|
select={cloudSelect}
|
||||||
|
toggle={cloudToggle}
|
||||||
|
scroll={cloudScroll}
|
||||||
|
scrollRef={scrollWrapper}
|
||||||
|
lifetime={<LifetimePlan />}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
import { Switch } from '@affine/component';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
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 type { FallbackProps } from 'react-error-boundary';
|
||||||
|
|
||||||
import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary';
|
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 { AIPlan } from './ai/ai-plan';
|
||||||
import { type FixedPrice, getPlanDetail } from './cloud-plans';
|
import { CloudPlans } from './cloud-plans';
|
||||||
import { CloudPlanLayout, PlanLayout } from './layout';
|
import { CloudPlanLayout, PlanLayout } from './layout';
|
||||||
import { PlanCard } from './plan-card';
|
|
||||||
import { PlansSkeleton } from './skeleton';
|
import { PlansSkeleton } from './skeleton';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
const getRecurringLabel = ({
|
|
||||||
recurring,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
recurring: SubscriptionRecurring;
|
|
||||||
t: ReturnType<typeof useI18n>;
|
|
||||||
}) => {
|
|
||||||
return recurring === SubscriptionRecurring.Monthly
|
|
||||||
? t['com.affine.payment.recurring-monthly']()
|
|
||||||
: t['com.affine.payment.recurring-yearly']();
|
|
||||||
};
|
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
const loggedIn =
|
|
||||||
useLiveData(useService(AuthService).session.status$) === 'authenticated';
|
|
||||||
const planDetail = useMemo(() => getPlanDetail(t), [t]);
|
|
||||||
const scrollWrapper = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
const subscriptionService = useService(SubscriptionService);
|
const subscriptionService = useService(SubscriptionService);
|
||||||
const proSubscription = useLiveData(subscriptionService.subscription.pro$);
|
|
||||||
const prices = useLiveData(subscriptionService.prices.prices$);
|
const prices = useLiveData(subscriptionService.prices.prices$);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,165 +20,11 @@ const Settings = () => {
|
|||||||
subscriptionService.prices.revalidate();
|
subscriptionService.prices.revalidate();
|
||||||
}, [subscriptionService]);
|
}, [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<SubscriptionRecurring>(
|
|
||||||
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 ? (
|
|
||||||
<p>
|
|
||||||
{t['com.affine.payment.subtitle-canceled']({
|
|
||||||
plan: `${getRecurringLabel({
|
|
||||||
recurring: currentRecurring,
|
|
||||||
t,
|
|
||||||
})} ${currentPlan}`,
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p>
|
|
||||||
<Trans
|
|
||||||
plan={currentPlan}
|
|
||||||
i18nKey="com.affine.payment.subtitle-active"
|
|
||||||
values={{ currentPlan }}
|
|
||||||
>
|
|
||||||
You are currently on the {{ currentPlan }} plan. If you have any
|
|
||||||
questions, please contact our
|
|
||||||
<a
|
|
||||||
href="mailto:support@toeverything.info"
|
|
||||||
style={{ color: 'var(--affine-link-color)' }}
|
|
||||||
>
|
|
||||||
customer support
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<p>{t['com.affine.payment.subtitle-not-signed-in']()}</p>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cloudToggle = (
|
|
||||||
<div className={styles.recurringToggleWrapper}>
|
|
||||||
<div>
|
|
||||||
{recurring === SubscriptionRecurring.Yearly ? (
|
|
||||||
<div className={styles.recurringToggleRecurring}>
|
|
||||||
{t['com.affine.payment.cloud.pricing-plan.toggle-yearly']()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className={styles.recurringToggleRecurring}>
|
|
||||||
<span>
|
|
||||||
{t[
|
|
||||||
'com.affine.payment.cloud.pricing-plan.toggle-billed-yearly'
|
|
||||||
]()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{yearlyDiscount ? (
|
|
||||||
<div className={styles.recurringToggleDiscount}>
|
|
||||||
{t['com.affine.payment.cloud.pricing-plan.toggle-discount']({
|
|
||||||
discount: yearlyDiscount,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
checked={recurring === SubscriptionRecurring.Yearly}
|
|
||||||
onChange={checked =>
|
|
||||||
setRecurring(
|
|
||||||
checked
|
|
||||||
? SubscriptionRecurring.Yearly
|
|
||||||
: SubscriptionRecurring.Monthly
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cloudScroll = (
|
|
||||||
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
|
|
||||||
{Array.from(planDetail.values()).map(detail => {
|
|
||||||
return <PlanCard key={detail.plan} {...{ detail, recurring }} />;
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const cloudSelect = (
|
|
||||||
<div className={styles.cloudSelect}>
|
|
||||||
<b>{t['com.affine.payment.cloud.pricing-plan.select.title']()}</b>
|
|
||||||
<span>{t['com.affine.payment.cloud.pricing-plan.select.caption']()}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (prices === null) {
|
if (prices === null) {
|
||||||
return <PlansSkeleton />;
|
return <PlansSkeleton />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <PlanLayout cloudTip cloud={<CloudPlans />} ai={<AIPlan />} />;
|
||||||
<PlanLayout
|
|
||||||
aiTip
|
|
||||||
cloud={
|
|
||||||
<CloudPlanLayout
|
|
||||||
caption={cloudCaption}
|
|
||||||
select={cloudSelect}
|
|
||||||
toggle={cloudToggle}
|
|
||||||
scroll={cloudScroll}
|
|
||||||
scrollRef={scrollWrapper}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
ai={<AIPlan />}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AFFiNEPricingPlans = () => {
|
export const AFFiNEPricingPlans = () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const scrollArea = style({
|
|||||||
paddingLeft: 'var(--setting-modal-gap-x)',
|
paddingLeft: 'var(--setting-modal-gap-x)',
|
||||||
width: 'var(--setting-modal-width)',
|
width: 'var(--setting-modal-width)',
|
||||||
overflowX: 'auto',
|
overflowX: 'auto',
|
||||||
scrollSnapType: 'x mandatory',
|
// scrollSnapType: 'x mandatory',
|
||||||
paddingBottom: '21px',
|
paddingBottom: '21px',
|
||||||
/** Avoid box-shadow clipping */
|
/** Avoid box-shadow clipping */
|
||||||
paddingTop: '21px',
|
paddingTop: '21px',
|
||||||
@@ -73,7 +73,7 @@ export const affineCloudHeader = style({
|
|||||||
export const aiDivider = style({
|
export const aiDivider = style({
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
selectors: {
|
selectors: {
|
||||||
'[data-ai-visible] &': {
|
'[data-cloud-visible] &': {
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -105,7 +105,7 @@ export const aiScrollTip = style({
|
|||||||
animation: `${slideInBottom} 0.3s ease 0.5s forwards`,
|
animation: `${slideInBottom} 0.3s ease 0.5s forwards`,
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'[data-ai-visible] &': {
|
'[data-cloud-visible] &': {
|
||||||
transform: 'translateY(100px)',
|
transform: 'translateY(100px)',
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
},
|
},
|
||||||
@@ -115,38 +115,15 @@ export const aiScrollTip = style({
|
|||||||
globalStyle(`div.${aiScrollTip}`, {
|
globalStyle(`div.${aiScrollTip}`, {
|
||||||
display: 'flex !important',
|
display: 'flex !important',
|
||||||
});
|
});
|
||||||
export const aiScrollTipLabel = style({
|
export const cloudScrollTipTitle = style({
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const aiScrollTipText = style({
|
|
||||||
padding: '0px 10px 0px 8px',
|
|
||||||
fontSize: cssVar('fontSm'),
|
fontSize: cssVar('fontSm'),
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
color: cssVar('textPrimaryColor'),
|
color: cssVar('textPrimaryColor'),
|
||||||
});
|
});
|
||||||
export const aiScrollTipTag = style({
|
export const cloudScrollTipCaption = style({
|
||||||
background: 'linear-gradient(180deg, #41B0FF 0%, #0873BE 100%)',
|
fontSize: cssVar('fontXs'),
|
||||||
borderRadius: 3,
|
fontWeight: 400,
|
||||||
fontWeight: 600,
|
lineHeight: '20px',
|
||||||
fontSize: 10,
|
color: cssVar('textSecondaryColor'),
|
||||||
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%)',
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +1,16 @@
|
|||||||
import { Button, Divider, IconButton } from '@affine/component';
|
import { Button, Divider, IconButton } from '@affine/component';
|
||||||
import { SettingHeader } from '@affine/component/setting-components';
|
import { SettingHeader } from '@affine/component/setting-components';
|
||||||
import { openSettingModalAtom } from '@affine/core/atoms';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import {
|
import {
|
||||||
ArrowDownBigIcon,
|
openSettingModalAtom,
|
||||||
ArrowRightBigIcon,
|
type PlansScrollAnchor,
|
||||||
ArrowUpSmallIcon,
|
} from '@affine/core/atoms';
|
||||||
} from '@blocksuite/icons/rc';
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
type HtmlHTMLAttributes,
|
type HtmlHTMLAttributes,
|
||||||
type PropsWithChildren,
|
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -21,7 +18,7 @@ import {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal, flushSync } from 'react-dom';
|
||||||
|
|
||||||
import { settingModalScrollContainerAtom } from '../../atoms';
|
import { settingModalScrollContainerAtom } from '../../atoms';
|
||||||
import * as styles from './layout.css';
|
import * as styles from './layout.css';
|
||||||
@@ -47,7 +44,7 @@ interface PricingCollapsibleProps
|
|||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
caption?: ReactNode;
|
caption?: ReactNode;
|
||||||
}
|
}
|
||||||
const PricingCollapsible = ({
|
export const PricingCollapsible = ({
|
||||||
title,
|
title,
|
||||||
caption,
|
caption,
|
||||||
children,
|
children,
|
||||||
@@ -78,97 +75,96 @@ const PricingCollapsible = ({
|
|||||||
export interface PlanLayoutProps {
|
export interface PlanLayoutProps {
|
||||||
cloud?: ReactNode;
|
cloud?: ReactNode;
|
||||||
ai?: 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 t = useI18n();
|
||||||
const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
const [modal, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
||||||
const aiPricingPlanRef = useRef<HTMLDivElement>(null);
|
const scrollAnchor = modal.activeTab === 'plans' ? modal.scrollAnchor : null;
|
||||||
const aiScrollTipRef = useRef<HTMLDivElement>(null);
|
const plansRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const cloudScrollTipRef = useRef<HTMLDivElement>(null);
|
||||||
const settingModalScrollContainer = useAtomValue(
|
const settingModalScrollContainer = useAtomValue(
|
||||||
settingModalScrollContainerAtom
|
settingModalScrollContainerAtom
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateAiTipState = useCallback(() => {
|
const updateCloudTipState = useCallback(() => {
|
||||||
if (!aiTip) return;
|
if (!cloudTip) return;
|
||||||
const aiContainer = aiPricingPlanRef.current;
|
const cloudContainer =
|
||||||
if (!settingModalScrollContainer || !aiContainer) return;
|
plansRootRef.current?.querySelector('#cloudPricingPlan');
|
||||||
|
if (!settingModalScrollContainer || !cloudContainer) return;
|
||||||
|
|
||||||
const minVisibleHeight = 30;
|
const minVisibleHeight = 30;
|
||||||
|
|
||||||
const containerRect = settingModalScrollContainer.getBoundingClientRect();
|
const containerRect = settingModalScrollContainer.getBoundingClientRect();
|
||||||
const aiTop = aiContainer.getBoundingClientRect().top - containerRect.top;
|
const cloudTop =
|
||||||
const aiIntoView = aiTop < containerRect.height - minVisibleHeight;
|
cloudContainer.getBoundingClientRect().top - containerRect.top;
|
||||||
if (aiIntoView) {
|
const cloudIntoView = cloudTop < containerRect.height - minVisibleHeight;
|
||||||
settingModalScrollContainer.dataset.aiVisible = '';
|
if (cloudIntoView) {
|
||||||
|
settingModalScrollContainer.dataset.cloudVisible = '';
|
||||||
}
|
}
|
||||||
}, [aiTip, settingModalScrollContainer]);
|
}, [cloudTip, settingModalScrollContainer]);
|
||||||
|
|
||||||
// TODO(@catsjuice): Need a better solution to handle this situation
|
// TODO(@catsjuice): Need a better solution to handle this situation
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!scrollAnchor) return;
|
if (!scrollAnchor) return;
|
||||||
setTimeout(() => {
|
flushSync(() => {
|
||||||
if (scrollAnchor === 'aiPricingPlan' && aiPricingPlanRef.current) {
|
const target = plansRootRef.current?.querySelector(`#${scrollAnchor}`);
|
||||||
aiPricingPlanRef.current.scrollIntoView();
|
if (target) {
|
||||||
|
target.scrollIntoView();
|
||||||
setOpenSettingModal(prev => ({ ...prev, scrollAnchor: undefined }));
|
setOpenSettingModal(prev => ({ ...prev, scrollAnchor: undefined }));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [scrollAnchor, setOpenSettingModal]);
|
}, [scrollAnchor, setOpenSettingModal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!settingModalScrollContainer || !aiScrollTipRef.current) return;
|
if (!settingModalScrollContainer || !cloudScrollTipRef.current) return;
|
||||||
|
|
||||||
settingModalScrollContainer.addEventListener('scroll', updateAiTipState);
|
settingModalScrollContainer.addEventListener('scroll', updateCloudTipState);
|
||||||
updateAiTipState();
|
updateCloudTipState();
|
||||||
return () => {
|
return () => {
|
||||||
settingModalScrollContainer.removeEventListener(
|
settingModalScrollContainer.removeEventListener(
|
||||||
'scroll',
|
'scroll',
|
||||||
updateAiTipState
|
updateCloudTipState
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [settingModalScrollContainer, updateAiTipState]);
|
}, [settingModalScrollContainer, updateCloudTipState]);
|
||||||
|
|
||||||
const scrollAiIntoView = useCallback(() => {
|
const scrollToAnchor = useCallback((anchor: PlansScrollAnchor) => {
|
||||||
aiPricingPlanRef.current?.scrollIntoView({ behavior: 'smooth' });
|
const target = plansRootRef.current?.querySelector(`#${anchor}`);
|
||||||
|
target && target.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.plansLayoutRoot}>
|
<div className={styles.plansLayoutRoot} ref={plansRootRef}>
|
||||||
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */}
|
{/* TODO(@catsjuice): SettingHeader component shouldn't have margin itself */}
|
||||||
<SettingHeader
|
<SettingHeader
|
||||||
style={{ marginBottom: '0px' }}
|
style={{ marginBottom: '0px' }}
|
||||||
title={t['com.affine.payment.title']()}
|
title={t['com.affine.payment.title']()}
|
||||||
/>
|
/>
|
||||||
{cloud}
|
|
||||||
{ai ? (
|
{ai ? (
|
||||||
<>
|
<>
|
||||||
|
<div id="aiPricingPlan">{ai}</div>
|
||||||
<Divider className={styles.aiDivider} />
|
<Divider className={styles.aiDivider} />
|
||||||
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
|
||||||
{ai}
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div id="cloudPricingPlan">{cloud}</div>
|
||||||
|
|
||||||
{aiTip && settingModalScrollContainer
|
{cloudTip && settingModalScrollContainer
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div className={styles.aiScrollTip} ref={aiScrollTipRef}>
|
<div className={styles.aiScrollTip} ref={cloudScrollTipRef}>
|
||||||
<div className={styles.aiScrollTipLabel}>
|
<div>
|
||||||
<ArrowDownBigIcon
|
<div className={styles.cloudScrollTipTitle}>
|
||||||
width={24}
|
{t['com.affine.cloud-scroll-tip.title']()}
|
||||||
height={24}
|
|
||||||
color={cssVar('iconColor')}
|
|
||||||
/>
|
|
||||||
<div className={styles.aiScrollTipText}>
|
|
||||||
{t['com.affine.ai-scroll-tip.title']()}
|
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.aiScrollTipTag}>
|
<div className={styles.cloudScrollTipCaption}>
|
||||||
<div className={styles.aiScrollTipTagInner}>
|
{t['com.affine.cloud-scroll-tip.caption']()}
|
||||||
{t['com.affine.ai-scroll-tip.tag']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={scrollAiIntoView} type="primary">
|
<Button
|
||||||
|
onClick={() => scrollToAnchor('cloudPricingPlan')}
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
{t['com.affine.ai-scroll-tip.view']()}
|
{t['com.affine.ai-scroll-tip.view']()}
|
||||||
</Button>
|
</Button>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -186,6 +182,7 @@ export interface PlanCardProps {
|
|||||||
select?: ReactNode;
|
select?: ReactNode;
|
||||||
toggle?: ReactNode;
|
toggle?: ReactNode;
|
||||||
scroll?: ReactNode;
|
scroll?: ReactNode;
|
||||||
|
lifetime?: ReactNode;
|
||||||
scrollRef?: React.RefObject<HTMLDivElement>;
|
scrollRef?: React.RefObject<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
export const CloudPlanLayout = ({
|
export const CloudPlanLayout = ({
|
||||||
@@ -194,6 +191,7 @@ export const CloudPlanLayout = ({
|
|||||||
select,
|
select,
|
||||||
toggle,
|
toggle,
|
||||||
scroll,
|
scroll,
|
||||||
|
lifetime,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}: PlanCardProps) => {
|
}: PlanCardProps) => {
|
||||||
return (
|
return (
|
||||||
@@ -214,22 +212,7 @@ export const CloudPlanLayout = ({
|
|||||||
<ScrollArea.Thumb className={styles.scrollThumb}></ScrollArea.Thumb>
|
<ScrollArea.Thumb className={styles.scrollThumb}></ScrollArea.Thumb>
|
||||||
</ScrollArea.Scrollbar>
|
</ScrollArea.Scrollbar>
|
||||||
</ScrollArea.Root>
|
</ScrollArea.Root>
|
||||||
</PricingCollapsible>
|
{lifetime ? <div id="lifetimePricingPlan">{lifetime}</div> : null}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AIPlanLayoutProps {
|
|
||||||
title?: ReactNode;
|
|
||||||
caption?: ReactNode;
|
|
||||||
}
|
|
||||||
export const AIPlanLayout = ({
|
|
||||||
title = 'AFFiNE AI',
|
|
||||||
caption,
|
|
||||||
children,
|
|
||||||
}: PropsWithChildren<AIPlanLayoutProps>) => {
|
|
||||||
return (
|
|
||||||
<PricingCollapsible title={title} caption={caption}>
|
|
||||||
{children}
|
|
||||||
</PricingCollapsible>
|
</PricingCollapsible>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
export const bgAFFiNERaw = `<svg
|
||||||
|
width="352"
|
||||||
|
height="320"
|
||||||
|
viewBox="0 0 352 320"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="affine-svg"
|
||||||
|
>
|
||||||
|
<g filter="url(#filter0_dddd_782_4147)">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
class="affine"
|
||||||
|
fill-rule="evenodd"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
d="M116.696 47.3266C123.09 36.2513 127.89 27.9391 130.138 24.0455L130.131 24.0532C131.853 21.0201 136.347 21.0584 138.07 24.0532C138.899 25.4399 140.482 28.2114 142.109 31.0592L142.112 31.0646L142.113 31.0662C143.926 34.2404 145.793 37.5081 146.729 39.0578C159.768 61.6638 175.427 88.7902 191.338 116.352L191.353 116.378C198.777 129.239 206.256 142.194 213.548 154.829L116.696 47.3266ZM95.6843 83.7222C65.9312 135.266 27.4975 201.875 21.1035 213.078C19.9705 215.804 22.0605 219.243 25.031 219.297C25.6193 219.354 29.0911 219.347 31.3338 219.343H31.3345H31.3351H31.3356H31.3361C32.1278 219.342 32.766 219.341 33.0697 219.343C38.9111 219.345 45.1459 219.347 51.6934 219.348L95.6843 83.7222ZM94.8237 219.35C111.168 219.349 128.22 219.347 145.141 219.345C180.097 219.34 214.49 219.336 240.911 219.343L241.212 219.343H241.213C242.07 219.344 243.123 219.345 243.177 219.297C243.387 219.315 243.57 219.273 243.751 219.232C243.88 219.202 244.008 219.173 244.142 219.167C246.615 218.424 248.108 215.513 247.105 213.085C247.007 212.944 246.967 212.852 246.908 212.721C246.824 212.532 246.703 212.26 246.324 211.638C242.34 204.727 238.108 197.391 233.697 189.745L94.8237 219.35ZM144.914 11.4689C136.24 5.09634 123.501 7.80009 118.203 17.1521C113.965 24.553 101.087 46.8414 85.5332 73.7598C55.3395 126.015 15.0642 195.719 8.34886 207.831C4.21469 217.757 9.97191 229.53 20.3226 232.417C23.369 233.225 26.5304 233.17 29.5054 233.117C30.7322 233.095 31.9274 233.074 33.0697 233.114C60.2291 233.14 95.7969 233.126 131.767 233.111C171.174 233.095 211.065 233.078 240.911 233.114C242.565 233.145 245.451 233.114 247.886 232.425C255.396 230.334 260.977 223.188 261.199 215.398C261.284 212.817 260.801 210.236 259.867 207.846C259.015 205.998 258.311 204.797 257.619 203.617L257.619 203.617C257.354 203.164 257.09 202.714 256.82 202.232L251.048 192.229C238.163 169.9 221.518 141.059 204.873 112.217L204.167 110.993C187.749 82.5434 171.382 54.1817 158.672 32.1567L152.899 22.1537L150.013 17.1521C148.704 14.9845 146.989 13.0084 144.914 11.4689ZM215.983 167.938C213.319 165.616 210.582 163.395 207.767 161.299L113.985 206.625C113.754 206.788 113.522 206.95 113.289 207.111L226.643 179.013L215.983 167.938ZM134.763 187.748L196.966 154.14C193.36 152.042 189.636 150.163 185.788 148.543L134.763 187.748ZM144.168 170.768L174.282 144.611C172.663 144.183 171.023 143.802 169.362 143.469C166.442 142.936 163.506 142.562 160.552 142.374L144.168 170.768ZM129.17 179.638C127.529 182.104 125.738 184.462 123.815 186.722C122.704 187.988 121.561 189.211 120.388 190.394L112.8 151.268L129.17 179.638ZM112.596 133.826C111.282 131.172 110.136 128.44 109.139 125.639C108.601 124.047 108.115 122.449 107.679 120.846L145.334 133.826H112.596ZM105.342 83.7389L190.087 141.396C190.209 141.45 190.33 141.504 190.452 141.558C190.535 141.596 190.619 141.633 190.702 141.671L110.653 58.5003L108.898 64.5935C107.307 70.9253 106.076 77.3241 105.342 83.7389ZM88.0881 211.55C80.8724 214.586 73.3244 216.987 65.645 218.921L64.3555 219.241L95.4864 111.157L88.0881 211.55ZM111.196 198.43C107.536 201.207 103.681 203.696 99.6707 205.931L101.828 129.939C102 130.403 102.176 130.866 102.356 131.328L111.196 198.43ZM163.608 132.998L104.577 96.6275C104.573 100.712 104.808 104.79 105.317 108.847L163.608 132.998ZM119.438 145.959L131.963 167.657H131.97C132.927 169.312 135.308 169.312 136.265 167.657L148.79 145.959C149.74 144.304 148.545 142.236 146.639 142.236H121.589C119.675 142.236 118.481 144.304 119.438 145.959Z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<filter id="filter0_dddd_782_4147" x="0" y="0" width="351.209" height="319.144" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||||
|
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dx="4" dy="3"/>
|
||||||
|
<feGaussianBlur stdDeviation="5.5"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0.1 0"/>
|
||||||
|
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_782_4147"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dx="14" dy="14"/>
|
||||||
|
<feGaussianBlur stdDeviation="10"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0.09 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect1_dropShadow_782_4147" result="effect2_dropShadow_782_4147"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dx="32" dy="30"/>
|
||||||
|
<feGaussianBlur stdDeviation="13.5"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0.05 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect2_dropShadow_782_4147" result="effect3_dropShadow_782_4147"/>
|
||||||
|
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||||
|
<feOffset dx="58" dy="54"/>
|
||||||
|
<feGaussianBlur stdDeviation="16"/>
|
||||||
|
<feColorMatrix type="matrix" values="0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0 0.34902 0 0 0 0.01 0"/>
|
||||||
|
<feBlend mode="normal" in2="effect3_dropShadow_782_4147" result="effect4_dropShadow_782_4147"/>
|
||||||
|
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_782_4147" result="shape"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const bgIconsRaw = `<svg
|
||||||
|
class="icons-svg"
|
||||||
|
width="167"
|
||||||
|
height="174"
|
||||||
|
viewBox="0 0 167 174"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<g class="icons">
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M148.989 6.74753C145.221 5.58339 141.223 7.69404 140.059 11.4618C139.9 11.9777 139.802 12.4964 139.761 13.0105C139.706 13.704 139.099 14.2216 138.405 14.1667C135.915 13.9694 133.532 15.5142 132.764 17.9999C131.873 20.8811 133.487 23.9385 136.369 24.8287L150.816 29.2925C154.583 30.4566 158.581 28.3459 159.746 24.5782C160.91 20.8104 158.799 16.8123 155.031 15.6482C154.986 15.6341 154.94 15.6205 154.894 15.6073C154.309 15.439 153.928 14.8767 153.989 14.2711C154.319 10.9618 152.289 7.76701 148.989 6.74753ZM137.651 10.7178C139.226 5.62029 144.636 2.76471 149.733 4.33972C153.864 5.61605 156.521 9.40853 156.544 13.5133C161.155 15.3711 163.647 20.4868 162.153 25.3221C160.578 30.4197 155.169 33.2753 150.072 31.7003L135.625 27.2365C131.414 25.9354 129.055 21.467 130.356 17.256C131.356 14.0189 134.226 11.878 137.413 11.6499C137.476 11.3384 137.556 11.0274 137.651 10.7178Z" />
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M123.676 148.269L128.617 149.932C129.889 146.577 131.619 143.709 133.495 141.716C133.878 141.31 134.28 140.927 134.697 140.58C129.985 140.892 125.67 143.774 123.676 148.269ZM140.477 138.924C132.398 136.205 123.644 140.549 120.924 148.628C118.205 156.707 122.549 165.46 130.628 168.18C138.706 170.9 147.46 166.555 150.18 158.477C152.9 150.398 148.555 141.644 140.477 138.924ZM139.699 141.234C138.714 140.903 137.144 141.396 135.269 143.387C133.669 145.087 132.106 147.632 130.929 150.71L140.953 154.085C141.877 150.921 142.172 147.949 141.925 145.628C141.636 142.909 140.684 141.566 139.699 141.234ZM143.264 154.863C144.281 151.422 144.638 148.091 144.348 145.37C144.289 144.815 144.2 144.267 144.078 143.738C147.642 146.836 149.336 151.741 148.206 156.526L143.264 154.863ZM140.175 156.394L130.151 153.02C129.227 156.183 128.932 159.155 129.179 161.477C129.469 164.196 130.42 165.539 131.405 165.871C132.39 166.202 133.96 165.708 135.835 163.717C137.436 162.018 138.998 159.473 140.175 156.394ZM136.407 166.525C136.824 166.177 137.226 165.795 137.609 165.388C139.485 163.396 141.215 160.528 142.487 157.173L147.428 158.836C145.435 163.331 141.119 166.213 136.407 166.525ZM127.026 163.367C126.904 162.838 126.815 162.29 126.756 161.734C126.467 159.013 126.823 155.683 127.84 152.242L122.899 150.578C121.768 155.364 123.462 160.269 127.026 163.367Z" />
|
||||||
|
<g class="star">
|
||||||
|
<path fill="currentColor" d="M16.4781 27.0219C16.2651 26.4531 15.6593 26.1346 15.07 26.2816C14.4807 26.4286 14.0955 26.9944 14.1746 27.5965C14.6893 31.5122 14.222 34.4734 12.8463 36.769C11.4706 39.0645 9.07964 40.8729 5.38392 42.2654C4.81558 42.4796 4.49833 43.0861 4.64655 43.675C4.79477 44.264 5.36129 44.6481 5.9633 44.5677C9.81233 44.0538 12.8303 44.5064 15.1718 45.8808C17.5046 47.25 19.3365 49.6344 20.6191 53.3293C20.8222 53.9144 21.4388 54.2473 22.0395 54.0961C22.6401 53.9449 23.0256 53.3597 22.9275 52.7481C22.3179 48.9507 22.7939 45.9282 24.2039 43.5753C25.6139 41.2225 28.0549 39.3775 31.6911 38.1244C32.2767 37.9226 32.6109 37.3068 32.4611 36.7058C32.3112 36.1048 31.7269 35.718 31.1151 35.8148C27.252 36.426 24.2855 35.9348 21.9781 34.5233C19.662 33.1064 17.8399 30.6585 16.4781 27.0219Z" />
|
||||||
|
<path fill="currentColor" d="M27.5899 18.6428C27.5071 18.4216 27.2715 18.2978 27.0423 18.355C26.8132 18.4121 26.6634 18.6321 26.6941 18.8663C26.8943 20.3891 26.7125 21.5407 26.1776 22.4334C25.6426 23.3261 24.7128 24.0293 23.2755 24.5709C23.0545 24.6542 22.9311 24.89 22.9888 25.1191C23.0464 25.3481 23.2667 25.4975 23.5008 25.4662C24.9977 25.2664 26.1713 25.4424 27.0819 25.9769C27.9891 26.5093 28.7015 27.4366 29.2003 28.8735C29.2793 29.101 29.5191 29.2305 29.7527 29.1717C29.9863 29.1129 30.1362 28.8853 30.098 28.6475C29.861 27.1707 30.0461 25.9953 30.5944 25.0803C31.1427 24.1653 32.092 23.4478 33.5061 22.9605C33.7338 22.882 33.8638 22.6425 33.8055 22.4088C33.7472 22.1751 33.52 22.0247 33.2821 22.0623C31.7798 22.3 30.6261 22.109 29.7288 21.56C28.8281 21.009 28.1195 20.0571 27.5899 18.6428Z" />
|
||||||
|
</g>
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M39.763 123.789C37.1323 124.113 35.508 126.75 36.1349 129.679L38.9392 142.784C39.5661 145.713 42.2069 147.826 44.8375 147.502L47.2471 147.206L48.676 153.883C48.7894 154.413 49.1784 154.855 49.6616 155.004C50.1447 155.153 50.6269 154.979 50.8832 154.562L56.0842 146.119L62.7695 145.298C65.4002 144.974 67.0246 142.337 66.3977 139.408L63.5933 126.304C62.9664 123.374 60.3257 121.262 57.695 121.585L39.763 123.789ZM38.4884 129.39C38.1713 127.908 38.993 126.574 40.3238 126.41L58.2559 124.206C59.5867 124.042 60.9226 125.111 61.2397 126.593L64.0441 139.697C64.3612 141.179 63.5395 142.513 62.2087 142.677L55.0359 143.559C54.7238 143.597 54.454 143.772 54.2859 144.045L50.3526 150.43L49.3203 145.606C49.1654 144.882 48.513 144.36 47.863 144.44L44.2766 144.881C42.9458 145.045 41.6099 143.976 41.2928 142.494L38.4884 129.39ZM46.0992 137.8C46.9663 137.693 47.5013 136.822 47.2942 135.855C47.0872 134.888 46.2165 134.191 45.3494 134.298C44.4824 134.405 43.9474 135.275 44.1544 136.242C44.3615 137.21 45.2322 137.907 46.0992 137.8ZM52.9661 134.72C53.1732 135.687 52.6381 136.558 51.7711 136.665C50.9041 136.772 50.0334 136.075 49.8263 135.108C49.6193 134.141 50.1543 133.27 51.0213 133.163C51.8884 133.056 52.7591 133.753 52.9661 134.72ZM57.443 135.53C58.31 135.423 58.8451 134.552 58.638 133.585C58.431 132.618 57.5602 131.921 56.6932 132.028C55.8262 132.135 55.2912 133.006 55.4982 133.973C55.7053 134.94 56.576 135.637 57.443 135.53Z" />
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M93.966 64.4554L93.9641 64.4567L92.0333 65.8359L94.2622 66.0913C94.8579 66.1595 95.2854 66.6977 95.2172 67.2933C95.1489 67.889 94.6108 68.3165 94.0151 68.2483L88.9821 67.6717C88.3864 67.6035 87.9589 67.0653 88.0271 66.4697L88.5945 61.5165C88.6628 60.9209 89.2009 60.4933 89.7966 60.5616C90.3922 60.6298 90.8198 61.168 90.7515 61.7636L90.4621 64.2901L92.7097 62.6846C95.1632 60.9478 98.2435 60.0723 101.465 60.4414C108.415 61.2375 113.403 67.5162 112.607 74.4654C111.811 81.4146 105.532 86.4027 98.5826 85.6066C92.5931 84.9205 88.0626 80.1627 87.433 74.4134C87.3678 73.8175 87.798 73.2814 88.394 73.2162C88.9899 73.1509 89.526 73.5811 89.5912 74.1771C90.1129 78.9404 93.8694 82.8814 98.8297 83.4496C104.588 84.1092 109.79 79.9762 110.45 74.2183C111.109 68.4604 106.976 63.258 101.218 62.5984C98.5483 62.2926 96.0001 63.0161 93.966 64.4554Z" />
|
||||||
|
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="M100.889 65.4744C101.484 65.5427 101.912 66.0809 101.844 66.6765L101.154 72.7008L104.694 77.157C105.067 77.6264 104.989 78.3093 104.519 78.6822C104.05 79.0551 103.367 78.9769 102.994 78.5075L99.2113 73.7462C99.0083 73.4907 98.9152 73.1651 98.9523 72.8409L99.6868 66.4294C99.755 65.8338 100.293 65.4062 100.889 65.4744Z" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -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<HTMLDivElement> & {
|
||||||
|
type: 1 | 2;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={clsx(card, className)} data-type={type} {...attrs}>
|
||||||
|
<div
|
||||||
|
className={bg}
|
||||||
|
dangerouslySetInnerHTML={{ __html: `${bgAFFiNERaw}${bgIconsRaw}` }}
|
||||||
|
/>
|
||||||
|
<div className={content}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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'),
|
||||||
|
});
|
||||||
@@ -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<HTMLUListElement>) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className={clsx(benefits, className)} {...attrs}>
|
||||||
|
<li className={li}>
|
||||||
|
<AfFiNeIcon />
|
||||||
|
<span>{t['com.affine.payment.lifetime.benefit-1']()}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className={li}>
|
||||||
|
<DoneIcon />
|
||||||
|
<span>{t['com.affine.payment.lifetime.benefit-2']()}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className={li}>
|
||||||
|
<DoneIcon />
|
||||||
|
<span>
|
||||||
|
{t['com.affine.payment.lifetime.benefit-3']({
|
||||||
|
capacity: '1T',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li className={li}>
|
||||||
|
<DoneIcon />
|
||||||
|
<span>{t['com.affine.payment.lifetime.benefit-4']()}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<BelieverCard type={1}>
|
||||||
|
<div className={styles.caption1}>
|
||||||
|
{t['com.affine.payment.lifetime.caption-1']()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.title}>
|
||||||
|
{t['com.affine.payment.lifetime.title']()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.price}>{readableLifetimePrice}</div>
|
||||||
|
|
||||||
|
{isBeliever ? (
|
||||||
|
<Button className={styles.purchase} size="default" disabled>
|
||||||
|
{t['com.affine.payment.lifetime.purchased']()}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Upgrade
|
||||||
|
className={styles.purchase}
|
||||||
|
recurring={SubscriptionRecurring.Lifetime}
|
||||||
|
>
|
||||||
|
{t['com.affine.payment.lifetime.purchase']()}
|
||||||
|
</Upgrade>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.caption2}>
|
||||||
|
<Trans
|
||||||
|
i18nKey="com.affine.payment.lifetime.caption-2"
|
||||||
|
components={{
|
||||||
|
a: <a className={styles.usePolicyLink} href="#" />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BelieverBenefits style={{ padding: '8px 6px' }} />
|
||||||
|
</BelieverCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
@@ -8,9 +8,10 @@ import { SubscriptionPlan, SubscriptionStatus } from '@affine/graphql';
|
|||||||
import { Trans, useI18n } from '@affine/i18n';
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
import { DoneIcon } from '@blocksuite/icons/rc';
|
import { DoneIcon } from '@blocksuite/icons/rc';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import clsx from 'clsx';
|
||||||
import { useAtom, useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { HTMLAttributes, PropsWithChildren } from 'react';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { openPaymentDisableAtom } from '../../../../../atoms';
|
import { openPaymentDisableAtom } from '../../../../../atoms';
|
||||||
@@ -89,6 +90,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
|||||||
const loggedIn =
|
const loggedIn =
|
||||||
useLiveData(useService(AuthService).session.status$) === 'authenticated';
|
useLiveData(useService(AuthService).session.status$) === 'authenticated';
|
||||||
const subscriptionService = useService(SubscriptionService);
|
const subscriptionService = useService(SubscriptionService);
|
||||||
|
const isBeliever = useLiveData(subscriptionService.subscription.isBeliever$);
|
||||||
const primarySubscription = useLiveData(
|
const primarySubscription = useLiveData(
|
||||||
subscriptionService.subscription.pro$
|
subscriptionService.subscription.pro$
|
||||||
);
|
);
|
||||||
@@ -101,6 +103,7 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
|||||||
// if free => 'Sign up free'
|
// if free => 'Sign up free'
|
||||||
// else => 'Buy Pro'
|
// else => 'Buy Pro'
|
||||||
// else
|
// else
|
||||||
|
// if isBeliever => 'Included in Lifetime'
|
||||||
// if isCurrent
|
// if isCurrent
|
||||||
// if canceled => 'Resume'
|
// if canceled => 'Resume'
|
||||||
// else => 'Current Plan'
|
// else => 'Current Plan'
|
||||||
@@ -125,6 +128,15 @@ const ActionButton = ({ detail, recurring }: PlanCardProps) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lifetime
|
||||||
|
if (isBeliever) {
|
||||||
|
return (
|
||||||
|
<Button className={styles.planAction} disabled>
|
||||||
|
{t['com.affine.payment.cloud.lifetime.included']()}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const isCanceled = !!primarySubscription?.canceledAt;
|
const isCanceled = !!primarySubscription?.canceledAt;
|
||||||
const isFree = detail.plan === SubscriptionPlan.Free;
|
const isFree = detail.plan === SubscriptionPlan.Free;
|
||||||
const isCurrent =
|
const isCurrent =
|
||||||
@@ -229,13 +241,20 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button className={styles.planAction} type="primary">
|
<Button className={styles.planAction} type="primary">
|
||||||
{t['com.affine.payment.book-a-demo']()}
|
{t['com.affine.payment.tell-us-use-case']()}
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Upgrade = ({ recurring }: { recurring: SubscriptionRecurring }) => {
|
export const Upgrade = ({
|
||||||
|
className,
|
||||||
|
recurring,
|
||||||
|
children,
|
||||||
|
...attrs
|
||||||
|
}: HTMLAttributes<HTMLButtonElement> & {
|
||||||
|
recurring: SubscriptionRecurring;
|
||||||
|
}) => {
|
||||||
const [isMutating, setMutating] = useState(false);
|
const [isMutating, setMutating] = useState(false);
|
||||||
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
|
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -291,13 +310,14 @@ const Upgrade = ({ recurring }: { recurring: SubscriptionRecurring }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
className={styles.planAction}
|
className={clsx(styles.planAction, className)}
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={upgrade}
|
onClick={upgrade}
|
||||||
disabled={isMutating}
|
disabled={isMutating}
|
||||||
loading={isMutating}
|
loading={isMutating}
|
||||||
|
{...attrs}
|
||||||
>
|
>
|
||||||
{t['com.affine.payment.upgrade']()}
|
{children ?? t['com.affine.payment.upgrade']()}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Skeleton } from '@affine/component';
|
import { Skeleton } from '@affine/component';
|
||||||
|
|
||||||
|
import { AIPlanLayout } from './ai/layout';
|
||||||
import { CloudPlanLayout, PlanLayout } from './layout';
|
import { CloudPlanLayout, PlanLayout } from './layout';
|
||||||
import * as styles from './skeleton.css';
|
import * as styles from './skeleton.css';
|
||||||
|
|
||||||
@@ -48,6 +49,34 @@ const ScrollSkeleton = () => (
|
|||||||
export const PlansSkeleton = () => {
|
export const PlansSkeleton = () => {
|
||||||
return (
|
return (
|
||||||
<PlanLayout
|
<PlanLayout
|
||||||
|
ai={
|
||||||
|
<AIPlanLayout
|
||||||
|
caption={
|
||||||
|
<RoundedSkeleton
|
||||||
|
variant="rectangular"
|
||||||
|
radius={2}
|
||||||
|
width="200px"
|
||||||
|
height="20px"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
actionButtons={
|
||||||
|
<>
|
||||||
|
<RoundedSkeleton
|
||||||
|
variant="rectangular"
|
||||||
|
radius={20}
|
||||||
|
width="206px"
|
||||||
|
height="37px"
|
||||||
|
/>
|
||||||
|
<RoundedSkeleton
|
||||||
|
variant="rectangular"
|
||||||
|
radius={20}
|
||||||
|
width="193px"
|
||||||
|
height="37px"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
cloud={
|
cloud={
|
||||||
<CloudPlanLayout
|
<CloudPlanLayout
|
||||||
toggle={
|
toggle={
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ export const planTitleTitle = style({
|
|||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: cssVar('fontBase'),
|
fontSize: cssVar('fontBase'),
|
||||||
lineHeight: '20px',
|
lineHeight: '20px',
|
||||||
|
height: 20,
|
||||||
});
|
});
|
||||||
export const planTitleTitleCaption = style({
|
export const planTitleTitleCaption = style({
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
@@ -176,6 +177,7 @@ export const planPriceDesc = style({
|
|||||||
});
|
});
|
||||||
export const planAction = style({
|
export const planAction = style({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
fontWeight: 500,
|
||||||
});
|
});
|
||||||
export const planBenefits = style({
|
export const planBenefits = style({
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: cssVar('fontXs'),
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const UserInfo = ({ onAccountSettingClick, active }: UserInfoProps) => {
|
|||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
size={28}
|
size={28}
|
||||||
|
rounded={2}
|
||||||
name={account.label}
|
name={account.label}
|
||||||
url={account.avatar}
|
url={account.avatar}
|
||||||
className="avatar"
|
className="avatar"
|
||||||
@@ -262,7 +263,6 @@ const subTabConfigs = [
|
|||||||
title: keyof ReturnType<typeof useI18n>;
|
title: keyof ReturnType<typeof useI18n>;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
const avatarImageProps = { style: { borderRadius: 2 } };
|
|
||||||
const WorkspaceListItem = ({
|
const WorkspaceListItem = ({
|
||||||
activeSubTab,
|
activeSubTab,
|
||||||
meta,
|
meta,
|
||||||
@@ -326,9 +326,7 @@ const WorkspaceListItem = ({
|
|||||||
style={{
|
style={{
|
||||||
marginRight: '10px',
|
marginRight: '10px',
|
||||||
}}
|
}}
|
||||||
imageProps={avatarImageProps}
|
rounded={2}
|
||||||
fallbackProps={avatarImageProps}
|
|
||||||
hoverWrapperProps={avatarImageProps}
|
|
||||||
/>
|
/>
|
||||||
<span className="setting-name">{name}</span>
|
<span className="setting-name">{name}</span>
|
||||||
{isCurrent ? (
|
{isCurrent ? (
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ export const CloudWorkspaceMembersPanel = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
// page:
|
// page:
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
|
|
||||||
const avatarImageProps = { style: { borderRadius: 8 } };
|
|
||||||
|
|
||||||
export const ProfilePanel = () => {
|
export const ProfilePanel = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
|
|
||||||
@@ -146,9 +144,7 @@ export const ProfilePanel = () => {
|
|||||||
meta={workspace.meta}
|
meta={workspace.meta}
|
||||||
size={56}
|
size={56}
|
||||||
name={name}
|
name={name}
|
||||||
imageProps={avatarImageProps}
|
rounded={8}
|
||||||
fallbackProps={avatarImageProps}
|
|
||||||
hoverWrapperProps={avatarImageProps}
|
|
||||||
colorfulFallback
|
colorfulFallback
|
||||||
hoverIcon={isOwner ? <CameraIcon /> : undefined}
|
hoverIcon={isOwner ? <CameraIcon /> : undefined}
|
||||||
onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined}
|
onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined}
|
||||||
|
|||||||
@@ -405,7 +405,6 @@ Could you make a new website based on these notes and send back just the html fi
|
|||||||
getCurrentStore().set(openSettingModalAtom, {
|
getCurrentStore().set(openSettingModalAtom, {
|
||||||
activeTab: 'billing',
|
activeTab: 'billing',
|
||||||
open: true,
|
open: true,
|
||||||
scrollAnchor: 'aiPricingPlan',
|
|
||||||
});
|
});
|
||||||
mixpanel.track('PlansViewed', {
|
mixpanel.track('PlansViewed', {
|
||||||
segment: 'payment wall',
|
segment: 'payment wall',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { cssVar } from '@toeverything/theme';
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
export const userAccountContainer = style({
|
export const userAccountContainer = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
padding: '4px 0px 4px 12px',
|
padding: '4px 4px 4px 12px',
|
||||||
gap: '8px',
|
gap: '8px',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { notify, Tooltip } from '@affine/component';
|
import { notify, Tooltip } from '@affine/component';
|
||||||
import { type AvatarProps } from '@affine/component/ui/avatar';
|
|
||||||
import { Loading } from '@affine/component/ui/loading';
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
||||||
import { openSettingModalAtom } from '@affine/core/atoms';
|
import { openSettingModalAtom } from '@affine/core/atoms';
|
||||||
@@ -96,6 +95,7 @@ const useSyncEngineSyncProgress = () => {
|
|||||||
setSettingModalAtom({
|
setSettingModalAtom({
|
||||||
open: true,
|
open: true,
|
||||||
activeTab: 'plans',
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
});
|
});
|
||||||
}, [setSettingModalAtom]);
|
}, [setSettingModalAtom]);
|
||||||
|
|
||||||
@@ -276,9 +276,6 @@ const WorkspaceInfo = ({ name }: { name: string }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const avatarImageProps = {
|
|
||||||
style: { borderRadius: 3 },
|
|
||||||
} satisfies AvatarProps['imageProps'];
|
|
||||||
export const WorkspaceCard = forwardRef<
|
export const WorkspaceCard = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
HTMLAttributes<HTMLDivElement>
|
HTMLAttributes<HTMLDivElement>
|
||||||
@@ -302,8 +299,7 @@ export const WorkspaceCard = forwardRef<
|
|||||||
<WorkspaceAvatar
|
<WorkspaceAvatar
|
||||||
key={currentWorkspace.id}
|
key={currentWorkspace.id}
|
||||||
meta={currentWorkspace.meta}
|
meta={currentWorkspace.meta}
|
||||||
imageProps={avatarImageProps}
|
rounded={3}
|
||||||
fallbackProps={avatarImageProps}
|
|
||||||
data-testid="workspace-avatar"
|
data-testid="workspace-avatar"
|
||||||
size={32}
|
size={32}
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ export class SubscriptionPrices extends Entity {
|
|||||||
prices ? prices.find(price => price.plan === 'AI') : null
|
prices ? prices.find(price => price.plan === 'AI') : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
readableLifetimePrice$ = this.proPrice$.map(price =>
|
||||||
|
price?.lifetimeAmount
|
||||||
|
? `$${(price.lifetimeAmount / 100).toFixed(2).replace(/\.0+$/, '')}`
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly serverConfigService: ServerConfigService,
|
private readonly serverConfigService: ServerConfigService,
|
||||||
private readonly store: SubscriptionStore
|
private readonly store: SubscriptionStore
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
|
import { type SubscriptionQuery, SubscriptionRecurring } from '@affine/graphql';
|
||||||
import { SubscriptionPlan } from '@affine/graphql';
|
import { SubscriptionPlan } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
backoffRetry,
|
backoffRetry,
|
||||||
@@ -38,6 +38,9 @@ export class Subscription extends Entity {
|
|||||||
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
? subscriptions.find(sub => sub.plan === SubscriptionPlan.AI)
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
isBeliever$ = this.pro$.map(
|
||||||
|
sub => sub?.recurring === SubscriptionRecurring.Lifetime
|
||||||
|
);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
|
|||||||
@@ -668,6 +668,7 @@ query prices {
|
|||||||
currency
|
currency
|
||||||
amount
|
amount
|
||||||
yearlyAmount
|
yearlyAmount
|
||||||
|
lifetimeAmount
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,5 +5,6 @@ query prices {
|
|||||||
currency
|
currency
|
||||||
amount
|
amount
|
||||||
yearlyAmount
|
yearlyAmount
|
||||||
|
lifetimeAmount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1765,6 +1765,7 @@ export type PricesQuery = {
|
|||||||
currency: string;
|
currency: string;
|
||||||
amount: number | null;
|
amount: number | null;
|
||||||
yearlyAmount: number | null;
|
yearlyAmount: number | null;
|
||||||
|
lifetimeAmount: number | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,8 @@
|
|||||||
"com.affine.ai-scroll-tip.tag": "New",
|
"com.affine.ai-scroll-tip.tag": "New",
|
||||||
"com.affine.ai-scroll-tip.title": "Meet AFFiNE AI",
|
"com.affine.ai-scroll-tip.title": "Meet AFFiNE AI",
|
||||||
"com.affine.ai-scroll-tip.view": "View",
|
"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.action.edgeless-only.dialog-title": "Please switch to edgeless mode",
|
||||||
"com.affine.ai.login-required.dialog-cancel": "Cancel",
|
"com.affine.ai.login-required.dialog-cancel": "Cancel",
|
||||||
"com.affine.ai.login-required.dialog-confirm": "Sign in",
|
"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.benefit.g3-3": "Open source & Privacy ensured",
|
||||||
"com.affine.payment.ai.billing-tip.end-at": "You have purchased AFFiNE AI. The expiration date is {{end}}.",
|
"com.affine.payment.ai.billing-tip.end-at": "You have purchased AFFiNE AI. The expiration date is {{end}}.",
|
||||||
"com.affine.payment.ai.billing-tip.next-bill-at": "You have purchased AFFiNE AI. The next payment date is {{due}}.",
|
"com.affine.payment.ai.billing-tip.next-bill-at": "You have purchased AFFiNE AI. The next payment date is {{due}}.",
|
||||||
"com.affine.payment.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.caption-purchased": "You have purchased AFFiNE AI",
|
||||||
"com.affine.payment.ai.pricing-plan.learn": "Learn About AFFiNE AI",
|
"com.affine.payment.ai.pricing-plan.learn": "Learn About AFFiNE AI",
|
||||||
"com.affine.payment.ai.pricing-plan.title": "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.purchase-button-label": "Purchase",
|
||||||
"com.affine.payment.ai.usage.used-caption": "Times used",
|
"com.affine.payment.ai.usage.used-caption": "Times used",
|
||||||
"com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} Times",
|
"com.affine.payment.ai.usage.used-detail": "{{used}}/{{limit}} Times",
|
||||||
|
"com.affine.payment.ai.subscribe.billed-annually": "Billed annually",
|
||||||
"com.affine.payment.benefit-1": "Unlimited local workspaces",
|
"com.affine.payment.benefit-1": "Unlimited local workspaces",
|
||||||
"com.affine.payment.benefit-2": "Unlimited login devices",
|
"com.affine.payment.benefit-2": "Unlimited login devices",
|
||||||
"com.affine.payment.benefit-3": "Unlimited blocks",
|
"com.affine.payment.benefit-3": "Unlimited blocks",
|
||||||
@@ -960,12 +963,16 @@
|
|||||||
"com.affine.payment.billing-setting.upgrade": "Upgrade",
|
"com.affine.payment.billing-setting.upgrade": "Upgrade",
|
||||||
"com.affine.payment.billing-setting.view-invoice": "View Invoice",
|
"com.affine.payment.billing-setting.view-invoice": "View Invoice",
|
||||||
"com.affine.payment.billing-setting.year": "year",
|
"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 <a>Believer Plan</a>. 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.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.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.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.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.blob-limit.title": "You have reached the limit",
|
||||||
"com.affine.payment.book-a-demo": "Book a Demo",
|
"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.buy-pro": "Buy Pro",
|
||||||
"com.affine.payment.change-to": "Change to {{to}} Billing",
|
"com.affine.payment.change-to": "Change to {{to}} Billing",
|
||||||
"com.affine.payment.cloud.free.benefit.g1": "Include in FOSS",
|
"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.benefit.g2-3": "Embed-able & Integrations with IT support.",
|
||||||
"com.affine.payment.cloud.team.description": "Best for scalable teams.",
|
"com.affine.payment.cloud.team.description": "Best for scalable teams.",
|
||||||
"com.affine.payment.cloud.team.name": "Team / Enterprise",
|
"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. <a>Fair Use Policies</a> 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.contact-sales": "Contact Sales",
|
||||||
"com.affine.payment.current-plan": "Current Plan",
|
"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.",
|
"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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user