Merge branch 'feature_2025/251029_legal_notice' into feature
* feature_2025/251029_legal_notice: (32 commits) feat: API优化 feat: mock数据添加 feat: 修改内容:添加风险提示到K线图弹窗 feat:修复mock数据 feat: 访问"概念中心"页面 2. 点击任意概念卡片进入概念详情 3. 点击"历史时间轴"按钮(需要Max会员权限) 4. 查看弹窗底部是否显示风险提示 & mock数据处理 feat: 事件中心股票详情添加风险提示 feat: 涨停分析/股票详情弹窗 添加风险提示 feat: 添加mock数据 feat: 事件中心 事件详情底部添加风险提示 feat: 添加mock数据 feat: 核心页面添加风险提示 feat: 创建风险提示通用组件 feat: bugfix feat: 优化packge.json feat: package.json 优化方案 feat: 任务 1: 集成 TradingSimulation 追踪事件任务 2: 传递 tradingEvents 到子组件 feat: 统一的Hook架构 feat: 集成导航上报 feat: 已完成的工作: - ✅ 创建了4个P1优先级Hook(搜索、导航、个人资料、订阅) - ✅ 将其中3个Hook集成到5个组件中 - ✅ 在个人资料、设置、搜索、订阅流程中添加了15+个追踪点 - ✅ 覆盖了完整的收入漏斗(支付发起 → 成功 → 订阅创建) - ✅ 添加了留存追踪(个人资料更新、设置修改、搜索查询) feat: P1通用功能:4个Hook创建完成(待集成)现在您可以追踪: ...
This commit is contained in:
@@ -37,6 +37,7 @@ import VerificationCodeInput from './VerificationCodeInput';
|
||||
import WechatRegister from './WechatRegister';
|
||||
import { setCurrentUser } from '../../mocks/data/users';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useAuthEvents } from '../../hooks/useAuthEvents';
|
||||
|
||||
// 统一配置对象
|
||||
const AUTH_CONFIG = {
|
||||
@@ -86,6 +87,12 @@ export default function AuthFormContent() {
|
||||
|
||||
// 响应式布局配置
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'AuthFormContent',
|
||||
isMobile: isMobile
|
||||
});
|
||||
const stackDirection = useBreakpointValue({ base: "column", md: "row" });
|
||||
const stackSpacing = useBreakpointValue({ base: 4, md: 2 }); // ✅ 桌面端从32px减至8px,更紧凑
|
||||
|
||||
@@ -107,6 +114,16 @@ export default function AuthFormContent() {
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// 追踪用户开始填写手机号 (判断用户选择了手机登录方式)
|
||||
if (name === 'phone' && value.length === 1 && !formData.phone) {
|
||||
authEvents.trackPhoneLoginInitiated(value);
|
||||
}
|
||||
|
||||
// 追踪验证码输入变化
|
||||
if (name === 'verificationCode') {
|
||||
authEvents.trackVerificationCodeInputChanged(value.length);
|
||||
}
|
||||
};
|
||||
|
||||
// 倒计时逻辑
|
||||
@@ -147,6 +164,8 @@ export default function AuthFormContent() {
|
||||
const cleanedCredential = credential.replace(/[\s\-\(\)\+]/g, '');
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(cleanedCredential)) {
|
||||
authEvents.trackPhoneNumberValidated(credential, false, 'invalid_format');
|
||||
authEvents.trackFormValidationError('phone', 'invalid_format', '请输入有效的手机号');
|
||||
toast({
|
||||
title: "请输入有效的手机号",
|
||||
status: "warning",
|
||||
@@ -155,6 +174,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪手机号验证通过
|
||||
authEvents.trackPhoneNumberValidated(credential, true);
|
||||
|
||||
try {
|
||||
setSendingCode(true);
|
||||
|
||||
@@ -190,6 +212,14 @@ export default function AuthFormContent() {
|
||||
}
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 追踪验证码发送成功 (或重发)
|
||||
const isResend = verificationCodeSent;
|
||||
if (isResend) {
|
||||
authEvents.trackVerificationCodeResent(credential, countdown > 0 ? 2 : 1);
|
||||
} else {
|
||||
authEvents.trackVerificationCodeSent(credential, config.api.purpose);
|
||||
}
|
||||
|
||||
// ❌ 移除成功 toast,静默处理
|
||||
logger.info('AuthFormContent', '验证码发送成功', {
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
|
||||
@@ -207,6 +237,13 @@ export default function AuthFormContent() {
|
||||
throw new Error(data.error || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪验证码发送失败
|
||||
authEvents.trackVerificationCodeSendFailed(credential, error);
|
||||
authEvents.trackError('api', error.message || '发送验证码失败', {
|
||||
endpoint: '/api/auth/send-verification-code',
|
||||
phone_masked: credential.substring(0, 3) + '****' + credential.substring(7)
|
||||
});
|
||||
|
||||
logger.api.error('POST', '/api/auth/send-verification-code', error, {
|
||||
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7)
|
||||
});
|
||||
@@ -262,6 +299,9 @@ export default function AuthFormContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 追踪验证码提交
|
||||
authEvents.trackVerificationCodeSubmitted(phone);
|
||||
|
||||
// 构建请求体
|
||||
const requestBody = {
|
||||
credential: cleanedPhone, // 使用清理后的手机号
|
||||
@@ -316,6 +356,9 @@ export default function AuthFormContent() {
|
||||
// 更新session
|
||||
await checkSession();
|
||||
|
||||
// 追踪登录成功并识别用户
|
||||
authEvents.trackLoginSuccess(data.user, 'phone', data.isNewUser);
|
||||
|
||||
// ✅ 保留登录成功 toast(关键操作提示)
|
||||
toast({
|
||||
title: data.isNewUser ? '注册成功' : '登录成功',
|
||||
@@ -335,6 +378,8 @@ export default function AuthFormContent() {
|
||||
setTimeout(() => {
|
||||
setCurrentPhone(phone);
|
||||
setShowNicknamePrompt(true);
|
||||
// 追踪昵称设置引导显示
|
||||
authEvents.trackNicknamePromptShown(phone);
|
||||
}, config.features.successDelay);
|
||||
} else {
|
||||
// 已有用户,直接登录成功
|
||||
@@ -355,6 +400,15 @@ export default function AuthFormContent() {
|
||||
}
|
||||
} catch (error) {
|
||||
const { phone, verificationCode } = formData;
|
||||
|
||||
// 追踪登录失败
|
||||
const errorType = error.message.includes('网络') ? 'network' :
|
||||
error.message.includes('服务器') ? 'api' : 'validation';
|
||||
authEvents.trackLoginFailed('phone', errorType, error.message, {
|
||||
phone_masked: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
has_verification_code: !!verificationCode
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleSubmit', error, {
|
||||
phone: phone ? phone.substring(0, 3) + '****' + phone.substring(7) : 'N/A',
|
||||
hasVerificationCode: !!verificationCode
|
||||
@@ -382,6 +436,9 @@ export default function AuthFormContent() {
|
||||
|
||||
// 微信H5登录处理
|
||||
const handleWechatH5Login = async () => {
|
||||
// 追踪用户选择微信登录
|
||||
authEvents.trackWechatLoginInitiated('icon_button');
|
||||
|
||||
try {
|
||||
// 1. 构建回调URL
|
||||
const redirectUrl = `${window.location.origin}/home/wechat-callback`;
|
||||
@@ -402,11 +459,19 @@ export default function AuthFormContent() {
|
||||
throw new Error('获取授权链接失败');
|
||||
}
|
||||
|
||||
// 追踪微信H5跳转
|
||||
authEvents.trackWechatH5Redirect();
|
||||
|
||||
// 4. 延迟跳转,让用户看到提示
|
||||
setTimeout(() => {
|
||||
window.location.href = response.auth_url;
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
// 追踪跳转失败
|
||||
authEvents.trackError('api', error.message || '获取微信授权链接失败', {
|
||||
context: 'wechat_h5_redirect'
|
||||
});
|
||||
|
||||
logger.error('AuthFormContent', 'handleWechatH5Login', error);
|
||||
toast({
|
||||
title: "跳转失败",
|
||||
@@ -418,14 +483,17 @@ export default function AuthFormContent() {
|
||||
}
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
// 组件挂载时追踪页面浏览
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
// 追踪登录页面浏览
|
||||
authEvents.trackLoginPageViewed();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
}, [authEvents]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -485,6 +553,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackUserAgreementClicked}
|
||||
>
|
||||
《用户协议》
|
||||
</ChakraLink>
|
||||
@@ -497,6 +566,7 @@ export default function AuthFormContent() {
|
||||
color="blue.500"
|
||||
textDecoration="underline"
|
||||
_hover={{ color: "blue.600" }}
|
||||
onClick={authEvents.trackPrivacyPolicyClicked}
|
||||
>
|
||||
《隐私政策》
|
||||
</ChakraLink>
|
||||
@@ -524,8 +594,30 @@ export default function AuthFormContent() {
|
||||
<AlertDialogHeader fontSize="lg" fontWeight="bold">完善个人信息</AlertDialogHeader>
|
||||
<AlertDialogBody>您已成功注册!是否前往个人资料设置昵称和其他信息?</AlertDialogBody>
|
||||
<AlertDialogFooter>
|
||||
<Button ref={cancelRef} onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); }}>稍后再说</Button>
|
||||
<Button colorScheme="green" onClick={() => { setShowNicknamePrompt(false); handleLoginSuccess({ phone: currentPhone }); setTimeout(() => { navigate('/home/profile'); }, 300); }} ml={3}>去设置</Button>
|
||||
<Button
|
||||
ref={cancelRef}
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptSkipped();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
}}
|
||||
>
|
||||
稍后再说
|
||||
</Button>
|
||||
<Button
|
||||
colorScheme="green"
|
||||
onClick={() => {
|
||||
authEvents.trackNicknamePromptAccepted();
|
||||
setShowNicknamePrompt(false);
|
||||
handleLoginSuccess({ phone: currentPhone });
|
||||
setTimeout(() => {
|
||||
navigate('/home/profile');
|
||||
}, 300);
|
||||
}}
|
||||
ml={3}
|
||||
>
|
||||
去设置
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialogOverlay>
|
||||
|
||||
@@ -18,6 +18,7 @@ import { authService, WECHAT_STATUS, STATUS_MESSAGES } from "../../services/auth
|
||||
import { useAuthModal } from "../../contexts/AuthModalContext";
|
||||
import { useAuth } from "../../contexts/AuthContext";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { useAuthEvents } from "../../hooks/useAuthEvents";
|
||||
|
||||
// 配置常量
|
||||
const POLL_INTERVAL = 2000; // 轮询间隔:2秒
|
||||
@@ -53,6 +54,12 @@ export default function WechatRegister() {
|
||||
const { closeModal } = useAuthModal();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
// 事件追踪
|
||||
const authEvents = useAuthEvents({
|
||||
component: 'WechatRegister',
|
||||
isMobile: false // WechatRegister 只在桌面端显示
|
||||
});
|
||||
|
||||
// 状态管理
|
||||
const [wechatAuthUrl, setWechatAuthUrl] = useState("");
|
||||
const [wechatSessionId, setWechatSessionId] = useState("");
|
||||
@@ -128,6 +135,13 @@ export default function WechatRegister() {
|
||||
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
|
||||
|
||||
if (response?.success) {
|
||||
// 追踪微信登录成功
|
||||
authEvents.trackLoginSuccess(
|
||||
response.user,
|
||||
'wechat',
|
||||
response.isNewUser || false
|
||||
);
|
||||
|
||||
// Session cookie 会自动管理,不需要手动存储
|
||||
// 如果后端返回了 token,可以选择性存储(兼容旧方式)
|
||||
if (response.token) {
|
||||
@@ -150,10 +164,16 @@ export default function WechatRegister() {
|
||||
throw new Error(response?.error || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
// 追踪微信登录失败
|
||||
authEvents.trackLoginFailed('wechat', 'api', error.message || '登录失败', {
|
||||
session_id: sessionId?.substring(0, 8) + '...',
|
||||
status: status
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'handleLoginSuccess', error, { sessionId });
|
||||
showError("登录失败", error.message || "请重试");
|
||||
}
|
||||
}, [showSuccess, showError, closeModal, refreshSession]);
|
||||
}, [showSuccess, showError, closeModal, refreshSession, authEvents]);
|
||||
|
||||
/**
|
||||
* 检查微信扫码状态
|
||||
@@ -193,6 +213,16 @@ export default function WechatRegister() {
|
||||
// 组件卸载后不再更新状态
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
// 追踪状态变化
|
||||
if (wechatStatus !== status) {
|
||||
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status);
|
||||
|
||||
// 特别追踪扫码事件
|
||||
if (status === WECHAT_STATUS.SCANNED) {
|
||||
authEvents.trackWechatQRScanned(currentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
setWechatStatus(status);
|
||||
|
||||
// 处理成功状态
|
||||
@@ -205,6 +235,9 @@ export default function WechatRegister() {
|
||||
}
|
||||
// 处理过期状态
|
||||
else if (status === WECHAT_STATUS.EXPIRED) {
|
||||
// 追踪二维码过期
|
||||
authEvents.trackWechatQRExpired(currentSessionId, QR_CODE_TIMEOUT / 1000);
|
||||
|
||||
clearTimers();
|
||||
sessionIdRef.current = null; // 清理 sessionId
|
||||
if (isMountedRef.current) {
|
||||
@@ -297,6 +330,16 @@ export default function WechatRegister() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 追踪用户选择微信登录(首次或刷新)
|
||||
const isRefresh = Boolean(wechatSessionId);
|
||||
if (isRefresh) {
|
||||
const oldSessionId = wechatSessionId;
|
||||
authEvents.trackWechatLoginInitiated('qr_refresh');
|
||||
// 稍后会在成功时追踪刷新事件
|
||||
} else {
|
||||
authEvents.trackWechatLoginInitiated('qr_area');
|
||||
}
|
||||
|
||||
// 生产环境:调用真实 API
|
||||
const response = await authService.getWechatQRCode();
|
||||
|
||||
@@ -312,6 +355,13 @@ export default function WechatRegister() {
|
||||
throw new Error(response.message || '获取二维码失败');
|
||||
}
|
||||
|
||||
// 追踪二维码显示 (首次或刷新)
|
||||
if (isRefresh) {
|
||||
authEvents.trackWechatQRRefreshed(wechatSessionId, response.data.session_id);
|
||||
} else {
|
||||
authEvents.trackWechatQRDisplayed(response.data.session_id, response.data.auth_url);
|
||||
}
|
||||
|
||||
// 同时更新 ref 和 state,确保轮询能立即读取到最新值
|
||||
sessionIdRef.current = response.data.session_id;
|
||||
setWechatAuthUrl(response.data.auth_url);
|
||||
@@ -326,6 +376,11 @@ export default function WechatRegister() {
|
||||
// 启动轮询检查扫码状态
|
||||
startPolling();
|
||||
} catch (error) {
|
||||
// 追踪获取二维码失败
|
||||
authEvents.trackError('api', error.message || '获取二维码失败', {
|
||||
context: 'get_wechat_qrcode'
|
||||
});
|
||||
|
||||
logger.error('WechatRegister', 'getWechatQRCode', error);
|
||||
if (isMountedRef.current) {
|
||||
showError("获取微信授权失败", error.message || "请稍后重试");
|
||||
@@ -335,7 +390,7 @@ export default function WechatRegister() {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [startPolling, showError]);
|
||||
}, [startPolling, showError, wechatSessionId, authEvents]);
|
||||
|
||||
/**
|
||||
* 安全的按钮点击处理,确保所有错误都被捕获,防止被 ErrorBoundary 捕获
|
||||
|
||||
@@ -51,6 +51,7 @@ import SubscriptionButton from '../Subscription/SubscriptionButton';
|
||||
import SubscriptionModal from '../Subscription/SubscriptionModal';
|
||||
import { CrownIcon, TooltipContent } from '../Subscription/CrownTooltip';
|
||||
import InvestmentCalendar from '../../views/Community/components/InvestmentCalendar';
|
||||
import { useNavigationEvents } from '../../hooks/useNavigationEvents';
|
||||
|
||||
/** 二级导航栏组件 - 显示当前一级菜单下的所有二级菜单项 */
|
||||
const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
@@ -61,6 +62,9 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const borderColorValue = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'secondary_nav' });
|
||||
|
||||
// 定义二级导航结构
|
||||
const secondaryNavConfig = {
|
||||
'/community': {
|
||||
@@ -162,7 +166,11 @@ const SecondaryNav = ({ showCompletenessAlert }) => {
|
||||
) : (
|
||||
<Button
|
||||
key={index}
|
||||
onClick={() => navigate(item.path)}
|
||||
onClick={() => {
|
||||
// 🎯 追踪侧边栏菜单点击
|
||||
navEvents.trackSidebarMenuClicked(item.label, item.path, 2, false);
|
||||
navigate(item.path);
|
||||
}}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
bg={isActive ? 'blue.50' : 'transparent'}
|
||||
@@ -313,6 +321,9 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
// ⚠️ 必须在组件顶层调用所有Hooks(不能在JSX中调用)
|
||||
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'top_nav' });
|
||||
|
||||
// 辅助函数:判断导航项是否激活
|
||||
const isActive = useCallback((paths) => {
|
||||
return paths.some(path => location.pathname.includes(path));
|
||||
@@ -337,7 +348,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</MenuButton>
|
||||
<MenuList minW="260px" p={2}>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/community')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('事件中心', 'dropdown', '/community');
|
||||
navigate('/community');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/community') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/community') ? '3px solid' : 'none'}
|
||||
@@ -353,7 +368,11 @@ const NavItems = ({ isAuthenticated, user }) => {
|
||||
</Flex>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={() => navigate('/concepts')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪菜单项点击
|
||||
navEvents.trackMenuItemClicked('概念中心', 'dropdown', '/concepts');
|
||||
navigate('/concepts');
|
||||
}}
|
||||
borderRadius="md"
|
||||
bg={location.pathname.includes('/concepts') ? 'blue.50' : 'transparent'}
|
||||
borderLeft={location.pathname.includes('/concepts') ? '3px solid' : 'none'}
|
||||
@@ -489,6 +508,9 @@ export default function HomeNavbar() {
|
||||
const brandHover = useColorModeValue('blue.600', 'blue.300');
|
||||
const toast = useToast();
|
||||
|
||||
// 🎯 初始化导航埋点Hook
|
||||
const navEvents = useNavigationEvents({ component: 'main_navbar' });
|
||||
|
||||
// ⚡ 提取 userId 为独立变量,避免 user 对象引用变化导致无限循环
|
||||
const userId = user?.id;
|
||||
const prevUserIdRef = React.useRef(userId);
|
||||
@@ -882,7 +904,11 @@ export default function HomeNavbar() {
|
||||
color={brandText}
|
||||
cursor="pointer"
|
||||
_hover={{ color: brandHover }}
|
||||
onClick={() => navigate('/home')}
|
||||
onClick={() => {
|
||||
// 🎯 追踪Logo点击
|
||||
navEvents.trackLogoClicked();
|
||||
navigate('/home');
|
||||
}}
|
||||
style={{ minWidth: isMobile ? '100px' : '140px' }}
|
||||
noOfLines={1}
|
||||
>
|
||||
@@ -912,7 +938,13 @@ export default function HomeNavbar() {
|
||||
<IconButton
|
||||
aria-label="切换主题"
|
||||
icon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
|
||||
onClick={toggleColorMode}
|
||||
onClick={() => {
|
||||
// 🎯 追踪主题切换
|
||||
const fromTheme = colorMode;
|
||||
const toTheme = colorMode === 'light' ? 'dark' : 'light';
|
||||
navEvents.trackThemeChanged(fromTheme, toTheme);
|
||||
toggleColorMode();
|
||||
}}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
minW={{ base: '36px', md: '40px' }}
|
||||
|
||||
83
src/components/PostHogProvider.js
Normal file
83
src/components/PostHogProvider.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// src/components/PostHogProvider.js
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { initPostHog } from '../lib/posthog';
|
||||
import { usePageTracking } from '../hooks/usePageTracking';
|
||||
|
||||
/**
|
||||
* PostHog Provider Component
|
||||
* Initializes PostHog SDK and provides automatic page view tracking
|
||||
*
|
||||
* Usage:
|
||||
* <PostHogProvider>
|
||||
* <App />
|
||||
* </PostHogProvider>
|
||||
*/
|
||||
export const PostHogProvider = ({ children }) => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
// Initialize PostHog once when component mounts
|
||||
useEffect(() => {
|
||||
// Only run in browser
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Initialize PostHog
|
||||
initPostHog();
|
||||
setIsInitialized(true);
|
||||
|
||||
// Log initialization
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('✅ PostHogProvider initialized');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Automatically track page views
|
||||
usePageTracking({
|
||||
enabled: isInitialized,
|
||||
getProperties: (location) => {
|
||||
// Add custom properties based on route
|
||||
const properties = {};
|
||||
|
||||
// Identify page type based on path
|
||||
if (location.pathname === '/home' || location.pathname === '/home/') {
|
||||
properties.page_type = 'landing';
|
||||
} else if (location.pathname.startsWith('/home/center')) {
|
||||
properties.page_type = 'dashboard';
|
||||
} else if (location.pathname.startsWith('/auth/')) {
|
||||
properties.page_type = 'auth';
|
||||
} else if (location.pathname.startsWith('/community')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'community';
|
||||
} else if (location.pathname.startsWith('/concepts')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'concepts';
|
||||
} else if (location.pathname.startsWith('/stocks')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'stocks';
|
||||
} else if (location.pathname.startsWith('/limit-analyse')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'limit_analyse';
|
||||
} else if (location.pathname.startsWith('/trading-simulation')) {
|
||||
properties.page_type = 'feature';
|
||||
properties.feature_name = 'trading_simulation';
|
||||
} else if (location.pathname.startsWith('/company')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'company';
|
||||
} else if (location.pathname.startsWith('/event-detail')) {
|
||||
properties.page_type = 'detail';
|
||||
properties.content_type = 'event';
|
||||
}
|
||||
|
||||
return properties;
|
||||
},
|
||||
});
|
||||
|
||||
// Don't render children until PostHog is initialized
|
||||
// This prevents tracking events before SDK is ready
|
||||
if (!isInitialized) {
|
||||
return children; // Or return a loading spinner
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default PostHogProvider;
|
||||
60
src/components/RiskDisclaimer/RiskDisclaimer.js
Normal file
60
src/components/RiskDisclaimer/RiskDisclaimer.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// src/components/RiskDisclaimer/RiskDisclaimer.js
|
||||
import React from 'react';
|
||||
import { Box, Text, HStack, Icon, useColorModeValue } from '@chakra-ui/react';
|
||||
|
||||
/**
|
||||
* 风险提示组件
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.text - 风险提示文本内容
|
||||
* @param {string} props.variant - 文本变体类型 ('default', 'homepage', 'section')
|
||||
* @param {Object} props.sx - 额外的样式对象
|
||||
*/
|
||||
const RiskDisclaimer = ({
|
||||
text,
|
||||
variant = 'default',
|
||||
sx = {},
|
||||
mt = 0,
|
||||
mb = 0,
|
||||
...rest
|
||||
}) => {
|
||||
// 极简风格 - 透明背景,固定灰色文字
|
||||
const textColor = '#999999'; // 固定中性灰,不受主题影响
|
||||
|
||||
// 预定义的文本变体
|
||||
const textVariants = {
|
||||
homepage: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。站内所有文章均不构成投资建议,请投资者注意风险,独立审慎决策。',
|
||||
default: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。',
|
||||
section: '风险提示:解析内容由价值前沿人工采集整理自新闻、公告、研报等公开信息,团队辛苦编写,未经许可严禁转载。本部分产品内容均不构成投资建议,请投资者注意风险,独立审慎决策。'
|
||||
};
|
||||
|
||||
// 使用传入的text或预定义的variant
|
||||
const displayText = text || textVariants[variant] || textVariants.default;
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="transparent"
|
||||
p={0}
|
||||
mt={mt}
|
||||
mb={mb}
|
||||
width="100%"
|
||||
sx={sx}
|
||||
{...rest}
|
||||
>
|
||||
<HStack spacing={0} align="flex-start">
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color={textColor}
|
||||
lineHeight="1.6"
|
||||
fontWeight="normal"
|
||||
textAlign="center"
|
||||
width="100%"
|
||||
>
|
||||
{displayText}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default RiskDisclaimer;
|
||||
2
src/components/RiskDisclaimer/index.js
Normal file
2
src/components/RiskDisclaimer/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
// src/components/RiskDisclaimer/index.js
|
||||
export { default } from './RiskDisclaimer';
|
||||
@@ -7,6 +7,7 @@ import moment from 'moment';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import CitedContent from '../Citation/CitedContent';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -563,19 +564,8 @@ const StockChartAntdModal = ({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* 调试信息 */}
|
||||
{process.env.NODE_ENV === 'development' && chartData && (
|
||||
<div style={{ marginTop: 16, padding: 12, backgroundColor: '#f0f0f0', borderRadius: 6, fontSize: '12px' }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 4 }}>调试信息:</Text>
|
||||
<Text>数据条数: {chartData.data ? chartData.data.length : 0}</Text>
|
||||
<br />
|
||||
<Text>交易日期: {chartData.trade_date}</Text>
|
||||
<br />
|
||||
<Text>图表类型: {activeChartType}</Text>
|
||||
<br />
|
||||
<Text>原始事件时间: {eventTime}</Text>
|
||||
</div>
|
||||
)}
|
||||
{/* 风险提示 */}
|
||||
<RiskDisclaimer variant="default" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as echarts from 'echarts';
|
||||
import moment from 'moment';
|
||||
import { stockService } from '../../services/eventService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import RiskDisclaimer from '../RiskDisclaimer';
|
||||
|
||||
const StockChartModal = ({
|
||||
isOpen,
|
||||
@@ -545,6 +546,11 @@ const StockChartModal = ({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 风险提示 */}
|
||||
<Box px={4} pb={4}>
|
||||
<RiskDisclaimer variant="default" />
|
||||
</Box>
|
||||
|
||||
{process.env.NODE_ENV === 'development' && chartData && (
|
||||
<Box p={4} bg="gray.50" fontSize="xs" color="gray.600">
|
||||
<Text fontWeight="bold">调试信息:</Text>
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
||||
|
||||
// Icons
|
||||
import {
|
||||
@@ -54,6 +55,14 @@ export default function SubscriptionContent() {
|
||||
// Auth context
|
||||
const { user } = useAuth();
|
||||
|
||||
// 🎯 初始化订阅埋点Hook(传入当前订阅信息)
|
||||
const subscriptionEvents = useSubscriptionEvents({
|
||||
currentSubscription: {
|
||||
plan: user?.subscription_plan || 'free',
|
||||
status: user?.subscription_status || 'none'
|
||||
}
|
||||
});
|
||||
|
||||
// Chakra color mode
|
||||
const textColor = useColorModeValue('gray.700', 'white');
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
@@ -161,6 +170,13 @@ export default function SubscriptionContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🎯 追踪定价方案选择
|
||||
subscriptionEvents.trackPricingPlanSelected(
|
||||
plan.name,
|
||||
selectedCycle,
|
||||
selectedCycle === 'monthly' ? plan.monthly_price : plan.yearly_price
|
||||
);
|
||||
|
||||
setSelectedPlan(plan);
|
||||
onPaymentModalOpen();
|
||||
};
|
||||
@@ -170,6 +186,17 @@ export default function SubscriptionContent() {
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const price = selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price;
|
||||
|
||||
// 🎯 追踪支付发起
|
||||
subscriptionEvents.trackPaymentInitiated({
|
||||
planName: selectedPlan.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: price,
|
||||
billingCycle: selectedCycle,
|
||||
orderId: null // Will be set after order creation
|
||||
});
|
||||
|
||||
const response = await fetch('/api/payment/create-order', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -204,6 +231,13 @@ export default function SubscriptionContent() {
|
||||
throw new Error('网络错误');
|
||||
}
|
||||
} catch (error) {
|
||||
// 🎯 追踪支付失败
|
||||
subscriptionEvents.trackPaymentFailed({
|
||||
planName: selectedPlan.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: selectedCycle === 'monthly' ? selectedPlan.monthly_price : selectedPlan.yearly_price
|
||||
}, error.message);
|
||||
|
||||
toast({
|
||||
title: '创建订单失败',
|
||||
description: error.message,
|
||||
@@ -251,6 +285,26 @@ export default function SubscriptionContent() {
|
||||
setAutoCheckInterval(null);
|
||||
|
||||
logger.info('SubscriptionContent', '自动检测到支付成功', { orderId });
|
||||
|
||||
// 🎯 追踪支付成功
|
||||
subscriptionEvents.trackPaymentSuccessful({
|
||||
planName: selectedPlan?.name,
|
||||
paymentMethod: 'wechat_pay',
|
||||
amount: paymentOrder?.amount,
|
||||
billingCycle: selectedCycle,
|
||||
orderId: orderId,
|
||||
transactionId: data.transaction_id
|
||||
});
|
||||
|
||||
// 🎯 追踪订阅创建
|
||||
subscriptionEvents.trackSubscriptionCreated({
|
||||
plan: selectedPlan?.name,
|
||||
billingCycle: selectedCycle,
|
||||
amount: paymentOrder?.amount,
|
||||
startDate: new Date().toISOString(),
|
||||
endDate: null // Will be calculated by backend
|
||||
});
|
||||
|
||||
toast({
|
||||
title: '支付成功!',
|
||||
description: '订阅已激活,正在跳转...',
|
||||
|
||||
Reference in New Issue
Block a user