forked from 77media/video-flow
新增 邀请赠积分
This commit is contained in:
parent
876da82139
commit
331257e8f4
@ -1,39 +0,0 @@
|
|||||||
import { get, post } from './request'
|
|
||||||
import { ApiResponse } from './common'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 签到数据接口
|
|
||||||
*/
|
|
||||||
export interface CheckinData {
|
|
||||||
hasCheckedInToday: boolean
|
|
||||||
points: number
|
|
||||||
lastCheckinDate: string | null
|
|
||||||
pointsHistory: Array<{ date: string; points: number; expiryDate: string }>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 签到响应接口
|
|
||||||
*/
|
|
||||||
export interface CheckinResponse {
|
|
||||||
success: boolean
|
|
||||||
points: number
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取用户签到状态和积分信息
|
|
||||||
* @returns Promise<CheckinData>
|
|
||||||
*/
|
|
||||||
export const getCheckinStatus = async (): Promise<CheckinData> => {
|
|
||||||
const response = await get<ApiResponse<CheckinData>>('/api/user/checkin/status')
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行签到操作
|
|
||||||
* @returns Promise<CheckinResponse>
|
|
||||||
*/
|
|
||||||
export const performCheckin = async (): Promise<CheckinResponse> => {
|
|
||||||
const response = await post<ApiResponse<CheckinResponse>>('/api/user/checkin', {})
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
36
api/signin.ts
Normal file
36
api/signin.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { get, post } from './request'
|
||||||
|
import { ApiResponse } from './common'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到数据接口
|
||||||
|
*/
|
||||||
|
export interface SigninData {
|
||||||
|
has_signin: boolean
|
||||||
|
credits: number
|
||||||
|
// signin_record: Array<{ date: string; points: number; expiryDate: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 签到响应接口
|
||||||
|
*/
|
||||||
|
export interface SigninResponse {
|
||||||
|
credits: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户签到状态和积分信息
|
||||||
|
* @returns Promise<SigninData>
|
||||||
|
*/
|
||||||
|
export const getSigninStatus = async (): Promise<ApiResponse> => {
|
||||||
|
const response = await get<ApiResponse<SigninData>>('/api/user_fission/check_today_signin')
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行签到操作
|
||||||
|
* @returns Promise<SigninResponse>
|
||||||
|
*/
|
||||||
|
export const performSignin = async (): Promise<ApiResponse> => {
|
||||||
|
const response = await post<ApiResponse<SigninResponse>>('/api/user_fission/signin', {})
|
||||||
|
return response
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { get } from '@/api/request';
|
||||||
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -8,12 +9,43 @@ import { DashboardLayout } from "@/components/layout/dashboard-layout";
|
|||||||
* Sections: Invite Flow, My Invitation Code, Invite Records (with pagination)
|
* Sections: Invite Flow, My Invitation Code, Invite Records (with pagination)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邀请记录项
|
||||||
|
*/
|
||||||
type InviteRecord = {
|
type InviteRecord = {
|
||||||
id: string;
|
user_email: string;
|
||||||
invitedUsername: string;
|
user_id: string;
|
||||||
registeredAt: number; // epoch ms
|
user_name: string;
|
||||||
rewardA: string; // reward item 1 (content TBD)
|
reg_time: number;
|
||||||
rewardB: string; // reward item 2 (content TBD)
|
purchase_status: number;
|
||||||
|
reward_status: number;
|
||||||
|
invite_reward: number;
|
||||||
|
pay_reward: 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: {
|
||||||
|
invited_list: InviteRecord[];
|
||||||
|
pagination: PaginationInfo;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@ -37,39 +69,79 @@ function formatLocalTime(epochMs: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate mocked invite records for demo purpose only.
|
* 从后端获取邀请记录
|
||||||
* @param {number} count - number of items
|
* @returns {Promise<InviteRecordsResponse>}
|
||||||
* @returns {InviteRecord[]} - mocked records
|
|
||||||
*/
|
*/
|
||||||
function generateMockRecords(count: number): InviteRecord[] {
|
async function fetchInviteRecords(): Promise<InviteRecordsResponse> {
|
||||||
const now = Date.now();
|
const res = await get<InviteRecordsResponse>('/api/user_fission/query_invite_record');
|
||||||
return Array.from({ length: count }).map((_, index) => ({
|
return res;
|
||||||
id: String(index + 1),
|
|
||||||
invitedUsername: `user_${index + 1}`,
|
|
||||||
registeredAt: now - index * 36_000, // different times
|
|
||||||
rewardA: index % 3 === 0 ? '+100 points' : '—',
|
|
||||||
rewardB: index % 5 === 0 ? '3-day membership' : '—',
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SharePage(): JSX.Element {
|
export default function SharePage(): JSX.Element {
|
||||||
// Mocked data (to be replaced by real API integration later)
|
// Mocked data (to be replaced by real API integration later)
|
||||||
const [inviteCode] = React.useState<string>('VF-ABCD-1234');
|
const [inviteCode, setInviteCode] = React.useState<string>('');
|
||||||
const [invitedCount] = React.useState<number>(37);
|
const [invitedCount, setInvitedCount] = React.useState<number>(0);
|
||||||
|
const [totalInviteCredits, setTotalInviteCredits] = React.useState<number>(0);
|
||||||
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
const [copyState, setCopyState] = React.useState<'idle' | 'copied' | 'error'>('idle');
|
||||||
|
|
||||||
const [records] = React.useState<InviteRecord[]>(() => generateMockRecords(47));
|
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 [pageIndex, setPageIndex] = React.useState<number>(0);
|
||||||
|
const [expandedRowIds, setExpandedRowIds] = React.useState<Set<string>>(() => new Set());
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(records.length / PAGE_SIZE));
|
React.useEffect(() => {
|
||||||
const pagedRecords = React.useMemo(() => {
|
let mounted = true;
|
||||||
const start = pageIndex * PAGE_SIZE;
|
(async () => {
|
||||||
return records.slice(start, start + PAGE_SIZE);
|
try {
|
||||||
}, [records, pageIndex]);
|
const response = await fetchInviteRecords();
|
||||||
|
if (mounted && response.successful) {
|
||||||
|
setRecords(response.data.invited_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 () => {
|
const handleCopy = React.useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(inviteCode);
|
await navigator.clipboard.writeText(location.origin + '/signup?inviteCode=' + inviteCode);
|
||||||
setCopyState('copied');
|
setCopyState('copied');
|
||||||
window.setTimeout(() => setCopyState('idle'), 1600);
|
window.setTimeout(() => setCopyState('idle'), 1600);
|
||||||
} catch {
|
} catch {
|
||||||
@ -78,8 +150,18 @@ export default function SharePage(): JSX.Element {
|
|||||||
}
|
}
|
||||||
}, [inviteCode]);
|
}, [inviteCode]);
|
||||||
|
|
||||||
const canPrev = pageIndex > 0;
|
|
||||||
const canNext = pageIndex < totalPages - 1;
|
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 (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
@ -101,22 +183,22 @@ export default function SharePage(): JSX.Element {
|
|||||||
<ol data-alt="steps" className="mt-4 grid gap-4 sm:grid-cols-3">
|
<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">
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-white/80">Step 1</span>
|
<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">Share</span>
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Share</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Copy your invitation code and share it with friends.</p>
|
<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>
|
||||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-white/80">Step 2</span>
|
<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">Register</span>
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Register</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">Friends register and enter your invitation code.</p>
|
||||||
</li>
|
</li>
|
||||||
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
<li data-alt="step" className="rounded-md border border-white/20 p-4">
|
||||||
<div data-alt="step-header" className="flex items-center justify-between">
|
<div data-alt="step-header" className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium text-white/80">Step 3</span>
|
<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">Reward</span>
|
<span className="rounded bg-white/10 px-2 py-0.5 text-xs text-white bg-custom-purple/50">Reward</span>
|
||||||
</div>
|
</div>
|
||||||
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
|
<p data-alt="step-desc" className="mt-2 text-sm text-white/70">You both receive rewards after successful registration.</p>
|
||||||
</li>
|
</li>
|
||||||
@ -125,7 +207,7 @@ export default function SharePage(): JSX.Element {
|
|||||||
|
|
||||||
{/* Section 2: My Invitation Code */}
|
{/* 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">
|
<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-3">
|
<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">
|
<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>
|
<h2 data-alt="section-title" className="text-lg font-medium text-white">My Invitation Code</h2>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@ -144,9 +226,14 @@ export default function SharePage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<p data-alt="hint" className="mt-2 text-xs text-white/60">Share this code. Your friends can enter it during registration.</p>
|
<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>
|
||||||
|
<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">
|
<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="text-sm text-white/70">Invited Friends</span>
|
||||||
<span className="mt-1 text-2xl font-semibold text-white">{invitedCount}</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>
|
<span className="mt-2 text-xs text-white/60">Points detail will be available soon.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -162,20 +249,20 @@ export default function SharePage(): JSX.Element {
|
|||||||
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"
|
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"
|
type="button"
|
||||||
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
onClick={() => setPageIndex((p) => Math.max(0, p - 1))}
|
||||||
disabled={!canPrev}
|
disabled={!pagination.has_prev}
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
>
|
>
|
||||||
Prev
|
Prev
|
||||||
</button>
|
</button>
|
||||||
<span data-alt="page-info" className="text-sm text-white/70">
|
<span data-alt="page-info" className="text-sm text-white/70">
|
||||||
{pageIndex + 1} / {totalPages}
|
{pagination.page} / {pagination.total_pages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
data-alt="next-page"
|
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"
|
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"
|
type="button"
|
||||||
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
onClick={() => setPageIndex((p) => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={!canNext}
|
disabled={!pagination.has_next}
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
>
|
>
|
||||||
Next
|
Next
|
||||||
@ -184,24 +271,65 @@ export default function SharePage(): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-alt="table-wrapper" className="overflow-x-auto">
|
<div data-alt="table-wrapper" className="overflow-x-auto">
|
||||||
<table data-alt="records-table" className="min-w-full divide-y divide-white/10">
|
<table data-alt="records-table" className="min-w-full divide-y divide-white/10 table-fixed">
|
||||||
<thead data-alt="table-head" className="bg-black">
|
<thead data-alt="table-head" className="bg-black">
|
||||||
<tr data-alt="table-head-row">
|
<tr data-alt="table-head-row">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Invited Username</th>
|
<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">Registered At</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">Registration Reward</th>
|
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">Reward</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-white/60">First Payment Reward</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
|
<tbody data-alt="table-body" className="divide-y divide-white/10 bg-black">
|
||||||
{pagedRecords.map((r) => (
|
{pagedRecords.map((r) => {
|
||||||
<tr key={r.id} data-alt="table-row" className="hover:bg-white/5">
|
const isExpanded = expandedRowIds.has(r.user_id);
|
||||||
<td className="px-4 py-3 text-sm text-white">{r.invitedUsername}</td>
|
return (
|
||||||
<td className="px-4 py-3 text-sm text-white/80">{formatLocalTime(r.registeredAt)}</td>
|
<React.Fragment key={r.user_id}>
|
||||||
<td className="px-4 py-3 text-sm text-white/90">{r.rewardA}</td>
|
<tr data-alt="table-row" className="hover:bg-white/5">
|
||||||
<td className="px-4 py-3 text-sm text-white/90">{r.rewardB}</td>
|
<td className="px-4 py-3 text-sm text-white w-48">{r.user_name}</td>
|
||||||
</tr>
|
<td className="px-4 py-3 text-sm text-white/80 w-56 whitespace-nowrap">{formatLocalTime(r.reg_time * 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]">
|
||||||
|
{r.invite_reward + r.pay_reward}
|
||||||
|
</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">{r.invite_reward}</td>
|
||||||
|
<td className="px-2 py-2">Register</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="px-2 py-2">{r.pay_reward}</td>
|
||||||
|
<td className="px-2 py-2">First Payment</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { signInWithGoogle, registerUser, sendVerificationLink } from "@/lib/auth";
|
import { signInWithGoogle, sendVerificationLink, registerUserWithInvite } from "@/lib/auth";
|
||||||
import { GradientText } from "@/components/ui/gradient-text";
|
import { GradientText } from "@/components/ui/gradient-text";
|
||||||
import { GoogleLoginButton } from "@/components/ui/google-login-button";
|
import { GoogleLoginButton } from "@/components/ui/google-login-button";
|
||||||
import { Eye, EyeOff, Mail } from "lucide-react";
|
import { Eye, EyeOff, Mail } from "lucide-react";
|
||||||
@ -31,6 +31,16 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
// Handle scroll indicator for small screens
|
// Handle scroll indicator for small screens
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const codeFromUrl = url.searchParams.get("inviteCode");
|
||||||
|
const codeFromSession = sessionStorage.getItem("inviteCode");
|
||||||
|
const code = codeFromUrl || codeFromSession || "";
|
||||||
|
if (code) {
|
||||||
|
setInviteCode(code);
|
||||||
|
sessionStorage.setItem("inviteCode", code);
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const scrollableElement = document.querySelector('.signup-form > div:nth-child(2)');
|
const scrollableElement = document.querySelector('.signup-form > div:nth-child(2)');
|
||||||
const formElement = document.querySelector('.signup-form');
|
const formElement = document.querySelector('.signup-form');
|
||||||
@ -208,19 +218,23 @@ export default function SignupPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Use new registration API
|
// Use new registration API
|
||||||
await registerUser({
|
const response = await registerUserWithInvite({
|
||||||
userName: name,
|
name: name,
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
inviteCode: inviteCode || undefined,
|
invite_code: inviteCode || undefined,
|
||||||
});
|
});
|
||||||
|
// Clear inviteCode after successful registration
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem("inviteCode");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// Show activation modal instead of redirecting to login
|
// Show activation modal instead of redirecting to login
|
||||||
setShowActivationModal(true);
|
setShowActivationModal(true);
|
||||||
setResendCooldown(60);
|
setResendCooldown(60);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Signup error:", error);
|
console.error("Signup error:", error);
|
||||||
setFormError(error.message||error.msg || "Registration failed, please try again");
|
setFormError(error.message || "Registration failed, please try again");
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,8 @@ import {
|
|||||||
PanelsLeftBottom,
|
PanelsLeftBottom,
|
||||||
ArrowLeftToLine,
|
ArrowLeftToLine,
|
||||||
BookHeart,
|
BookHeart,
|
||||||
PanelRightClose
|
PanelRightClose,
|
||||||
|
Gift
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
@ -33,6 +34,7 @@ const navigationItems = [
|
|||||||
title: 'Main',
|
title: 'Main',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
{ name: 'My Portfolio', href: '/movies', icon: BookHeart },
|
||||||
|
{ name: 'Share', href: '/share', icon: Gift },
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -4,15 +4,13 @@ import { useState, useEffect } from "react"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Coins, Trophy, HelpCircle } from "lucide-react"
|
import { Coins, Trophy, HelpCircle } from "lucide-react"
|
||||||
import { getCheckinStatus, performCheckin, CheckinData } from "@/api/checkin"
|
import { getSigninStatus, performSignin, SigninData } from "@/api/signin"
|
||||||
|
|
||||||
|
|
||||||
export default function CheckinPage() {
|
export default function SigninPage() {
|
||||||
const [checkinData, setCheckinData] = useState<CheckinData>({
|
const [signinData, setSigninData] = useState<SigninData>({
|
||||||
hasCheckedInToday: false,
|
has_signin: false,
|
||||||
points: 0,
|
credits: 0
|
||||||
lastCheckinDate: null,
|
|
||||||
pointsHistory: [],
|
|
||||||
})
|
})
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const [showTip, setShowTip] = useState(false)
|
const [showTip, setShowTip] = useState(false)
|
||||||
@ -20,15 +18,15 @@ export default function CheckinPage() {
|
|||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch checkin status
|
* Fetch signin status
|
||||||
*/
|
*/
|
||||||
const fetchCheckinStatus = async () => {
|
const fetchSigninStatus = async () => {
|
||||||
try {
|
try {
|
||||||
setIsInitialLoading(true)
|
setIsInitialLoading(true)
|
||||||
const data = await getCheckinStatus()
|
const data = await getSigninStatus()
|
||||||
setCheckinData(data)
|
setSigninData(data.data as SigninData)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch checkin status:', error)
|
console.error('Failed to fetch signin status:', error)
|
||||||
// Keep default state
|
// Keep default state
|
||||||
} finally {
|
} finally {
|
||||||
setIsInitialLoading(false)
|
setIsInitialLoading(false)
|
||||||
@ -36,25 +34,25 @@ export default function CheckinPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCheckinStatus()
|
fetchSigninStatus()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform checkin operation
|
* Perform signin operation
|
||||||
*/
|
*/
|
||||||
const handleCheckin = async () => {
|
const handleSignin = async () => {
|
||||||
if (checkinData.hasCheckedInToday) return
|
if (signinData.has_signin) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const response = await performCheckin()
|
const response = await performSignin()
|
||||||
|
|
||||||
if (response.success) {
|
if (response.successful) {
|
||||||
// Refresh status after successful checkin
|
// Refresh status after successful signin
|
||||||
await fetchCheckinStatus()
|
await fetchSigninStatus()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Checkin failed:', error)
|
console.error('Signin failed:', error)
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@ -79,14 +77,14 @@ export default function CheckinPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-md space-y-6">
|
<div className="mx-auto max-w-md space-y-6">
|
||||||
|
|
||||||
{/* Checkin status card */}
|
{/* Signin status card */}
|
||||||
<Card className="bg-transparent border-0 shadow-none">
|
<Card className="bg-transparent border-0 shadow-none">
|
||||||
<CardHeader className="text-center pb-4 pt-0">
|
<CardHeader className="text-center pb-4 pt-0">
|
||||||
<h1 className="text-3xl font-bold text-balance bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
<h1 className="text-3xl font-bold text-balance bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||||
Daily Check-in
|
Daily Sign-in
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
<p className="text-muted-foreground">Check in to earn credits, credits valid for 7 days</p>
|
<p className="text-muted-foreground">Sign in to earn credits. Credits are valid for 7 days</p>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onMouseEnter={() => setShowTip(true)}
|
onMouseEnter={() => setShowTip(true)}
|
||||||
@ -98,8 +96,8 @@ export default function CheckinPage() {
|
|||||||
{showTip && (
|
{showTip && (
|
||||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 p-3 bg-popover border rounded-lg shadow-lg z-10">
|
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 w-80 p-3 bg-popover border rounded-lg shadow-lg z-10">
|
||||||
<div className="text-sm space-y-1 text-left">
|
<div className="text-sm space-y-1 text-left">
|
||||||
<p className="font-medium text-foreground">Check-in Rules</p>
|
<p className="font-medium text-foreground">Sign-in Rules</p>
|
||||||
<p className="text-muted-foreground">• Daily check-in earns 100 credits</p>
|
<p className="text-muted-foreground">• Daily sign-in earns 100 credits</p>
|
||||||
<p className="text-muted-foreground">• Credits are valid for 7 days</p>
|
<p className="text-muted-foreground">• Credits are valid for 7 days</p>
|
||||||
<p className="text-muted-foreground">• Expired credits will be automatically cleared</p>
|
<p className="text-muted-foreground">• Expired credits will be automatically cleared</p>
|
||||||
</div>
|
</div>
|
||||||
@ -118,33 +116,33 @@ export default function CheckinPage() {
|
|||||||
<span className="text-sm text-muted-foreground">Current Credits</span>
|
<span className="text-sm text-muted-foreground">Current Credits</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-bold bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
<div className="text-3xl font-bold bg-gradient-to-r from-custom-blue to-custom-purple bg-clip-text text-transparent">
|
||||||
{checkinData.points}
|
{signinData.credits || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check-in button */}
|
{/* Sign-in button */}
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCheckin}
|
onClick={handleSignin}
|
||||||
disabled={checkinData.hasCheckedInToday || isLoading}
|
disabled={signinData.has_signin || isLoading}
|
||||||
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-custom-blue to-custom-purple hover:from-custom-blue/90 hover:to-custom-purple/90 text-white"
|
className="w-full h-12 text-lg font-semibold bg-gradient-to-r from-custom-blue to-custom-purple hover:from-custom-blue/90 hover:to-custom-purple/90 text-white"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center gap-2 text-white">
|
<div className="flex items-center gap-2 text-white">
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
Checking in...
|
Signing in...
|
||||||
</div>
|
</div>
|
||||||
) : checkinData.hasCheckedInToday ? (
|
) : signinData.has_signin ? (
|
||||||
<div className="flex items-center gap-2 text-white">
|
<div className="flex items-center gap-2 text-white">
|
||||||
<Trophy className="w-4 h-4" />
|
<Trophy className="w-4 h-4" />
|
||||||
Checked in Today
|
Signed in Today
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-white">
|
<div className="flex items-center gap-2 text-white">
|
||||||
<Coins className="w-4 h-4" />
|
<Coins className="w-4 h-4" />
|
||||||
Check In Now +100 Credits
|
Sign In Now +100 Credits
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
@ -33,7 +33,7 @@ import {
|
|||||||
} from "@/lib/stripe";
|
} from "@/lib/stripe";
|
||||||
import UserCard from "@/components/common/userCard";
|
import UserCard from "@/components/common/userCard";
|
||||||
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
import { showInsufficientPointsNotification } from "@/utils/notifications";
|
||||||
import CheckinBox from "./checkin-box";
|
import SigninBox from "./signin-box";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@ -61,7 +61,7 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
const [isLoadingSubscription, setIsLoadingSubscription] = useState(false);
|
||||||
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
const [isBuyingTokens, setIsBuyingTokens] = useState(false);
|
||||||
const [customAmount, setCustomAmount] = useState<string>("");
|
const [customAmount, setCustomAmount] = useState<string>("");
|
||||||
const [isCheckinModalOpen, setIsCheckinModalOpen] = useState(false);
|
const [isSigninModalOpen, setIsSigninModalOpen] = useState(false);
|
||||||
|
|
||||||
// 获取用户订阅信息
|
// 获取用户订阅信息
|
||||||
const fetchSubscriptionInfo = async () => {
|
const fetchSubscriptionInfo = async () => {
|
||||||
@ -249,8 +249,8 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
/**
|
/**
|
||||||
* 处理签到功能,打开签到modal
|
* 处理签到功能,打开签到modal
|
||||||
*/
|
*/
|
||||||
const handleCheckin = () => {
|
const handleSignin = () => {
|
||||||
setIsCheckinModalOpen(true);
|
setIsSigninModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -408,16 +408,16 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
{currentUser.email}
|
{currentUser.email}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{/* Check-in entry */}
|
{/* Sign-in entry */}
|
||||||
{/* <div>
|
<div>
|
||||||
<button
|
<button
|
||||||
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
className="px-2 py-1 text-xs bg-gray-600 text-white rounded hover:bg-gray-700 transition-colors disabled:opacity-50 flex items-center justify-center"
|
||||||
onClick={() => handleCheckin()}
|
onClick={() => handleSignin()}
|
||||||
title="Daily Check-in"
|
title="Daily Sign-in"
|
||||||
>
|
>
|
||||||
<CalendarDays className="h-3 w-3" />
|
<CalendarDays className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</div> */}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI 积分 */}
|
{/* AI 积分 */}
|
||||||
@ -552,15 +552,15 @@ export function TopBar({ collapsed, isDesktop=true }: { collapsed: boolean, isDe
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Check-in Modal */}
|
{/* Sign-in Modal */}
|
||||||
<Dialog open={isCheckinModalOpen} onOpenChange={setIsCheckinModalOpen}>
|
<Dialog open={isSigninModalOpen} onOpenChange={setIsSigninModalOpen}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
|
className="max-w-md mx-auto bg-white/95 dark:bg-gray-900/95 backdrop-blur-xl border-0 shadow-2xl"
|
||||||
data-alt="checkin-modal"
|
data-alt="signin-modal"
|
||||||
>
|
>
|
||||||
<DialogTitle></DialogTitle>
|
<DialogTitle></DialogTitle>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<CheckinBox />
|
<SigninBox />
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
61
lib/auth.ts
61
lib/auth.ts
@ -8,6 +8,26 @@ const JAVA_BASE_URL = process.env.NEXT_PUBLIC_JAVA_URL || '';
|
|||||||
const TOKEN_KEY = 'token';
|
const TOKEN_KEY = 'token';
|
||||||
const USER_KEY = 'currentUser';
|
const USER_KEY = 'currentUser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册用户响应数据
|
||||||
|
*/
|
||||||
|
type RegisterUserData = {
|
||||||
|
user_id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
invite_code: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册用户API响应
|
||||||
|
*/
|
||||||
|
type RegisterUserResponse = {
|
||||||
|
code: number;
|
||||||
|
message: string;
|
||||||
|
data: RegisterUserData;
|
||||||
|
successful: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录用户
|
* 登录用户
|
||||||
*/
|
*/
|
||||||
@ -370,7 +390,48 @@ export const registerUser = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const registerUserWithInvite = async ({
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
invite_code,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
password: string;
|
||||||
|
email: string;
|
||||||
|
invite_code?: string;
|
||||||
|
}): Promise<RegisterUserResponse> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${BASE_URL}/api/user_fission/register_with_invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name,
|
||||||
|
password,
|
||||||
|
email,
|
||||||
|
invite_code,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if(!data.successful){
|
||||||
|
throw new Error(data.message || '注册失败');
|
||||||
|
}
|
||||||
|
return data as RegisterUserResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Register with invite failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送验证链接
|
||||||
|
* @param {string} email - 邮箱
|
||||||
|
* @returns {Promise<any>} 发送验证链接结果
|
||||||
|
*/
|
||||||
export const sendVerificationLink = async (email: string) => {
|
export const sendVerificationLink = async (email: string) => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${JAVA_BASE_URL}/api/user/sendVerificationLink?email=${email}`);
|
const response = await fetch(`${JAVA_BASE_URL}/api/user/sendVerificationLink?email=${email}`);
|
||||||
|
|||||||
@ -30,6 +30,16 @@ const nextConfig = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优化缓存配置,解决缓存错误问题
|
||||||
|
if (dev) {
|
||||||
|
config.cache = {
|
||||||
|
type: 'memory',
|
||||||
|
// 设置内存缓存大小限制(512MB)
|
||||||
|
maxGenerations: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"test:watch": "jest --watch"
|
"test:watch": "jest --watch",
|
||||||
|
"clear-cache": "bash scripts/clear-cache.sh",
|
||||||
|
"dev:clean": "npm run clear-cache && npm run dev"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user