新增 邀请赠积分

This commit is contained in:
moux1024 2025-09-19 20:33:15 +08:00
parent 876da82139
commit 331257e8f4
10 changed files with 358 additions and 146 deletions

View File

@ -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
View 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
}

View File

@ -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>

View File

@ -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);
} }

View File

@ -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 },
], ],
} }
]; ];

View File

@ -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>

View File

@ -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>

View File

@ -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}`);

View File

@ -30,6 +30,16 @@ const nextConfig = {
}, },
}; };
} }
// 优化缓存配置,解决缓存错误问题
if (dev) {
config.cache = {
type: 'memory',
// 设置内存缓存大小限制512MB
maxGenerations: 1,
};
}
return config; return config;
}, },

View File

@ -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",