Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 214 additions & 17 deletions app/rewards/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { useAppKitAccount } from "@reown/appkit/react";
import { useAppKit } from "@reown/appkit/react";
import { useRewardToken } from "@/lib/hooks/useRewardToken";
import { useERC20Token } from "@/lib/hooks/useERC20Token";
import { useProjectNFT } from "@/lib/hooks/useProjectNFT";
import { formatDistanceToNow } from "date-fns";
import { zhCN } from "date-fns/locale";
import { Coins, History, Wallet, ChevronRight, Gift, Clock, ExternalLink, Plus, Info } from "lucide-react";
import { Coins, History, Wallet, ChevronRight, Gift, Clock, ExternalLink, Plus, Info, Award, CheckCircle, XCircle, Medal } from "lucide-react";
import { sepolia } from "wagmi/chains";

export default function RewardsPage() {
Expand All @@ -17,29 +18,62 @@ export default function RewardsPage() {
availableRewards,
lastClaimTime,
claimRewards,
isLoading,
isSuccess,
errorMessage,
isLoading: isRewardLoading,
isSuccess: isRewardSuccess,
errorMessage: rewardErrorMessage,
isSepoliaNetwork
} = useRewardToken();
const {
balance,
} = useERC20Token();
const {
projectsStatus,
mintProjectNFT,
isLoading: isNFTLoading,
isMintSuccess,
errorMessage: nftErrorMessage,
} = useProjectNFT();
const [showSuccessMessage, setShowSuccessMessage] = useState(false);
const [showNetworkInfo, setShowNetworkInfo] = useState(false);
const [switchError, setSwitchError] = useState<string | null>(null);
const [showNFTSuccess, setShowNFTSuccess] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'rewards' | 'nfts'>('rewards');

// 处理成功领取后的提示
useEffect(() => {
if (isSuccess) {
if (isRewardSuccess) {
setShowSuccessMessage(true);
const timer = setTimeout(() => {
setShowSuccessMessage(false);
}, 3000);

return () => clearTimeout(timer);
}
}, [isSuccess]);
}, [isRewardSuccess]);

// 处理NFT铸造成功提示
useEffect(() => {
if (isMintSuccess) {
setShowNFTSuccess(true);
const timer = setTimeout(() => {
setShowNFTSuccess(false);
}, 3000);

return () => clearTimeout(timer);
}
}, [isMintSuccess]);

// 处理错误信息
useEffect(() => {
if (rewardErrorMessage) {
setErrorMsg(rewardErrorMessage);
} else if (nftErrorMessage) {
setErrorMsg(nftErrorMessage);
} else {
setErrorMsg(null);
}
}, [rewardErrorMessage, nftErrorMessage]);

