🔍 MR: Draft: [feat]: 福利码需求

代码变更影响范围深度分析与回归测试指南 · 由 AI 极速推演生成

项目仓库:SlotYoungSC
分支流向feat/h5-70059-dev-chenzy-promocodeh5-70059-dev
分析触发人:罗哲华

🧠 回归测试专项拓扑脑图

graph TD Root[🔍 回归测试拓扑脑图] --> A[🟢 交互与渲染] Root --> B[🔵 逻辑完整性] Root --> C[🟣 性能与极值] A --> A1[兑换码输入与提交] A --> A2[奖励弹窗展示] A --> A3[记录列表与分页] B --> B1[状态机数据同步] B --> B2[Footer显隐逻辑] B --> B3[错误码与埋点] C --> C1[弱网与超时] C --> C2[高频交互防抖] C --> C3[首屏与资源] style Root fill:#ff9900,stroke:#333,stroke-width:2px,color:#fff style A fill:#28a745,stroke:#333,stroke-width:1px,color:#fff style B fill:#007bff,stroke:#333,stroke-width:1px,color:#fff style C fill:#6f42c1,stroke:#333,stroke-width:1px,color:#fff
🔍 点击全屏放大查看

📋 核心变更内容与测试边界提醒

💡 关键变动逻辑:
🚨 【高危必测区】
🛡️ 【安全豁免区】
📈 【极值边界点】

📊 风险评估级别

🟡
medium Risk
当前合并请求的整体发布风险评级

⚠️ 潜在隐患与风险点

  • 1
    [状态冲突白屏] 兑换码状态机未正确重置或弹窗销毁时异步请求未取消,导致组件卸载后setState引发白屏或内存泄漏。
  • 2
    [弱网重复提交] 弱网下提交兑换码请求超时,用户多次点击触发并发请求,导致重复发放奖励或接口限流报错。
  • 3
    [分页加载断裂] 核心Pagination组件重构后,与记录列表数据绑定异常,导致页码切换时数据重叠或加载态卡死。

🗺️ 业务波及页面与反向依赖调用链

受改动波及路由入口 业务功能与波及说明 反向依赖链路 trace 路径
无显著波及入口

💡 灰盒测试数据 Mock 与状态断言指南

测试手段 关键对象/键 数据构造 / 预期断言标准
API Mock JSON /api/redeem/apply Mock 500/timeout响应,断言前端显示错误提示且提交按钮恢复可点击状态,无重复请求。
LocalStorage 状态 redeemCode-store 清空或写满LocalStorage,断言状态机降级为内存模式,页面不崩溃且记录列表正常渲染。
React DOM 渲染断言 .redeem-dialog / .footer-container 提交成功后断言奖励弹窗DOM挂载,Footer在promotionViewModel控制下默认display:none。

📋 回归测试专项用例

🎯 测试维度:交互与渲染验证

受影响模块 用例标题 前置条件 测试步骤 预期结果
兑换码提交与奖励弹窗 弱网环境下兑换码提交与防抖验证 网络限速至3G,兑换码输入框已填入有效码 1. 快速连续点击提交按钮5次
2. 观察网络请求与按钮状态
3. 等待请求超时或成功
仅发出1次请求,按钮显示loading态,成功后弹出奖励弹窗,无重复提交。

🎯 测试维度:业务逻辑完整性

受影响模块 用例标题 前置条件 测试步骤 预期结果
兑换记录列表与分页 记录列表分页加载与状态同步验证 用户历史兑换记录超过20条,进入记录列表页 1. 滚动至底部触发分页加载
2. 切换页码至第2页
3. 刷新页面检查状态恢复
列表正确追加数据,页码高亮同步,刷新后基于持久化状态恢复至正确页码,无数据重叠。

🎯 测试维度:性能与加载极值

受影响模块 用例标题 前置条件 测试步骤 预期结果
全局布局与首屏渲染 Footer显隐逻辑切换与首屏性能验证 访问GMV活动页与个人中心页 1. 检查首屏DOM结构
2. 切换路由观察Footer显隐
3. 使用Lighthouse跑分
Footer默认隐藏不占位,路由切换无布局抖动,首屏FCP<1.5s,CLS<0.1。

📂 本次 MR 代码变更审阅 (GitHub 级体验)

可通过左侧文件列表快速定位跳转,右侧包含文件卡片化高亮 Diff 细节

