Spaces:
Running
Running
| import React from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { useNavigate } from 'react-router-dom'; | |
| import { useTranslation } from 'react-i18next'; | |
| import axios from 'axios'; | |
| import Sidebar from '../components/Sidebar'; | |
| import { useAuth } from '../context/AuthContext'; | |
| import { useProfileStats } from '../features/profile/hooks/useProfileStats'; | |
| import { useAchievementNotification } from '../context/AchievementNotificationContext'; | |
| import { | |
| Trophy, | |
| Star, | |
| Flame, | |
| Target, | |
| ShieldCheck, | |
| Zap, | |
| Map, | |
| Settings, | |
| Mail, | |
| Lock, | |
| Camera, | |
| BookOpen, | |
| X | |
| } from 'lucide-react'; | |
| const iconMap = { | |
| Map, | |
| Zap, | |
| Flame, | |
| ShieldCheck, | |
| Trophy, | |
| Target, | |
| Camera, | |
| Settings | |
| }; | |
| const AchievementBadge = ({ iconName, title, desc, unlocked, color, currentValue, targetValue }) => { | |
| const { t } = useTranslation(); | |
| const Icon = iconMap[iconName] || Star; | |
| const showProgress = !unlocked && targetValue > 1 && currentValue !== undefined; | |
| const cleanTitleKey = title.toLowerCase().replace(/\s+/g, '_'); | |
| const localizedTitle = t(`achievements.${cleanTitleKey}.title`, title); | |
| const localizedDesc = t(`achievements.${cleanTitleKey}.description`, desc); | |
| return ( | |
| <motion.div | |
| whileHover={unlocked ? { scale: 1.05, rotate: 2 } : {}} | |
| className={`p-6 rough-border flex flex-col items-center text-center gap-3 transition-all ${ | |
| unlocked ? 'bg-surface-container-lowest rough-shadow opacity-100' : 'bg-surface-variant/20 opacity-60 grayscale' | |
| }`} | |
| > | |
| <div className={`w-16 h-16 rounded-full flex items-center justify-center ${color} border-2 border-primary shadow-sm mb-2 relative`}> | |
| <Icon size={32} className="text-primary" /> | |
| {unlocked && ( | |
| <div className="absolute -bottom-1 -right-1 bg-secondary text-primary rounded-full p-1 border border-primary"> | |
| <Star size={12} fill="currentColor" /> | |
| </div> | |
| )} | |
| </div> | |
| <h4 className="text-xl font-display-lg text-primary">{localizedTitle}</h4> | |
| <p className="text-[10px] font-bold uppercase tracking-wider text-on-surface-variant flex-grow">{localizedDesc}</p> | |
| {showProgress && ( | |
| <div className="w-full mt-2"> | |
| <div className="flex justify-between text-[10px] font-bold text-on-surface-variant mb-1"> | |
| <span>Progress</span> | |
| <span>{currentValue} / {targetValue}</span> | |
| </div> | |
| <div className="w-full h-2 bg-primary/10 rough-border overflow-hidden"> | |
| <div | |
| className="h-full bg-secondary" | |
| style={{ width: `${Math.min((currentValue / targetValue) * 100, 100)}%` }} | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| </motion.div> | |
| ); | |
| }; | |
| const ProfilePage = () => { | |
| const { user, updateUser } = useAuth(); | |
| const navigate = useNavigate(); | |
| const { triggerNotification } = useAchievementNotification(); | |
| const { t } = useTranslation(); | |
| const fileInputRef = React.useRef(null); | |
| const [uploading, setUploading] = React.useState(false); | |
| const [isAllAchievementsOpen, setIsAllAchievementsOpen] = React.useState(false); | |
| const [activeFilter, setActiveFilter] = React.useState('all'); | |
| const [password, setPassword] = React.useState(''); | |
| const handleSaveChanges = async () => { | |
| if (!user) return; | |
| try { | |
| const payload = {}; | |
| if (password && password.trim() !== '') { | |
| payload.password = password; | |
| } else { | |
| triggerNotification({ | |
| id: 'account-update-info-' + Date.now(), | |
| title: t('profile.no_changes', 'No Changes'), | |
| desc: t('profile.no_changes_desc', 'No changes were made to your account details.'), | |
| icon: 'ShieldCheck', | |
| color: 'bg-blue-500/20', | |
| unlocked: true | |
| }); | |
| return; | |
| } | |
| const API_URL = import.meta.env.VITE_API_URL; | |
| const res = await axios.put(`${API_URL}/users/${user.id}`, payload); | |
| if (res.data) { | |
| updateUser(res.data); | |
| setPassword(''); | |
| triggerNotification({ | |
| id: 'account-update-success-' + Date.now(), | |
| title: t('profile.changes_saved', 'Changes Saved!'), | |
| desc: t('profile.changes_saved_desc', 'Your account details have been successfully updated.'), | |
| icon: 'ShieldCheck', | |
| color: 'bg-green-500/20', | |
| unlocked: true | |
| }); | |
| } | |
| } catch (err) { | |
| console.error('[ProfilePage] Failed to save changes:', err); | |
| triggerNotification({ | |
| id: 'account-update-error-' + Date.now(), | |
| title: t('profile.update_failed', 'Update Failed'), | |
| desc: err.response?.data?.message || err.message, | |
| icon: 'X', | |
| color: 'bg-red-500/20', | |
| unlocked: true | |
| }); | |
| } | |
| }; | |
| React.useEffect(() => { | |
| if (!user) { | |
| navigate('/auth'); | |
| } | |
| }, [user, navigate]); | |
| const { | |
| goalsMapped, | |
| nodesCreated, | |
| dayStreak, | |
| points, | |
| xp, | |
| maxXp, | |
| level, | |
| achievements, | |
| loading | |
| } = useProfileStats(user); | |
| const statsList = [ | |
| { label: t('profile.completed_goals', "Goals Mapped"), value: goalsMapped, icon: Map, color: "text-blue-500" }, | |
| { label: t('profile.nodes_created', "Nodes Created"), value: nodesCreated, icon: Zap, color: "text-yellow-500" }, | |
| { label: t('habits.active_streak', "Day Streak"), value: dayStreak, icon: Flame, color: "text-orange-500" }, | |
| { label: t('profile.total_points', "Points"), value: points, icon: Star, color: "text-secondary" }, | |
| ]; | |
| const unlockedCount = achievements ? achievements.filter(a => a.unlocked).length : 0; | |
| const handleCameraClick = () => { | |
| fileInputRef.current?.click(); | |
| }; | |
| const handleFileChange = (e) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = (event) => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| const canvas = document.createElement('canvas'); | |
| const MAX_WIDTH = 256; | |
| const MAX_HEIGHT = 256; | |
| let width = img.width; | |
| let height = img.height; | |
| if (width > height) { | |
| if (width > MAX_WIDTH) { | |
| height *= MAX_WIDTH / width; | |
| width = MAX_WIDTH; | |
| } | |
| } else { | |
| if (height > MAX_HEIGHT) { | |
| width *= MAX_HEIGHT / height; | |
| width = MAX_HEIGHT; | |
| } | |
| } | |
| canvas.width = width; | |
| canvas.height = height; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.drawImage(img, 0, 0, width, height); | |
| const dataUrl = canvas.toDataURL('image/jpeg', 0.85); | |
| uploadAvatar(dataUrl); | |
| }; | |
| img.src = event.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| }; | |
| const uploadAvatar = async (base64Image) => { | |
| if (!user) return; | |
| setUploading(true); | |
| try { | |
| const API_URL = import.meta.env.VITE_API_URL; | |
| const response = await axios.put(`${API_URL}/users/${user.id}`, { | |
| avatarUrl: base64Image | |
| }); | |
| updateUser(response.data); | |
| triggerNotification({ | |
| title: t('profile.avatar_updated', "Avatar Updated!"), | |
| desc: t('profile.avatar_updated_desc', "Looking sharp, visionary!"), | |
| icon: "ShieldCheck", | |
| color: "bg-green-500/10" | |
| }); | |
| } catch (err) { | |
| console.error("Failed to upload avatar", err); | |
| alert("Failed to update profile picture. Please try again."); | |
| } finally { | |
| setUploading(false); | |
| } | |
| }; | |
| return ( | |
| <div className="flex min-h-screen bg-background"> | |
| <Sidebar /> | |
| <main className="flex-grow flex flex-col overflow-hidden"> | |
| {/* Profile Header */} | |
| <header className="bg-surface-container-lowest border-b-4 border-primary p-12 flex flex-col md:flex-row items-center gap-10 z-10"> | |
| <div className="relative group"> | |
| <input | |
| type="file" | |
| ref={fileInputRef} | |
| accept="image/*" | |
| className="hidden" | |
| onChange={handleFileChange} | |
| /> | |
| <div className="w-40 h-40 rough-border overflow-hidden bg-surface-variant shadow-[8px_8px_0px_0px_rgba(0,0,0,0.1)] relative"> | |
| <img | |
| src={user?.avatarUrl || `https://api.dicebear.com/7.x/avataaars/svg?seed=${user?.displayName || 'Dan'}`} | |
| alt="Avatar" | |
| className="w-full h-full object-cover" | |
| /> | |
| {uploading && ( | |
| <div className="absolute inset-0 bg-primary/45 flex items-center justify-center backdrop-blur-sm"> | |
| <div className="w-10 h-10 border-4 border-secondary border-t-transparent rounded-full animate-spin" /> | |
| </div> | |
| )} | |
| </div> | |
| <button | |
| onClick={handleCameraClick} | |
| disabled={uploading} | |
| className="absolute -bottom-2 -right-2 w-12 h-12 bg-secondary text-primary rough-border rounded-full flex items-center justify-center hover:scale-110 active:scale-95 disabled:opacity-50 transition-all shadow-md z-20" | |
| > | |
| <Camera size={20} /> | |
| </button> | |
| </div> | |
| <div className="flex-grow flex flex-col gap-4 text-center md:text-left"> | |
| <div> | |
| <h1 className="text-6xl font-display-lg text-primary mb-1">{user?.displayName || 'New User'}</h1> | |
| <div className="flex items-center justify-center md:justify-start gap-4"> | |
| <span className="bg-primary text-on-primary px-4 py-1 rounded-full text-sm font-bold uppercase tracking-widest">{user?.isPremium ? 'Premium' : t('sidebar.free_plan')}</span> | |
| <span className="font-accent-note text-2xl text-secondary">{t('profile.user_level')} {level}</span> | |
| </div> | |
| </div> | |
| <div className="max-w-md"> | |
| <div className="flex justify-between text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-2"> | |
| <span>{t('profile.level_progress')}</span> | |
| <span>{xp} / {maxXp} XP</span> | |
| </div> | |
| <div className="w-full h-6 bg-primary/10 rough-border overflow-hidden relative"> | |
| <motion.div | |
| initial={{ width: 0 }} | |
| animate={{ width: `${Math.min((xp / maxXp) * 100, 100)}%` }} | |
| className="h-full bg-secondary transition-all" | |
| /> | |
| <div className="absolute inset-0 flex items-center justify-center text-[10px] font-bold text-primary mix-blend-multiply"> | |
| {t('profile.next_level_prefix', 'NEXT LEVEL:')} {level >= 5 ? t('profile.master', 'MASTER') : t('profile.visionary', 'VISIONARY')} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="flex gap-4"> | |
| {false && ( | |
| <button | |
| onClick={() => triggerNotification({ | |
| id: 'test-achievement-' + Date.now(), | |
| title: 'Sketch Master', | |
| desc: 'Create 100 task nodes overall.', | |
| icon: 'Zap', | |
| color: 'bg-yellow-500/20', | |
| unlocked: true | |
| })} | |
| className="bg-surface-container-lowest p-4 rough-border rough-shadow-hover hover:text-primary transition-all text-secondary" | |
| title="Test Achievement Unlock Animation" | |
| > | |
| <Trophy size={28} /> | |
| </button> | |
| )} | |
| <button | |
| onClick={() => navigate('/settings')} | |
| className="bg-surface-container-lowest p-4 rough-border rough-shadow-hover hover:text-primary transition-all text-primary" | |
| > | |
| <Settings size={28} /> | |
| </button> | |
| </div> | |
| </header> | |
| {/* Content Tabs */} | |
| <section className="flex-grow p-12 overflow-y-auto graph-paper-bg scrollbar-hide"> | |
| <div className="max-w-6xl mx-auto flex flex-col gap-16"> | |
| {/* Stats Grid */} | |
| <div className="grid grid-cols-1 md:grid-cols-4 gap-8"> | |
| {statsList.map((stat, i) => ( | |
| <div key={i} className="bg-surface-container-lowest p-6 rough-border border-dashed flex flex-col items-center gap-2"> | |
| <stat.icon className={stat.color} size={24} /> | |
| <div className="text-4xl font-display-lg text-primary">{loading ? '-' : stat.value}</div> | |
| <div className="text-[10px] font-bold uppercase tracking-[0.2em] text-on-surface-variant/60">{stat.label}</div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Achievements Section */} | |
| <div> | |
| <div className="flex justify-between items-end mb-8"> | |
| <div> | |
| <h2 className="text-5xl font-display-lg text-primary mb-2">{t('profile.achievements')}</h2> | |
| <p className="font-accent-note text-2xl text-secondary -rotate-1"> | |
| {t('profile.slogan', 'Your journey, documented in ink.')} | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-4"> | |
| <div className="text-sm font-bold text-on-surface-variant">{unlockedCount} / {achievements?.length || 6} {t('profile.unlocked')}</div> | |
| <button | |
| onClick={() => setIsAllAchievementsOpen(true)} | |
| className="bg-surface-container-lowest py-2 px-4 rough-border rough-shadow-hover hover:text-primary transition-all text-xs font-bold uppercase tracking-widest flex items-center gap-2" | |
| > | |
| <BookOpen size={14} /> | |
| <span>{t('profile.view_notebook', 'View Notebook')}</span> | |
| </button> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-8"> | |
| {!loading && achievements?.map((ach, i) => ( | |
| <AchievementBadge | |
| key={i} | |
| iconName={ach.icon} | |
| title={ach.title} | |
| desc={ach.desc} | |
| unlocked={ach.unlocked} | |
| color={ach.color} | |
| currentValue={ach.currentValue} | |
| targetValue={ach.targetValue} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| {/* Account Settings / Privacy */} | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-12"> | |
| <div className="flex flex-col gap-6"> | |
| <h3 className="text-4xl font-display-lg text-primary">{t('profile.account_details', 'Account Details')}</h3> | |
| <div className="bg-surface-container-lowest p-8 rough-border flex flex-col gap-6"> | |
| <div className="flex flex-col gap-2"> | |
| <label className="text-xs font-bold uppercase tracking-widest text-on-surface-variant flex items-center gap-2"> | |
| <Mail size={14} /> {t('auth.email')} | |
| </label> | |
| <input | |
| type="email" | |
| defaultValue={user?.email} | |
| disabled | |
| className="w-full p-3 bg-surface-container-low border-2 border-primary/10 rounded-lg outline-none transition-all cursor-not-allowed opacity-60" | |
| /> | |
| </div> | |
| <div className="flex flex-col gap-2"> | |
| <label className="text-xs font-bold uppercase tracking-widest text-on-surface-variant flex items-center gap-2"> | |
| <Lock size={14} /> {t('auth.password')} | |
| </label> | |
| <input | |
| type="password" | |
| value={password} | |
| onChange={(e) => setPassword(e.target.value)} | |
| placeholder="••••••••" | |
| className="w-full p-3 bg-surface-container-low border-2 border-primary/10 rounded-lg focus:border-primary outline-none transition-all" | |
| /> | |
| </div> | |
| <button | |
| onClick={handleSaveChanges} | |
| className="bg-primary text-on-primary py-3 px-6 font-bold rough-border rough-shadow-hover transition-all mt-2" | |
| > | |
| {t('profile.save_changes', 'Save Changes')} | |
| </button> | |
| </div> | |
| </div> | |
| {false && ( | |
| <div className="flex flex-col gap-6"> | |
| <h3 className="text-4xl font-display-lg text-primary">{t('profile.privacy_display', 'Privacy & Display')}</h3> | |
| <div className="bg-surface-container-lowest p-8 rough-border flex flex-col gap-8"> | |
| <div className="flex items-center justify-between"> | |
| <div> | |
| <div className="font-bold">{t('profile.public_profile', 'Public Profile')}</div> | |
| <div className="text-xs text-on-surface-variant">{t('profile.public_profile_desc', 'Allow others to see your achievements')}</div> | |
| </div> | |
| <div className="w-14 h-8 bg-secondary border-2 border-primary rounded-full p-1 flex justify-end items-center"> | |
| <div className="w-5 h-5 bg-primary rounded-full shadow-sm" /> | |
| </div> | |
| </div> | |
| <div className="flex items-center justify-between opacity-50"> | |
| <div> | |
| <div className="font-bold">{t('profile.focus_mode', 'Focus Mode')}</div> | |
| <div className="text-xs text-on-surface-variant">{t('profile.focus_mode_desc', 'Hide statistics during deep work sessions')}</div> | |
| </div> | |
| <div className="w-14 h-8 bg-surface-variant border-2 border-primary rounded-full p-1 flex justify-start items-center grayscale"> | |
| <div className="w-5 h-5 bg-on-surface-variant rounded-full shadow-sm" /> | |
| </div> | |
| </div> | |
| <div className="mt-auto font-accent-note text-2xl text-secondary text-center transform -rotate-1"> | |
| "{t('profile.privacy_quote', "Your data is stored locally first. You're in control.")}" | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </section> | |
| {/* Interactive Achievements Notebook Modal */} | |
| <AnimatePresence> | |
| {isAllAchievementsOpen && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> | |
| {/* Backdrop */} | |
| <motion.div | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| onClick={() => setIsAllAchievementsOpen(false)} | |
| className="absolute inset-0 bg-primary/20 backdrop-blur-sm" | |
| /> | |
| {/* Modal Body */} | |
| <motion.div | |
| initial={{ scale: 0.92, opacity: 0, y: 30, rotate: -1.5 }} | |
| animate={{ scale: 1, opacity: 1, y: 0, rotate: 0 }} | |
| exit={{ scale: 0.92, opacity: 0, y: 30, rotate: 1.5 }} | |
| transition={{ type: "spring", stiffness: 200, damping: 18 }} | |
| className="relative bg-surface-container-lowest w-full max-w-4xl p-8 md:p-10 rough-border border-4 border-primary shadow-[12px_12px_0px_0px_var(--color-primary)] max-h-[85vh] flex flex-col gap-6 overflow-hidden z-10" | |
| > | |
| {/* Close button */} | |
| <button | |
| onClick={() => setIsAllAchievementsOpen(false)} | |
| className="absolute top-6 right-6 text-on-surface-variant hover:text-primary transition-colors hover:scale-110" | |
| > | |
| <X size={28} className="stroke-[3px]" /> | |
| </button> | |
| {/* Title & Notebook Aesthetic */} | |
| <div className="flex flex-col gap-2"> | |
| <div className="text-xs font-bold uppercase tracking-[0.2em] text-secondary font-display-lg"> | |
| {t('profile.personal_archive', 'Personal Archive')} | |
| </div> | |
| <h2 className="text-5xl font-display-lg text-primary leading-none"> | |
| {t('profile.achievements_notebook', 'Achievements Notebook')} | |
| </h2> | |
| <p className="font-accent-note text-2xl text-secondary -rotate-0.5"> | |
| {t('profile.notebook_desc', "Every milestone you've mapped, sketched, and achieved.")} | |
| </p> | |
| </div> | |
| {/* Filtering tabs */} | |
| <div className="flex gap-4 border-b-2 border-primary/10 pb-4"> | |
| {['all', 'unlocked', 'locked'].map((filter) => ( | |
| <button | |
| key={filter} | |
| onClick={() => setActiveFilter(filter)} | |
| className={`px-4 py-2 text-xs font-bold uppercase tracking-widest rough-border transition-all ${ | |
| activeFilter === filter | |
| ? 'bg-primary text-on-primary shadow-[2px_2px_0px_0px_rgba(0,0,0,1)]' | |
| : 'bg-surface-container-low hover:bg-surface-container-high' | |
| }`} | |
| > | |
| {filter === 'all' && t('profile.all_achievements', 'All Achievements')} | |
| {filter === 'unlocked' && t('profile.unlocked_badge', 'Unlocked 🏆')} | |
| {filter === 'locked' && t('profile.locked_badge', 'In Progress 🔒')} | |
| </button> | |
| ))} | |
| </div> | |
| {/* Achievements Grid List (Scrollable) */} | |
| <div className="flex-grow overflow-y-auto scrollbar-hide grid grid-cols-1 md:grid-cols-2 gap-6 py-2 pr-2"> | |
| {achievements | |
| ?.filter(ach => { | |
| if (activeFilter === 'unlocked') return ach.unlocked; | |
| if (activeFilter === 'locked') return !ach.unlocked; | |
| return true; | |
| }) | |
| .map((ach, i) => { | |
| const BadgeIcon = iconMap[ach.icon] || Star; | |
| const progressPercent = Math.min((ach.currentValue / ach.targetValue) * 100, 100); | |
| const cleanTitleKey = ach.title.toLowerCase().replace(/\s+/g, '_'); | |
| const localizedTitle = t(`achievements.${cleanTitleKey}.title`, ach.title); | |
| const localizedDesc = t(`achievements.${cleanTitleKey}.description`, ach.desc); | |
| return ( | |
| <motion.div | |
| key={i} | |
| initial={{ opacity: 0, y: 15 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: i * 0.05 }} | |
| className={`p-6 rough-border flex items-center gap-5 transition-all ${ | |
| ach.unlocked | |
| ? 'bg-surface-container-lowest rough-shadow border-solid' | |
| : 'bg-surface-variant/5 border-dashed opacity-75' | |
| }`} | |
| > | |
| {/* Left Side: Badge Icon */} | |
| <div className={`w-16 h-16 rounded-full flex items-center justify-center ${ach.color} border-2 border-primary shadow-sm flex-shrink-0 relative ${!ach.unlocked && 'grayscale opacity-50'}`}> | |
| <BadgeIcon size={28} className="text-primary" /> | |
| {ach.unlocked && ( | |
| <div className="absolute -top-1 -right-1 bg-secondary text-primary rounded-full p-1 border border-primary"> | |
| <Star size={8} fill="currentColor" /> | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Side: Details & Progress Bar */} | |
| <div className="flex-grow flex flex-col gap-2 min-w-0"> | |
| <div className="flex justify-between items-start gap-2"> | |
| <h4 className="font-display-lg text-xl text-primary leading-tight truncate"> | |
| {localizedTitle} | |
| </h4> | |
| <span className={`text-[10px] font-bold uppercase tracking-widest px-2 py-0.5 border rounded-full ${ | |
| ach.unlocked | |
| ? 'bg-secondary/10 border-secondary text-secondary' | |
| : 'bg-surface-variant/20 border-primary/10 text-on-surface-variant/70' | |
| }`}> | |
| {ach.unlocked ? t('profile.unlocked') : t('profile.locked')} | |
| </span> | |
| </div> | |
| <p className="text-xs text-on-surface-variant/80 font-accent-note leading-relaxed"> | |
| {localizedDesc} | |
| </p> | |
| {/* Progress Tracker bar */} | |
| <div className="mt-1 flex flex-col gap-1"> | |
| <div className="flex justify-between text-[9px] font-bold uppercase tracking-widest text-on-surface-variant/60"> | |
| <span>Progress</span> | |
| <span>{ach.currentValue} / {ach.targetValue}</span> | |
| </div> | |
| <div className="w-full h-3 bg-primary/5 border border-primary/20 rounded-full overflow-hidden"> | |
| <div | |
| className={`h-full transition-all duration-500 ${ach.unlocked ? 'bg-secondary' : 'bg-primary/45'}`} | |
| style={{ width: `${progressPercent}%` }} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </motion.div> | |
| ); | |
| })} | |
| </div> | |
| {/* Footer Quote */} | |
| <div className="text-center font-accent-note text-2xl text-secondary mt-2 transform -rotate-0.5"> | |
| "{t('profile.success_quote', "Success is a series of small wins, mapped in ink and achieved with heart.")}" | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| </main> | |
| </div> | |
| ); | |
| }; | |
| export default ProfilePage; | |