diff --git a/README_PROFILE.md b/README_PROFILE.md new file mode 100644 index 0000000..00872e8 --- /dev/null +++ b/README_PROFILE.md @@ -0,0 +1,17 @@ +# User Profile Dashboard + +## Features +- View game statistics +- Game history with filters +- Achievement display +- Responsive design +- Dark mode support + +## Components +- ProfileHeader: User info and avatar +- StatsCard: Game statistics grid +- GameHistoryList: Chronological game list +- AchievementBadge: Individual achievement display + +## Usage +Navigate to `/profile` to view your dashboard. diff --git a/frontend/src/app/profile/layout.tsx b/frontend/src/app/profile/layout.tsx new file mode 100644 index 0000000..8c1251d --- /dev/null +++ b/frontend/src/app/profile/layout.tsx @@ -0,0 +1,3 @@ +export default function ProfileLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/frontend/src/app/profile/page.tsx b/frontend/src/app/profile/page.tsx index 2aeb258..c311080 100644 --- a/frontend/src/app/profile/page.tsx +++ b/frontend/src/app/profile/page.tsx @@ -1,351 +1,79 @@ -'use client'; +"use client"; -import { useState, useEffect } from 'react'; -import { usePlayerRegistration } from '@/hooks/useGameQueries'; -import { useRewards } from '@/hooks/useRewardManagement'; +import { useUserProfile } from '@/hooks/useUserProfile'; +import ProfileHeader from '@/components/profile/ProfileHeader'; +import StatsCard from '@/components/profile/StatsCard'; +import GameHistoryList from '@/components/profile/GameHistoryList'; +import { motion } from 'framer-motion'; import { useAccount } from 'wagmi'; import { useRouter } from 'next/navigation'; -import { motion } from 'framer-motion'; -import toast from 'react-hot-toast'; -import { sanitizeUsername } from '@/utils/sanitize'; -import { PlayerInfoSkeleton, StatsCardSkeleton } from '@/components/skeletons'; -import { useStore } from '@/store'; +import { useEffect } from 'react'; export default function ProfilePage() { + const { isConnected } = useAccount(); const router = useRouter(); - const { address, isConnected } = useAccount(); - const { playerInfo, isRegistered, updateUsername, updateIsLoading } = usePlayerRegistration(); - const { pendingRewards } = useRewards(); - const { balance } = useCeloBalance(); - - const { achievements } = useStore(); - - const [isEditing, setIsEditing] = useState(false); - const [newUsername, setNewUsername] = useState(''); + const { profile, isLoading, error } = useUserProfile(); + + useEffect(() => { + if (!isConnected) { + router.push('/signin'); + } + }, [isConnected, router]); if (!isConnected) { + return null; + } + + if (isLoading) { return ( -
-
-
🔌
-

- Connect Your Wallet -

-

- Please connect your wallet to view your profile -

+
+
+
+

