我的页签恢复邀请好友入口,AGC 客户端账号菜单接入邀请码与玩家社区
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Failing after 36s
Project CI / Repository checks (pull_request) Failing after 26s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 12m41s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m50s
Project CI / Backend tests (pull_request) Failing after 15s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m26s
Project CI / Native shell tests (pull_request) Failing after 36s
Project CI / Repository checks (pull_request) Failing after 26s
Project CI / Frontend tests (pull_request) Successful in 2m35s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m25s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 11m17s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 12m41s
- 主站「我的」页签常用功能宫格补回「邀请好友」,邀请码与邀请链接拆成两个复制动作 - 邀请弹层下沉到 packages/shared/src/components/PlatformProfileReferralModal,主站与客户端共用一份实现 - 邀请奖励说明不再写死次数与金额,每日上限由 todayInviterRewardCount 与 todayInviterRewardRemaining 推导 - 奖励说明从面板正文移入右上角帮助按钮,鼠标悬浮展开、触屏与键盘点击开合 - 帮助按钮复用平台 .platform-icon-button 配色,说明气泡复用 PlatformStatusMessage 的 warning token - AGC 客户端账号菜单新增「邀请码」与「玩家社区」两项,弹层标题与菜单入口文案保持一致 - 新增 Tauri 命令 read_profile_referral_invite_center,用登录 origin 补全 inviteLinkPath 后交给渲染层 - 客户端邀请中心按登录账号隔离,切号重置并作废在飞响应,不本地另存邀请码 - 新命令登记进 apps/ai-game-creator-shell/scripts/check-config.mjs 的 native-only 白名单门禁 - 微信群与 QQ 群二维码从 media/social-media-group 迁入共享组件 assets - 移除已下线文件的 Tailwind @source 与客户端专用弹层样式,原地删除旧邀请弹层实现 - 同步决策记录与产品基线文档中邀请奖励、跨端复用与文案口径
This commit is contained in:
@@ -18,6 +18,7 @@ const callbacks = {
|
||||
onOpenApiKeys: vi.fn(),
|
||||
onOpenCommunity: vi.fn(),
|
||||
onOpenFeedback: vi.fn(),
|
||||
onOpenInvite: vi.fn(),
|
||||
onOpenRecharge: vi.fn(),
|
||||
onOpenRewardCode: vi.fn(),
|
||||
onOpenSettings: vi.fn(),
|
||||
@@ -90,6 +91,7 @@ describe('PlatformActiveProfileView', () => {
|
||||
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /邀请好友/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
|
||||
@@ -98,6 +100,9 @@ describe('PlatformActiveProfileView', () => {
|
||||
screen.getByRole('button', { name: /开发者 API Key/u }),
|
||||
).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /邀请好友/u }));
|
||||
expect(callbacks.onOpenInvite).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '用户协议' }));
|
||||
const legalDialog = screen.getByRole('dialog', { name: '用户协议' });
|
||||
expect(legalDialog.parentElement?.className).toContain(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Pencil,
|
||||
Settings,
|
||||
Ticket,
|
||||
UserPlus,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
@@ -21,6 +22,7 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||||
import profileCoinsImage from '../../../media/profile/_Image (4).png';
|
||||
import profileInviteImage from '../../../media/profile/_Image (5).png';
|
||||
import profileGiftImage from '../../../media/profile/_Image (6).png';
|
||||
import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
@@ -69,6 +71,7 @@ type PlatformActiveProfileViewProps = {
|
||||
onOpenApiKeys: () => void;
|
||||
onOpenCommunity: () => void;
|
||||
onOpenFeedback: () => void;
|
||||
onOpenInvite: () => void;
|
||||
onOpenRecharge: () => void;
|
||||
onOpenRewardCode: () => void;
|
||||
onOpenSettings: () => void;
|
||||
@@ -278,6 +281,7 @@ export function PlatformActiveProfileView({
|
||||
onOpenApiKeys,
|
||||
onOpenCommunity,
|
||||
onOpenFeedback,
|
||||
onOpenInvite,
|
||||
onOpenRecharge,
|
||||
onOpenRewardCode,
|
||||
onOpenSettings,
|
||||
@@ -606,7 +610,7 @@ export function PlatformActiveProfileView({
|
||||
className="platform-profile-shortcut-panel"
|
||||
aria-label="常用功能"
|
||||
>
|
||||
<div className="platform-profile-shortcut-grid grid w-full grid-cols-4">
|
||||
<div className="platform-profile-shortcut-grid grid w-full grid-cols-5">
|
||||
<ProfileShortcutButton
|
||||
label="泥点充值"
|
||||
subLabel="充值泥点"
|
||||
@@ -614,6 +618,13 @@ export function PlatformActiveProfileView({
|
||||
imageSrc={profileCoinsImage}
|
||||
onClick={onOpenRecharge}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="邀请好友"
|
||||
subLabel="双方得泥点"
|
||||
icon={UserPlus}
|
||||
imageSrc={profileInviteImage}
|
||||
onClick={onOpenInvite}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="兑换码"
|
||||
subLabel="领取福利"
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
|
||||
import { PlatformMudPointWalletEntry } from '../../../packages/shared/src/components/PlatformMudPointWalletEntry';
|
||||
import { PlatformProfileRechargeModal } from '../../../packages/shared/src/components/PlatformProfileRechargeModal';
|
||||
import { PlatformProfileReferralModal } from '../../../packages/shared/src/components/PlatformProfileReferralModal';
|
||||
import { PlatformProfileWalletLedgerModal } from '../../../packages/shared/src/components/PlatformProfileWalletLedgerModal';
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
@@ -46,7 +47,10 @@ import { PlatformActiveProfileView } from './PlatformActiveProfileView';
|
||||
import type { PlatformEntryFlowShellProps } from './platformEntryActiveTypes';
|
||||
import { usePlatformDesktopLayout } from './platformEntryResponsive';
|
||||
import { PlatformProfileApiKeysModal } from './PlatformProfileApiKeysModal';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from './PlatformProfileRewardCodeRedeemModal';
|
||||
import {
|
||||
PlatformRechargePaymentConfirmationMask,
|
||||
@@ -511,6 +515,20 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('project', { path: '/project' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
/**
|
||||
* 「填邀请码」的粘贴动作。
|
||||
*
|
||||
* 宿主能力留在宿主:共享弹层只按 `canPasteRedeemCode` 决定是否渲染按钮,读取宿主
|
||||
* 剪贴板由主站自己完成,读到文本只填进输入框,不自动提交。
|
||||
*/
|
||||
const pasteReferralRedeemCode = useCallback(() => {
|
||||
void readNativeHostClipboardText().then((text) => {
|
||||
if (text) {
|
||||
profileCenter.setReferralRedeemCode(text);
|
||||
}
|
||||
});
|
||||
}, [profileCenter]);
|
||||
|
||||
const isMobileToolStage =
|
||||
!isDesktopLayout &&
|
||||
(selectionStage === 'creation-home' ||
|
||||
@@ -766,6 +784,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
profileCenter.openProfilePopupPanel('community')
|
||||
}
|
||||
onOpenFeedback={openFeedback}
|
||||
onOpenInvite={() =>
|
||||
profileCenter.openProfilePopupPanel('invite')
|
||||
}
|
||||
onOpenRecharge={openRecharge}
|
||||
onOpenRewardCode={profileCenter.openRewardCodeModal}
|
||||
onOpenSettings={() => authUi?.openSettingsModal()}
|
||||
@@ -870,14 +891,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformProfileReferralModal
|
||||
panel={profileCenter.profilePopupPanel}
|
||||
center={profileCenter.referralCenter}
|
||||
inviteLinkUrl={profileCenter.inviteLinkUrl}
|
||||
isLoading={profileCenter.isLoadingReferral}
|
||||
isSubmittingRedeem={profileCenter.isSubmittingReferralRedeem}
|
||||
redeemCode={profileCenter.referralRedeemCode}
|
||||
copyInviteState={profileCenter.inviteCopyState}
|
||||
copyInviteLinkState={profileCenter.inviteLinkCopyState}
|
||||
copyInviteCodeState={profileCenter.inviteCodeCopyState}
|
||||
error={profileCenter.referralError}
|
||||
success={profileCenter.referralSuccess}
|
||||
canPasteRedeemCode={canPasteFromNativeHostClipboard()}
|
||||
onPasteRedeemCode={pasteReferralRedeemCode}
|
||||
onClose={profileCenter.closeProfilePopupPanel}
|
||||
onCopyInvite={profileCenter.copyInviteInfo}
|
||||
onCopyInviteLink={profileCenter.copyInviteLink}
|
||||
onCopyInviteCode={profileCenter.copyInviteCode}
|
||||
onRedeemCodeChange={profileCenter.setReferralRedeemCode}
|
||||
onSubmitRedeemCode={profileCenter.submitReferralRedeemCode}
|
||||
/>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileReferralModal } from './PlatformProfileReferralModal';
|
||||
|
||||
vi.mock('./platformProfileHostClipboard', () => ({
|
||||
canPasteFromNativeHostClipboard: vi.fn(() => false),
|
||||
readNativeHostClipboardText: vi.fn(),
|
||||
}));
|
||||
|
||||
const canPasteFromNativeHostClipboardMock = vi.mocked(
|
||||
canPasteFromNativeHostClipboard,
|
||||
);
|
||||
const readNativeHostClipboardTextMock = vi.mocked(readNativeHostClipboardText);
|
||||
|
||||
function buildCenter(
|
||||
overrides: Partial<ProfileReferralInviteCenterResponse> = {},
|
||||
): ProfileReferralInviteCenterResponse {
|
||||
return {
|
||||
inviteCode: 'ABCD1234',
|
||||
inviteLinkPath: '/invite/ABCD1234',
|
||||
invitedCount: 1,
|
||||
rewardedInviteCount: 1,
|
||||
todayInviterRewardCount: 1,
|
||||
todayInviterRewardRemaining: 9,
|
||||
rewardPoints: 66,
|
||||
invitedUsers: [
|
||||
{
|
||||
userId: 'user-2',
|
||||
displayName: '海盐',
|
||||
avatarUrl: null,
|
||||
boundAt: '2026-06-10T08:00:00.000Z',
|
||||
},
|
||||
],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-06-10T08:00:00.000Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlatformProfileReferralModal', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
canPasteFromNativeHostClipboardMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders invite panel with shared profile content', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="invite"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={vi.fn()}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '邀请好友' });
|
||||
|
||||
expect(within(dialog).getByText('邀请码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('ABCD1234')).toBeTruthy();
|
||||
expect(within(dialog).getByText('海盐')).toBeTruthy();
|
||||
expect(within(dialog).getByText('成功邀请')).toBeTruthy();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: /复制邀请/u }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('submits redeem panel with the shared form shell', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCodeChange = vi.fn();
|
||||
const onSubmitRedeemCode = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="redeem"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode="ab12"
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={onRedeemCodeChange}
|
||||
onSubmitRedeemCode={onSubmitRedeemCode}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '填邀请码' });
|
||||
const input = within(dialog).getByRole('textbox', { name: '邀请码' });
|
||||
|
||||
await user.type(input, ' c');
|
||||
expect(onRedeemCodeChange).toHaveBeenCalled();
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: '提交' }));
|
||||
expect(onSubmitRedeemCode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('redeem panel can paste invite code from native host clipboard', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRedeemCodeChange = vi.fn();
|
||||
canPasteFromNativeHostClipboardMock.mockReturnValue(true);
|
||||
readNativeHostClipboardTextMock.mockResolvedValue('spring-2026');
|
||||
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="redeem"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={onRedeemCodeChange}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '粘贴' }));
|
||||
|
||||
expect(readNativeHostClipboardTextMock).toHaveBeenCalledTimes(1);
|
||||
expect(onRedeemCodeChange).toHaveBeenCalledWith('spring-2026');
|
||||
});
|
||||
|
||||
test('renders community QR panels', () => {
|
||||
render(
|
||||
<PlatformProfileReferralModal
|
||||
panel="community"
|
||||
center={buildCenter()}
|
||||
isLoading={false}
|
||||
isSubmittingRedeem={false}
|
||||
redeemCode=""
|
||||
copyInviteState="idle"
|
||||
error={null}
|
||||
success={null}
|
||||
onClose={vi.fn()}
|
||||
onCopyInvite={vi.fn()}
|
||||
onRedeemCodeChange={vi.fn()}
|
||||
onSubmitRedeemCode={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '玩家社区' });
|
||||
|
||||
expect(within(dialog).getByAltText('玩家社区微信群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
|
||||
expect(within(dialog).getByText('微信群')).toBeTruthy();
|
||||
expect(within(dialog).getByText('QQ群')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,343 +0,0 @@
|
||||
import { ClipboardPaste, Copy } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import communityQqQrImage from '../../../media/social-media-group/qq.png';
|
||||
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
|
||||
import type { ProfileReferralInviteCenterResponse } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
|
||||
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { PlatformTextField } from '../common/PlatformTextField';
|
||||
import type { CopyFeedbackState } from '../common/useCopyFeedback';
|
||||
import {
|
||||
canPasteFromNativeHostClipboard,
|
||||
readNativeHostClipboardText,
|
||||
} from './platformProfileHostClipboard';
|
||||
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
|
||||
import type { ProfileReferralPanel } from './usePlatformProfileCenterController';
|
||||
|
||||
type PlatformProfileReferralModalProps = {
|
||||
panel: ProfileReferralPanel;
|
||||
center: ProfileReferralInviteCenterResponse | null;
|
||||
isLoading: boolean;
|
||||
isSubmittingRedeem: boolean;
|
||||
redeemCode: string;
|
||||
copyInviteState: CopyFeedbackState;
|
||||
error: string | null;
|
||||
success: string | null;
|
||||
onClose: () => void;
|
||||
onCopyInvite: () => void;
|
||||
onRedeemCodeChange: (value: string) => void;
|
||||
onSubmitRedeemCode: () => void;
|
||||
};
|
||||
|
||||
const COMMUNITY_QR_CODES = [
|
||||
{
|
||||
label: '微信群',
|
||||
src: communityWechatQrImage,
|
||||
alt: '玩家社区微信群二维码',
|
||||
},
|
||||
{
|
||||
label: 'QQ群',
|
||||
src: communityQqQrImage,
|
||||
alt: '玩家社区 QQ 群二维码',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function ProfileReferralUserAvatar({
|
||||
name,
|
||||
avatarUrl,
|
||||
}: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
}) {
|
||||
const avatarLabel = (name.trim() || '玩').slice(0, 1).toUpperCase();
|
||||
|
||||
return (
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#ff4056] text-xs font-black text-white">
|
||||
{avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
avatarLabel
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePanelTitle(panel: ProfileReferralPanel) {
|
||||
if (panel === 'invite') {
|
||||
return '邀请好友';
|
||||
}
|
||||
if (panel === 'redeem') {
|
||||
return '填邀请码';
|
||||
}
|
||||
return '玩家社区';
|
||||
}
|
||||
|
||||
/**
|
||||
* 个人中心邀请能力统一弹层。
|
||||
* 承接邀请码、填码和社区二维码三种 profile panel,避免首页继续内联重复白底浮层实现。
|
||||
*/
|
||||
export function PlatformProfileReferralModal({
|
||||
panel,
|
||||
center,
|
||||
isLoading,
|
||||
isSubmittingRedeem,
|
||||
redeemCode,
|
||||
copyInviteState,
|
||||
error,
|
||||
success,
|
||||
onClose,
|
||||
onCopyInvite,
|
||||
onRedeemCodeChange,
|
||||
onSubmitRedeemCode,
|
||||
}: PlatformProfileReferralModalProps) {
|
||||
const title = resolvePanelTitle(panel);
|
||||
const normalizedRedeemCode = redeemCode
|
||||
.trim()
|
||||
.replace(/[^0-9a-z]/gi, '')
|
||||
.toUpperCase();
|
||||
const canPasteRedeemCode = canPasteFromNativeHostClipboard();
|
||||
const pasteRedeemCode = () => {
|
||||
void readNativeHostClipboardText().then((text) => {
|
||||
if (text) {
|
||||
onRedeemCodeChange(text);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
if (panel === 'community') {
|
||||
content = (
|
||||
<div className="mt-5 grid grid-cols-2 gap-3">
|
||||
{COMMUNITY_QR_CODES.map((qrCode) => (
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
key={qrCode.label}
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
padding="xs"
|
||||
className="text-center"
|
||||
>
|
||||
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-200 bg-white p-1.5">
|
||||
<img
|
||||
src={qrCode.src}
|
||||
alt={qrCode.alt}
|
||||
className="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-bold text-zinc-700">
|
||||
{qrCode.label}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
} else if (panel === 'redeem') {
|
||||
content = (
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-12 even:h-11"
|
||||
/>
|
||||
}
|
||||
isEmpty={Boolean(center?.hasRedeemedCode)}
|
||||
emptyState={
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="inline"
|
||||
tone="base"
|
||||
className="mt-5"
|
||||
>
|
||||
已填写邀请码
|
||||
</PlatformEmptyState>
|
||||
}
|
||||
>
|
||||
<form
|
||||
className="mt-5 space-y-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmitRedeemCode();
|
||||
}}
|
||||
>
|
||||
<PlatformTextField
|
||||
value={redeemCode}
|
||||
onChange={(event) => onRedeemCodeChange(event.target.value)}
|
||||
size="lg"
|
||||
density="roomy"
|
||||
tone="rose"
|
||||
className="rounded-xl text-center font-black uppercase tracking-[0.16em]"
|
||||
placeholder="邀请码"
|
||||
aria-label="邀请码"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
{canPasteRedeemCode ? (
|
||||
<PlatformActionButton
|
||||
type="button"
|
||||
surface="profile"
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
className="w-full"
|
||||
onClick={pasteRedeemCode}
|
||||
>
|
||||
<ClipboardPaste className="h-3.5 w-3.5" />
|
||||
粘贴
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
<PlatformActionButton
|
||||
type="submit"
|
||||
surface="profile"
|
||||
fullWidth
|
||||
size="md"
|
||||
className="rounded-xl"
|
||||
disabled={isSubmittingRedeem || !normalizedRedeemCode}
|
||||
>
|
||||
{isSubmittingRedeem ? '提交中' : '提交'}
|
||||
</PlatformActionButton>
|
||||
</form>
|
||||
</PlatformAsyncStatePanel>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<PlatformAsyncStatePanel
|
||||
isLoading={isLoading}
|
||||
loadingState={
|
||||
<PlatformProfileSkeletonList
|
||||
count={2}
|
||||
containerClassName="mt-5 space-y-3"
|
||||
itemClassName="h-20 odd:h-20 even:h-10"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="mt-5 space-y-3">
|
||||
<PlatformSubpanel
|
||||
as="div"
|
||||
surface="flat"
|
||||
radius="xs"
|
||||
padding="md"
|
||||
className="text-center"
|
||||
>
|
||||
<PlatformFieldLabel
|
||||
variant="section"
|
||||
className="block text-[11px] text-zinc-500"
|
||||
>
|
||||
邀请码
|
||||
</PlatformFieldLabel>
|
||||
<div className="mt-1 text-3xl font-black tracking-[0.16em] text-[#ff4056]">
|
||||
{center?.inviteCode ?? '--------'}
|
||||
</div>
|
||||
</PlatformSubpanel>
|
||||
<PlatformStatusMessage
|
||||
tone="warning"
|
||||
surface="profile"
|
||||
size="md"
|
||||
className="space-y-0.5 px-3.5 font-semibold"
|
||||
>
|
||||
<div>
|
||||
{`邀请一个用户注册,双方都可以获得${center?.rewardPoints ?? 30}泥点。`}
|
||||
</div>
|
||||
<div>每日最多获得十次邀请奖励。</div>
|
||||
</PlatformStatusMessage>
|
||||
<CopyFeedbackButton
|
||||
state={copyInviteState}
|
||||
onClick={onCopyInvite}
|
||||
disabled={!center?.inviteCode}
|
||||
idleLabel="复制邀请"
|
||||
copiedLabel="已复制"
|
||||
failedLabel="复制失败"
|
||||
idleIcon={<Copy className="h-4 w-4" />}
|
||||
actionSurface="profile"
|
||||
actionSize="md"
|
||||
actionFullWidth
|
||||
className="gap-2 rounded-xl"
|
||||
/>
|
||||
<PlatformSubpanel as="div" surface="flat" radius="xs" padding="sm">
|
||||
<PlatformFieldLabel
|
||||
variant="section"
|
||||
className="block text-zinc-900"
|
||||
>
|
||||
成功邀请
|
||||
</PlatformFieldLabel>
|
||||
{center?.invitedUsers?.length ? (
|
||||
<div className="mt-3 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
{center.invitedUsers.map((user) => (
|
||||
<PlatformProfileContentRow
|
||||
key={`${user.userId}-${user.boundAt}`}
|
||||
surface="soft"
|
||||
radius="xs"
|
||||
padding="row"
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<ProfileReferralUserAvatar
|
||||
name={user.displayName}
|
||||
avatarUrl={user.avatarUrl}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-bold text-zinc-900">
|
||||
{user.displayName || '玩家'}
|
||||
</div>
|
||||
</div>
|
||||
</PlatformProfileContentRow>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<PlatformEmptyState
|
||||
surface="subpanel"
|
||||
size="compact"
|
||||
className="mt-3 text-center text-xs font-semibold leading-normal"
|
||||
>
|
||||
暂无成功邀请
|
||||
</PlatformEmptyState>
|
||||
)}
|
||||
</PlatformSubpanel>
|
||||
</div>
|
||||
</PlatformAsyncStatePanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PlatformProfileSecondaryModalShell
|
||||
title={title}
|
||||
onClose={onClose}
|
||||
closeVariant="floatingPlain"
|
||||
closeIcon="×"
|
||||
overlayTone="soft"
|
||||
panelClassName="relative !max-w-[24rem] bg-white text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
|
||||
contentClassName="relative px-5 pb-5 pt-4"
|
||||
>
|
||||
<div className="text-center text-xl font-black">{title}</div>
|
||||
{content}
|
||||
|
||||
{error ? (
|
||||
<PlatformStatusMessage tone="error" className="mt-4">
|
||||
{error}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
{success ? (
|
||||
<PlatformStatusMessage tone="success" className="mt-4">
|
||||
{success}
|
||||
</PlatformStatusMessage>
|
||||
) : null}
|
||||
</PlatformProfileSecondaryModalShell>
|
||||
);
|
||||
}
|
||||
@@ -11,11 +11,62 @@ import {
|
||||
userB,
|
||||
} from './usePlatformProfileCenterController.testSupport';
|
||||
|
||||
const clipboardMock = vi.hoisted(() => ({
|
||||
copyTextToClipboard: vi.fn(async () => true),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/clipboard', () => clipboardMock);
|
||||
|
||||
describe('usePlatformProfileCenterController redemption lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/profile');
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
vi.clearAllMocks();
|
||||
clipboardMock.copyTextToClipboard.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test('copies the invite link and the invite code as separate actions', async () => {
|
||||
profileClientMocks.getPlatformProfileReferralInviteCenter.mockResolvedValue(
|
||||
{
|
||||
inviteCode: 'USER-A',
|
||||
inviteLinkPath: '/?inviteCode=USER-A',
|
||||
invitedCount: 0,
|
||||
rewardedInviteCount: 0,
|
||||
todayInviterRewardCount: 0,
|
||||
todayInviterRewardRemaining: 10,
|
||||
rewardPoints: 30,
|
||||
invitedUsers: [],
|
||||
hasRedeemedCode: false,
|
||||
boundInviterUserId: null,
|
||||
boundAt: null,
|
||||
updatedAt: '2026-10-01T00:00:00.000Z',
|
||||
},
|
||||
);
|
||||
const expectedInviteLink = `${window.location.origin}/?inviteCode=USER-A`;
|
||||
const { result } = renderController(userA);
|
||||
|
||||
await act(async () => {
|
||||
result.current.openProfilePopupPanel('invite');
|
||||
});
|
||||
|
||||
expect(result.current.profilePopupPanel).toBe('invite');
|
||||
expect(result.current.inviteLinkUrl).toBe(expectedInviteLink);
|
||||
|
||||
await act(async () => {
|
||||
result.current.copyInviteLink();
|
||||
});
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
|
||||
expectedInviteLink,
|
||||
);
|
||||
expect(result.current.referralSuccess).toBe('邀请链接已复制');
|
||||
|
||||
await act(async () => {
|
||||
result.current.copyInviteCode();
|
||||
});
|
||||
expect(clipboardMock.copyTextToClipboard).toHaveBeenLastCalledWith(
|
||||
'USER-A',
|
||||
);
|
||||
expect(result.current.referralSuccess).toBe('邀请码已复制');
|
||||
});
|
||||
|
||||
test('ignores a reward-code response after the account changes', async () => {
|
||||
|
||||
@@ -422,7 +422,9 @@ export function usePlatformProfileCenterController({
|
||||
useState(false);
|
||||
const [referralError, setReferralError] = useState<string | null>(null);
|
||||
const [referralSuccess, setReferralSuccess] = useState<string | null>(null);
|
||||
const { copyState: inviteCopyState, copyText: copyInviteText } =
|
||||
const { copyState: inviteLinkCopyState, copyText: copyInviteLinkText } =
|
||||
useCopyFeedback();
|
||||
const { copyState: inviteCodeCopyState, copyText: copyInviteCodeText } =
|
||||
useCopyFeedback();
|
||||
const pendingWechatRechargeOrderRef =
|
||||
useRef<WechatRechargeOrderLifecycle | null>(null);
|
||||
@@ -1701,21 +1703,43 @@ export function usePlatformProfileCenterController({
|
||||
setProfilePopupPanel(null);
|
||||
}, []);
|
||||
|
||||
const copyInviteInfo = useCallback(() => {
|
||||
if (!referralCenter?.inviteCode) {
|
||||
/**
|
||||
* 邀请链接是分享给好友的正式入口(好友打开后自动带上邀请码),
|
||||
* 因此这里只用站点 origin 补全后端返回的相对路径。
|
||||
*/
|
||||
const inviteLinkUrl = useMemo(() => {
|
||||
const inviteLinkPath = referralCenter?.inviteLinkPath;
|
||||
if (!inviteLinkPath) {
|
||||
return null;
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
return inviteLinkPath;
|
||||
}
|
||||
try {
|
||||
return new URL(inviteLinkPath, window.location.origin).href;
|
||||
} catch {
|
||||
return inviteLinkPath;
|
||||
}
|
||||
}, [referralCenter]);
|
||||
|
||||
const copyInviteLink = useCallback(() => {
|
||||
if (!inviteLinkUrl) {
|
||||
return;
|
||||
}
|
||||
void copyInviteLinkText(inviteLinkUrl).then((copied) => {
|
||||
setReferralSuccess(copied ? '邀请链接已复制' : '复制失败');
|
||||
});
|
||||
}, [copyInviteLinkText, inviteLinkUrl]);
|
||||
|
||||
const inviteUrl =
|
||||
typeof window === 'undefined'
|
||||
? referralCenter.inviteLinkPath
|
||||
: new URL(referralCenter.inviteLinkPath, window.location.origin).href;
|
||||
void copyInviteText(`${referralCenter.inviteCode} ${inviteUrl}`).then(
|
||||
(copied) => {
|
||||
setReferralSuccess(copied ? '已复制' : '复制失败');
|
||||
},
|
||||
);
|
||||
}, [copyInviteText, referralCenter]);
|
||||
const copyInviteCode = useCallback(() => {
|
||||
const inviteCode = referralCenter?.inviteCode;
|
||||
if (!inviteCode) {
|
||||
return;
|
||||
}
|
||||
void copyInviteCodeText(inviteCode).then((copied) => {
|
||||
setReferralSuccess(copied ? '邀请码已复制' : '复制失败');
|
||||
});
|
||||
}, [copyInviteCodeText, referralCenter]);
|
||||
|
||||
const submitReferralRedeemCode = useCallback(() => {
|
||||
const inviteCode = referralRedeemCode
|
||||
@@ -1850,7 +1874,10 @@ export function usePlatformProfileCenterController({
|
||||
closeNativeWechatPayment,
|
||||
closeProfilePopupPanel,
|
||||
confirmNativeWechatPayment,
|
||||
inviteCopyState: inviteCopyState as CopyFeedbackState,
|
||||
copyInviteCode,
|
||||
inviteCodeCopyState: inviteCodeCopyState as CopyFeedbackState,
|
||||
inviteLinkCopyState: inviteLinkCopyState as CopyFeedbackState,
|
||||
inviteLinkUrl,
|
||||
isLoadingRechargeCenter,
|
||||
isLoadingReferral,
|
||||
isLoadingWalletLedger,
|
||||
@@ -1892,6 +1919,6 @@ export function usePlatformProfileCenterController({
|
||||
walletLedgerError,
|
||||
wechatRechargeOrderConfirmationState,
|
||||
buyRechargeProduct,
|
||||
copyInviteInfo,
|
||||
copyInviteLink,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user