9819d8db0b
continuous-integration/drone/push Build is failing
- implemented /notifications page with overdue/upcoming alerts - Fixed sidebar scrolling in collapsed mode - Moved notification button to sidebar footer - Enhanced Achievements page with streak stats and tooltips - Improved XP history to show task titles - Added missing translations (en/de) - Removed top bar header - Fixed Docker environment routing
743 lines
51 KiB
TypeScript
743 lines
51 KiB
TypeScript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
|
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame, Scroll, BookOpen, Hammer, Award, Medal, Star, Crown, Zap, Sparkles, Sun, Timer } from 'lucide-react';
|
|
import { useState, useCallback } from 'react';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Goal, Reward, User } from '@shared/schema';
|
|
import { getLevelFromXP, getRankKey, LEVEL_THRESHOLDS } from '@/lib/gamification';
|
|
import { cn } from "@/lib/utils";
|
|
import { RewardCard } from '@/components/gamification/RewardCard';
|
|
import { useToast } from "@/hooks/use-toast";
|
|
import { Tooltip as UITooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
|
|
export default function AchievementsPage({ user }: { user: User }) {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const { toast } = useToast();
|
|
const [isGoalModalOpen, setIsGoalModalOpen] = useState(false);
|
|
|
|
// Mock User Data (Replace with context later)
|
|
// const user = { xp: 350, streak: 5, bestStreak: 12 };
|
|
const level = getLevelFromXP(user.xp);
|
|
const rankKey = getRankKey(level);
|
|
|
|
// Fetch Analytics Data
|
|
const { data: weeklyData } = useQuery({
|
|
queryKey: ['/api/analytics/weekly'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/analytics/weekly');
|
|
return res.json();
|
|
}
|
|
});
|
|
|
|
const { data: yearlyData } = useQuery({
|
|
queryKey: ['/api/analytics/yearly'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/analytics/yearly');
|
|
return res.json();
|
|
}
|
|
});
|
|
|
|
const { data: monthlyData } = useQuery({
|
|
queryKey: ['/api/analytics/monthly'],
|
|
queryFn: async () => {
|
|
const res = await fetch('/api/analytics/monthly');
|
|
return res.json();
|
|
}
|
|
});
|
|
|
|
// Fetch Goals
|
|
const { data: goals = [] } = useQuery<Goal[]>({
|
|
queryKey: ['/api/goals'],
|
|
});
|
|
|
|
// Create Goal Mutation
|
|
const createGoalMutation = useMutation({
|
|
mutationFn: async (newGoal: any) => {
|
|
const res = await fetch('/api/goals', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(newGoal)
|
|
});
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/goals'] });
|
|
setIsGoalModalOpen(false);
|
|
}
|
|
});
|
|
|
|
|
|
// Rewards
|
|
const { data: rewards = [] } = useQuery<Reward[]>({
|
|
queryKey: ['/api/rewards', user.id],
|
|
queryFn: async () => {
|
|
const res = await fetch(`/api/rewards?userId=${user.id}`);
|
|
return res.json();
|
|
}
|
|
});
|
|
|
|
const { data: history = [] } = useQuery<any[]>({
|
|
queryKey: ['/api/user/history'],
|
|
});
|
|
|
|
const { data: inventory = [] } = useQuery<any[]>({
|
|
queryKey: ['/api/user/inventory'],
|
|
});
|
|
|
|
const handleCreateGoal = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target as HTMLFormElement);
|
|
createGoalMutation.mutate({
|
|
title: formData.get('title'),
|
|
target: parseInt(formData.get('target') as string),
|
|
type: formData.get('type'),
|
|
userId: user.id
|
|
});
|
|
};
|
|
|
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="rounded-lg border bg-background/95 p-3 shadow-xl backdrop-blur-sm">
|
|
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
|
{t(`analytics.${label}`) === `analytics.${label}` ?
|
|
(label && !isNaN(label) ? `${t('analytics.cw')} ${label}` : label)
|
|
: t(`analytics.${label}`)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="h-2 w-2 rounded-full bg-primary" />
|
|
<span className="text-sm font-bold">
|
|
{payload[0].value} XP
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Create Reward Mutation
|
|
const createRewardMutation = useMutation({
|
|
mutationFn: async (newReward: { title: string, description: string, cost: number }) => {
|
|
const res = await fetch('/api/rewards', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
...newReward,
|
|
icon: "gift", // Default icon for now
|
|
type: "virtual"
|
|
})
|
|
});
|
|
if (!res.ok) throw new Error("Failed to create reward");
|
|
return res.json();
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
|
|
toast({ title: t('rewards.created', 'Reward created!') });
|
|
},
|
|
onError: () => {
|
|
toast({ title: t('rewards.createError', 'Failed to create reward'), variant: "destructive" });
|
|
}
|
|
});
|
|
|
|
const handleCreateReward = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.target as HTMLFormElement);
|
|
createRewardMutation.mutate({
|
|
title: formData.get('title') as string,
|
|
description: formData.get('description') as string,
|
|
cost: parseInt(formData.get('cost') as string)
|
|
});
|
|
};
|
|
const [activeTab, setActiveTab] = useState("overview");
|
|
|
|
|
|
return (
|
|
<div className="space-y-6 pb-20 md:pb-0">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight mb-1">{t('achievements.title')}</h1>
|
|
<p className="text-muted-foreground">{t('achievements.subtitle')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="md:inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground w-full md:w-auto grid grid-cols-2 md:grid-cols-none gap-1 md:gap-0 h-auto md:h-10">
|
|
{['overview', 'rewards', 'inventory', 'history', 'rules'].map((tab) => (
|
|
<button
|
|
key={tab}
|
|
onClick={() => setActiveTab(tab)}
|
|
className={cn(
|
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
activeTab === tab ? "bg-background text-foreground shadow-sm" : "hover:bg-background/50"
|
|
)}
|
|
>
|
|
{tab === 'rewards' ? t('rewards.shopTitle') :
|
|
tab === 'rules' ? t('gamification.rules.title') :
|
|
t(`achievements.${tab}`)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{activeTab === 'overview' && (
|
|
<div className="space-y-4">
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
{/* Level Card */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">{t('gamification.level', { level })}</CardTitle>
|
|
<Trophy className="h-4 w-4 text-yellow-500" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold text-foreground">{t(`ranks.${rankKey}`)}</div>
|
|
<p className="text-xs text-muted-foreground flex items-center mt-1">
|
|
<span className="text-green-500 flex items-center mr-1">
|
|
<TrendingUp className="h-3 w-3 mr-1" /> +15%
|
|
</span>
|
|
{t('achievements.fromLastWeek')}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Streak Card */}
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<CardTitle className="text-sm font-medium">{t('achievements.currentStreak')}</CardTitle>
|
|
<UITooltip>
|
|
<TooltipTrigger>
|
|
<div className="h-4 w-4 rounded-full border border-muted-foreground/30 flex items-center justify-center text-[10px] text-muted-foreground">?</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('gamification.rules.streakTooltip')}</p>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</div>
|
|
<Flame className="h-4 w-4 text-orange-500" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{t('achievements.days', { count: user.currentStreak })}</div>
|
|
<p className="text-xs text-muted-foreground mt-1 mb-3">
|
|
{t('achievements.bestStreak', { count: user.currentStreak })}
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 gap-2 pt-2 border-t">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[10px] text-muted-foreground uppercase">{t('achievements.weeklyStreak')}</span>
|
|
<UITooltip>
|
|
<TooltipTrigger>
|
|
<div className="h-3 w-3 rounded-full border border-muted-foreground/30 flex items-center justify-center text-[8px] text-muted-foreground">?</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('gamification.rules.streakBonus')}</p>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</div>
|
|
<div className="font-bold text-sm">0</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-[10px] text-muted-foreground uppercase">{t('achievements.monthlyStreak')}</span>
|
|
<UITooltip>
|
|
<TooltipTrigger>
|
|
<div className="h-3 w-3 rounded-full border border-muted-foreground/30 flex items-center justify-center text-[8px] text-muted-foreground">?</div>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{t('gamification.rules.streakBonus')}</p>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
</div>
|
|
<div className="font-bold text-sm">0</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{/* More stats placeholder */}
|
|
</div>
|
|
|
|
<div className="grid gap-4 md:grid-cols-7">
|
|
<Card className="col-span-4">
|
|
<CardHeader>
|
|
<CardTitle>{t('achievements.xpActivity')}</CardTitle>
|
|
<CardDescription>{t('achievements.xpDescription')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="pl-2">
|
|
<Tabs defaultValue="weekly" className="space-y-4">
|
|
<div className="flex items-center justify-end px-4">
|
|
<TabsList>
|
|
<TabsTrigger value="weekly">{t('achievements.weekly')}</TabsTrigger>
|
|
<TabsTrigger value="monthly">{t('achievements.monthly')}</TabsTrigger>
|
|
<TabsTrigger value="yearly">{t('achievements.yearly')}</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
<TabsContent value="monthly" className="h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={monthlyData}>
|
|
<XAxis
|
|
dataKey="labelKey"
|
|
stroke="#888888"
|
|
fontSize={12}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(val) => {
|
|
const translationKey = `analytics.${val}`;
|
|
const translated = t(translationKey);
|
|
return translated !== translationKey ? translated : `${t('analytics.cw')} ${val}`;
|
|
}}
|
|
/>
|
|
<YAxis
|
|
stroke="#888888"
|
|
fontSize={12}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => `${value}`}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
|
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
|
|
{monthlyData?.map((entry: any, index: number) => (
|
|
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</TabsContent>
|
|
<TabsContent value="weekly" className="h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={weeklyData}>
|
|
<XAxis
|
|
dataKey="labelKey"
|
|
stroke="#888888"
|
|
fontSize={12}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(val) => t(`analytics.${val}`)}
|
|
/>
|
|
<YAxis
|
|
stroke="#888888"
|
|
fontSize={12}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(value) => `${value}`}
|
|
/>
|
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
|
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
|
|
{weeklyData?.map((entry: any, index: number) => (
|
|
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
|
|
))}
|
|
</Bar>
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</TabsContent>
|
|
<TabsContent value="yearly" className="h-[300px]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<BarChart data={yearlyData}>
|
|
<XAxis
|
|
dataKey="labelKey"
|
|
stroke="#888888"
|
|
fontSize={12}
|
|
tickLine={false}
|
|
axisLine={false}
|
|
tickFormatter={(val) => t(`analytics.${val}`)}
|
|
/>
|
|
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
|
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
|
<Bar dataKey="xp" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} opacity={0.8} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="col-span-3">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div>
|
|
<CardTitle>{t('achievements.goals')}</CardTitle>
|
|
<CardDescription>{t('achievements.goalsDescription')}</CardDescription>
|
|
</div>
|
|
<Dialog open={isGoalModalOpen} onOpenChange={setIsGoalModalOpen}>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm" variant="outline">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t('achievements.addGoal')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('achievements.createGoal')}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleCreateGoal} className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.goalTitle')}</label>
|
|
<Input name="title" placeholder={t('achievements.goalTitlePlaceholder')} required />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.targetValue')}</label>
|
|
<Input name="target" type="number" placeholder="50" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.type')}</label>
|
|
<Select name="type" defaultValue="weekly_tasks">
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="weekly_tasks">{t('achievements.types.weekly_tasks')}</SelectItem>
|
|
<SelectItem value="total_xp">{t('achievements.types.total_xp')}</SelectItem>
|
|
<SelectItem value="streak">{t('achievements.types.streak')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
<Button type="submit" className="w-full">{t('achievements.createGoal')}</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-6">
|
|
{goals.length === 0 ? (
|
|
<div className="text-center text-muted-foreground py-8">
|
|
<Target className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
|
<p className="text-sm">{t('achievements.noGoals')}</p>
|
|
</div>
|
|
) : (
|
|
goals.map((goal) => (
|
|
<div key={goal.id} className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
{goal.completed ? (
|
|
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
|
) : (
|
|
<Circle className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
<span className={`text-sm font-medium ${goal.completed ? 'line-through text-muted-foreground' : ''}`}>
|
|
{goal.title}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{goal.current} / {goal.target}
|
|
</span>
|
|
</div>
|
|
<Progress value={(goal.current / goal.target) * 100} className="h-2" />
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'rewards' && (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
<Card className="col-span-full mb-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-primary/20">
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<div className="flex flex-col gap-1">
|
|
<div className="flex items-center gap-2">
|
|
<Trophy className="h-5 w-5 text-primary" />
|
|
<CardTitle>{t('rewards.shopTitle')}</CardTitle>
|
|
</div>
|
|
<CardDescription>{t('achievements.rewardsDescription', { xp: user.xp })}</CardDescription>
|
|
</div>
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button size="sm">
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{t('achievements.addReward')}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t('achievements.createCustomReward')}</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleCreateReward} className="space-y-4 py-4">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.rewardTitle')}</label>
|
|
<Input name="title" required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.rewardDescription')}</label>
|
|
<Input name="description" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">{t('achievements.rewardCost')}</label>
|
|
<Input name="cost" type="number" required />
|
|
</div>
|
|
<Button type="submit" className="w-full">{t('achievements.createRewardBtn')}</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</CardHeader>
|
|
</Card>
|
|
|
|
{rewards.map(reward => (
|
|
<div key={reward.id} className="h-full">
|
|
<RewardCard
|
|
reward={reward}
|
|
userXp={user.xp}
|
|
userId={user.id}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
|
|
{activeTab === 'inventory' && (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
{inventory.length === 0 ? (
|
|
<div className="col-span-full text-center py-12 text-muted-foreground">
|
|
<Trophy className="h-12 w-12 mx-auto mb-3 opacity-20" />
|
|
<p>{t('achievements.noInventory')}</p>
|
|
<Button variant="link" onClick={() => document.querySelector('[value="rewards"]')?.dispatchEvent(new MouseEvent('click', { bubbles: true }))}>
|
|
{t('achievements.goToShop')}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
inventory.map((item) => (
|
|
<Card key={item.id} className="overflow-hidden">
|
|
<div className="h-32 bg-muted flex items-center justify-center text-4xl">
|
|
{/* Quick icon mapping or default */}
|
|
{item.reward.icon === 'coffee' ? '☕' :
|
|
item.reward.icon === 'gamepad-2' ? '🎮' :
|
|
item.reward.icon === 'palette' ? '🎨' : '🎁'}
|
|
</div>
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-base">{item.reward.title.startsWith('rewards.') ? t(item.reward.title) : item.reward.title}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-xs text-muted-foreground mb-2">
|
|
{item.reward.description?.startsWith('rewards.') ? t(item.reward.description) : item.reward.description}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('achievements.purchasedAt', { date: new Date(item.purchasedAt).toLocaleDateString() })}
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === 'history' && (
|
|
<div className="space-y-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('achievements.xpHistory')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-1">
|
|
{history.length === 0 ? (
|
|
<p className="text-center py-8 text-muted-foreground">{t('achievements.noHistory')}</p>
|
|
) : (
|
|
history.map((event) => (
|
|
<div key={event.id} className="flex items-center justify-between py-3 border-b last:border-0 hover:bg-muted/50 px-2 rounded-md transition-colors">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-full ${event.source === 'complete_task_late' ? 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400' :
|
|
(event.source === 'task_completion' || event.source === 'complete_task') ? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400' :
|
|
event.source === 'daily_streak' ? 'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400' :
|
|
event.source === 'ai_action' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400' :
|
|
'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400'
|
|
}`}>
|
|
{event.source === 'complete_task_late' ? <Timer className="h-4 w-4" /> :
|
|
event.source.includes('complete') ? <CheckCircle2 className="h-4 w-4" /> :
|
|
event.source === 'daily_streak' ? <Flame className="h-4 w-4" /> :
|
|
event.source === 'ai_action' ? <Sparkles className="h-4 w-4" /> :
|
|
<Trophy className="h-4 w-4" />}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-sm">
|
|
{event.details?.taskTitle ? event.details.taskTitle : (t(`gamification.source.${event.source}`, { defaultValue: event.source }) as string)}
|
|
{event.source === 'complete_task_late' && <span className="ml-2 text-xs text-red-500 font-normal">({t('gamification.late', 'Late')})</span>}
|
|
{(event.source === 'complete_task' || event.source === 'task_completion') && <span className="ml-2 text-xs text-green-500 font-normal">({t('gamification.onTime', 'On Time')})</span>}
|
|
</p>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
<span>{new Date(event.createdAt).toLocaleString()}</span>
|
|
{event.details?.taskTitle && (
|
|
<>
|
|
<span>•</span>
|
|
<span>{t(`gamification.source.${event.source}`, { defaultValue: event.source })}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<span className="font-bold text-green-600 dark:text-green-400">
|
|
+{event.amount} XP
|
|
</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)}
|
|
{activeTab === 'rules' && (
|
|
<div className="space-y-4">
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{/* Level Requirements */}
|
|
<Card className="border-primary/10 shadow-md">
|
|
<CardHeader className="bg-muted/30 pb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Trophy className="h-5 w-5 text-primary" />
|
|
<div>
|
|
<CardTitle>{t('gamification.rules.levelRequirements')}</CardTitle>
|
|
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="divide-y divide-border">
|
|
{LEVEL_THRESHOLDS.slice(0, 20).map((threshold, index) => {
|
|
const level = index + 1;
|
|
const rankKey = getRankKey(level);
|
|
const isCurrentLevel = getLevelFromXP(user.xp) === level;
|
|
|
|
// Visual Configuration for Ranks
|
|
const getRankStyle = (l: number) => {
|
|
if (l >= 20) return { icon: Sparkles, color: "text-rose-500", bg: "bg-rose-500/10", border: "border-rose-500/20" };
|
|
if (l >= 19) return { icon: Sun, color: "text-amber-500", bg: "bg-amber-500/10", border: "border-amber-500/20" };
|
|
if (l >= 18) return { icon: Crown, color: "text-yellow-600", bg: "bg-yellow-600/10", border: "border-yellow-600/20" };
|
|
if (l >= 17) return { icon: Zap, color: "text-violet-500", bg: "bg-violet-500/10", border: "border-violet-500/20" };
|
|
if (l >= 16) return { icon: Star, color: "text-cyan-500", bg: "bg-cyan-500/10", border: "border-cyan-500/20" };
|
|
if (l >= 15) return { icon: Award, color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20" };
|
|
if (l >= 14) return { icon: BookOpen, color: "text-indigo-500", bg: "bg-indigo-500/10", border: "border-indigo-500/20" };
|
|
if (l >= 13) return { icon: Scroll, color: "text-emerald-500", bg: "bg-emerald-500/10", border: "border-emerald-500/20" };
|
|
if (l >= 12) return { icon: Hammer, color: "text-slate-500", bg: "bg-slate-500/10", border: "border-slate-500/20" };
|
|
if (l >= 11) return { icon: Medal, color: "text-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/20" };
|
|
|
|
// 1-10
|
|
if (l >= 10) return { icon: Sun, color: "text-rose-500", bg: "bg-rose-500/10", border: "border-rose-500/20" };
|
|
if (l >= 9) return { icon: Sparkles, color: "text-purple-500", bg: "bg-purple-500/10", border: "border-purple-500/20" };
|
|
if (l >= 8) return { icon: Zap, color: "text-violet-500", bg: "bg-violet-500/10", border: "border-violet-500/20" };
|
|
if (l >= 7) return { icon: Crown, color: "text-yellow-600", bg: "bg-yellow-600/10", border: "border-yellow-600/20" };
|
|
if (l >= 6) return { icon: Star, color: "text-yellow-500", bg: "bg-yellow-500/10", border: "border-yellow-500/20" };
|
|
if (l >= 5) return { icon: Medal, color: "text-orange-500", bg: "bg-orange-500/10", border: "border-orange-500/20" };
|
|
if (l >= 4) return { icon: Award, color: "text-blue-500", bg: "bg-blue-500/10", border: "border-blue-500/20" };
|
|
if (l >= 3) return { icon: Hammer, color: "text-cyan-500", bg: "bg-cyan-500/10", border: "border-cyan-500/20" };
|
|
if (l >= 2) return { icon: BookOpen, color: "text-green-500", bg: "bg-green-500/10", border: "border-green-500/20" };
|
|
return { icon: Scroll, color: "text-slate-500", bg: "bg-slate-500/10", border: "border-slate-500/20" };
|
|
};
|
|
|
|
const style = getRankStyle(level);
|
|
const RankIcon = style.icon;
|
|
|
|
return (
|
|
<div
|
|
key={index}
|
|
className={`flex items-center justify-between p-4 transition-all hover:bg-muted/50 ${isCurrentLevel ? 'bg-primary/5 ring-1 ring-inset ring-primary/20' : ''}`}
|
|
>
|
|
<div className="flex items-center gap-4">
|
|
<div className={`h-10 w-10 rounded-lg ${style.bg} ${style.color} flex items-center justify-center border ${style.border}`}>
|
|
<RankIcon className="h-5 w-5" />
|
|
</div>
|
|
<div>
|
|
<div className={`font-semibold ${isCurrentLevel ? 'text-primary' : ''}`}>
|
|
{t(`ranks.${rankKey}`)}
|
|
{isCurrentLevel && <span className="ml-2 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">{t('gamification.level', { level })}</span>}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
|
{t('gamification.rules.level', { level })}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="text-right">
|
|
<div className="font-mono font-medium text-sm">
|
|
{t('gamification.rules.xp', { xp: threshold })}
|
|
</div>
|
|
{isCurrentLevel && (
|
|
<div className="text-[10px] text-primary font-medium mt-0.5">
|
|
Current
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* XP Rewards */}
|
|
<Card className="border-primary/10 shadow-md h-fit">
|
|
<CardHeader className="bg-muted/30 pb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Target className="h-5 w-5 text-green-500" />
|
|
<div>
|
|
<CardTitle>{t('gamification.rules.actions')}</CardTitle>
|
|
<CardDescription>{t('gamification.rules.xpSystem')}</CardDescription>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
<div className="divide-y divide-border">
|
|
{[
|
|
{ action: 'createTask', points: 10, icon: Plus, color: 'text-blue-500', bg: 'bg-blue-500/10' },
|
|
{ action: 'createSubtask', points: 5, icon: Plus, color: 'text-cyan-500', bg: 'bg-cyan-500/10' },
|
|
{ action: 'updateTask', points: 2, icon: CheckCircle2, color: 'text-slate-500', bg: 'bg-slate-500/10' },
|
|
{ action: 'completeTask', points: 50, icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10' },
|
|
{ action: 'completeTaskLate', points: 20, icon: CheckCircle2, color: 'text-yellow-500', bg: 'bg-yellow-500/10' },
|
|
{ action: 'aiAction', points: 5, icon: Sparkles, color: 'text-purple-500', bg: 'bg-purple-500/10' },
|
|
{ action: 'dailyStreak', points: 100, icon: Flame, color: 'text-orange-500', bg: 'bg-orange-500/10', tooltip: 'streakTooltip' },
|
|
{ action: 'weeklyStreak', points: 300, icon: Flame, color: 'text-orange-600', bg: 'bg-orange-600/10', tooltip: 'streakBonus' },
|
|
{ action: 'monthlyStreak', points: 1000, icon: Flame, color: 'text-red-500', bg: 'bg-red-500/10', tooltip: 'streakBonus' },
|
|
].map((item, index) => {
|
|
const ActionIcon = item.icon;
|
|
const content = (
|
|
<div key={index} className="flex items-center justify-between p-4 hover:bg-muted/50 transition-colors cursor-help">
|
|
<div className="flex items-center gap-3">
|
|
<div className={`p-2 rounded-md ${item.bg} ${item.color}`}>
|
|
<ActionIcon className="h-4 w-4" />
|
|
</div>
|
|
<span className="font-medium text-sm">{t(`gamification.rules.${item.action}`)}</span>
|
|
</div>
|
|
<div className="font-bold text-green-600 dark:text-green-400 bg-green-100 dark:bg-green-900/30 px-2 py-1 rounded text-xs">
|
|
+{item.points} XP
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (item.tooltip) {
|
|
return (
|
|
<UITooltip key={index}>
|
|
<TooltipTrigger asChild>
|
|
{content}
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p className="max-w-[200px]">{t(`gamification.rules.${item.tooltip}`)}</p>
|
|
</TooltipContent>
|
|
</UITooltip>
|
|
);
|
|
}
|
|
|
|
return content;
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|