forked from 77media/video-flow
417 lines
24 KiB
TypeScript
417 lines
24 KiB
TypeScript
"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 both receive rewards after your friend activates their account.</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. Credits can be redeemed for premium templates, effects, and membership time.</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">Welcome Bonus</h3>
|
||
<p className='text-sm'>All <strong>new users</strong> receive a bonus of <span className="credit-amount">500 credits</span> upon successful registration!</p>
|
||
</div>
|
||
|
||
<div className="reward-section invite">
|
||
<h3 className="text-medium font-medium text-white">Invite & Earn</h3>
|
||
<p className='text-sm'>Invite friends to join using your unique referral link. Both you and your friend will get <span className="credit-amount">500 credits</span> once they successfully sign up.</p>
|
||
|
||
<div className="highlight">
|
||
<p className='text-sm'>If your invited friend completes their first purchase, you will receive a <strong>bonus equal to 20% of the credits</strong> they earn from that purchase.</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="reward-section login">
|
||
<h3 className="text-medium font-medium text-white">Daily Login</h3>
|
||
<p className='text-sm'>Starting the day after registration, log in daily to claim <span className="credit-amount">100 credits</span>.</p>
|
||
<p className='text-sm'>This reward can be claimed for <strong>7 consecutive days</strong>.</p>
|
||
|
||
<div className="note">
|
||
<p className='text-sm'><strong>Please note:</strong> Daily login credits will <strong>reset</strong> automatically on the 8th day, so remember to use them in time!</p>
|
||
</div>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
|