117 lines
3.9 KiB
TypeScript
117 lines
3.9 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { Button } from '../ui/button';
|
|
import { SearchInput } from '../ui/input';
|
|
import { AchievementCard } from './AchievementCard';
|
|
import { Achievement } from '../../types';
|
|
import { Trophy, Lock, CheckCircle, Loader2 } from 'lucide-react';
|
|
import { gamificationService } from '../../services/gamificationService';
|
|
import { logger } from '@/utils/logger';
|
|
|
|
export const AchievementsView: React.FC = () => {
|
|
const [filter, setFilter] = useState<'all' | 'earned' | 'locked'>('all');
|
|
const [search, setSearch] = useState('');
|
|
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await gamificationService.getAchievements('me');
|
|
setAchievements(data);
|
|
} catch (e) {
|
|
logger.error('Error loading achievements', {
|
|
error: e instanceof Error ? e.message : String(e),
|
|
stack: e instanceof Error ? e.stack : undefined,
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
fetchData();
|
|
}, []);
|
|
|
|
const filtered = achievements.filter((ach) => {
|
|
const matchSearch = ach.name.toLowerCase().includes(search.toLowerCase());
|
|
const isEarned = ach.progress >= ach.maxProgress;
|
|
if (filter === 'earned') return matchSearch && isEarned;
|
|
if (filter === 'locked') return matchSearch && !isEarned;
|
|
return matchSearch;
|
|
});
|
|
|
|
const earnedCount = achievements.filter(
|
|
(a) => a.progress >= a.maxProgress,
|
|
).length;
|
|
|
|
if (loading)
|
|
return (
|
|
<div className="flex justify-center py-20">
|
|
<Loader2 className="w-10 h-10 text-kodo-cyan animate-spin" />
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="space-y-6 animate-fadeIn pb-20">
|
|
{/* Header */}
|
|
<div className="flex flex-col md:flex-row justify-between items-end border-b border-kodo-steel/50 pb-6 gap-4">
|
|
<div>
|
|
<h2 className="text-3xl font-display font-bold text-white mb-2">
|
|
ACHIEVEMENTS
|
|
</h2>
|
|
<p className="text-gray-400 font-mono text-sm">
|
|
Track your milestones and earn rewards.
|
|
</p>
|
|
</div>
|
|
<div className="bg-kodo-ink px-4 py-2 rounded-lg border border-kodo-steel flex items-center gap-3">
|
|
<Trophy className="w-5 h-5 text-kodo-gold" />
|
|
<span className="text-sm font-bold text-white">
|
|
{earnedCount} / {achievements.length} Unlocked
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex flex-col md:flex-row gap-4 items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50">
|
|
<div className="flex gap-2 w-full md:w-auto">
|
|
<Button
|
|
variant={filter === 'all' ? 'primary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setFilter('all')}
|
|
>
|
|
All
|
|
</Button>
|
|
<Button
|
|
variant={filter === 'earned' ? 'primary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setFilter('earned')}
|
|
icon={<CheckCircle className="w-3 h-3" />}
|
|
>
|
|
Earned
|
|
</Button>
|
|
<Button
|
|
variant={filter === 'locked' ? 'primary' : 'ghost'}
|
|
size="sm"
|
|
onClick={() => setFilter('locked')}
|
|
icon={<Lock className="w-3 h-3" />}
|
|
>
|
|
Locked
|
|
</Button>
|
|
</div>
|
|
<div className="w-full md:w-96">
|
|
<SearchInput
|
|
placeholder="Search achievements..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{filtered.map((ach) => (
|
|
<AchievementCard key={ach.id} achievement={ach} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|