技术方案概述
在 (/dashboard) 页面新增两个独立模块:
-
NPS 打分入口 - 带限频展示的净推荐值评分入口(放置在左侧栏,个性化推荐模块下方、市场数据模块上方)
-
意见反馈入口 - 常驻的用户反馈快捷入口(放置在右侧栏,推荐计划模块下方、VIP专区模块上方)
技术实现方案
1. 前置条件校验
1.1 用户资格校验
typescript
interface UserEligibility {
isKYCVerified: boolean; // KYC 认证状态
isMainAccount: boolean; // 是否主账户(非子账户)
isComplianceAllowed: boolean; // 合规区域检查
isPCWeb: boolean; // 是否 PC Web 端
isValidLocale: boolean; // locale 不为 'en-GB'
}
// 校验逻辑
function checkUserEligibility(): boolean {
const user = useUserInfo();
const compliance = useComplianceConfig();
const device = useDeviceDetect();
const { locale } = useRouter();
return user.isKYCVerified
&& user.isMainAccount
&& compliance.isAllowed('nps_feedback')
&& device.isPCWeb
&& locale !== 'en-GB'; // en-GB 不展示
}
1.2 合规配置
2. NPS 打分入口实现
2.1 展示逻辑
typescript
interface NPSDisplayConfig {
canShowFromBackend: boolean; // 后端返回:用户是否可打分
lastClosedTimestamp: number; // 本地缓存:上次关闭时间戳
cooldownPeriod: number; // 冷却期:90天(毫秒)
}
function shouldShowNPS(config: NPSDisplayConfig): boolean {
const now = Date.now();
const cooldownExpired = !config.lastClosedTimestamp
|| (now - config.lastClosedTimestamp) >= config.cooldownPeriod;
return cooldownExpired && config.canShowFromBackend;
}
2.2 后端接口定义
typescript
// POST /[support-site]/v1/private/feedback/allowed
// 请求参数:无
// 响应格式
interface FeedbackAllowedResponse {
ret_code: number;
ret_msg: string;
result: {
scoreAllowed: number; // 0=不允许打分, 1=允许打分
};
ext_code: string;
ext_info: any;
time_now: string;
}
// 使用示例
async function checkNPSAllowed(): Promise<boolean> {
const response = await fetch('/[support-site]/v1/private/feedback/allowed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const data: FeedbackAllowedResponse = await response.json();
// scoreAllowed 为 1 时可展示 NPS 入口
return data.ret_code === 0 && data.result.scoreAllowed === 1;
}
2.3 API 封装
typescript
// api/feedback.ts
import { request } from '@/utils/request';
export async function checkFeedbackAllowed(): Promise<FeedbackAllowedResponse> {
return request('/[support-site]/v1/private/feedback/allowed', {
method: 'POST',
});
}
// hooks/useNPSAllowed.ts
import { useQuery } from 'react-query';
import { checkFeedbackAllowed } from '@/api/feedback';
export function useNPSAllowed() {
return useQuery(['nps-allowed'], checkFeedbackAllowed, {
staleTime: 5 * 60 * 1000, // 5 分钟缓存
retry: 2,
});
}
2.4 本地存储方案
typescript
// LocalStorage Key
const STORAGE_KEY = '[project]_nps_closed_at';
// 存储关闭时间
function recordNPSClosure(): void {
localStorage.setItem(STORAGE_KEY, Date.now().toString());
}
// 读取关闭时间
function getLastClosedTime(): number | null {
const value = localStorage.getItem(STORAGE_KEY);
return value ? parseInt(value, 10) : null;
}
2.5 组件实现
tsx
// components/NPSEntry/index.tsx
import { memo, useState, useEffect } from 'react';
interface NPSEntryProps {
onClose: () => void;
onScoreClick: (score: number) => void;
}
export const NPSEntry = memo(function NPSEntry(props: NPSEntryProps) {
const [visible, setVisible] = useState(false);
const { data: allowedData, isLoading } = useNPSAllowed();
useEffect(() => {
if (isLoading || !allowedData) return;
const lastClosed = getLastClosedTime();
const cooldownExpired = !lastClosed
|| (Date.now() - lastClosed) >= 90 * 24 * 60 * 60 * 1000;
// scoreAllowed 为 1 且前端冷却期已过时展示
const canShowNPS = allowedData.result.scoreAllowed === 1;
setVisible(cooldownExpired && canShowNPS);
}, [allowedData, isLoading]);
const handleClose = () => {
recordNPSClosure();
setVisible(false);
props.onClose();
};
const handleScore = (score: number) => {
// 跳转到用户反馈页
const url = `https://www.[company].com/en/user-feedback?nps_score=${score}&utm_source=dashboard`;
window.open(url, '_blank');
props.onScoreClick(score);
};
if (!visible) return null;
return (
<div className="nps-entry">
<div className="nps-header">
<span>{t('nps.title')}</span>
<CloseIcon onClick={handleClose} />
</div>
<div className="nps-scores">
{[...Array(11)].map((_, i) => (
<button
key={i}
onClick={() => handleScore(i)}
className="nps-score-btn"
>
{i}
</button>
))}
</div>
</div>
);
});
export default NPSEntry;
3. 意见反馈入口实现
3.1 组件实现
tsx
// components/FeedbackEntry/index.tsx
import { memo } from 'react';
interface FeedbackEntryProps {
onClick: () => void;
}
export const FeedbackEntry = memo(function FeedbackEntry(props: FeedbackEntryProps) {
const handleClick = () => {
// 跳转到反馈页面
window.open('https://www.[company].com/en/user-feedback', '_blank');
props.onClick();
};
return (
<div className="feedback-entry" onClick={handleClick}>
<span>{t('feedback.title')}</span>
<ArrowIcon />
</div>
);
});
export default FeedbackEntry;
4. 页面集成
4.0 布局说明
- NPS 入口: 放置在左侧栏
- 反馈入口: 放置在右侧栏,作为卡片形式展示
4.1 在 UserDashboardPage 组件中集成
tsx
// containers/userDashboardPage/index.tsx
import NPSEntry from '@/components/NPSEntry';
import FeedbackEntry from '@/components/FeedbackEntry';
function UserDashboardPage() {
const eligible = checkUserEligibility();
return (
<div className={styles.homeContent}>
{/* 左侧栏 */}
<div className={styles.leftContent}>
{isNormal ? <Assets /> : null}
{locale !== 'en-GB' && <PersonalizedModule />}
{/* NPS 打分入口 - 个性化模块下方、市场数据模块上方 */}
{eligible && locale !== 'en-GB' && (
<NPSEntry
onClose={() => trackEvent('pageClick', { button_name: 'nps_close_button' })}
onScoreClick={(score) => trackEvent('pageClick', {
button_name: 'nps_score_button',
button_id: `score_${score}`
})}
/>
)}
{locale !== 'en-GB' && <MarketDataModule />}
{locale !== 'en-GB' && <EventsModule />}
<AnnouncementModule />
</div>
{/* 右侧栏 */}
<div className={styles.rightContent}>
{/* ...其他组件... */}
<LearnModule />
<RewardsCard />
{/* 推荐计划模块 */}
{locale !== 'en-GB' && (
<ComplianceElement config={complianceConfig?.showReferral}>
{verified && <ReferralProgram />}
</ComplianceElement>
)}
{/* 意见反馈入口 - 推荐计划模块下方、VIP专区模块上方 */}
{eligible && locale !== 'en-GB' && (
<FeedbackEntry
onClick={() => trackEvent('pageClick', { button_name: 'feedback_entrance_button' })}
/>
)}
{locale !== 'en-GB' && !isPreVip ? <VIPSection /> : null}
{locale !== 'en-GB' && <BenefitsCard />}
</div>
</div>
);
}
5. 数据埋点实现
5.1 埋点工具函数
typescript
// utils/tracking.ts
import { track } from '@company/tracking';
interface TrackingParams {
button_name?: string;
button_id?: string;
section_type?: string;
module_name?: string;
}
// 曝光埋点
export function trackView(elementName: string, params: TrackingParams) {
track('pageView', {
element_name: elementName,
...params,
});
}
// 点击埋点
export function trackClick(buttonName: string, params: TrackingParams) {
track('pageClick', {
button_name: buttonName,
...params,
});
}
5.2 埋点接入点
typescript
// NPS 曝光
useEffect(() => {
if (visible) {
trackView('nps_entrance', { section_type: 'nps_module' });
}
}, [visible]);
// 反馈入口曝光
useEffect(() => {
trackView('feedback_entrance', { section_type: 'feedback_module' });
}, []);
// NPS 分数点击
function handleScoreClick(score: number) {
trackClick('nps_score_button', {
button_id: `score_${score}`,
section_type: 'nps_module',
});
}
// 反馈入口点击
function handleFeedbackClick() {
trackClick('feedback_entrance_button', {
section_type: 'feedback_module',
});
}
// NPS 关闭
function handleClose() {
trackClick('nps_close_button', {
module_name: 'nps_module',
});
}