mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
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 (
|
||||
<PlanLayout
|
||||
aiTip
|
||||
cloud={
|
||||
<CloudPlanLayout
|
||||
caption={cloudCaption}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
export const plansLayoutRoot = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -70,3 +70,80 @@ export const affineCloudHeader = style({
|
||||
alignItems: 'center',
|
||||
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 { openSettingModalAtom } from '@affine/core/atoms';
|
||||
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 ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import { useAtom } from 'jotai';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import {
|
||||
type HtmlHTMLAttributes,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { settingModalScrollContainerAtom } from '../../atoms';
|
||||
import * as styles from './layout.css';
|
||||
|
||||
export const SeeAllLink = () => {
|
||||
@@ -70,12 +78,32 @@ const PricingCollapsible = ({
|
||||
export interface PlanLayoutProps {
|
||||
cloud?: ReactNode;
|
||||
ai?: ReactNode;
|
||||
aiTip?: boolean;
|
||||
}
|
||||
|
||||
export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||
export const PlanLayout = ({ cloud, ai, aiTip }: PlanLayoutProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [{ scrollAnchor }, setOpenSettingModal] = useAtom(openSettingModalAtom);
|
||||
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
|
||||
useLayoutEffect(() => {
|
||||
@@ -88,6 +116,23 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||
});
|
||||
}, [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 (
|
||||
<div className={styles.plansLayoutRoot}>
|
||||
{/* TODO: SettingHeader component shouldn't have margin itself */}
|
||||
@@ -98,12 +143,35 @@ export const PlanLayout = ({ cloud, ai }: PlanLayoutProps) => {
|
||||
{cloud}
|
||||
{ai ? (
|
||||
<>
|
||||
<Divider />
|
||||
<Divider className={styles.aiDivider} />
|
||||
<div ref={aiPricingPlanRef} id="aiPricingPlan">
|
||||
{ai}
|
||||
</div>
|
||||
</>
|
||||
) : 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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,16 @@ import {
|
||||
} from '@toeverything/infra';
|
||||
import { useSetAtom } from 'jotai';
|
||||
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 { settingModalScrollContainerAtom } from './atoms';
|
||||
import { GeneralSetting } from './general-setting';
|
||||
import { SettingSidebar } from './setting-sidebar';
|
||||
import * as style from './style.css';
|
||||
@@ -56,6 +63,9 @@ const SettingModalInner = ({
|
||||
|
||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const setSettingModalScrollContainer = useSetAtom(
|
||||
settingModalScrollContainerAtom
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!modalProps.open) return;
|
||||
@@ -66,13 +76,24 @@ const SettingModalInner = ({
|
||||
if (!modalContentRef.current || !modalContentWrapperRef.current) return;
|
||||
|
||||
const wrapperWidth = modalContentWrapperRef.current.offsetWidth;
|
||||
const wrapperHeight = modalContentWrapperRef.current.offsetHeight;
|
||||
const contentWidth = modalContentRef.current.offsetWidth;
|
||||
|
||||
modalContentRef.current?.style.setProperty(
|
||||
const wrapper = modalContentWrapperRef.current;
|
||||
|
||||
wrapper?.style.setProperty(
|
||||
'--setting-modal-width',
|
||||
`${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',
|
||||
`${(wrapperWidth - contentWidth) / 2}px`
|
||||
);
|
||||
@@ -87,6 +108,13 @@ const SettingModalInner = ({
|
||||
};
|
||||
}, [modalProps.open]);
|
||||
|
||||
useEffect(() => {
|
||||
setSettingModalScrollContainer(modalContentWrapperRef.current);
|
||||
return () => {
|
||||
setSettingModalScrollContainer(null);
|
||||
};
|
||||
}, [setSettingModalScrollContainer]);
|
||||
|
||||
const onTabChange = useCallback(
|
||||
(key: ActiveTab, meta: WorkspaceMetadata | null) => {
|
||||
onSettingClick({ activeTab: key, workspaceMetadata: meta });
|
||||
|
||||
Reference in New Issue
Block a user