Files
Genarrative/src/components/platform-entry/PlatformProfileReferralModal.tsx
T
kdletters e8c2e8d532 接入填码原生剪贴板读取
邀请码填写和兑换码弹窗在宿主声明 clipboard.readText 时显示粘贴入口

粘贴动作只读取宿主纯文本并填入现有受控输入框,不自动提交或伪造兑换成功

补充填码粘贴回归测试和 profile 剪贴板 helper

更新 Expo/Tauri 宿主壳方案、HostBridge 协议和共享决策记录
2026-06-18 06:48:46 +08:00

344 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}