用户反馈入口

技术方案概述

在 (/dashboard) 页面新增两个独立模块:

  1. NPS 打分入口 - 带限频展示的净推荐值评分入口(放置在左侧栏,个性化推荐模块下方、市场数据模块上方)

  2. 意见反馈入口 - 常驻的用户反馈快捷入口(放置在右侧栏,推荐计划模块下方、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',
  });
}
相关推荐
im_AMBER2 小时前
万字长文:手撕JS深浅拷贝完全指南
前端·javascript·面试
@大迁世界2 小时前
20.“可复用组件”具体指的是什么?如何设计与产出这类组件?.
开发语言·前端·javascript·ecmascript
Bigger2 小时前
第二章:我是如何剖析 Claude Code QueryEngine 与大模型交互机制的
前端·ai编程·源码阅读
FelixBitSoul2 小时前
彻底吃透 React Hook:它背后的执行模型到底是什么? 🚀
前端
Huanzhi_Lin2 小时前
Nginx本地资源服务器-常用脚本
服务器·前端·nginx·batch·静态资源服务器
weixin199701080162 小时前
《好看视频商品详情页前端性能优化实战》
前端·性能优化·音视频
有意义2 小时前
深入理解浏览器存储方案:从Cookie到JWT登录认证
前端·面试·浏览器
jiayong232 小时前
第 6 课:第二轮真实重构,拆出任务表格组件
前端·重构
jiayong232 小时前
第 4 课:怎么把一个大页面拆成多个组件
运维·服务器·前端