feat(core): add ai pricing tip for plans page (#6704)
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const settingModalScrollContainerAtom = atom<HTMLElement | null>(null);
|
||||||
@@ -190,6 +190,7 @@ const Settings = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PlanLayout
|
<PlanLayout
|
||||||
|
aiTip
|
||||||
cloud={
|
cloud={
|
||||||
<CloudPlanLayout
|
<CloudPlanLayout
|
||||||
caption={cloudCaption}
|
caption={cloudCaption}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
export const plansLayoutRoot = style({
|
export const plansLayoutRoot = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
@@ -70,3 +70,80 @@ export const affineCloudHeader = style({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
});
|
});
|
||||||
|
export const aiDivider = style({
|
||||||
|
opacity: 0,
|
||||||
|
selectors: {
|
||||||
|
'[data-ai-visible] &': {
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const slideInBottom = keyframes({
|
||||||
|
from: {
|
||||||
|
marginBottom: -100,
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const aiScrollTip = style({
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 1,
|
||||||
|
bottom: 12,
|
||||||
|
width: 'var(--setting-modal-content-width)',
|
||||||
|
background: cssVar('white'),
|
||||||
|
borderRadius: 8,
|
||||||
|
border: `1px solid ${cssVar('borderColor')}`,
|
||||||
|
transition: 'transform 0.36s ease 0.4s, opacity 0.3s ease 0.46s',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '12px 20px 12px 16px',
|
||||||
|
boxShadow: cssVar('shadow1'),
|
||||||
|
display: 'flex',
|
||||||
|
marginBottom: -100,
|
||||||
|
|
||||||
|
animation: `${slideInBottom} 0.3s ease 0.5s forwards`,
|
||||||
|
|
||||||
|
selectors: {
|
||||||
|
'[data-ai-visible] &': {
|
||||||
|
transform: 'translateY(100px)',
|
||||||
|
opacity: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const aiScrollTipLabel = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
export const aiScrollTipText = style({
|
||||||
|
padding: '0px 10px 0px 8px',
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: '22px',
|
||||||
|
color: cssVar('textPrimaryColor'),
|
||||||
|
});
|
||||||
|
export const aiScrollTipTag = style({
|
||||||
|
background: 'linear-gradient(180deg, #41B0FF 0%, #0873BE 100%)',
|
||||||
|
borderRadius: 3,
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 10,
|
||||||
|
lineHeight: '12px',
|
||||||
|
letterSpacing: '-1%',
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
boxShadow:
|
||||||
|
'0px 0px 1px 0px #45474926, 1px 2px 2px 0px #45474921, 2px 4px 3px 0px #45474914, 4px 6px 3px 0px #45474905, 6px 10px 3px 0px #45474900',
|
||||||
|
padding: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const aiScrollTipTagInner = style({
|
||||||
|
borderRadius: 2,
|
||||||
|
padding: '2px 3px',
|
||||||
|
fontWeight: 'inherit',
|
||||||
|
fontSize: 'inherit',
|
||||||
|
lineHeight: 'inherit',
|
||||||
|
content: 'var(--content, "")',
|
||||||
|
letterSpacing: 'inherit',
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, #56B9FF 0%, #23A4FF 37.88%, #1E96EB 75%)',
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { 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 { openSettingModalAtom } from '@affine/core/atoms';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ArrowRightBigIcon, ArrowUpSmallIcon } from '@blocksuite/icons';
|
import {
|
||||||
|
ArrowDownBigIcon,
|
||||||
|
ArrowRightBigIcon,
|
||||||
|
ArrowUpSmallIcon,
|
||||||
|
} from '@blocksuite/icons';
|
||||||
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 { useAtom } from 'jotai';
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import {
|
import {
|
||||||
type HtmlHTMLAttributes,
|
type HtmlHTMLAttributes,
|
||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useLayoutEffect,
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
|
import { settingModalScrollContainerAtom } from '../../atoms';
|
||||||
import * as styles from './layout.css';
|
import * as styles from './layout.css';
|
||||||
|
|
||||||
export const SeeAllLink = () => {
|
export const SeeAllLink = () => {
|
||||||
@@ -70,12 +78,32 @@ const PricingCollapsible = ({
|
|||||||
export interface PlanLayoutProps {
|
export interface PlanLayoutProps {
|
||||||
cloud?: ReactNode;
|
cloud?: ReactNode;
|
||||||
ai?: ReactNode;
|
ai?: ReactNode;
|
||||||
|
aiTip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
export const PlanLayout = ({ cloud, ai, aiTip }: PlanLayoutProps) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
||||||
const aiPricingPlanRef = useRef<HTMLDivElement>(null);
|
const aiPricingPlanRef = useRef<HTMLDivElement>(null);
|
||||||
|
const aiScrollTipRef = useRef<HTMLDivElement>(null);
|
||||||
|
const settingModalScrollContainer = useAtomValue(
|
||||||
|
settingModalScrollContainerAtom
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateAiTipState = useCallback(() => {
|
||||||
|
if (!aiTip) return;
|
||||||
|
const aiContainer = aiPricingPlanRef.current;
|
||||||
|
if (!settingModalScrollContainer || !aiContainer) return;
|
||||||
|
|
||||||
|
const minVisibleHeight = 30;
|
||||||
|
|
||||||
|
const containerRect = settingModalScrollContainer.getBoundingClientRect();
|
||||||
|
const aiTop = aiContainer.getBoundingClientRect().top - containerRect.top;
|
||||||
|
const aiIntoView = aiTop < containerRect.height - minVisibleHeight;
|
||||||
|
if (aiIntoView) {
|
||||||
|
settingModalScrollContainer.dataset.aiVisible = '';
|
||||||
|
}
|
||||||
|
}, [aiTip, settingModalScrollContainer]);
|
||||||
|
|
||||||
// TODO: Need a better solution to handle this situation
|
// TODO: Need a better solution to handle this situation
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -88,6 +116,23 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
|||||||
});
|
});
|
||||||
}, [scrollAnchor, setOpenSettingModal]);
|
}, [scrollAnchor, setOpenSettingModal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settingModalScrollContainer || !aiScrollTipRef.current) return;
|
||||||
|
|
||||||
|
settingModalScrollContainer.addEventListener('scroll', updateAiTipState);
|
||||||
|
updateAiTipState();
|
||||||
|
return () => {
|
||||||
|
settingModalScrollContainer.removeEventListener(
|
||||||
|
'scroll',
|
||||||
|
updateAiTipState
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}, [settingModalScrollContainer, updateAiTipState]);
|
||||||
|
|
||||||
|
const scrollAiIntoView = useCallback(() => {
|
||||||
|
aiPricingPlanRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.plansLayoutRoot}>
|
<div className={styles.plansLayoutRoot}>
|
||||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||||
@@ -98,12 +143,35 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
|||||||
{cloud}
|
{cloud}
|
||||||
{ai ? (
|
{ai ? (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider className={styles.aiDivider} />
|
||||||
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
||||||
{ai}
|
{ai}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{aiTip && settingModalScrollContainer
|
||||||
|
? createPortal(
|
||||||
|
<div className={styles.aiScrollTip} ref={aiScrollTipRef}>
|
||||||
|
<div className={styles.aiScrollTipLabel}>
|
||||||
|
<ArrowDownBigIcon
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
color={cssVar('iconColor')}
|
||||||
|
/>
|
||||||
|
<div className={styles.aiScrollTipText}>Meet AFFiNE AI</div>
|
||||||
|
<div className={styles.aiScrollTipTag}>
|
||||||
|
<div className={styles.aiScrollTipTagInner}>NEW</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={scrollAiIntoView} type="primary">
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</div>,
|
||||||
|
settingModalScrollContainer,
|
||||||
|
'aiScrollTip'
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,9 +16,16 @@ import {
|
|||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
import {
|
||||||
|
Suspense,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
import { AccountSetting } from './account-setting';
|
import { AccountSetting } from './account-setting';
|
||||||
|
import { settingModalScrollContainerAtom } from './atoms';
|
||||||
import { GeneralSetting } from './general-setting';
|
import { GeneralSetting } from './general-setting';
|
||||||
import { SettingSidebar } from './setting-sidebar';
|
import { SettingSidebar } from './setting-sidebar';
|
||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
@@ -56,6 +63,9 @@ const SettingModalInner = ({
|
|||||||
|
|
||||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||||
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
|
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const setSettingModalScrollContainer = useSetAtom(
|
||||||
|
settingModalScrollContainerAtom
|
||||||
|
);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!modalProps.open) return;
|
if (!modalProps.open) return;
|
||||||
@@ -66,13 +76,24 @@ const SettingModalInner = ({
|
|||||||
if (!modalContentRef.current || !modalContentWrapperRef.current) return;
|
if (!modalContentRef.current || !modalContentWrapperRef.current) return;
|
||||||
|
|
||||||
const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
|
const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
|
||||||
|
const wrapperHeight = modalContentWrapperRef.current.offsetHeight;
|
||||||
const contentWidth = modalContentRef.current.offsetWidth;
|
const contentWidth = modalContentRef.current.offsetWidth;
|
||||||
|
|
||||||
modalContentRef.current?.style.setProperty(
|
const wrapper = modalContentWrapperRef.current;
|
||||||
|
|
||||||
|
wrapper?.style.setProperty(
|
||||||
'--setting-modal-width',
|
'--setting-modal-width',
|
||||||
`${wrapperWidth}px`
|
`${wrapperWidth}px`
|
||||||
);
|
);
|
||||||
modalContentRef.current?.style.setProperty(
|
wrapper?.style.setProperty(
|
||||||
|
'--setting-modal-height',
|
||||||
|
`${wrapperHeight}px`
|
||||||
|
);
|
||||||
|
wrapper?.style.setProperty(
|
||||||
|
'--setting-modal-content-width',
|
||||||
|
`${contentWidth}px`
|
||||||
|
);
|
||||||
|
wrapper?.style.setProperty(
|
||||||
'--setting-modal-gap-x',
|
'--setting-modal-gap-x',
|
||||||
`${(wrapperWidth - contentWidth) / 2}px`
|
`${(wrapperWidth - contentWidth) / 2}px`
|
||||||
);
|
);
|
||||||
@@ -87,6 +108,13 @@ const SettingModalInner = ({
|
|||||||
};
|
};
|
||||||
}, [modalProps.open]);
|
}, [modalProps.open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSettingModalScrollContainer(modalContentWrapperRef.current);
|
||||||
|
return () => {
|
||||||
|
setSettingModalScrollContainer(null);
|
||||||
|
};
|
||||||
|
}, [setSettingModalScrollContainer]);
|
||||||
|
|
||||||
const onTabChange = useCallback(
|
const onTabChange = useCallback(
|
||||||
(key: ActiveTab, meta: WorkspaceMetadata | null) => {
|
(key: ActiveTab, meta: WorkspaceMetadata | null) => {
|
||||||
onSettingClick({ activeTab: key, workspaceMetadata: meta });
|
onSettingClick({ activeTab: key, workspaceMetadata: meta });
|
||||||
|
|||||||
Reference in New Issue
Block a user