init: 初始化 AssetX 项目仓库
包含 webapp(Next.js 用户端)、webapp-back(Go 后端)、 antdesign(管理后台)、landingpage(营销落地页)、 数据库 SQL 和配置文件。
This commit is contained in:
204
webapp/components/points/ActivityHistory.tsx
Normal file
204
webapp/components/points/ActivityHistory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
webapp/components/points/BindInviteCard.tsx
Normal file
67
webapp/components/points/BindInviteCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
webapp/components/points/DepositCard.tsx
Normal file
113
webapp/components/points/DepositCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
webapp/components/points/EarnOpportunityCard.tsx
Normal file
90
webapp/components/points/EarnOpportunityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
172
webapp/components/points/PointsCards.tsx
Normal file
172
webapp/components/points/PointsCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
webapp/components/points/PointsDashboard.tsx
Normal file
194
webapp/components/points/PointsDashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
webapp/components/points/ReferralCodeCard.tsx
Normal file
117
webapp/components/points/ReferralCodeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
webapp/components/points/TeamTVLCard.tsx
Normal file
104
webapp/components/points/TeamTVLCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
webapp/components/points/TopPerformers.tsx
Normal file
159
webapp/components/points/TopPerformers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
webapp/components/points/VipCard.tsx
Normal file
116
webapp/components/points/VipCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user