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
17 changes: 17 additions & 0 deletions README_PROFILE.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions frontend/src/app/profile/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
356 changes: 42 additions & 314 deletions frontend/src/app/profile/page.tsx

Large diffs are not rendered by default.

21 changes: 21 additions & 0 deletions frontend/src/components/profile/AchievementBadge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
whileHover={{ scale: unlocked ? 1.1 : 1 }}
className={`p-4 rounded-lg text-center ${
unlocked ? 'bg-yellow-100 border-2 border-yellow-400' : 'bg-gray-100 opacity-50'
}`}
>
<div className="text-4xl mb-2">{icon}</div>
<div className="text-sm font-semibold">{title}</div>
</motion.div>
);
}
17 changes: 17 additions & 0 deletions frontend/src/components/profile/EmptyState.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="text-center py-12">
<div className="text-6xl mb-4">{icon}</div>
<h3 className="text-xl font-bold mb-2">{title}</h3>
<p className="text-gray-600 mb-4">{description}</p>
{action}
</div>
);
}
85 changes: 85 additions & 0 deletions frontend/src/components/profile/GameHistoryList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-8 text-center">
<div className="text-6xl mb-4">🎮</div>
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-2">
No Games Yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Start playing to build your game history!
</p>
</div>
);
}

return (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
Game History
</h2>

<div className="space-y-4">
{history.map((game, index) => (
<GameHistoryItem key={game.gameId} game={game} index={index} />
))}
</div>
</div>
);
}

interface GameHistoryItemProps {
game: GameHistory;
index: number;
}

function GameHistoryItem({ game, index }: GameHistoryItemProps) {
const percentage = (game.score / game.totalQuestions) * 100;

return (
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
className="border-l-4 border-blue-500 bg-gray-50 dark:bg-gray-700 rounded-r-lg p-4"
>
<div className="flex justify-between items-start">
<div>
<div className="font-semibold text-gray-900 dark:text-white">
Game #{game.gameId}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDistanceToNow(game.timestamp)} ago
</div>
</div>
<div className="text-right">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">
{game.score}/{game.totalQuestions}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{percentage.toFixed(0)}%
</div>
</div>
</div>

<div className="mt-2 flex items-center justify-between">
<span className="text-sm px-2 py-1 bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300 rounded">
{game.difficulty}
</span>
<span className="text-sm font-medium text-green-600 dark:text-green-400">
+{game.rewardEarned} USDC
</span>
</div>
</motion.div>
);
}
51 changes: 51 additions & 0 deletions frontend/src/components/profile/ProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-gradient-to-r from-blue-600 to-purple-600 rounded-xl shadow-xl p-8 text-white"
>
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className="w-20 h-20 bg-white/20 rounded-full flex items-center justify-center text-4xl">
👤
</div>
<div>
<h1 className="text-3xl font-bold mb-1">
{username || formattedAddress}
</h1>
<p className="text-blue-100 text-sm">
{address}
</p>
<p className="text-blue-200 text-xs mt-1">
Member since {memberSince}
</p>
</div>
</div>

<button
className="px-6 py-3 bg-white/10 hover:bg-white/20 rounded-lg font-semibold transition-colors backdrop-blur-sm border border-white/20"
onClick={() => {/* TODO: Implement edit profile */}}
>
Edit Profile
</button>
</div>
</motion.div>
);
}
9 changes: 9 additions & 0 deletions frontend/src/components/profile/ProfileSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export default function ProfileSkeleton() {
return (
<div className="animate-pulse space-y-6">
<div className="bg-gray-300 h-40 rounded-xl"></div>
<div className="bg-gray-300 h-64 rounded-xl"></div>
<div className="bg-gray-300 h-96 rounded-xl"></div>
</div>
);
}
80 changes: 80 additions & 0 deletions frontend/src/components/profile/StatsCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6">
<h2 className="text-2xl font-bold mb-6 text-gray-900 dark:text-white">
Game Statistics
</h2>

<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
<StatItem
label="Total Games"
value={stats.totalGames}
icon="🎮"
/>
<StatItem
label="Wins"
value={stats.totalWins}
icon="🏆"
color="text-green-600"
/>
<StatItem
label="Losses"
value={stats.totalLosses}
icon="📉"
color="text-red-600"
/>
<StatItem
label="Win Rate"
value={`${stats.winRate.toFixed(1)}%`}
icon="📊"
color="text-blue-600"
/>
<StatItem
label="Total Rewards"
value={`${stats.totalRewards} USDC`}
icon="💰"
color="text-yellow-600"
/>
<StatItem
label="Avg Score"
value={stats.averageScore.toFixed(1)}
icon="⭐"
color="text-purple-600"
/>
</div>
</div>
);
}

interface StatItemProps {
label: string;
value: string | number;
icon: string;
color?: string;
}

function StatItem({ label, value, icon, color = 'text-gray-900' }: StatItemProps) {
return (
<motion.div
whileHover={{ scale: 1.05 }}
className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 text-center"
>
<div className="text-3xl mb-2">{icon}</div>
<div className={`text-2xl font-bold ${color} dark:text-white mb-1`}>
{value}
</div>
<div className="text-sm text-gray-600 dark:text-gray-300">
{label}
</div>
</motion.div>
);
}
3 changes: 3 additions & 0 deletions frontend/src/components/profile/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as ProfileHeader } from './ProfileHeader';
export { default as StatsCard } from './StatsCard';
export { default as GameHistoryList } from './GameHistoryList';
24 changes: 24 additions & 0 deletions frontend/src/hooks/useGameHistory.ts
Original file line number Diff line number Diff line change
@@ -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<GameHistory[]>([]);
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 };
}
63 changes: 63 additions & 0 deletions frontend/src/hooks/useUserProfile.ts
Original file line number Diff line number Diff line change
@@ -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<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(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,
};
}
Loading
Loading