public/images/redeem/redeem_code_bg.webp
+0 -0
src/app/(models)/redeemCodeModel.ts
+28 -0
@@ -0,0 +1,28 @@
+/**
+ * 兑换码活动相关API请求函数集合
+ * @module redeemCode/api
+ */
+import { Response } from '@/app/response';
+import http from '@/app/toolbox/http';
+import { apiUrl } from '@/core/constant/apiUrl';
+
+/**
+ * 提交兑换码
+ * @async
+ * @param {string} code - 兑换码
+ * @returns {Promise<Response.RedeemCodeApplyResponse>} 兑换结果Promise对象
+ * @description 提交兑换码并获取奖励信息
+ */
+export function applyRedeemCode(code: string): Promise<Response.RedeemCodeApplyResponse> {
+ return http.post<Response.RedeemCodeApplyResponse>(apiUrl.RedeemCodeApply, { promoCode: code });
+}
+
+/**
+ * 获取兑换码记录列表
+ * @async
+ * @returns {Promise<Response.RedeemCodeRecords>} 兑换记录列表数据Promise对象
+ * @description 获取用户历史兑换码记录,支持分页
+ */
+export function getRedeemCodeRecords(): Promise<Response.RedeemCodeRecords> {
+ return http.post<Response.RedeemCodeRecords>(apiUrl.RedeemCodeRecords);
+}
src/app/(viewmodels)/promotionViewModel.ts
+1 -1
@@ -300,7 +300,7 @@ const initialState: IPromotionStore = {
lobbyShow: true,
sidebarShow: true,
introductionShow: true,
- footerShow: true,
+ footerShow: false,
tagText: 'No Rank',
tagClassName: 'bg-[linear-gradient(288deg,#0C9E52_26.73%,#54DA68_80.13%)] min-w-[108px]',
trackType: TrackPromosDetail.GmvTournament,
src/app/(viewmodels)/redeemCodeViewModel.ts
+116 -0
@@ -0,0 +1,116 @@
+/**
+ * 兑换码活动ViewModel
+ * 管理兑换码提交和记录查询的状态
+ */
+import { logger } from '@iap/kit-logger';
+
+import { applyRedeemCode, getRedeemCodeRecords } from '@/app/(models)/redeemCodeModel';
+import { Response } from '@/app/response';
+import { createImmer } from '@/core/base/createImmer';
+
+interface Actions {
+ // 提交兑换码
+ applyRedeemCode: (code: string) => Promise<Response.RedeemCodeApplyResponse>;
+ // 获取兑换记录
+ getRecords: () => Promise<void>;
+ // 设置当前展示页码
+ setCurrentPage: (page: number) => void;
+ // 重置状态
+ reset: () => void;
+}
+
+export interface IRedeemCodeStore {
+ // 持久化所需字段
+ hydrated: boolean;
+ // 兑换记录列表
+ records: Response.RedeemCodeRecord[];
+ // 加载状态
+ loading: boolean;
+ // 当前页码
+ currentPage: number;
+ // 是否还有更多数据
+ hasMore: boolean;
+ // 总记录数
+ total: number;
+}
+
+export type TRedeemCodeStore = IRedeemCodeStore & Actions;
+
+const initialState: IRedeemCodeStore = {
+ hydrated: false,
+ records: [],
+ loading: false,
+ currentPage: 1,
+ hasMore: true,
+ total: 0
+};
+
+// Selectors
+export const selectRedeemRecords = (state: TRedeemCodeStore) => state.records;
+export const selectRedeemLoading = (state: TRedeemCodeStore) => state.loading;
+export const selectRedeemHasMore = (state: TRedeemCodeStore) => state.hasMore;
+export const selectRedeemTotal = (state: TRedeemCodeStore) => state.total;
+
+export const useRedeemCodeViewModel = createImmer<TRedeemCodeStore>(
+ (set, get) => ({
+ ...initialState,
+
+ // 提交兑换码
+ applyRedeemCode: async (code: string) => {
+ try {
+ const result = await applyRedeemCode(code.trim());
+ logger.info('[RedeemCodeViewModel] Apply redeem code success:', result);
+ return result;
+ } catch (error) {
+ logger.warn('[RedeemCodeViewModel] Apply redeem code failed:', error);
+ throw error;
+ }
+ },
+
+ // 获取兑换记录
+ getRecords: async () => {
+ if (get().loading) return;
+
+ set({ loading: true });
+
+ try {
+ const response = await getRedeemCodeRecords();
+
+ set(() => ({
+ records: response,
+ hasMore: (response?.length || 0) > 0,
+ total: response.length || 0
+ }));
+
+ logger.info('[RedeemCodeViewModel] Get records success, page:', 'total:', response.length);
+ } catch (error) {
+ logger.error('[RedeemCodeViewModel] Get records failed:', error);
+ // 如果是第一页且请求失败,设置为空数组
+ } finally {
+ set({ loading: false });
+ }
+ },
+
+ // 设置当前展示页码
+ setCurrentPage: (page: number) => {
+ set({ currentPage: page });
+ },
+
+ // 重置状态
+ reset: () => {
+ set({
+ records: [],
+ loading: false,
+ currentPage: 1,
+ hasMore: true,
+ total: 0
+ });
+ }
+ }),
+ 'redeemCode-store',
+ (state: TRedeemCodeStore) => ({
+ records: state.records,
+ total: state.total,
+ hydrated: state.hydrated
+ })
+);
src/app/(views)/collect-reward/hooks/useCollectRewardModal.ts
+43 -0
@@ -0,0 +1,43 @@
+import { ReactNode } from 'react';
+
+import { useDialog } from '@iap/ui';
+
+import CollectRewardDialog from '../index';
+
+interface CollectRewardOptions {
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ gcBonus?: number | string;
+ scBonus?: number | string;
+}
+
+export const useCollectRewardModal = () => {
+ const dialog = useDialog();
+
+ const openCollectRewardDialog = async (options: CollectRewardOptions = {}) => {
+ return new Promise(async (resolve) => {
+ try {
+ dialog.openDialog(CollectRewardDialog, {
+ mode: 'Full',
+ open: true,
+ theme: {
+ fullBodyClassName: 'w-[460px] xs:w-[546px]'
+ },
+ isCloseBtn: false,
+ isClickMaskClose: false,
+ gcBonus: options.gcBonus,
+ scBonus: options.scBonus,
+ title: options.title,
+ subtitle: options.subtitle,
+ onClose: () => {
+ resolve(true);
+ }
+ });
+ } catch {}
+ });
+ };
+
+ return {
+ openCollectRewardDialog
+ };
+};
src/app/(views)/collect-reward/index.tsx
+74 -0
@@ -0,0 +1,74 @@
+import { ReactNode, useState } from 'react';
+
+import { driveTool } from '@iap/kit-util';
+
+import Button from '@/core/components/button/Button';
+import Image from '@/core/components/image/Image';
+
+interface CollectRewardDialogProps {
+ title?: ReactNode;
+ subtitle?: ReactNode;
+ gcBonus?: number;
+ scBonus?: number;
+ onClaim?: () => void | Promise<void>;
+ closeDialog?: () => void;
+}
+
+export default function CollectRewardDialog({
+ title = (
+ <>
+ Redeemed <b className='text-[#FFA600]'>Successfully</b>
+ </>
+ ),
+ subtitle = '',
+ gcBonus = 100000,
+ scBonus = 0,
+ onClaim,
+ closeDialog,
+}: CollectRewardDialogProps) {
+ const [claiming, setClaiming] = useState(false);
+
+ const handleClaim = async () => {
+ setClaiming(true);
+ try {
+ await onClaim?.();
+ } finally {
+ setClaiming(false);
+ closeDialog?.();
+ }
+ };
+
+ return (
+ <div className='bg-[#24282F] font-alumni font-[800] text-center rounded-[32px] p-[28px_35px_24px] xs:p-[32px_45px]'>
+ <p className='text-white font-[800] leading-[1] text-[50px] xs:text-[60px] mb-[24px]'>{title}</p>
+ {subtitle && (
+ <p className='text-white/50 font-[500] leading-[1] text-[30px] xs:text-[36px] mb-[17px] xs:mb-[20px]'>
+ {subtitle}
+ </p>
+ )}
+ <div className='bg-white/20 font-[800] flex flex-col gap-[10px] xs:gap-[14px] rounded-[20px] xs:rounded-[24px] p-[20px_28px] xs:p-[40px_32px_26px] text-[40px] xs:text-[48px]'>
+ <div className='flex justify-between h-[40px] items-center'>
+ <div className='w-[36px] xs:w-[38px] aspect-[1/1] relative'>
+ <Image src='/images/store/gc.webp' alt='gc' fill sizes='100%' />
+ </div>
+ <span className='text-white'>GC {driveTool.formatNumberWithCommas(gcBonus)}</span>
+ </div>
+ <div className='flex justify-between h-[40px] items-center'>
+ <div className='w-[36px] xs:w-[38px] aspect-[1/1] relative'>
+ <Image src='/images/store/sc.webp' alt='sc' fill sizes='100%' />
+ </div>
+ <span className='text-[#0DCC73]'>SC {scBonus}</span>
+ </div>
+ </div>
+
+ <div className='mt-[24px]'>
+ <Button
+ label='GOT IT'
+ className='font-alumni rounded-full xs:rounded-full text-[40px] xs:text-[44px] h-[80px] xs:h-[96px] font-[800]'
+ loading={claiming}
+ onClick={handleClaim}
+ />
+ </div>
+ </div>
+ );
+}
src/app/(views)/profile/PersonalInfo.tsx
+4 -0
@@ -26,6 +26,7 @@ import AccountIdPanel from '@/app/components/personalInfo/AccountIdPanel';
import BasicInfoPanel from '@/app/components/personalInfo/BasicInfoPanel';
import PasswordPanel from '@/app/components/personalInfo/PasswordPanel';
import { PersonalBlock, PersonalLabel } from '@/app/components/personalInfo/PersonalCommon';
+import RedeemCodePanel from '@/app/components/personalInfo/RedeemCodePanel';
import SocialPanel from '@/app/components/personalInfo/SocialPanel';
import VerifyPanel from '@/app/components/personalInfo/VerifyPanel';
import VipProgressBar from '@/app/components/personalInfo/VipProgressBar';
@@ -390,6 +391,9 @@ const PersonalInfo: FC = () => {
</div>
</div>
+ {/* 兑换码入口 */}
+ <RedeemCodePanel />
+
<PersonalBlock className='hidden xs:block' onClick={handleContactSupport}>
<PersonalLabel text='Contact Support' />
</PersonalBlock>
src/app/activity/gmvTournament/GmvGiftDialog.tsx
+1 -1
@@ -122,7 +122,7 @@ export const GmvGiftDialog = ({ closeDialog, entrySource, source, reopenDialog }
const handleActionClick = async () => {
if (isFreeGiftPack) {
- TrackGmv.freePackCollectClick();
+ TrackGmv.freePackCollectClick(gcAmount, scAmount);
await handleClaimGift();
return;
}
src/app/activity/gmvTournament/GmvSettlementDialog.tsx
+1 -1
@@ -56,7 +56,7 @@ export const GmvSettlementDialog = ({ closeDialog, entrySource, source }: GmvSet
}, []);
const collectHandle = () => {
- TrackGmv.settlementCollect();
+ TrackGmv.settlementCollect(settleInfo?.gcBonus || 0, settleInfo?.scBonus || 0);
toast.showToast({
type: 'success',
message: `Claimed ${settleInfo?.scBonus > 0 ? settleInfo?.scBonus + ' SC' : ''}${settleInfo?.scBonus > 0 && settleInfo?.gcBonus > 0 ? ' + ' : ''}${settleInfo?.gcBonus > 0 ? settleInfo?.gcBonus + ' GC' : ''}!`,
src/app/components/personalInfo/RedeemCodePanel/RedeemCode.tsx
+59 -0
@@ -0,0 +1,59 @@
+import type { FC } from 'react';
+
+import classnames from 'classnames';
+
+import { useRedeemCodeSubmit } from '@/app/components/redeemCode/hooks/useRedeemCodeSubmit';
+import Button from '@/core/components/button/Button';
+import Input from '@/core/components/input/Input';
+
+const RedeemCode: FC = () => {
+ const { code, setCode, errorMsg, loading, handleSubmit, handleKeyDown } = useRedeemCodeSubmit({
+ onSuccess: () => setCode(''),
+ });
+
+ return (
+ <div className='lg:flex gap-[40px] mb-[20px] justify-between'>
+ {/* 左侧说明文字 */}
+ <div className='text-[16px] font-[roboto] text-white/50 space-y-[4px] lg:mb-0 mb-[10px]'>
+ <p>1. Code case sensitivity applies.</p>
+ <p>2. Contact customer support if you experience any issues.</p>
+ <p>3. Save up to 50 past redemption records.</p>
+ </div>
+
+ {/* 右侧表单 */}
+ <div className='flex-1 flex items-center'>
+ <div className='flex gap-[8px] lg:justify-end flex-1'>
+ <div className='relative flex-1 min-w-[300px] lg:max-w-[400px]'>
+ <Input
+ value={code}
+ onChange={setCode}
+ placeholder={'Enter Your Redeem Code'}
+ className='h-[52px] py-[12px] px-[20px] bg-[#1F222A] rounded-[12px]'
+ onKeyDown={handleKeyDown}
+ bodyClassName={classnames(
+ 'h-[28px] text-[16px] xs:text-[32px] !placeholder:text-[#5F6268]',
+ )}
+ borderClassName={classnames('before:rounded-[12px]')}
+ />
+ {errorMsg && (
+ <div className='absolute left-0 top-full mt-[4px] font-[roboto] font-[800] text-[#FF0707] text-[16px] whitespace-nowrap'>
+ {errorMsg}
+ </div>
+ )}
+ </div>
+ <div className=''>
+ <Button
+ label={'REDEEM'}
+ onClick={handleSubmit}
+ loading={loading}
+ disabled={!code.trim()}
+ className='w-[200px] rounded-[12px] px-[12px] h-[52px] text-[18px] font-[roboto] font-[800]'
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default RedeemCode;
src/app/components/personalInfo/RedeemCodePanel/Table.tsx
+99 -0
@@ -0,0 +1,99 @@
+import type { FC } from 'react';
+import { useEffect } from 'react';
+
+import { formatTool } from '@iap/kit-util';
+import dayjs from 'dayjs';
+
+import {
+ useRedeemCodeViewModel,
+ selectRedeemRecords,
+ selectRedeemLoading,
+} from '@/app/(viewmodels)/redeemCodeViewModel';
+import { selectLoginType, useUserViewModel } from '@/app/(viewmodels)/userViewModel';
+import { LoginType } from '@/app/response/System';
+import BlockLoading from '@/core/components/loading/BlockLoading';
+import Pagination from '@/core/components/pagination/Pagination';
+
+const PAGE_SIZE = 5;
+
+const Table: FC = () => {
+ const records = useRedeemCodeViewModel(selectRedeemRecords);
+ const loading = useRedeemCodeViewModel(selectRedeemLoading);
+ const { getRecords, currentPage, setCurrentPage } = useRedeemCodeViewModel();
+ const userId = useUserViewModel((state) => state.userInfo.id);
+ const loginType = useUserViewModel(selectLoginType);
+
+ useEffect(() => {
+ if (userId && loginType === LoginType.normal) {
+ getRecords();
+ }
+ }, []);
+
+ const displayedRecords = records.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
+
+ const formatTime = (timestamp: number) => {
+ return dayjs.unix(timestamp / 1000).format('MM/DD/YYYY HH:mm:ss');
+ };
+
+ if (loading && records.length === 0) {
+ return (
+ <div className='flex justify-center items-center py-[40px]'>
+ <BlockLoading />
+ </div>
+ );
+ }
+
+ return (
+ <div className='mt-[20px] xs:mt-[30px] pb-[20px]'>
+ {/* 表头 */}
+ <div className='grid grid-cols-3 bg-[#1A1D24] rounded-[16px] h-[64px] leading-[44px] py-[10px] xs:py-[12px] xs:rounded-[0] text-[16px] xs:text-[20px] text-white/50 font-[roboto] font-[700]'>
+ <div className='text-center px-[10px] xs:px-[16px]'>TIME</div>
+ <div className='relative text-center px-[10px] xs:px-[16px] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-[30px] before:w-[1px] before:bg-white/20 after:absolute after:right-0 after:top-1/2 after:-translate-y-1/2 after:h-[30px] after:w-[1px] after:bg-white/20'>
+ REWARD
+ </div>
+ <div className='text-center px-[10px] xs:px-[16px]'>CODE</div>
+ </div>
+
+ {/* 数据行 */}
+ <div className='rounded-b-[12px] xs:rounded-b-[16px]'>
+ {records.length === 0 && (
+ <div className='text-center text-white/50 text-[20px] py-[18px] font-[roboto]'>
+ No data available.
+ </div>
+ )}
+ {displayedRecords.map((record, index) => (
+ <div
+ key={`${record.promoCode}-${index}`}
+ className={`grid grid-cols-3 h-[64px] leading-[44px] py-[10px] xs:py-[16px] text-[16px] xs:text-[20px] rounded-[16px] ${
+ index % 2 === 1 ? 'bg-[#1F222A]' : ''
+ }`}
+ >
+ <span className='text-white/50 text-center'>{formatTime(record.createTime)}</span>
+
+ <span className='relative text-center flex items-center justify-center gap-[16px] before:absolute before:left-0 before:top-1/2 before:-translate-y-1/2 before:h-[30px] before:w-[1px] before:bg-white/20 after:absolute after:right-0 after:top-1/2 after:-translate-y-1/2 after:h-[30px] after:w-[1px] after:bg-white/20'>
+ <span className='text-[#FFA600] font-[800]'>
+ GC {formatTool.ToKMBTUnitString(record.gc, 2)}
+ </span>
+ <span className='text-[#0DCC73] font-[800]'>SC {record.sc}</span>
+ </span>
+
+ <span className='text-center text-white/50 font-mono truncate' title={record.promoCode}>
+ {record.promoCode}
+ </span>
+ </div>
+ ))}
+ </div>
+
+ <Pagination
+ total={records.length}
+ pageSize={PAGE_SIZE}
+ currentPage={currentPage}
+ onPrev={() => setCurrentPage(currentPage - 1)}
+ onNext={() => setCurrentPage(currentPage + 1)}
+ className='mt-[16px]'
+ />
+ </div>
+ );
+};
+
+export default Table;
src/app/components/personalInfo/RedeemCodePanel/index.tsx
+79 -0
@@ -0,0 +1,79 @@
+import type { FC } from 'react';
+import { useCallback } from 'react';
+
+import { useBoolean } from 'ahooks';
+import classnames from 'classnames';
+
+import ArrowIcon from '@/app/assets/icons/arrow.svg';
+import { PersonalBlock, PersonalLabel } from '@/app/components/personalInfo/PersonalCommon';
+import { useRedeemCodeDialog } from '@/app/components/redeemCode/hooks/useRedeemCodeDialog';
+import TrackProfile from '@/core/track/TrackProfile';
+
+import RedeemCode from './RedeemCode';
+import Table from './Table';
+
+/**
+ * 兑换码面板容器组件
+ * PC端:点击展开/收起,显示RedeemCode表单和Table记录
+ * 移动端:点击打开弹窗
+ */
+const RedeemCodePanel: FC = () => {
+ const [spread, { toggle }] = useBoolean(false);
+ const { openRedeemCodeDialog } = useRedeemCodeDialog();
+
+ const handleClick = useCallback(() => {
+ // 判断是否为移动端(通过window宽度判断)
+ const isMobile = typeof window !== 'undefined' && window.innerWidth < 1024;
+
+ if (isMobile) {
+ // 移动端打开弹窗
+ openRedeemCodeDialog();
+ } else {
+ // PC端展开/收起
+ toggle();
+ }
+
+ // 埋点追踪
+ TrackProfile.clickRedeemCode();
+ }, [toggle, openRedeemCodeDialog]);
+
+ return (
+ <PersonalBlock className='pt-[16px] xs:pt-[0] !min-h-[56px]'>
+ <PersonalLabel
+ text='Redeem Code'
+ isSpread={false}
+ extra={
+ <>
+ <div className='absolute w-full h-full hidden xs:block' onClick={handleClick}></div>
+ <div
+ className='absolute w-[36px] h-[36px] rounded-[12px] xs:hidden bg-[#1F222A] flex justify-center items-center cursor-pointer'
+ onClick={handleClick}
+ >
+ <div
+ className={classnames(
+ 'duration-200 w-[8px] text-white/40 -scale-x-[1] pointer-events-none',
+ spread && 'rotate-90',
+ )}
+ >
+ <ArrowIcon />
+ </div>
+ </div>
+ </>
+ }
+ />
+ <div
+ className={classnames(
+ 'grid transition-all duration-300 xs:hidden',
+ spread ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
+ )}
+ >
+ <div className='overflow-hidden'>
+ <RedeemCode />
+ <Table />
+ </div>
+ </div>
+ </PersonalBlock>
+ );
+};
+
+export default RedeemCodePanel;
src/app/components/redeemCode/hooks/useRedeemCodeDialog.tsx
+91 -0
@@ -0,0 +1,91 @@
+import { useRef } from 'react';
+
+import { useDialog } from '@iap/ui';
+
+import RecordsDialog from '@/app/components/redeemCode/recordsDialog';
+import RedeemCodeDialog from '@/app/components/redeemCode/redeemCodeDialog';
+
+/**
+ * 兑换码弹窗管理Hook
+ * 必须在React组件内部调用以获取正确的dialog上下文
+ */
+export const useRedeemCodeDialog = () => {
+ const dialog = useDialog();
+ const redeemCodeDialogRef = useRef('');
+ const recordsDialogRef = useRef('');
+
+ /**
+ * 打开兑换码输入弹窗
+ */
+ const openRedeemCodeDialog = () => {
+ // 如果已有弹窗打开,先关闭
+ if (redeemCodeDialogRef.current) {
+ dialog.closeDialog(redeemCodeDialogRef.current);
+ }
+
+ redeemCodeDialogRef.current = dialog.openDialog(RedeemCodeDialog, {
+ mode: 'Full',
+ open: true,
+ isClickMaskClose: false,
+ isCloseBtn: true,
+ theme: {
+ fullBodyClassName: 'w-[480px] xs:w-[638px]',
+ closeButtonClassName: 'top-[40px] right-[26px]',
+ },
+ onClose: () => {
+ redeemCodeDialogRef.current = '';
+ },
+ });
+ };
+
+ /**
+ * 打开兑换记录弹窗
+ */
+ const openRecordsDialog = () => {
+ // 如果已有弹窗打开,先关闭
+ if (recordsDialogRef.current) {
+ dialog.closeDialog(recordsDialogRef.current);
+ }
+
+ recordsDialogRef.current = dialog.openDialog(RecordsDialog, {
+ mode: 'Full',
+ open: true,
+ isClickMaskClose: false,
+ isCloseBtn: true,
+ theme: {
+ fullBodyClassName: 'w-[638px]',
+ closeButtonClassName: 'top-[40px] right-[26px]',
+ },
+ onClose: () => {
+ recordsDialogRef.current = '';
+ },
+ });
+ };
+
+ /**
+ * 关闭兑换码弹窗
+ */
+ const closeRedeemCodeDialog = () => {
+ if (redeemCodeDialogRef.current) {
+ dialog.closeDialog(redeemCodeDialogRef.current);
+ redeemCodeDialogRef.current = '';
+ }
+ };
+
+ /**
+ * 关闭记录弹窗
+ */
+ const closeRecordsDialog = () => {
+ if (recordsDialogRef.current) {
+ dialog.closeDialog(recordsDialogRef.current);
+ recordsDialogRef.current = '';
+ }
+ };
+
+ return {
+ openRedeemCodeDialog,
+ openRecordsDialog,
+ closeRedeemCodeDialog,
+ closeRecordsDialog,
+ };
+};
src/app/components/redeemCode/hooks/useRedeemCodeSubmit.ts
+81 -0
@@ -0,0 +1,81 @@
+import { useState } from 'react';
+
+import { useRedeemCodeViewModel } from '@/app/(viewmodels)/redeemCodeViewModel';
+import { selectUserId, useUserViewModel } from '@/app/(viewmodels)/userViewModel';
+import { useCollectRewardModal } from '@/app/(views)/collect-reward/hooks/useCollectRewardModal';
+import TrackProfile from '@/core/track/TrackProfile';
+
+interface UseRedeemCodeSubmitOptions {
+ onSuccess?: () => void | Promise<void>;
+}
+
+export const useRedeemCodeSubmit = ({ onSuccess }: UseRedeemCodeSubmitOptions = {}) => {
+ const userId = useUserViewModel(selectUserId);
+ const refreshUserInfo = useUserViewModel((state) => state.getUserInfo);
+ const { applyRedeemCode, getRecords, setCurrentPage } = useRedeemCodeViewModel();
+ const { openCollectRewardDialog } = useCollectRewardModal();
+
+ const [code, setCodeRaw] = useState('');
+ const setCode = (val: string) => setCodeRaw(val.slice(0, 30));
+ const [errorMsg, setErrorMsg] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ const handleSubmit = async () => {
+ setErrorMsg('');
+
+ if (!code.trim()) {
+ setErrorMsg('Please enter your redeem code');
+ return;
+ }
+
+ if (!userId) {
+ setErrorMsg('User not logged in');
+ return;
+ }
+
+ setLoading(true);
+
+ try {
+ const result = await applyRedeemCode(code);
+
+ if (result) {
+ await refreshUserInfo();
+ setCurrentPage(1);
+ getRecords();
+
+ await openCollectRewardDialog({
+ gcBonus: result.rewardGc,
+ scBonus: result.rewardSc
+ });
+ TrackProfile.redeemCodeClollect(1, code, [result?.rewardGc || 0, result?.rewardSc || 0]);
+
+ await onSuccess?.();
+ } else {
+ setErrorMsg('Invalid code. Try again or contact support');
+ TrackProfile.redeemCodeClollect(2, code, [0, 0]);
+ }
+ } catch (error: any) {
+ TrackProfile.redeemCodeClollect(2, code, [0, 0]);
+ const errorMessage =
+ error?.message || error?.response?.message || 'Invalid code. Try again or contact support';
+ setErrorMsg(errorMessage);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === 'Enter' && !loading) {
+ handleSubmit();
+ }
+ };
+
+ return {
+ code,
+ setCode,
+ errorMsg,
+ loading,
+ handleSubmit,
+ handleKeyDown
+ };
+};
src/app/components/redeemCode/index.ts
+3 -0
@@ -0,0 +1,3 @@
+// 导出组件
+export { default as RedeemCodeDialog } from './redeemCodeDialog';
+export { default as RecordsDialog } from './recordsDialog';
src/app/components/redeemCode/recordsDialog.tsx
+115 -0
@@ -0,0 +1,115 @@
+import type { FC } from 'react';
+import { useEffect, useRef, useState } from 'react';
+
+import { formatTool } from '@iap/kit-util';
+import dayjs from 'dayjs';
+
+import {
+ useRedeemCodeViewModel,
+ selectRedeemRecords,
+ selectRedeemLoading,
+} from '@/app/(viewmodels)/redeemCodeViewModel';
+
+interface RecordsDialogProps {
+ closeDialog?: () => void;
+}
+
+/**
+ * 移动端兑换记录弹窗组件
+ * 图三设计:可滚动加载的兑换历史
+ */
+const RecordsDialog: FC<RecordsDialogProps> = () => {
+ const records = useRedeemCodeViewModel(selectRedeemRecords);
+ const loading = useRedeemCodeViewModel(selectRedeemLoading);
+ const { getRecords } = useRedeemCodeViewModel();
+
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [showGradient, setShowGradient] = useState(false);
+
+ const checkGradient = () => {
+ const el = containerRef.current;
+ if (!el) return;
+ const hasScroll = el.scrollHeight > el.clientHeight;
+ const atBottom = el.scrollHeight - el.scrollTop <= el.clientHeight + 2;
+ setShowGradient(hasScroll && !atBottom);
+ };
+
+ useEffect(() => {
+ getRecords();
+ }, []);
+
+ useEffect(() => {
+ checkGradient();
+ }, [records]);
+
+ return (
+ <div className='bg-[#24282F] rounded-[20px] xs:rounded-[32px] py-[20px] xs:py-[32px]'>
+ {/* 标题 */}
+ <p className='text-white text-[24px] xs:text-[36px] font-[800] mb-[20px] text-center'>Redeem History</p>
+ {/* 表头 */}
+ <div className='h-[88px] leading-[56px] flex bg-[#1A1D24] text-center text-[28px] text-white/20 font-[800]'>
+ <span className='py-[16px] flex-1'>TIME</span>
+ <span className=' flex-1 relative py-[16px] before:absolute before:left-0 before:top-0 before:h-full before:w-[1px] before:bg-white/10 after:absolute after:right-0 after:top-0 after:h-full after:w-[1px] after:bg-white/10'>
+ REWARD
+ </span>
+ <span className='py-[16px] w-[262px]'>CODE</span>
+ </div>
+ {/* 可滚动列表 */}
+ <div className='relative'>
+ <div ref={containerRef} onScroll={checkGradient} className='h-[500px] overflow-y-auto'>
+ {records.length === 0 && !loading ? (
+ <div className='flex items-center justify-center h-full text-white/50 text-[36px] font-[800]'>
+ No data available.
+ </div>
+ ) : (
+ <div className='text-[24px] font-[roboto] font-[600]'>
+ {records.map((record, index) => (
+ <div
+ key={`${record.promoCode}-${index}`}
+ className={`flex py-[16px] ${index % 2 === 1 ? 'bg-[#1F222A]' : ''}`}
+ >
+ {/* 时间列 */}
+ <span className='flex-1 flex flex-col text-center'>
+ <span className='text-white/50'>
+ {dayjs.unix(record.createTime / 1000).format('MM/DD/YYYY')}
+ </span>
+ <span className='text-white/50'>
+ {dayjs.unix(record.createTime / 1000).format('HH:mm:ss')}
+ </span>
+ </span>
+
+ {/* 奖励列 */}
+ <span className='flex-1 flex flex-col text-center'>
+ <span className='text-[#FFA600] font-[800]'>
+ GC {formatTool.ToKMBTUnitString(record.gc, 2)}
+ </span>
+ <span className='text-[#0DCC73] font-[800]'>SC {record.sc}</span>
+ </span>
+
+ {/* 兑换码列 */}
+ <span className='text-white/50 text-center break-all px-[14px] flex items-center w-[262px] justify-center'>
+ {record.promoCode}
+ </span>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ {showGradient && (
+ <div
+ className='absolute bottom-0 left-0 right-0 h-[80px] pointer-events-none'
+ style={{ background: 'linear-gradient(0deg, #24282F 0%, rgba(36, 40, 47, 0.00) 100%)' }}
+ />
+ )}
+ </div>
+
+ {/* 底部说明 */}
+ <div className='mt-[32px] text-[28px] text-white/50 space-y-[4px] px-[32px] font-[700] font-[roboto]'>
+ <p>1. Save up to 50 past redemption records.</p>
+ <p>2. For any issues, contact our customer support.</p>
+ </div>
+ </div>
+ );
+};
+
+export default RecordsDialog;
src/app/components/redeemCode/redeemCodeDialog.tsx
+90 -0
@@ -0,0 +1,90 @@
+import type { FC } from 'react';
+import { useEffect, useRef } from 'react';
+
+import { useRedeemCodeDialog } from '@/app/components/redeemCode/hooks/useRedeemCodeDialog';
+import { useRedeemCodeSubmit } from '@/app/components/redeemCode/hooks/useRedeemCodeSubmit';
+import Button from '@/core/components/button/Button';
+import Image from '@/core/components/image/Image';
+import Input from '@/core/components/input/Input';
+import TrackProfile from '@/core/track/TrackProfile';
+
+interface RedeemCodeDialogProps {
+ closeDialog?: () => void;
+}
+
+const RedeemCodeDialog: FC<RedeemCodeDialogProps> = ({ closeDialog }) => {
+ const { openRecordsDialog } = useRedeemCodeDialog();
+ const { code, setCode, errorMsg, loading, handleSubmit, handleKeyDown } = useRedeemCodeSubmit({
+ onSuccess: () => closeDialog?.(),
+ });
+
+ const inputRef = useRef<any>(null);
+
+ useEffect(() => {
+ TrackProfile.redeemCodeShow();
+ setTimeout(() => {
+ inputRef.current?.focus?.();
+ }, 100);
+ }, []);
+
+ return (
+ <div className='w-[480px] xs:w-[638px] bg-[#24282F] rounded-[20px] xs:rounded-[32px] py-[32px] text-center'>
+ {/* 标题 */}
+ <p className='text-white text-[24px] xs:text-[36px] font-[roboto] font-[800] mb-[20px]'>Redeem Code</p>
+
+ {/* 装饰图 */}
+ <div className='w-full -mt-[50px] -mb-[40px]'>
+ <Image
+ src='/images/redeem/redeem_code_bg.webp'
+ alt='Benefits'
+ width={200}
+ height={120}
+ className='w-full h-auto'
+ />
+ </div>
+ <div className='px-[26px]'>
+ {/* 输入框 */}
+ <Input
+ ref={inputRef}
+ placeholder='Enter Your Redeem Code'
+ value={code}
+ onChange={setCode}
+ onKeyDown={handleKeyDown}
+ className='mb-[12px]'
+ bodyClassName='text-center'
+ />
+
+ {/* 错误提示 */}
+ {errorMsg && (
+ <p className='text-[#FF0707] text-[12px] text-left xs:text-[28px] mb-[12px] font-[800]'>
+ {errorMsg}
+ </p>
+ )}
+
+ {/* 说明文字 */}
+ <div className='text-left text-[16px] xs:text-[28px] font-[700] font-[roboto] text-white/50 mb-[36px] space-y-[4px]'>
+ <p>1. Code case sensitivity applies.</p>
+ <p>2. Contact customer support if you experience any issues.</p>
+ </div>
+
+ {/* 按钮组 */}
+ <div className='flex gap-[12px]'>
+ <Button
+ label='Records'
+ onClick={openRecordsDialog}
+ className='flex-1 border border-[#FFA600] text-[#FFA600] bg-transparent hover:bg-[#FFA600]/10 rounded-[12px] xs:rounded-[24px] h-[44px] xs:h-[88px] text-[16px] xs:text-[32px] font-[700]'
+ />
+ <Button
+ label='Submit'
+ onClick={handleSubmit}
+ loading={loading}
+ disabled={!code.trim()}
+ className='flex-1 rounded-[12px] xs:rounded-[24px] h-[44px] xs:h-[88px] text-[16px] xs:text-[32px] font-[700]'
+ />
+ </div>
+ </div>
+ </div>
+ );
+};
+
+export default RedeemCodeDialog;
src/app/response/RedeemCode.ts
+22 -0
@@ -0,0 +1,22 @@
+// 兑换码活动相关类型定义
+
+export declare namespace NSRedeemCode {
+ // 兑换码提交响应
+ export interface ApplyResponse {
+ promoCode: string;
+ resultMap?: any;
+ rewardGc?: number; // GC奖励数量
+ rewardSc?: number; // SC奖励数量
+ }
+
+ // 兑换记录项
+ export interface Record {
+ promoCode: string; // 兑换码
+ gc: number; // GC奖励数量
+ sc: number; // SC奖励数量
+ createTime: number; // 创建时间(时间戳,秒)
+ }
+
+ // 兑换记录列表响应
+ export type RecordsResponse = Record[];
+}
src/app/response/index.ts
+6 -0
@@ -3,6 +3,7 @@ import { NSGame } from '@/app/response/Game';
import { NSMail } from '@/app/response/Mail';
import { NSPersonalInfo } from '@/app/response/PersonalInfo';
import { NSRedeem } from '@/app/response/Redeem';
+import { NSRedeemCode } from '@/app/response/RedeemCode';
import { NSRewards } from '@/app/response/Rewards';
import { NSStore } from '@/app/response/Store';
import { NSUser } from '@/app/response/User';
@@ -77,4 +78,9 @@ export declare namespace Response {
export type MailItemDetails = NSMail.MailItemDetails;
export type UpdateMailStatusResponse = NSMail.UpdateMailStatusResponse;
+
+ // 兑换码活动相关类型
+ export type RedeemCodeApplyResponse = NSRedeemCode.ApplyResponse;
+ export type RedeemCodeRecord = NSRedeemCode.Record;
+ export type RedeemCodeRecords = NSRedeemCode.RecordsResponse;
}
src/app/toolbox/http/handleErrorCode.ts
+7 -0
@@ -120,6 +120,13 @@ const errorCodeMappings: Record<number, ErrorCodeHandler> = {
1022920: { message: 'Reward already claimed', action: 'toast' }, // 奖励已领取,无法重复领取
1022123: { message: 'No more coins.', action: 'toast' }, // cashback: 余额不足
+ // === 兑换码相关错误 ===
+ 1022950: { message: 'Invalid code. Please try again.', action: 'custom' }, // 福利码无效
+ 1022951: { message: 'This code has expired.', action: 'custom' }, // 福利码过期
+ 1022952: { message: 'You must meet the requirements in order to redeem.', action: 'custom' }, // 不符合领取条件
+ 1022953: { message: 'You must meet the rollover requirements in order to redeem.', action: 'custom' }, // GMV不达标
+ 1022960: { message: 'Requirements not met.', action: 'custom' }, // 未满足活动条件
+
// 社交登录相关错误提示
1022861: { message: 'Social media login provider error.', action: 'toast' }, // 三方社媒登录方式错误
1022862: { message: 'Origin format error.', action: 'toast' }, // 第三方社媒登录origin格式错误
src/core/components/pagination/Pagination.tsx
+53 -0
@@ -0,0 +1,53 @@
+import type { FC } from 'react';
+
+import classnames from 'classnames';
+
+import ArrowIcon from '@/app/assets/icons/arrow.svg';
+import Button from '@/core/components/button/Button';
+
+interface PaginationProps {
+ total: number;
+ pageSize?: number;
+ currentPage: number;
+ onPrev: () => void;
+ onNext: () => void;
+ className?: string;
+}
+
+const Pagination: FC<PaginationProps> = ({ total = 0, pageSize = 5, currentPage, onPrev, onNext, className }) => {
+ const totalPages = Math.ceil(total / pageSize);
+ const disablePrev = currentPage <= 1;
+ const disableNext = currentPage >= totalPages;
+
+ const buttonClass =
+ 'flex justify-center items-center bg-[#1F222A] flex justify-center items-center h-[36px] xs:h-[54px] rounded-[12px] xs:rounded-[16px] hover:bg-[rgba(255,255,255,0.05';
+ const dirButtonClass = 'relative text-white/40 w-[6px] xs:w-[12px] h-[9.6px] xs:h-[24px]';
+
+ return (
+ <div className={classnames('flex justify-center items-center gap-[12px]', className)}>
+ <Button
+ label=''
+ icon={ArrowIcon}
+ disabled={disablePrev}
+ iconClassName={classnames(dirButtonClass, disablePrev && 'text-[#5F6268]')}
+ className={classnames('w-[36px]', buttonClass)}
+ onClick={onPrev}
+ disableClassName=''
+ />
+
+ <span className='text-white/50 text-[20px] font-[roboto] font-[700]'>{currentPage}</span>
+
+ <Button
+ label=''
+ icon={ArrowIcon}
+ disabled={disableNext}
+ iconClassName={classnames(dirButtonClass, disableNext && 'text-[#5F6268]')}
+ className={classnames('w-[36px] scale-x-[-1]', buttonClass)}
+ onClick={onNext}
+ disableClassName=''
+ />
+ </div>
+ );
+};
+
+export default Pagination;
src/core/constant/apiUrl.ts
+5 -1
@@ -201,5 +201,9 @@ export const apiUrl = {
GetGmvTournamentSettle: '/hs-apis/activity/v1.0/gmv_tournament/settle_rank',
// 消耗次日返
BonusReturnInfo: '/hs-apis/activity/v1.0/bonus_return/get_info',
- ClaimBonusReturn: '/hs-apis/activity/v1.0/bonus_return/claim'
+ ClaimBonusReturn: '/hs-apis/activity/v1.0/bonus_return/claim',
+
+ // 兑换码活动相关
+ RedeemCodeApply: '/hs-apis/acct/v1.0/promo_code/apply',
+ RedeemCodeRecords: '/hs-apis/acct/v1.0/promo_code/get_reward_record'
};
src/core/track/TrackGmv.ts
+10 -4
@@ -44,8 +44,11 @@ class TrackGmv extends TrackBase {
}
// 免费礼包collect点击
- freePackCollectClick() {
- this.T513('tournament_pack_free_click');
+ freePackCollectClick(gc: number, sc: number) {
+ this.T513('tournament_pack_free_click', undefined, undefined, [
+ ['rewards_gc', String(gc)],
+ ['rewards_sc', String(sc)]
+ ]);
}
// 付费礼包购买点击
@@ -59,8 +62,11 @@ class TrackGmv extends TrackBase {
}
// 结算弹窗奖励领取
- settlementCollect() {
- this.T513('tournament_settlement_reward');
+ settlementCollect(gc: number, sc: number) {
+ this.T513('tournament_settlement_reward', undefined, undefined, [
+ ['rewards_gc', String(gc)],
+ ['rewards_sc', String(sc)]
+ ]);
}
}
src/core/track/TrackProfile.ts
+19 -0
@@ -130,6 +130,25 @@ class TrackProfile extends TrackBase {
successSocial(socialPlatform: SocialProvider) {
this.T513('auth_social_connect_suc', undefined, undefined, [['social_platform', socialPlatform]]);
}
+
+ // 点击兑换码入口
+ clickRedeemCode() {
+ this.T513('click_redeem_code');
+ }
+
+ // 兑换码弹窗展示
+ redeemCodeShow() {
+ this.T513('redeem_code_show');
+ }
+
+ // 兑换码弹窗内领取
+ redeemCodeClollect(success: number, code: string, result: number[]) {
+ this.T513('redeem_code_submit', undefined, undefined, [
+ ['eb_key1', String(success)],
+ ['redeem_code', code],
+ ['redeem_result', JSON.stringify(result)]
+ ]);
+ }
}
export default new TrackProfile();