// 格式化上次领取时间
const formattedLastClaimTime = lastClaimTime ?
Expand Down Expand Up @@ -94,7 +128,7 @@ export default function RewardsPage() {
<Wallet className="w-16 h-16 text-blue-400 mb-4" />
<h2 className="text-2xl font-bold text-white mb-2">连接您的钱包</h2>
<p className="text-gray-400 text-center mb-6">
请先连接您的钱包以查看和领取奖励代币
请先连接您的钱包以查看和领取奖励代币以及项目成就NFT
</p>
<button
onClick={handleConnectWallet}
Expand All @@ -114,7 +148,7 @@ export default function RewardsPage() {
</svg>
<h2 className="text-2xl font-bold text-white mb-2">请切换至 Sepolia 测试网</h2>
<p className="text-gray-400 text-center mb-6">
CFC 奖励仅在 Sepolia 测试网上可用,请切换您的钱包网络
CFC 奖励和项目NFT仅在 Sepolia 测试网上可用,请切换您的钱包网络
</p>

{/* 错误信息 */}
Expand Down Expand Up @@ -209,13 +243,13 @@ export default function RewardsPage() {
</div>
<button
onClick={claimRewards}
disabled={isLoading || Number(availableRewards) <= 0}
disabled={isRewardLoading || Number(availableRewards) <= 0}
className={`w-full py-3 rounded-lg font-medium transition-all duration-200 flex items-center justify-center
${Number(availableRewards) > 0
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-gray-700/30 text-gray-500 cursor-not-allowed'}`}
>
{isLoading ? (
{isRewardLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
处理中...
Expand All @@ -235,6 +269,152 @@ export default function RewardsPage() {
</div>
</div>
);

// 渲染NFT卡片
const renderNFTCards = () => (
<div className="space-y-6">
<h3 className="text-xl font-semibold text-white mb-4">项目成就NFT</h3>

{isNFTLoading && projectsStatus.length === 0 ? (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
<span className="ml-3 text-gray-400">加载项目数据中...</span>
</div>
) : projectsStatus.length === 0 ? (
<div className="text-center py-10 bg-gray-800/20 rounded-xl border border-gray-700/30">
<Award className="h-16 w-16 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-300 mb-2">暂无项目数据</h3>
<p className="text-gray-500 max-w-md mx-auto">
您尚未参与任何学习项目,或者项目数据正在同步中。参与社区学习项目后即可获得专属NFT成就。
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{projectsStatus.map((project) => (
<div
key={project.projectId}
className="bg-gray-800/20 rounded-xl border border-gray-700/30 overflow-hidden"
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h4 className="text-lg font-medium text-white">{project.projectName}</h4>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-400">得分:</span>
<span className="text-sm font-medium text-white">{project.score}</span>
</div>
</div>

<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-gray-400">打卡天数:</span>
<span className="text-white">
{project.completedTasks}/{project.totalTasks || '?'}
</span>
</div>

<div className="flex justify-between items-center">
<span className="text-gray-400">NFT状态:</span>
{project.hasMinted ? (
<span className="text-green-400 flex items-center">
<CheckCircle className="h-4 w-4 mr-1" />
已铸造
</span>
) : project.canMint ? (
<span className="text-yellow-400 flex items-center">
<Medal className="h-4 w-4 mr-1" />
可铸造
</span>
) : (
<span className="text-red-400 flex items-center">
<XCircle className="h-4 w-4 mr-1" />
未达标
</span>
)}
</div>
</div>
</div>

<div className="p-4">
<button
onClick={() => mintProjectNFT(project.projectId)}
disabled={isNFTLoading || !project.canMint || project.hasMinted}
className={`w-full py-3 rounded-lg font-medium transition-all duration-200 flex items-center justify-center
${project.canMint && !project.hasMinted
? 'bg-purple-600 hover:bg-purple-700 text-white'
: 'bg-gray-700/30 text-gray-500 cursor-not-allowed'}`}
>
{isNFTLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
处理中...
</>
) : project.hasMinted ? (
<>
<CheckCircle className="mr-2 h-4 w-4" />
已铸造成就NFT
</>
) : project.canMint ? (
<>
<Medal className="mr-2 h-4 w-4" />
领取成就NFT
</>
) : (
<>
<XCircle className="mr-2 h-4 w-4" />
未达到领取条件
</>
)}
</button>
</div>
</div>
))}
</div>
)}

<div className="mt-4 bg-gray-800/20 rounded-xl p-5 border border-gray-700/30">
<h3 className="text-lg font-medium text-white mb-3">关于成就NFT</h3>
<p className="text-gray-400 text-sm leading-relaxed">
成就NFT是对您完成社区学习项目的永久记录和认证。每个NFT代表您在特定项目中的参与和成就,
并可作为您学习历程的证明。项目得分达到通过标准即可领取对应的专属NFT。
</p>
</div>
</div>
);

// 渲染内容区域标签
const renderTabs = () => (
<div className="mb-8 border-b border-gray-800">
<div className="flex space-x-6">
<button
onClick={() => setActiveTab('rewards')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'rewards'
? 'text-blue-400 border-b-2 border-blue-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<div className="flex items-center">
<Coins className="mr-2 h-5 w-5" />
代币奖励
</div>
</button>

<button
onClick={() => setActiveTab('nfts')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'nfts'
? 'text-purple-400 border-b-2 border-purple-400'
: 'text-gray-400 hover:text-gray-300'
}`}
>
<div className="flex items-center">
<Medal className="mr-2 h-5 w-5" />
成就NFT
</div>
</button>
</div>
</div>
);

// 渲染合适的内容
const renderContent = () => {
Expand All @@ -243,7 +423,12 @@ export default function RewardsPage() {
} else if (!isSepoliaNetwork) {
return renderNetworkSwitchPrompt();
} else {
return renderRewardsCard();
return (
<>
{renderTabs()}
{activeTab === 'rewards' ? renderRewardsCard() : renderNFTCards()}
</>
);
}
};

Expand All @@ -256,21 +441,21 @@ export default function RewardsPage() {
CFC 奖励中心
</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
参与社区活动和贡献代码,获取 CFC 代币奖励。这些代币可用于社区治理和解锁特殊功能
参与社区活动和贡献代码,获取 CFC 代币奖励和专属成就NFT。这些奖励证明了您的技能和贡献
</p>
</div>

{/* 动态内容区域 */}
{renderContent()}

{/* 错误信息 */}
{errorMessage && (
{errorMsg && (
<div className="mt-6 p-4 bg-red-900/20 border border-red-500/30 rounded-lg text-red-300">
{errorMessage}
{errorMsg}
</div>
)}

{/* 成功信息 */}
{/* 成功信息 - 代币 */}
{showSuccessMessage && (
<div className="mt-6 p-4 bg-green-900/20 border border-green-500/30 rounded-lg text-green-300 flex items-center">
<div className="mr-2 bg-green-500/20 p-1.5 rounded-full">
Expand All @@ -282,6 +467,18 @@ export default function RewardsPage() {
</div>
)}

{/* 成功信息 - NFT */}
{showNFTSuccess && (
<div className="mt-6 p-4 bg-purple-900/20 border border-purple-500/30 rounded-lg text-purple-300 flex items-center">
<div className="mr-2 bg-purple-500/20 p-1.5 rounded-full">
<svg className="h-4 w-4 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
您已成功铸造项目成就NFT!
</div>
)}

{/* 奖励说明 */}
<div className="mt-12 bg-gray-800/20 rounded-xl p-6 border border-gray-700/30">
<h2 className="text-xl font-semibold text-white mb-4">如何获取更多奖励</h2>
Expand All @@ -291,8 +488,8 @@ export default function RewardsPage() {
<span className="text-blue-400 text-sm font-bold">1</span>
</div>
<div>
<p className="text-gray-300">参与 Hackathon 活动</p>
<p className="text-sm text-gray-500">根据项目完成情况和评分,获得相应的代币奖励。</p>
<p className="text-gray-300">参与共学项目</p>
<p className="text-sm text-gray-500">完成社区共学项目任务,获得代币奖励和专属项目成就NFT。</p>
</div>
</li>
<li className="flex items-start">
Expand Down
42 changes: 42 additions & 0 deletions contracts/CFCNFT.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CoLearnNFT is ERC721URIStorage, Ownable {
uint256 private _nextTokenId;

constructor() ERC721("CoLearnNFT", "CLNFT") Ownable(msg.sender) {
_nextTokenId = 1; // tokenId 从1开始
}

/// @notice 仅限管理员调用,铸造 NFT 并将 IPFS URI 上链
/// @param to NFT 接收者地址
/// @param tokenURI_ 存储在 IPFS 上的 metadata URI
/// @return tokenId 新生成的 NFT tokenId
function mintNFT(address to, string calldata tokenURI_) external returns (uint256) {
uint256 tokenId = _nextTokenId;
_nextTokenId++;
_mint(to, tokenId);
_setTokenURI(tokenId, tokenURI_);
return tokenId;
}

/// @notice 批量铸造 NFT
/// @param recipients 接收者地址数组
/// @param tokenURIs 存储在 IPFS 上的 metadata URI 数组
/// @return tokenIds 新生成的 NFT tokenId 数组
function batchMintNFT(address[] calldata recipients, string[] calldata tokenURIs) external returns (uint256[] memory) {
require(recipients.length == tokenURIs.length, "Mismatched input lengths");
uint256[] memory tokenIds = new uint256[](recipients.length);
for (uint256 i = 0; i < recipients.length; i++) {
uint256 tokenId = _nextTokenId;
_nextTokenId++;
_mint(recipients[i], tokenId);
_setTokenURI(tokenId, tokenURIs[i]);
tokenIds[i] = tokenId;
}
return tokenIds;
}
}
17 changes: 17 additions & 0 deletions contracts/CFCToken.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.25;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";

contract CFCToken is ERC20, Ownable {
constructor() ERC20("CrazyForCode Token", "CFC") Ownable(msg.sender) { }

function mint(address to, uint256 amount) external onlyOwner {
_mint(to, amount);
}

function burn(address from, uint256 amount) external onlyOwner {
_burn(from, amount);
}
}
Loading
Loading