2025-09-20 21:11:45 +08:00

346 lines
19 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.

"use client";
import React from 'react';
import { get } from '@/api/request';
import { DashboardLayout } from "@/components/layout/dashboard-layout";
/**
* Share (Invite) Page - Static UI with mocked data.
* Sections: Invite Flow, My Invitation Code, Invite Records (with pagination)
*/
/**
* 邀请记录项
*/
type InviteRecord = {
user_email: string;
user_id: string;
user_name: string;
created_at: number;
reward_status: number;
activation_credits: number;
first_payment_credits: number;
};
/**
* 分页信息
*/
type PaginationInfo = {
page: number;
page_size: number;
total: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
/**
* 邀请记录API响应
*/
type InviteRecordsResponse = {
successful: boolean;
code: number;
message: string;
data: {
record_list: InviteRecord[];
pagination: PaginationInfo;
};
};
const PAGE_SIZE = 10;
/**
* Format epoch ms using browser preferred language.
* @param {number} epochMs - timestamp in milliseconds
* @returns {string} - localized date time string
*/
function formatLocalTime(epochMs: number): string {
try {
const locale = typeof navigator !== 'undefined' ? navigator.language : 'en-US';
const dtf = new Intl.DateTimeFormat(locale, {
dateStyle: 'medium',
timeStyle: 'medium',
});
return dtf.format(new Date(epochMs));
} catch {
return new Date(epochMs).toLocaleString();
}
}
/**
* 从后端获取邀请记录
* @returns {Promise<InviteRecordsResponse>}
*/
async function fetchInviteRecords(): Promise<InviteRecordsResponse> {
const res = await get<InviteRecordsResponse>('/api/user_fission/query_invite_record');
return res;
}
export default function SharePage(): JSX.Element {
// Mocked data (to be replaced by real API integration later)
const [inviteCode, setInviteCode] = React.useState<string>('');
const [invitedCount, setInvitedCount] = React.useState<number>(0);
const [totalInviteCredits, setTotalInviteCredits] = React.useState<number>(0);
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
const [records, setRecords] = React.useState<InviteRecord[]>([]);
const [pagination, setPagination] = React.useState<PaginationInfo>({
page: 1,
page_size: 20,
total: 0,
total_pages: 1,
has_next: false,
has_prev: false
});
const [pageIndex, setPageIndex] = React.useState<number>(0);
const [expandedRowIds, setExpandedRowIds] = React.useState<Set<string>>(() => new Set());
React.useEffect(() => {
let mounted = true;
(async () => {
try {
const response = await fetchInviteRecords();
if (mounted && response.successful) {
setRecords(response.data.record_list);
setPagination(response.data.pagination);
}
} catch {
// 保持静默失败,页面仍可用
}
// 获取邀请统计信息
try {
const res = await get<any>('/api/user_fission/my_invite_stats');
const stats = res?.data ?? {};
if (mounted) {
if (typeof stats.total_invited === 'number') {
setInvitedCount(stats.total_invited);
}
if (typeof stats.total_invite_credits === 'number') {
setTotalInviteCredits(stats.total_invite_credits);
}
}
} catch {
// 保持静默失败
}
try {
const res = await get<any>('/api/user_fission/my_invite_code');
const code = res?.data?.invite_code ?? res?.data?.inviteCode ?? '';
if (mounted && typeof code === 'string') setInviteCode(code);
} catch {
// 保持静默失败
}
})();
return () => {
mounted = false;
};
}, []);
const totalPages = pagination.total_pages;
const pagedRecords = records; // 后端已经分页直接使用records
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(location.origin + '/signup?inviteCode=' + inviteCode);
setCopyState('copied');
window.setTimeout(() => setCopyState('idle'), 1600);
} catch {
setCopyState('error');
window.setTimeout(() => setCopyState('idle'), 1600);
}
}, [inviteCode]);
const toggleRow = React.useCallback((id: string) => {
setExpandedRowIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}, []);
return (
<DashboardLayout>
<div data-alt="share-page" className="w-full h-full overflow-y-auto bg-black text-white">
<div
data-alt="container"
className="w-full max-w-[95%] mx-auto px-4 py-10 sm:px-6 md:px-8 lg:px-12 xl:px-16 2xl:px-20"
>
<header data-alt="page-header" className="mb-8 flex items-end justify-between">
<div data-alt="title-box">
<h1 data-alt="title" className="text-2xl font-semibold text-white">Invite Friends</h1>
<p data-alt="subtitle" className="mt-1 text-sm text-white/60">Invite friends to join and earn rewards.</p>
</div>
</header>
{/* Section 1: Invite Flow */}
<section data-alt="invite-flow" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invitation Flow</h2>
<ol data-alt="steps" className="mt-4 grid gap-4 sm:grid-cols-3">
<li data-alt="step" className="rounded-md border border-white/20 p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 1</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation code and share it with friends.</p>
</li>
<li data-alt="step" className="rounded-md border border-white/20 p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 2</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
</li>
<li data-alt="step" className="rounded-md border border-white/20 p-4">
<div data-alt="step-header" className="flex items-center justify-between">
<span className="text-sm font-medium text-custom-blue/50">Step 3</span>
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
</div>
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
</li>
</ol>
</section>
{/* Section 2: My Invitation Code */}
<section data-alt="my-invite-code" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="code-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
<div data-alt="code-box" className="sm:col-span-2">
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Code</h2>
<div className="flex items-center gap-3">
<div data-alt="code" className="rounded-md border border-white/20 bg-white/10 px-4 py-2 text-lg font-semibold tracking-wider text-white">
{inviteCode}
</div>
<button
data-alt="copy-button"
className="inline-flex h-9 items-center justify-center rounded-full bg-gradient-to-r from-custom-blue to-custom-purple px-3 text-sm font-medium text-black/90 hover:opacity-90 active:translate-y-px"
onClick={handleCopy}
type="button"
aria-label="Copy invitation code"
>
{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Failed' : 'Copy'}
</button>
</div>
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this code. Your friends can enter it during registration.</p>
</div>
<div data-alt="total-credits" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
<span className="text-sm text-white/70">Total Credits</span>
<span className="mt-1 text-2xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-custom-blue to-custom-purple">{totalInviteCredits}</span>
<span className="mt-2 text-xs text-white/60">All credits earned from invitations.</span>
</div>
<div data-alt="invited-count" className="flex flex-col items-start justify-center rounded-md border border-white/20 bg-white/5 p-4">
<span className="text-sm text-white/70">Invited Friends</span>
<span className="mt-1 text-2xl font-semibold text-transparent bg-clip-text bg-gradient-to-r from-custom-blue to-custom-purple">{invitedCount}</span>
<span className="mt-2 text-xs text-white/60">Points detail will be available soon.</span>
</div>
</div>
</section>
{/* Section 3: Invite Records */}
<section data-alt="invite-records" className="rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="section-header" className="mb-4 flex items-center justify-between">
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invite Records</h2>
<div data-alt="pagination" className="flex items-center gap-2">
<button
data-alt="prev-page"
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
disabled={!pagination.has_prev}
aria-label="Previous page"
>
Prev
</button>
<span data-alt="page-info" className="text-sm text-white/70">
{pagination.page} / {pagination.total_pages}
</span>
<button
data-alt="next-page"
className="inline-flex h-8 items-center justify-center rounded border border-gray-300 bg-white px-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
type="button"
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
disabled={!pagination.has_next}
aria-label="Next page"
>
Next
</button>
</div>
</div>
<div data-alt="table-wrapper" className="overflow-x-auto">
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
<thead data-alt="table-head" className="bg-black">
<tr data-alt="table-head-row">
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-48">Invited Username</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 w-56">Registered At</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
</tr>
</thead>
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
{pagedRecords.map((r) => {
const inviteRewardDisplay = r.reward_status === 1 ? r.activation_credits : 0;
const payRewardDisplay = r.first_payment_credits;
const totalReward = inviteRewardDisplay + payRewardDisplay;
const isExpanded = expandedRowIds.has(r.user_id);
return (
<React.Fragment key={r.user_id}>
<tr data-alt="table-row" className="hover:bg-white/5">
<td className="px-4 py-3 text-sm text-white w-48">{r.user_name}</td>
<td className="px-4 py-3 text-sm text-white/80 w-56 whitespace-nowrap">{formatLocalTime(r.created_at * 1000)}</td>
<td className="px-4 py-3 text-sm text-white/90">
<div data-alt="reward-cell" className="flex items-center justify-between gap-3">
<div data-alt="reward-summary" className="flex-1 truncate text-[#FFCC6D]">
{totalReward}
</div>
<button
type="button"
data-alt="expand-button"
className="inline-flex items-center rounded border border-white/20 px-2 py-1 text-xs text-white/90 hover:bg-white/10"
aria-expanded={isExpanded}
aria-label={isExpanded ? 'Collapse reward details' : 'Expand reward details'}
onClick={() => toggleRow(r.user_id)}
>
{isExpanded ? 'Hide' : 'Details'}
</button>
</div>
</td>
</tr>
{isExpanded && (
<tr data-alt="row-details">
<td className="px-4 py-0 w-48" />
<td className="px-4 py-0 w-56" />
<td className="px-4 py-3 bg-white/5">
<div data-alt="details-wrapper" className="overflow-x-auto">
<table data-alt="reward-subtable" className="min-w-[320px] text-sm">
<tbody data-alt="subtable-body" className="text-white/90">
<tr>
<td className="px-2 py-2">{inviteRewardDisplay ? inviteRewardDisplay : 'Unverified'}</td>
<td className="px-2 py-2">Register Reward</td>
</tr>
<tr>
<td className="px-2 py-2">{payRewardDisplay ? payRewardDisplay : 'Unpaid'}</td>
<td className="px-2 py-2">First Pay Reward</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</div>
</div>
</DashboardLayout>
);
}