init: 初始化 AssetX 项目仓库

包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、
antdesign(管理后台)、landingpage(营销落地页)、
数据库 SQL 和配置文件。
This commit is contained in:
2026-03-27 11:26:43 +00:00
commit 2ee4553b71
634 changed files with 988255 additions and 0 deletions

View File

@@ -0,0 +1,204 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { fetchActivities, type ActivitiesData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
type FilterTab = "all" | "referrals" | "deposits";
export default function ActivityHistory() {
const { t } = useApp();
const [activeTab, setActiveTab] = useState<FilterTab>("all");
const [currentPage, setCurrentPage] = useState(1);
const [data, setData] = useState<ActivitiesData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadActivities();
}, [activeTab, currentPage]);
async function loadActivities() {
setLoading(true);
const result = await fetchActivities(activeTab, currentPage, 5);
setData(result);
setLoading(false);
}
const totalPages = data?.pagination.totalPage || 1;
return (
<div className="w-full bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6 animate-fade-in">
{/* Top Section - Title and Filter Tabs */}
<div className="flex items-center justify-between">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.activityHistory")}
</h3>
{/* Filter Tabs */}
<div className="bg-[#f9fafb] dark:bg-gray-700 rounded-xl p-1 flex items-center gap-0 h-9">
<button
onClick={() => setActiveTab("all")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[60px] ${
activeTab === "all"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.all")}
</button>
<button
onClick={() => setActiveTab("referrals")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[90px] ${
activeTab === "referrals"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.referrals")}
</button>
<button
onClick={() => setActiveTab("deposits")}
className={`px-4 h-full rounded-lg text-body-small font-bold transition-all min-w-[85px] ${
activeTab === "deposits"
? "bg-white dark:bg-gray-600 text-text-primary dark:text-white shadow-sm"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{t("points.deposits")}
</button>
</div>
</div>
{/* Table Card */}
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 overflow-hidden">
{/* Table Header Section */}
<div className="bg-bg-surface dark:bg-gray-800 border-b border-border-gray dark:border-gray-700 p-6 flex items-center justify-between">
<div className="flex flex-col gap-1">
<h4 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.activityHistory")}
</h4>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.trackActivities")}
</p>
</div>
<div className="flex items-center gap-2">
<Image src="/icons/actions/icon-refresh.svg" alt="Refresh" width={16} height={16} />
<span className="text-caption-tiny font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.refreshLastUpdated").replace("{time}", "2")}
</span>
</div>
</div>
{/* Table */}
<div className="overflow-auto">
{/* Table Header */}
<div className="bg-bg-subtle dark:bg-gray-700/30 border-b border-border-gray dark:border-gray-700 flex">
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.user")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.friends")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.code")}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-caption-tiny font-medium text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{t("points.points")}
</span>
</div>
</div>
{/* Table Body */}
{loading ? (
<div className="p-6 text-center">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full animate-pulse" />
</div>
) : data && data.activities && data.activities.length > 0 ? (
data.activities.map((row, index) => (
<div
key={index}
className={`flex ${
index !== data.activities.length - 1 ? "border-b border-border-gray dark:border-gray-700" : ""
}`}
>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.userAddress}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.friendAddress || "-"}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold text-text-primary dark:text-white font-inter">
{row.inviteCode || "-"}
</span>
</div>
<div className="flex-1 px-6 py-4">
<span className="text-body-small font-bold leading-[150%]" style={{ color: "#10b981" }}>
+{row.points}
</span>
</div>
</div>
))
) : (
<div className="p-6 text-center">
<span className="text-text-tertiary dark:text-gray-400">{t("points.noActivitiesFound")}</span>
</div>
)}
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-4">
{/* Previous Button */}
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage === 1}
className="w-10 h-10 flex items-center justify-center disabled:opacity-50"
>
<Image src="/icons/ui/icon-chevron-left.svg" alt="Previous" width={10} height={10} />
</button>
{/* Page Numbers */}
<div className="flex items-center gap-3">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`px-[10px] py-[3px] rounded-lg text-sm leading-[22px] transition-all ${
currentPage === page
? "bg-bg-subtle dark:bg-gray-700 text-text-primary dark:text-white"
: "text-text-tertiary dark:text-gray-400"
}`}
>
{page}
</button>
))}
</div>
{/* Next Button */}
<button
onClick={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
disabled={currentPage === totalPages}
className="w-10 h-10 flex items-center justify-center disabled:opacity-50"
>
<Image src="/icons/ui/icon-chevron-right.svg" alt="Next" width={10} height={10} />
</button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
interface BindInviteCardProps {
placeholder?: string;
onApply?: (code: string) => void;
}
export default function BindInviteCard({
placeholder,
onApply,
}: BindInviteCardProps) {
const { t } = useApp();
const [code, setCode] = useState("");
const handleApply = () => {
if (code.trim()) {
onApply?.(code.trim());
}
};
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3 h-6">
<Image src="/components/card/icon2.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.bindInvite")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.bindInviteDescription")}
</p>
{/* Input and Button */}
<div className="flex flex-col gap-4">
{/* Input Field */}
<div className="flex items-center gap-2">
<div className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 h-[46px] flex items-center">
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
placeholder={placeholder || t("points.enterCode")}
className="w-full bg-transparent text-body-default font-bold text-text-primary dark:text-white leading-[150%] font-inter outline-none placeholder:text-[#d1d5db] dark:placeholder:text-gray-500"
/>
</div>
</div>
{/* Apply Button */}
<Button
onClick={handleApply}
disabled={!code.trim()}
className="bg-text-primary dark:bg-white rounded-xl h-11 text-body-small font-bold text-white dark:text-gray-900 disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("points.apply")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import Image from "next/image";
import { Button } from "@heroui/react";
import { useApp } from "@/contexts/AppContext";
interface DepositCardProps {
logo?: string;
title: string;
subtitle: string;
badge: string;
lockPeriod: string;
progressPercent: number;
pointsBoost: string;
onDeposit?: () => void;
}
export default function DepositCard({
logo,
title,
subtitle,
badge,
lockPeriod,
progressPercent,
pointsBoost,
onDeposit,
}: DepositCardProps) {
const { t } = useApp();
return (
<div className="w-full bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6">
{/* Top Section */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
{/* Logo and Title */}
<div className="flex items-center gap-3 md:gap-6">
{logo ? (
<Image src={logo} alt={title} width={40} height={40} className="rounded-full flex-shrink-0" />
) : (
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{ background: "linear-gradient(135deg, rgba(0, 187, 167, 1) 0%, rgba(0, 122, 85, 1) 100%)" }}
>
<span className="text-[10px] font-bold text-white leading-[125%] tracking-[-0.11px]">
{t("points.logo")}
</span>
</div>
)}
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{title}
</h3>
<div
className="rounded-full px-3 py-1 flex items-center justify-center flex-shrink-0"
style={{ background: "#ff6900", boxShadow: "0px 4px 6px -4px rgba(255, 105, 0, 0.2), 0px 10px 15px -3px rgba(255, 105, 0, 0.2)" }}
>
<span className="text-[10px] font-bold text-white leading-[150%] tracking-[0.01em]">
{badge}
</span>
</div>
</div>
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[133%]">
{subtitle}
</span>
</div>
</div>
{/* Lock Period and Button */}
<div className="flex items-center justify-between md:justify-end gap-4 md:gap-12">
<div className="flex flex-col gap-1">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.lockPeriod")}
</span>
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{lockPeriod}
</span>
</div>
<Button
onClick={onDeposit}
className="bg-text-primary dark:bg-white rounded-xl h-11 px-4 text-body-small font-bold text-white dark:text-gray-900 flex items-center gap-1 w-[163px] flex-shrink-0"
>
{t("points.startLoop")}
<Image src="/icons/actions/icon-arrow-right.svg" alt="" width={16} height={16} />
</Button>
</div>
</div>
{/* Bottom Section - Progress Bar */}
<div className="flex items-center justify-between border-t border-[#f1f5f9] dark:border-gray-600 pt-6">
{/* Progress Bar */}
<div className="relative flex-1 h-2 bg-[#f1f5f9] dark:bg-gray-700 rounded-full overflow-hidden max-w-[447px]">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
width: `${progressPercent}%`,
background: "#00bc7d",
boxShadow: "0px 0px 15px 0px rgba(16, 185, 129, 0.4)"
}}
/>
</div>
{/* Points Badge */}
<span
className="text-[10px] font-black leading-[150%] tracking-[1.12px] ml-4"
style={{ color: "#00bc7d" }}
>
{pointsBoost}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,90 @@
"use client";
import { Button } from "@heroui/react";
import BorderedButton from "@/components/common/BorderedButton";
import { useApp } from "@/contexts/AppContext";
interface EarnOpportunityCardProps {
pointsLabel: string;
title: string;
subtitle: string;
metricLabel: string;
metricValue: string;
buttonText: string;
onButtonClick?: () => void;
}
export default function EarnOpportunityCard({
pointsLabel,
title,
subtitle,
metricLabel,
metricValue,
buttonText,
onButtonClick,
}: EarnOpportunityCardProps) {
const { t } = useApp();
return (
<div className="flex-1 bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Top Section - Logo and Points Badge */}
<div className="flex items-start justify-between">
{/* Logo */}
<div
className="w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0"
style={{
background: "linear-gradient(135deg, rgba(0, 187, 167, 1) 0%, rgba(0, 122, 85, 1) 100%)"
}}
>
<span className="text-[10px] font-bold text-white leading-[125%] tracking-[-0.11px]">
{t("points.logo")}
</span>
</div>
{/* Points Badge */}
<div
className="rounded-full px-3 py-1 flex items-center justify-center"
style={{
background: "#fff5ef",
border: "1px solid #ffc9ad"
}}
>
<span className="text-[10px] font-bold leading-[150%] tracking-[0.01em]" style={{ color: "#ff6900" }}>
{pointsLabel}
</span>
</div>
</div>
{/* Middle Section - Title and Subtitle */}
<div className="flex flex-col gap-1">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{title}
</h3>
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{subtitle}
</p>
</div>
{/* Bottom Section - Metric and Button */}
<div className="flex items-center gap-6">
{/* Metric */}
<div className="flex-1 flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{metricLabel}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{metricValue}
</span>
</div>
{/* Button */}
<BorderedButton
size="lg"
onClick={onButtonClick}
className="bg-[#f3f4f6] dark:bg-gray-700 text-text-primary dark:text-white"
>
{buttonText}
</BorderedButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,172 @@
"use client";
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { useAccount } from "wagmi";
import EarnOpportunityCard from "./EarnOpportunityCard";
import ActivityHistory from "./ActivityHistory";
import TopPerformers from "./TopPerformers";
import ReferralCodeCard from "./ReferralCodeCard";
import BindInviteCard from "./BindInviteCard";
import TeamTVLCard from "./TeamTVLCard";
import DepositCard from "./DepositCard";
import { fetchTeamTVL, bindInviteCode, registerWallet, type TeamTVLData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
export default function PointsCards() {
const { t } = useApp();
const { address } = useAccount();
const [mounted, setMounted] = useState(false);
const [teamTVL, setTeamTVL] = useState<TeamTVLData | null>(null);
const [loading, setLoading] = useState(true);
const [inviteCode, setInviteCode] = useState('');
const [inviteUsedCount, setInviteUsedCount] = useState(0);
const [inviteLoading, setInviteLoading] = useState(false);
useEffect(() => { setMounted(true); }, []);
// Register wallet and load user data when address connects
useEffect(() => {
if (address) {
registerAndLoad(address);
}
}, [address]);
async function registerAndLoad(walletAddress: string) {
setInviteLoading(true);
// Register wallet (creates user if not exists), get invite code
const userData = await registerWallet(walletAddress);
if (userData) {
setInviteCode(userData.inviteCode);
setInviteUsedCount(userData.usedCount);
}
setInviteLoading(false);
// Load team TVL for this wallet
setLoading(true);
const teamData = await fetchTeamTVL(walletAddress);
setTeamTVL(teamData);
setLoading(false);
}
const handleCopy = () => {
if (inviteCode) {
navigator.clipboard.writeText(inviteCode);
toast.success(t("points.copiedToClipboard"));
}
};
const handleShare = async () => {
if (!inviteCode) return;
const shareUrl = `${window.location.origin}?ref=${inviteCode}`;
if (navigator.share) {
try {
await navigator.share({ url: shareUrl });
} catch {
// user cancelled, do nothing
}
} else {
navigator.clipboard.writeText(shareUrl);
toast.success(t("points.shareLinkCopied"));
}
};
const handleApply = async (code: string) => {
if (!code) {
toast.error(t("points.pleaseEnterInviteCode"));
return;
}
// TODO: Get signature from wallet
const signature = "0x..."; // Placeholder
const result = await bindInviteCode(code, signature, address);
if (result.success) {
toast.success(result.message || t("points.inviteCodeBoundSuccess"));
if (address) registerAndLoad(address); // Reload data
} else {
toast.error(result.error || t("points.failedToBindInviteCode"));
}
};
return (
<div className="flex flex-col gap-6">
{/* First Row - Cards 1, 2, 3 */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.1s' }}>
<ReferralCodeCard
code={inviteCode || t("points.loading")}
usedCount={inviteUsedCount}
loading={!mounted || inviteLoading || !address}
onCopy={handleCopy}
onShare={handleShare}
/>
<BindInviteCard
onApply={handleApply}
/>
<TeamTVLCard
currentTVL={teamTVL?.currentTVL || "$0"}
targetTVL={teamTVL?.targetTVL || "$10M"}
progressPercent={teamTVL?.progressPercent || 0}
members={teamTVL?.totalMembers || 0}
roles={teamTVL?.roles || []}
loading={loading}
/>
</div>
{/* Second Row - Card 4 */}
<div className="animate-fade-in" style={{ animationDelay: '0.2s' }}>
<DepositCard
title="Deposit USDC to ALP"
subtitle="Native Staking"
badge="UP TO 3X"
lockPeriod="30 DAYS"
progressPercent={65}
pointsBoost="+10% POINTS"
onDeposit={() => {}}
/>
</div>
{/* Third Row - Earn Opportunities */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.3s' }}>
<EarnOpportunityCard
pointsLabel="7X Points"
title="Pendle YT"
subtitle="Yield Trading optimization"
metricLabel="EST. APY"
metricValue="50%-300%"
buttonText="ZAP IN"
onButtonClick={() => {}}
/>
<EarnOpportunityCard
pointsLabel="4X Points"
title="Curve LP"
subtitle="Liquidity Pool provision"
metricLabel="CURRENT APY"
metricValue="15%-35%"
buttonText="Add Liquidity"
onButtonClick={() => {}}
/>
<EarnOpportunityCard
pointsLabel="2X-8X Points"
title="Morpho"
subtitle="Lending Loop strategy"
metricLabel="MAX LTV"
metricValue="85%"
buttonText="Start LOOP"
onButtonClick={() => {}}
/>
</div>
{/* Fourth Row - Activity History and Top Performers */}
<div className="flex flex-col md:flex-row gap-6 animate-fade-in" style={{ animationDelay: '0.4s' }}>
<div className="flex-[2]">
<ActivityHistory />
</div>
<div className="flex-1">
<TopPerformers />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,194 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import VipCard from "./VipCard";
import { useApp } from "@/contexts/AppContext";
import { fetchDashboard, type DashboardData } from "@/lib/api/points";
export default function PointsDashboard() {
const { t } = useApp();
const [dashboard, setDashboard] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadDashboard() {
setLoading(true);
const data = await fetchDashboard();
setDashboard(data);
setLoading(false);
}
loadDashboard();
}, []);
if (loading) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 animate-pulse">
<div className="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl" />
</div>
);
}
if (!dashboard) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 animate-fade-in">
<p className="text-text-tertiary dark:text-gray-400">{t("points.failedToLoadDashboard")}</p>
</div>
);
}
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-4 md:p-8 flex flex-col gap-6 animate-fade-in">
{/* Header Row */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="bg-[#f9fafb] dark:bg-gray-700 border border-border-normal dark:border-gray-600 rounded-full px-3 py-1 flex items-center justify-center">
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{dashboard.season.seasonName}
</span>
</div>
{dashboard.season.isLive && (
<div className="bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1 flex items-center gap-2">
<div className="w-1.5 h-1.5 rounded-full bg-[#10b981] opacity-55" />
<span className="text-caption-tiny font-bold text-[#10b981] dark:text-green-400 leading-[150%] tracking-[0.01em]">
{t("points.live")}
</span>
</div>
)}
</div>
<div className="rounded-[14px] px-4 py-2 flex items-center gap-2 h-[34.78px] shadow-lg bg-gradient-to-r from-[#fee685] to-[#fdc700] rotate-1">
<Image src="/components/points/icon-star.svg" alt="" width={17} height={17} />
<span className="text-caption-tiny font-bold text-text-primary leading-[150%] tracking-[0.01em]">
{t("points.xPoints")}
</span>
</div>
</div>
{/* ── 移动端布局 ── */}
<div className="md:hidden flex flex-col gap-4">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{t("points.pointsDashboard")}
</h2>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
<span>{t("points.unlockUpTo")} </span>
<span className="font-bold text-[#fdc700]">{t("points.xPoints")}</span>
<span> {t("points.withEcosystemMultipliers")}</span>
</p>
</div>
{/* Stats Row - 3 items in one line */}
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-white/5 dark:border-gray-600 px-4 py-3 flex items-center justify-between gap-2">
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.yourTotalPoints")}
</span>
<span className="text-[22px] font-extrabold leading-[110%] tracking-[-0.01em] bg-gradient-to-b from-[#111827] to-[#4b5563] bg-clip-text text-transparent dark:from-white dark:to-gray-400">
{dashboard.totalPoints.toLocaleString()}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.globalRank")}
</span>
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[130%]">
#{dashboard.globalRank.toLocaleString()}
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
Top {dashboard.topPercentage}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600 flex-shrink-0" />
<div className="flex flex-col gap-0.5">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.endsIn")}
</span>
<span className="text-body-large font-bold text-text-primary dark:text-white leading-[130%]">
{dashboard.season.daysRemaining}d {dashboard.season.hoursRemaining}h
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{new Date(dashboard.season.endTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
</div>
{/* VipCard - full width, own row */}
<VipCard
memberTier={dashboard.memberTier}
level={dashboard.vipLevel}
currentPoints={dashboard.totalPoints}
totalPoints={dashboard.totalPoints + dashboard.pointsToNextTier}
nextTier={dashboard.nextTier}
pointsToNextTier={dashboard.pointsToNextTier}
/>
</div>
{/* ── 桌面端布局 ── */}
<div className="hidden md:flex gap-6">
{/* Left Section - Points Info */}
<div className="flex-1 flex flex-col gap-6">
{/* Title */}
<div className="flex flex-col gap-1">
<h2 className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{t("points.pointsDashboard")}
</h2>
<p className="text-body-small font-regular text-text-tertiary dark:text-gray-400 leading-[150%]">
<span>{t("points.unlockUpTo")} </span>
<span className="font-bold text-[#fdc700]">{t("points.xPoints")}</span>
<span> {t("points.withEcosystemMultipliers")}</span>
</p>
</div>
{/* Stats Grid */}
<div className="flex items-center justify-between gap-6">
<div className="flex flex-col gap-1">
<span className="text-body-small font-bold text-text-secondary dark:text-gray-300 leading-[150%]">
{t("points.yourTotalPoints")}
</span>
<span className="text-[48px] font-extrabold leading-[110%] tracking-[-0.01em] font-inter bg-gradient-to-b from-[#111827] to-[#4b5563] bg-clip-text text-transparent dark:from-white dark:to-gray-400">
{dashboard.totalPoints.toLocaleString()}
</span>
</div>
<div className="bg-bg-subtle dark:bg-gray-700 rounded-2xl border border-white/5 dark:border-gray-600 px-6 h-28 flex items-center gap-12">
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.globalRank")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
#{dashboard.globalRank.toLocaleString()}
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
Top {dashboard.topPercentage}
</span>
</div>
<div className="w-px h-8 bg-border-normal dark:bg-gray-600" />
<div className="flex flex-col gap-1">
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.endsIn")}
</span>
<span className="text-heading-h3 font-bold text-text-primary dark:text-white leading-[130%] tracking-[-0.005em]">
{dashboard.season.daysRemaining}d {dashboard.season.hoursRemaining}h
</span>
<span className="text-[10px] font-regular text-text-secondary dark:text-gray-300 leading-[150%] tracking-[0.01em]">
{new Date(dashboard.season.endTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
</div>
</div>
</div>
</div>
{/* Right Section - VIP Card */}
<VipCard
memberTier={dashboard.memberTier}
level={dashboard.vipLevel}
currentPoints={dashboard.totalPoints}
totalPoints={dashboard.totalPoints + dashboard.pointsToNextTier}
nextTier={dashboard.nextTier}
pointsToNextTier={dashboard.pointsToNextTier}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from "react";
import { Copy } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { Button } from "@heroui/react";
import Image from "next/image";
import BorderedButton from "@/components/common/BorderedButton";
import { useApp } from "@/contexts/AppContext";
interface ReferralCodeCardProps {
code: string;
usedCount?: number;
loading?: boolean;
onCopy?: () => void;
onShare?: () => void;
}
export default function ReferralCodeCard({
code = "PR0T0-8821",
usedCount = 0,
loading = false,
onCopy,
onShare,
}: ReferralCodeCardProps) {
const { t } = useApp();
const [copied, setCopied] = useState(false);
const handleCopyClick = () => {
onCopy?.();
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6">
{/* Header */}
<div className="flex items-center gap-3 h-6">
<Image src="/components/card/icon0.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.referralCode")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.shareYourCodeDescription").replace("{count}", usedCount.toString())}
</p>
{/* Code Display and Buttons */}
<div className="flex flex-col gap-4">
{/* Code Display Row */}
<div className="flex items-center gap-2">
{/* Code Container */}
<div className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-3 h-[46px] flex items-center">
{loading ? (
<div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-24 animate-pulse" />
) : (
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%] font-inter">
{code === "Loading..." ? t("points.loading") : code}
</span>
)}
</div>
{/* Copy Button */}
<Button
isIconOnly
onClick={handleCopyClick}
className="rounded-xl w-[46px] h-[46px] min-w-[46px] bg-text-primary dark:bg-white"
>
<AnimatePresence mode="wait" initial={false}>
{copied ? (
<motion.svg
key="check"
width="20" height="20" viewBox="0 0 24 24"
fill="none" stroke="#22c55e" strokeWidth="2.5"
strokeLinecap="round" strokeLinejoin="round"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.2 }}
>
<motion.path
d="M4 12 L10 18 L20 7"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
/>
</motion.svg>
) : (
<motion.span
key="copy"
initial={{ opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.7 }}
transition={{ duration: 0.15 }}
>
<Copy size={20} className="text-white dark:text-black" />
</motion.span>
)}
</AnimatePresence>
</Button>
</div>
{/* Share Button */}
<BorderedButton
size="lg"
fullWidth
onClick={onShare}
isTheme
>
{t("points.share")}
</BorderedButton>
</div>
</div>
);
}

View File

@@ -0,0 +1,104 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
interface RoleIndicator {
icon: string;
label: string;
current: number;
target: number;
}
interface TeamTVLCardProps {
currentTVL: string;
targetTVL?: string;
progressPercent: number;
members: number;
roles: RoleIndicator[];
loading?: boolean;
}
export default function TeamTVLCard({
currentTVL = "$8.5M",
targetTVL = "$10M",
progressPercent = 85,
members = 12,
roles = [],
loading = false,
}: TeamTVLCardProps) {
const { t } = useApp();
return (
<div className="flex-[32] bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8 flex flex-col gap-6 relative">
{/* Header */}
<div className="flex items-center gap-3">
<Image src="/components/card/icon3.svg" alt="" width={24} height={24} />
<span className="text-body-default font-bold text-text-primary dark:text-white leading-[150%]">
{t("points.teamTVLReward")}
</span>
</div>
{/* Description */}
<p className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.buildYourTeam")}
</p>
{/* Progress Section */}
<div className="flex flex-col gap-4">
{/* TVL Info */}
<div className="flex flex-col gap-1">
<div className="flex items-start justify-between">
<span className="text-caption-tiny font-regular text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.currentTeamTVL")}
</span>
{loading ? (
<div className="h-3 bg-gray-300 dark:bg-gray-600 rounded w-20 animate-pulse" />
) : (
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{currentTVL} / {targetTVL}
</span>
)}
</div>
{/* Progress Bar */}
<div className="relative w-full h-2 bg-[#f3f4f6] dark:bg-gray-700 rounded-full overflow-hidden">
<div
className="absolute left-0 top-0 h-full rounded-full"
style={{
width: `${progressPercent}%`,
background: "linear-gradient(90deg, rgba(20, 71, 230, 1) 0%, rgba(3, 43, 189, 1) 100%)"
}}
/>
</div>
</div>
{/* Role Indicators */}
<div className="flex items-center gap-2">
{roles.map((role, index) => (
<div
key={index}
className="flex-1 bg-bg-subtle dark:bg-gray-700 rounded-xl border border-border-gray dark:border-gray-600 px-4 py-2 flex flex-col items-center justify-center gap-0"
>
<div className="flex items-center justify-center h-4">
<Image src={role.icon} alt={role.label} width={16} height={16} />
</div>
<span className="text-[10px] font-bold text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{role.label}
</span>
<span className="text-caption-tiny font-bold text-text-primary dark:text-white leading-[150%] tracking-[0.01em]">
{role.current}/{role.target}
</span>
</div>
))}
</div>
</div>
{/* Members Badge */}
<div className="absolute right-6 top-8 bg-[#e1f8ec] dark:bg-green-900/30 border border-[#b8ecd2] dark:border-green-700 rounded-full px-3 py-1 flex items-center">
<span className="text-[10px] font-bold text-[#10b981] dark:text-green-400 leading-[100%] tracking-[0.01em]">
{t("points.membersCount").replace("{count}", members.toString())}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,159 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { fetchLeaderboard, formatPoints, type LeaderboardData } from "@/lib/api/points";
import { useApp } from "@/contexts/AppContext";
export default function TopPerformers() {
const { t } = useApp();
const [leaderboard, setLeaderboard] = useState<LeaderboardData | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadLeaderboard() {
setLoading(true);
const data = await fetchLeaderboard(5);
setLeaderboard(data);
setLoading(false);
}
loadLeaderboard();
}, []);
const getRowStyle = (rank: number) => {
switch (rank) {
case 1:
return {
bg: "#fff5ef",
borderColor: "#ff6900",
rankColor: "#ff6900",
textColor: "#111827",
pointsSize: "text-body-large",
};
case 2:
return {
bg: "#fffbf5",
borderColor: "#ffb933",
rankColor: "#ffb933",
textColor: "#111827",
pointsSize: "text-body-large",
};
case 3:
return {
bg: "#f3f4f6",
borderColor: "#6b7280",
rankColor: "#6b7280",
textColor: "#111827",
pointsSize: "text-body-large",
};
default:
return {
bg: "transparent",
borderColor: "transparent",
rankColor: "#d1d5db",
textColor: "#9ca1af",
pointsSize: "text-body-small",
};
}
};
if (loading) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full animate-pulse">
<div className="p-6 border-b border-[#f1f5f9] dark:border-gray-700">
<div className="h-6 bg-gray-200 dark:bg-gray-700 rounded w-32" />
</div>
<div className="flex-1 flex flex-col">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-[72px] border-l-4 border-transparent px-6 flex items-center">
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-full" />
</div>
))}
</div>
</div>
);
}
if (!leaderboard) {
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 p-8">
<p className="text-text-tertiary dark:text-gray-400">{t("points.failedToLoadLeaderboard")}</p>
</div>
);
}
return (
<div className="bg-bg-surface dark:bg-gray-800 rounded-3xl border border-border-gray dark:border-gray-700 flex flex-col overflow-hidden h-full animate-fade-in">
{/* Header */}
<div className="border-b border-[#f1f5f9] dark:border-gray-700 px-6 pt-8 pb-8">
<h3 className="text-heading-h4 font-bold text-text-primary dark:text-white leading-[140%]">
{t("points.topPerformers")}
</h3>
</div>
{/* Performers List */}
<div className="flex-1 flex flex-col">
{leaderboard.topUsers.map((performer) => {
const style = getRowStyle(performer.rank);
const isTopThree = performer.rank <= 3;
const height = performer.rank <= 3 ? "h-[72px]" : "h-[68px]";
return (
<div
key={performer.rank}
className={`flex items-center justify-between px-6 ${height} border-l-4`}
style={{
backgroundColor: style.bg,
borderLeftColor: style.borderColor,
}}
>
{/* Left - Rank and Address */}
<div className="flex items-center gap-5">
{/* Rank Number */}
<span
className={`font-black italic leading-[133%] ${
isTopThree ? "text-2xl tracking-[0.07px]" : "text-lg tracking-[-0.44px]"
}`}
style={{ color: style.rankColor }}
>
{performer.rank}
</span>
{/* Address */}
<span
className="text-body-small font-bold font-inter leading-[150%]"
style={{ color: style.textColor }}
>
{performer.address}
</span>
</div>
{/* Right - Points */}
<span
className={`${style.pointsSize} font-bold leading-[150%]`}
style={{
color: isTopThree ? "#111827" : "#4b5563",
}}
>
{formatPoints(performer.points)}
</span>
</div>
);
})}
</div>
{/* Footer - My Rank */}
<div className="border-t border-border-gray dark:border-gray-700 px-6 py-6 flex items-center justify-between">
<div className="flex items-center gap-1">
<Image src="/icons/ui/icon-chart.svg" alt="Chart" width={16} height={16} />
<span className="text-[10px] font-medium text-text-tertiary dark:text-gray-400 leading-[150%] tracking-[0.01em]">
{t("points.myRank")}
</span>
</div>
<span className="text-body-small font-bold text-text-primary dark:text-white leading-[150%]">
{leaderboard.myPoints.toLocaleString()}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
"use client";
import Image from "next/image";
import { useApp } from "@/contexts/AppContext";
interface VipCardProps {
memberTier: string;
level: number;
currentPoints: number;
totalPoints: number;
nextTier: string;
pointsToNextTier: number;
}
export default function VipCard({
memberTier = "Silver Member",
level = 2,
currentPoints = 2450,
totalPoints = 3000,
nextTier = "Gold",
pointsToNextTier = 550,
}: VipCardProps) {
const { t } = useApp();
const progressPercent = (currentPoints / totalPoints) * 100;
const progressBarPercent = Math.min(progressPercent * 0.47, 38.4); // 根据原型调整比例
return (
<div className="w-full md:w-[340px] h-[198px] relative rounded-xl overflow-hidden" style={{ background: "linear-gradient(180deg, #484848 0%, #1e1e1e 100%)" }}>
{/* Top Left - Member Info */}
<div className="absolute left-6 top-[33px]">
<div className="flex flex-col gap-1">
<span className="text-body-default font-bold text-white leading-[150%]">
{memberTier}
</span>
<span className="text-body-small font-bold text-text-tertiary dark:text-gray-400 leading-[150%]">
{t("points.currentTier")}
</span>
</div>
</div>
{/* Top Right - VIP Badge */}
<div className="absolute right-6 top-[26px]">
<div className="relative w-[55px] h-[28px]">
{/* Badge Layers */}
<div className="absolute left-[0.45px] top-[6px]">
<Image src="/components/points/polygon-30.svg" alt="" width={24} height={15} />
</div>
<div className="absolute left-[28.55px] top-[6px]">
<Image src="/components/points/polygon-40.svg" alt="" width={24} height={15} />
</div>
<div className="absolute left-[4px] top-[3px]">
<Image src="/components/points/polygon-20.svg" alt="" width={21} height={25} />
</div>
<div className="absolute left-[4px] top-[3px]">
<Image src="/components/points/mask-group1.svg" alt="" width={21} height={25} />
</div>
{/* Decorative circles */}
<div className="absolute left-[12.84px] top-0 w-[3.32px] h-[3.32px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
<div className="absolute left-0 top-[5.14px] w-[2.72px] h-[2.72px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
<div className="absolute left-[26.2px] top-[5.14px] w-[2.72px] h-[2.72px] rounded-full" style={{ background: "linear-gradient(180deg, #ffcd1d 0%, #ff971d 100%)" }} />
{/* Level Badge */}
<div className="absolute left-[24px] top-[9.62px]">
<Image src="/components/points/rectangle-420.svg" alt="" width={31} height={12} />
<span className="absolute left-[9.24px] top-0 text-[8.5px] font-semibold leading-[120%] uppercase" style={{ color: "#a07400" }}>
LV{level}
</span>
</div>
</div>
</div>
{/* Bottom - Progress Section */}
<div className="absolute left-1/2 -translate-x-1/2 top-[107px] w-[280px] flex flex-col gap-1">
{/* Progress Label */}
<div className="flex items-center justify-between">
<span className="text-caption-tiny font-regular leading-[150%] tracking-[0.01em]" style={{ color: "#d1d5db" }}>
{t("points.progress")}
</span>
<span className="text-caption-tiny font-bold text-white leading-[150%] tracking-[0.01em]">
{currentPoints} / {totalPoints}
</span>
</div>
{/* Progress Bar */}
<div className="relative w-full h-1 rounded-[5px]" style={{ background: "#343434" }}>
<div
className="absolute left-0 top-0 h-full rounded-[5px]"
style={{
width: `${progressBarPercent}%`,
background: "linear-gradient(90deg, rgba(255, 255, 255, 1) 95.15%, rgba(255, 255, 255, 0) 100%)"
}}
/>
<div
className="absolute w-[10px] h-[10px] rounded-full bg-white -top-[3px]"
style={{
left: `${Math.max(0, progressBarPercent - 3)}%`,
filter: "blur(3.42px)"
}}
/>
</div>
</div>
{/* Bottom - Next Tier */}
<div className="absolute left-6 top-[163px] flex items-center gap-1">
<span className="text-caption-tiny font-regular text-white leading-[150%] tracking-[0.01em]">
{t("points.pointsToNextTier")
.replace("{points}", pointsToNextTier.toString())
.split("{tier}")[0]}
<span className="font-bold">{nextTier}</span>
</span>
<Image src="/icons/actions/icon-arrow-gold.svg" alt="" width={16} height={16} />
</div>
</div>
);
}