veza/apps/web/src/components/gamification/AchievementsView.tsx

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>
);
};