Loading profile...

); } - if (!isRegistered) { + if (error) { return ( -
-
-
👤
-

- No Profile Yet -

-

- Register a username to create your profile -

- +
+
+
⚠️
+

Error Loading Profile

+

{error.message}

); } - const username = playerInfo?.[0] as string || ''; - const totalScore = playerInfo?.[1] as bigint || 0n; - const gamesPlayed = playerInfo?.[2] as bigint || 0n; - const correctAnswers = playerInfo?.[3] as bigint || 0n; - const totalQuestions = playerInfo?.[4] as bigint || 0n; - const bestScore = playerInfo?.[5] as bigint || 0n; - const rank = playerInfo?.[7] as bigint || 0n; - - const accuracy = totalQuestions > 0n - ? (Number(correctAnswers) / Number(totalQuestions) * 100).toFixed(1) - : '0'; - - const avgScore = gamesPlayed > 0n - ? (Number(totalScore) / Number(gamesPlayed)).toFixed(1) - : '0'; - - const handleUpdateUsername = async () => { - const sanitizedUsername = sanitizeUsername(newUsername); - - if (!sanitizedUsername || sanitizedUsername.length < 3) { - toast.error('Username must be at least 3 characters'); - return; - } - - try { - const loadingToast = toast.loading('Updating username... (costs 0.01 CELO)', { - duration: 60000, - }); - - await updateUsername(sanitizedUsername); - - toast.dismiss(loadingToast); - toast.success('Username updated successfully!'); - setIsEditing(false); - setNewUsername(''); - } catch (error: any) { - console.error('Error updating username:', error); - toast.dismiss(); - toast.error(error?.message || 'Failed to update username'); - } - }; + if (!profile) { + return null; + } return ( -
-
- {/* Header */} - -

- Player Profile -

-

- Your trivia game statistics and achievements -

-
+
+
+ - {/* Profile Header */} -
-
-
👤
- {!isEditing ? ( - <> -

{username}

- - - ) : ( -
- setNewUsername(e.target.value)} - placeholder="New username" - className="px-4 py-2 rounded-lg text-gray-900 w-full max-w-xs" - maxLength={20} - /> -
- - -
-
- )} -
-
-
-

Rank

-

- {rank > 0n ? `#${rank.toString()}` : 'Unranked'} -

-
-
-

Total Score

-

{totalScore.toString()}

-
-
-
+
- {/* Stats Grid */} - {!username ? ( - - ) : ( -
- -
-

CELO Balance

-
💎
-
-

{balance}

-

Current wallet balance

-
- - -
-

Pending Rewards

-
🎁
-
-

{pendingRewards}

-

Ready to claim

- -
- - -
-

Accuracy

-
🎯
-
-

{accuracy}%

-

- {correctAnswers.toString()}/{totalQuestions.toString()} correct -

-
- - -
-

Achievements

-
🏆
-
-

{achievements.filter(a => a.isUnlocked).length}

-

- {achievements.filter(a => a.isUnlocked).length}/{achievements.length} unlocked -

- -
-
- )} - - {/* Detailed Stats */} -

Game Statistics

-
-
-

Games Played

-

{gamesPlayed.toString()}

-
-
-

Best Score

-

{bestScore.toString()}

-
-
-

Avg Score

-

{avgScore}

-
-
-

Total Questions

-

{totalQuestions.toString()}

-
-
-
- - {/* Account Info */} - -

Account Information

-
-
- Wallet Address - - {address?.slice(0, 6)}...{address?.slice(-4)} - -
-
- Username - {username} -
-
- Leaderboard Rank - - {rank > 0n ? `#${rank.toString()}` : 'Unranked'} - -
-
-
- - {/* Quick Actions */} - - - - +
diff --git a/frontend/src/components/profile/AchievementBadge.tsx b/frontend/src/components/profile/AchievementBadge.tsx new file mode 100644 index 0000000..746b64d --- /dev/null +++ b/frontend/src/components/profile/AchievementBadge.tsx @@ -0,0 +1,21 @@ +import { motion } from 'framer-motion'; + +interface AchievementBadgeProps { + title: string; + icon: string; + unlocked: boolean; +} + +export default function AchievementBadge({ title, icon, unlocked }: AchievementBadgeProps) { + return ( + +
{icon}
+
{title}
+
+ ); +} diff --git a/frontend/src/components/profile/EmptyState.tsx b/frontend/src/components/profile/EmptyState.tsx new file mode 100644 index 0000000..d207e4a --- /dev/null +++ b/frontend/src/components/profile/EmptyState.tsx @@ -0,0 +1,17 @@ +interface EmptyStateProps { + icon: string; + title: string; + description: string; + action?: React.ReactNode; +} + +export default function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( +
+
{icon}
+

{title}

+

{description}

+ {action} +
+ ); +} diff --git a/frontend/src/components/profile/GameHistoryList.tsx b/frontend/src/components/profile/GameHistoryList.tsx new file mode 100644 index 0000000..f328f4a --- /dev/null +++ b/frontend/src/components/profile/GameHistoryList.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { GameHistory } from '@/types/profile'; +import { motion } from 'framer-motion'; +import { formatDistanceToNow } from '@/utils/dateUtils'; + +interface GameHistoryListProps { + history: GameHistory[]; +} + +export default function GameHistoryList({ history }: GameHistoryListProps) { + if (history.length === 0) { + return ( +
+
🎮
+

+ No Games Yet +

+

+ Start playing to build your game history! +

+
+ ); + } + + return ( +
+

+ Game History +

+ +
+ {history.map((game, index) => ( + + ))} +
+
+ ); +} + +interface GameHistoryItemProps { + game: GameHistory; + index: number; +} + +function GameHistoryItem({ game, index }: GameHistoryItemProps) { + const percentage = (game.score / game.totalQuestions) * 100; + + return ( + +
+
+
+ Game #{game.gameId} +
+
+ {formatDistanceToNow(game.timestamp)} ago +
+
+
+
+ {game.score}/{game.totalQuestions} +
+
+ {percentage.toFixed(0)}% +
+
+
+ +
+ + {game.difficulty} + + + +{game.rewardEarned} USDC + +
+
+ ); +} diff --git a/frontend/src/components/profile/ProfileHeader.tsx b/frontend/src/components/profile/ProfileHeader.tsx new file mode 100644 index 0000000..7dd07fb --- /dev/null +++ b/frontend/src/components/profile/ProfileHeader.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion } from 'framer-motion'; + +interface ProfileHeaderProps { + address: string; + username?: string; + joinedDate: number; +} + +export default function ProfileHeader({ address, username, joinedDate }: ProfileHeaderProps) { + const formattedAddress = `${address.slice(0, 6)}...${address.slice(-4)}`; + const memberSince = new Date(joinedDate).toLocaleDateString('en-US', { + month: 'long', + year: 'numeric', + }); + + return ( + +
+
+
+ 👤 +
+
+

+ {username || formattedAddress} +

+

+ {address} +

+

+ Member since {memberSince} +

+
+
+ + +
+
+ ); +} diff --git a/frontend/src/components/profile/ProfileSkeleton.tsx b/frontend/src/components/profile/ProfileSkeleton.tsx new file mode 100644 index 0000000..b0d6cc7 --- /dev/null +++ b/frontend/src/components/profile/ProfileSkeleton.tsx @@ -0,0 +1,9 @@ +export default function ProfileSkeleton() { + return ( +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/profile/StatsCard.tsx b/frontend/src/components/profile/StatsCard.tsx new file mode 100644 index 0000000..94f0829 --- /dev/null +++ b/frontend/src/components/profile/StatsCard.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { motion } from 'framer-motion'; +import { UserStats } from '@/types/profile'; + +interface StatsCardProps { + stats: UserStats; +} + +export default function StatsCard({ stats }: StatsCardProps) { + return ( +
+

+ Game Statistics +

+ +
+ + + + + + +
+
+ ); +} + +interface StatItemProps { + label: string; + value: string | number; + icon: string; + color?: string; +} + +function StatItem({ label, value, icon, color = 'text-gray-900' }: StatItemProps) { + return ( + +
{icon}
+
+ {value} +
+
+ {label} +
+
+ ); +} diff --git a/frontend/src/components/profile/index.ts b/frontend/src/components/profile/index.ts new file mode 100644 index 0000000..a684266 --- /dev/null +++ b/frontend/src/components/profile/index.ts @@ -0,0 +1,3 @@ +export { default as ProfileHeader } from './ProfileHeader'; +export { default as StatsCard } from './StatsCard'; +export { default as GameHistoryList } from './GameHistoryList'; diff --git a/frontend/src/hooks/useGameHistory.ts b/frontend/src/hooks/useGameHistory.ts new file mode 100644 index 0000000..278e1c3 --- /dev/null +++ b/frontend/src/hooks/useGameHistory.ts @@ -0,0 +1,24 @@ +import { useState, useEffect } from 'react'; +import { useAccount } from 'wagmi'; +import { GameHistory } from '@/types/profile'; + +export function useGameHistory() { + const { address } = useAccount(); + const [history, setHistory] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if (address) { + fetchHistory(); + } + }, [address]); + + const fetchHistory = async () => { + setIsLoading(true); + // TODO: Fetch from contract + setHistory([]); + setIsLoading(false); + }; + + return { history, isLoading, refetch: fetchHistory }; +} diff --git a/frontend/src/hooks/useUserProfile.ts b/frontend/src/hooks/useUserProfile.ts new file mode 100644 index 0000000..0417257 --- /dev/null +++ b/frontend/src/hooks/useUserProfile.ts @@ -0,0 +1,63 @@ +import { useAccount } from 'wagmi'; +import { useState, useEffect } from 'react'; +import { UserProfile, UserStats } from '@/types/profile'; + +/** + * Hook to fetch and manage user profile data + */ +export function useUserProfile() { + const { address } = useAccount(); + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!address) { + setProfile(null); + return; + } + + fetchProfile(); + }, [address]); + + const fetchProfile = async () => { + if (!address) return; + + setIsLoading(true); + setError(null); + + try { + // TODO: Implement actual profile fetching from contract/API + const mockProfile: UserProfile = { + address, + stats: { + totalGames: 0, + totalWins: 0, + totalLosses: 0, + winRate: 0, + totalRewards: '0', + averageScore: 0, + }, + createdAt: Date.now(), + lastActive: Date.now(), + }; + + setProfile(mockProfile); + } catch (err) { + setError(err as Error); + } finally { + setIsLoading(false); + } + }; + + const refreshProfile = () => { + fetchProfile(); + }; + + return { + profile, + isLoading, + error, + refreshProfile, + }; +} diff --git a/frontend/src/types/profile.ts b/frontend/src/types/profile.ts new file mode 100644 index 0000000..410d961 --- /dev/null +++ b/frontend/src/types/profile.ts @@ -0,0 +1,31 @@ +/** + * User Profile Types + * + * Type definitions for user profile and statistics + */ + +export interface UserStats { + totalGames: number; + totalWins: number; + totalLosses: number; + winRate: number; + totalRewards: string; + averageScore: number; +} + +export interface UserProfile { + address: string; + username?: string; + stats: UserStats; + createdAt: number; + lastActive: number; +} + +export interface GameHistory { + gameId: string; + timestamp: number; + score: number; + totalQuestions: number; + rewardEarned: string; + difficulty: 'easy' | 'medium' | 'hard'; +} diff --git a/frontend/src/utils/dateUtils.ts b/frontend/src/utils/dateUtils.ts new file mode 100644 index 0000000..e8a0d9d --- /dev/null +++ b/frontend/src/utils/dateUtils.ts @@ -0,0 +1,36 @@ +/** + * Date utility functions + */ + +export function formatDistanceToNow(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days} day${days > 1 ? 's' : ''}`; + if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''}`; + if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''}`; + return `${seconds} second${seconds !== 1 ? 's' : ''}`; +} + +export function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); +} + +export function formatDateTime(timestamp: number): string { + return new Date(timestamp).toLocaleString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/frontend/src/utils/profileUtils.ts b/frontend/src/utils/profileUtils.ts new file mode 100644 index 0000000..f58335a --- /dev/null +++ b/frontend/src/utils/profileUtils.ts @@ -0,0 +1,11 @@ +import { UserStats } from '@/types/profile'; + +export function calculateWinRate(wins: number, total: number): number { + if (total === 0) return 0; + return (wins / total) * 100; +} + +export function calculateAverageScore(scores: number[]): number { + if (scores.length === 0) return 0; + return scores.reduce((a, b) => a + b, 0) / scores.length; +}