2025-10-11 16:19:45 +08:00

407 lines
22 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;
// 并行发起所有请求,每个请求完成后立即处理
const fetchRecords = async () => {
try {
const response = await fetchInviteRecords();
if (!mounted) return; // 早期返回检查
if (response.successful) {
setRecords(response.data.record_list);
setPagination(response.data.pagination);
}
} catch (error) {
if (mounted) {
console.warn('Failed to fetch invite records:', error);
}
}
};
const fetchStats = async () => {
try {
const res = await get<any>('/api/user_fission/my_invite_stats');
if (!mounted) return; // 早期返回检查
const stats = res?.data ?? {};
if (typeof stats.total_invited === 'number') {
setInvitedCount(stats.total_invited);
}
if (typeof stats.total_invite_credits === 'number') {
setTotalInviteCredits(stats.total_invite_credits);
}
} catch (error) {
if (mounted) {
console.warn('Failed to fetch invite stats:', error);
}
}
};
const fetchInviteCode = async () => {
try {
const res = await get<any>('/api/user_fission/my_invite_code');
if (!mounted) return; // 早期返回检查
const code = res?.data?.invite_code ?? res?.data?.inviteCode ?? '';
if (typeof code === 'string') {
setInviteCode(code);
}
} catch (error) {
if (mounted) {
console.warn('Failed to fetch invite code:', error);
}
}
};
// 并行发起所有请求,每个请求完成后立即处理响应
fetchRecords();
fetchStats();
fetchInviteCode();
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: Set<string>) => {
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 overflow-x-hidden bg-black text-white">
<div
data-alt="container"
className="w-full max-w-[100%] mx-auto px-4 py-2 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: My Invitation Link */}
<section data-alt="my-invite-link" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="link-panel" className="mt-4 grid gap-6 sm:grid-cols-4">
<div data-alt="link-box" className="sm:col-span-2">
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Link</h2>
<div className="flex items-center gap-3">
<div data-alt="link-container" className="relative w-full max-w-2xl overflow-hidden rounded-md border border-white/20 bg-white/10">
<div
data-alt="link-content"
className="relative px-4 py-2 text-xs sm:text-sm font-mono text-white/90 break-all sm:truncate"
>
{inviteCode ? `${'https://www.movieflow.ai'}/signup?inviteCode=${inviteCode}` : '-'}
</div>
{/* 右侧渐变遮挡 */}
<div
data-alt="right-mask"
className="absolute right-0 top-0 h-full w-16 bg-gradient-to-l from-black/80 to-transparent pointer-events-none"
/>
</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 flex-shrink-0"
onClick={handleCopy}
type="button"
aria-label="Copy invitation link"
>
{copyState === 'copied' ? 'Copied' : copyState === 'error' ? 'Failed' : 'Copy'}
</button>
</div>
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this link. Your friends can register directly through it.</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">Point details will be available soon.</span>
</div>
</div>
</section>
{/* Section 2: Invite Flow - Two Columns (Left: Steps, Right: Rules) */}
<section data-alt="invite-flow" className="mb-8 rounded-lg border border-white/20 bg-black p-6 shadow-sm">
<div data-alt="two-col-wrapper" className="mt-4 grid grid-cols-1 gap-6 md:grid-cols-[30%_1fr]">
{/* Left: Steps */}
<div data-alt="steps-col" className="space-y-4">
<h2 data-alt="section-title" className="text-lg font-medium text-white">Invitation Flow</h2>
<ol data-alt="steps" className="space-y-4">
<li data-alt="step" className="rounded-md 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 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 link and share it with friends.</p>
</li>
<li data-alt="step" className="rounded-md 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 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 click the link and register directly.</p>
</li>
<li data-alt="step" className="rounded-md 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 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 will receive <strong>500 credits</strong> after your friends successfully sign in.</p>
</li>
</ol>
</div>
{/* Right: Rules */}
<div data-alt="rules-col" className="rounded-md">
<h2 data-alt="section-title" className="text-lg font-medium text-white mb-4">MovieFlow Credits Rewards Program</h2>
<div className='p-4 space-y-4'>
<p className="text-sm">
Welcome to MovieFlow! Our Credits Program is designed to reward your growth and contributions.
<br />
Credits can be redeemed for <strong>watermark</strong> removal.
<br />
In the future, credits may be used to redeem advanced template features.
</p>
<div className="content">
<h2 className="text-medium font-medium text-white">How to Earn Credits?</h2>
<div className="reward-section welcome">
<h3 className="text-medium font-medium text-white">Invite & Earn</h3>
<p className='text-sm'>Invite friends to join using your unique referral link.</p>
<p className='text-sm'>You will get <strong>500 credits</strong> once they successfully sign in.</p>
<br />
<p className='text-sm'>When your invited friends complete their first purchase, you will receive <strong>a 20% share of the credits</strong> they earn from that purchase.</p>
</div>
</div>
</div>
</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-6 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: number) => 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-6 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: number) => 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 sm:w-48">Invited Username</th>
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60 sm: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: any) => {
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 sm:w-48">{r.user_name}</td>
<td className="px-4 py-3 text-sm text-white/80 whitespace-nowrap sm:w-56">{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 sm:w-48" />
<td className="px-4 py-0 sm: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 Payment Reward</td>
</tr>
</tbody>
</table>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
</section>
</div>
</div>
</DashboardLayout>
);
}