diff --git a/src/App.jsx b/src/App.jsx
index b9ac263..ffc76ed 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -39,6 +39,7 @@ import TestPage from './domains/vocab/pages/TestPage'
import WordListPage from './domains/vocab/pages/WordListPage'
import StatsPage from './domains/vocab/pages/StatsPage'
import { WritingPage } from './domains/grammar'
+import { BadgeSection } from './domains/badge'
import { useChat } from './contexts/ChatContext'
import { useSettings } from './contexts/SettingsContext'
@@ -337,10 +338,149 @@ function FreetalkAiPage() {
function ReportsPage() {
+ const { isKorean } = useSettings()
+
+ // 더미 통계 데이터
+ const stats = {
+ totalStudyDays: 15,
+ totalWords: 285,
+ totalTests: 12,
+ averageScore: 82,
+ currentStreak: 5,
+ bestStreak: 8,
+ }
+
return (
- My Reports
- Learning analytics
+ {/* 헤더 */}
+
+
+
+
+
+
+
+ {isKorean ? '학습 리포트' : 'Learning Report'}
+
+
+ {isKorean ? '나의 학습 현황을 확인하세요' : 'Check your learning progress'}
+
+
+
+
+
+ {/* 통계 요약 카드 */}
+
+
+
+
+ {isKorean ? '총 학습일' : 'Study Days'}
+
+
+ {stats.totalStudyDays}
+
+
+ {isKorean ? '일' : 'days'}
+
+
+
+
+
+
+ {isKorean ? '학습한 단어' : 'Words Learned'}
+
+
+ {stats.totalWords}
+
+
+ {isKorean ? '개' : 'words'}
+
+
+
+
+
+
+ {isKorean ? '테스트 완료' : 'Tests Taken'}
+
+
+ {stats.totalTests}
+
+
+ {isKorean ? '회' : 'tests'}
+
+
+
+
+
+
+ {isKorean ? '평균 점수' : 'Average Score'}
+
+
+ {stats.averageScore}%
+
+
+ {isKorean ? '정확도' : 'accuracy'}
+
+
+
+
+
+ {/* 연속 학습 */}
+
+
+ {isKorean ? '연속 학습 기록' : 'Study Streak'}
+
+
+
+
+
+ {stats.currentStreak}
+
+
+ {isKorean ? '현재 연속' : 'Current Streak'}
+
+
+
+
+
+
+ {stats.bestStreak}
+
+
+ {isKorean ? '최고 기록' : 'Best Streak'}
+
+
+
+
+
+
+ {/* 배지 섹션 */}
+
)
}
diff --git a/src/api/badgeApi.js b/src/api/badgeApi.js
new file mode 100644
index 0000000..9b05c1e
--- /dev/null
+++ b/src/api/badgeApi.js
@@ -0,0 +1,32 @@
+import axios from 'axios'
+
+const badgeApi = axios.create({
+ baseURL: import.meta.env.VITE_BADGE_API_URL || import.meta.env.VITE_API_URL,
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+})
+
+// Request interceptor for JWT token
+badgeApi.interceptors.request.use(
+ (config) => {
+ const token = localStorage.getItem('accessToken')
+ if (token) {
+ config.headers.Authorization = `Bearer ${token}`
+ }
+ return config
+ },
+ (error) => Promise.reject(error)
+)
+
+// Response interceptor for error handling
+badgeApi.interceptors.response.use(
+ (response) => response.data,
+ (error) => {
+ console.error('Badge API Error:', error.response?.data || error.message)
+ return Promise.reject(error)
+ }
+)
+
+export default badgeApi
diff --git a/src/domains/badge/components/BadgeCard.jsx b/src/domains/badge/components/BadgeCard.jsx
new file mode 100644
index 0000000..b4e6a38
--- /dev/null
+++ b/src/domains/badge/components/BadgeCard.jsx
@@ -0,0 +1,243 @@
+import { useState } from 'react'
+import {
+ Box,
+ Typography,
+ Tooltip,
+ LinearProgress,
+ Fade,
+} from '@mui/material'
+import {
+ EmojiEvents as TrophyIcon,
+ Lock as LockIcon,
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import {
+ BADGE_NAMES_EN,
+ BADGE_DESCRIPTIONS_EN,
+ BADGE_CATEGORY_COLORS,
+} from '../constants/badgeConstants'
+
+export default function BadgeCard({ badge, size = 'medium' }) {
+ const { isKorean } = useSettings()
+ const [imageError, setImageError] = useState(false)
+
+ const isEarned = badge.earned
+ const progress = Math.min((badge.progress / badge.threshold) * 100, 100)
+
+ // Size configurations
+ const sizes = {
+ small: { card: 100, image: 60, icon: 24 },
+ medium: { card: 140, image: 80, icon: 32 },
+ large: { card: 180, image: 100, icon: 40 },
+ }
+ const config = sizes[size] || sizes.medium
+
+ // Get localized badge info
+ const badgeName = isKorean ? badge.name : (BADGE_NAMES_EN[badge.badgeType] || badge.name)
+ const badgeDescription = isKorean
+ ? badge.description
+ : (BADGE_DESCRIPTIONS_EN[badge.badgeType] || badge.description)
+
+ // Format earned date
+ const formatDate = (dateString) => {
+ if (!dateString) return ''
+ const date = new Date(dateString)
+ return isKorean
+ ? `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`
+ : date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })
+ }
+
+ // Tooltip content
+ const tooltipContent = (
+
+
+ {badgeName}
+
+
+ {badgeDescription}
+
+
+ {!isEarned && (
+
+
+
+ {isKorean ? '진행도' : 'Progress'}
+
+
+ {badge.progress} / {badge.threshold}
+
+
+
+
+ )}
+
+ {isEarned && badge.earnedAt && (
+
+
+
+ {formatDate(badge.earnedAt)}
+
+
+ )}
+
+ )
+
+ return (
+
+
+ {/* Badge Image Container */}
+
+ {!imageError ? (
+ setImageError(true)}
+ sx={{
+ width: '70%',
+ height: '70%',
+ objectFit: 'contain',
+ filter: isEarned ? 'none' : 'grayscale(100%) blur(1px)',
+ opacity: isEarned ? 1 : 0.4,
+ transition: 'all 0.3s ease',
+ }}
+ />
+ ) : (
+
+ )}
+
+ {/* Lock overlay for unearned badges */}
+ {!isEarned && (
+
+
+
+ )}
+
+
+ {/* Badge Name */}
+
+ {badgeName}
+
+
+ {/* Progress indicator for unearned */}
+ {!isEarned && size !== 'small' && (
+
+ {badge.progress}/{badge.threshold}
+
+ )}
+
+
+ )
+}
diff --git a/src/domains/badge/components/BadgeGrid.jsx b/src/domains/badge/components/BadgeGrid.jsx
new file mode 100644
index 0000000..dd321b1
--- /dev/null
+++ b/src/domains/badge/components/BadgeGrid.jsx
@@ -0,0 +1,74 @@
+import { Box, Typography, Skeleton } from '@mui/material'
+import BadgeCard from './BadgeCard'
+import { useSettings } from '../../../contexts/SettingsContext'
+
+export default function BadgeGrid({ badges = [], loading = false, size = 'medium' }) {
+ const { isKorean } = useSettings()
+
+ if (loading) {
+ return (
+
+ {Array.from({ length: 12 }).map((_, index) => (
+
+
+
+
+ ))}
+
+ )
+ }
+
+ if (badges.length === 0) {
+ return (
+
+
+ {isKorean ? '배지가 없습니다' : 'No badges available'}
+
+
+ )
+ }
+
+ // Sort badges: earned first, then by category
+ const sortedBadges = [...badges].sort((a, b) => {
+ if (a.earned && !b.earned) return -1
+ if (!a.earned && b.earned) return 1
+ return 0
+ })
+
+ return (
+
+ {sortedBadges.map((badge) => (
+
+ ))}
+
+ )
+}
diff --git a/src/domains/badge/components/BadgeSection.jsx b/src/domains/badge/components/BadgeSection.jsx
new file mode 100644
index 0000000..3b6bc12
--- /dev/null
+++ b/src/domains/badge/components/BadgeSection.jsx
@@ -0,0 +1,177 @@
+import { useState, useEffect } from 'react'
+import {
+ Box,
+ Typography,
+ Paper,
+ LinearProgress,
+ Alert,
+ Chip,
+} from '@mui/material'
+import {
+ EmojiEvents as TrophyIcon,
+ WorkspacePremium as BadgeIcon,
+} from '@mui/icons-material'
+import BadgeGrid from './BadgeGrid'
+import { badgeService } from '../services/badgeService'
+import { useSettings } from '../../../contexts/SettingsContext'
+
+export default function BadgeSection() {
+ const { isKorean } = useSettings()
+ const [badges, setBadges] = useState([])
+ const [totalCount, setTotalCount] = useState(0)
+ const [earnedCount, setEarnedCount] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ loadBadges()
+ }, [])
+
+ const loadBadges = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const response = await badgeService.getAll()
+ setBadges(response.badges || [])
+ setTotalCount(response.totalCount || 0)
+ setEarnedCount(response.earnedCount || 0)
+ } catch (err) {
+ console.error('Failed to load badges:', err)
+ setError(isKorean ? '배지를 불러오는데 실패했습니다' : 'Failed to load badges')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const progress = totalCount > 0 ? (earnedCount / totalCount) * 100 : 0
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {isKorean ? '획득한 배지' : 'Earned Badges'}
+
+
+ {isKorean
+ ? '학습 목표를 달성하고 배지를 모아보세요!'
+ : 'Achieve learning goals and collect badges!'}
+
+
+
+
+ {/* Stats Chip */}
+ }
+ label={
+
+ {earnedCount} / {totalCount}
+
+ }
+ sx={{
+ px: 1,
+ height: 36,
+ borderRadius: '18px',
+ backgroundColor: earnedCount === totalCount ? '#ecfdf5' : '#fef3c7',
+ color: earnedCount === totalCount ? '#059669' : '#d97706',
+ border: `1px solid ${earnedCount === totalCount ? '#10b981' : '#f59e0b'}`,
+ '& .MuiChip-icon': {
+ color: earnedCount === totalCount ? '#059669' : '#d97706',
+ },
+ }}
+ />
+
+
+ {/* Progress Bar */}
+
+
+
+ {isKorean ? '전체 진행률' : 'Overall Progress'}
+
+
+ {Math.round(progress)}%
+
+
+
+
+
+ {/* Error Alert */}
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Badge Grid */}
+
+
+ {/* Completion Message */}
+ {!loading && earnedCount === totalCount && totalCount > 0 && (
+
+
+ {isKorean
+ ? '축하합니다! 모든 배지를 획득했습니다! 🎉'
+ : 'Congratulations! You collected all badges! 🎉'}
+
+
+ )}
+
+ )
+}
diff --git a/src/domains/badge/constants/badgeConstants.js b/src/domains/badge/constants/badgeConstants.js
new file mode 100644
index 0000000..70c2c15
--- /dev/null
+++ b/src/domains/badge/constants/badgeConstants.js
@@ -0,0 +1,131 @@
+/**
+ * Badge domain constants
+ * Based on Backend BadgeType enum
+ */
+
+// 배지 타입
+export const BADGE_TYPES = {
+ FIRST_STEP: 'FIRST_STEP',
+ STREAK_3: 'STREAK_3',
+ STREAK_7: 'STREAK_7',
+ STREAK_30: 'STREAK_30',
+ WORDS_100: 'WORDS_100',
+ WORDS_500: 'WORDS_500',
+ WORDS_1000: 'WORDS_1000',
+ PERFECT_SCORE: 'PERFECT_SCORE',
+ TEST_10: 'TEST_10',
+ ACCURACY_90: 'ACCURACY_90',
+ GAME_FIRST_PLAY: 'GAME_FIRST_PLAY',
+ GAME_10_WINS: 'GAME_10_WINS',
+ QUICK_GUESSER: 'QUICK_GUESSER',
+ PERFECT_DRAWER: 'PERFECT_DRAWER',
+ MASTER: 'MASTER',
+}
+
+// 배지 카테고리
+export const BADGE_CATEGORIES = {
+ FIRST_STUDY: 'FIRST_STUDY',
+ STREAK: 'STREAK',
+ WORDS_LEARNED: 'WORDS_LEARNED',
+ PERFECT_TEST: 'PERFECT_TEST',
+ TESTS_COMPLETED: 'TESTS_COMPLETED',
+ ACCURACY: 'ACCURACY',
+ GAMES_PLAYED: 'GAMES_PLAYED',
+ GAMES_WON: 'GAMES_WON',
+ QUICK_GUESSES: 'QUICK_GUESSES',
+ PERFECT_DRAWS: 'PERFECT_DRAWS',
+ ALL_BADGES: 'ALL_BADGES',
+}
+
+// 배지 카테고리 라벨 (한국어)
+export const BADGE_CATEGORY_LABELS_KO = {
+ FIRST_STUDY: '첫 시작',
+ STREAK: '연속 학습',
+ WORDS_LEARNED: '단어 학습',
+ PERFECT_TEST: '완벽한 테스트',
+ TESTS_COMPLETED: '테스트 완료',
+ ACCURACY: '정확도',
+ GAMES_PLAYED: '게임 참여',
+ GAMES_WON: '게임 승리',
+ QUICK_GUESSES: '빠른 정답',
+ PERFECT_DRAWS: '완벽한 출제',
+ ALL_BADGES: '마스터',
+}
+
+// 배지 카테고리 라벨 (영어)
+export const BADGE_CATEGORY_LABELS_EN = {
+ FIRST_STUDY: 'First Steps',
+ STREAK: 'Streak',
+ WORDS_LEARNED: 'Words Learned',
+ PERFECT_TEST: 'Perfect Test',
+ TESTS_COMPLETED: 'Tests Completed',
+ ACCURACY: 'Accuracy',
+ GAMES_PLAYED: 'Games Played',
+ GAMES_WON: 'Games Won',
+ QUICK_GUESSES: 'Quick Guesser',
+ PERFECT_DRAWS: 'Perfect Drawer',
+ ALL_BADGES: 'Master',
+}
+
+// 배지 카테고리 색상
+export const BADGE_CATEGORY_COLORS = {
+ FIRST_STUDY: '#10b981',
+ STREAK: '#f59e0b',
+ WORDS_LEARNED: '#3b82f6',
+ PERFECT_TEST: '#8b5cf6',
+ TESTS_COMPLETED: '#06b6d4',
+ ACCURACY: '#ec4899',
+ GAMES_PLAYED: '#14b8a6',
+ GAMES_WON: '#f97316',
+ QUICK_GUESSES: '#6366f1',
+ PERFECT_DRAWS: '#84cc16',
+ ALL_BADGES: '#eab308',
+}
+
+// 배지 이름 (영어 - 백엔드는 한국어만 제공하므로 프론트에서 번역)
+export const BADGE_NAMES_EN = {
+ FIRST_STEP: 'First Step',
+ STREAK_3: '3-Day Streak',
+ STREAK_7: 'Week Streak',
+ STREAK_30: 'Month Streak',
+ WORDS_100: 'Word Collector',
+ WORDS_500: 'Word Expert',
+ WORDS_1000: 'Word Master',
+ PERFECT_SCORE: 'Perfectionist',
+ TEST_10: 'Test Challenger',
+ ACCURACY_90: 'Accuracy Pro',
+ GAME_FIRST_PLAY: 'First Game',
+ GAME_10_WINS: '10 Wins',
+ QUICK_GUESSER: 'Lightning Fast',
+ PERFECT_DRAWER: 'Perfect Host',
+ MASTER: 'Learning Master',
+}
+
+// 배지 설명 (영어)
+export const BADGE_DESCRIPTIONS_EN = {
+ FIRST_STEP: 'Completed your first study session',
+ STREAK_3: 'Studied for 3 consecutive days',
+ STREAK_7: 'Studied for 7 consecutive days',
+ STREAK_30: 'Studied for 30 consecutive days',
+ WORDS_100: 'Learned 100 words',
+ WORDS_500: 'Learned 500 words',
+ WORDS_1000: 'Learned 1000 words',
+ PERFECT_SCORE: 'Got a perfect score on a test',
+ TEST_10: 'Completed 10 tests',
+ ACCURACY_90: 'Achieved 90% overall accuracy',
+ GAME_FIRST_PLAY: 'Played your first game',
+ GAME_10_WINS: 'Won 10 games',
+ QUICK_GUESSER: 'Answered correctly within 5 seconds',
+ PERFECT_DRAWER: 'All players guessed your drawing correctly',
+ MASTER: 'Achieved all badges',
+}
+
+export default {
+ BADGE_TYPES,
+ BADGE_CATEGORIES,
+ BADGE_CATEGORY_LABELS_KO,
+ BADGE_CATEGORY_LABELS_EN,
+ BADGE_CATEGORY_COLORS,
+ BADGE_NAMES_EN,
+ BADGE_DESCRIPTIONS_EN,
+}
diff --git a/src/domains/badge/index.js b/src/domains/badge/index.js
new file mode 100644
index 0000000..fb86312
--- /dev/null
+++ b/src/domains/badge/index.js
@@ -0,0 +1,10 @@
+// Components
+export { default as BadgeCard } from './components/BadgeCard'
+export { default as BadgeGrid } from './components/BadgeGrid'
+export { default as BadgeSection } from './components/BadgeSection'
+
+// Services
+export * from './services/badgeService'
+
+// Constants
+export * from './constants/badgeConstants'
diff --git a/src/domains/badge/services/badgeService.js b/src/domains/badge/services/badgeService.js
new file mode 100644
index 0000000..e120e7b
--- /dev/null
+++ b/src/domains/badge/services/badgeService.js
@@ -0,0 +1,220 @@
+import badgeApi from '../../../api/badgeApi'
+
+// Mock 데이터 사용 여부
+const USE_MOCK = true
+
+// Placeholder 이미지 (실제 S3 이미지가 없을 경우 대비)
+const PLACEHOLDER_BADGE = 'https://via.placeholder.com/100x100/FFD700/000000?text=Badge'
+
+// ============================================
+// Mock 데이터
+// ============================================
+
+const mockBadges = [
+ {
+ badgeType: 'FIRST_STEP',
+ name: '첫 걸음',
+ description: '첫 학습을 완료했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135715.png',
+ category: 'FIRST_STUDY',
+ threshold: 1,
+ progress: 1,
+ earned: true,
+ earnedAt: '2024-12-01T10:30:00Z',
+ },
+ {
+ badgeType: 'STREAK_3',
+ name: '3일 연속 학습',
+ description: '3일 연속으로 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135783.png',
+ category: 'STREAK',
+ threshold: 3,
+ progress: 3,
+ earned: true,
+ earnedAt: '2024-12-05T09:15:00Z',
+ },
+ {
+ badgeType: 'STREAK_7',
+ name: '일주일 연속 학습',
+ description: '7일 연속으로 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135789.png',
+ category: 'STREAK',
+ threshold: 7,
+ progress: 5,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'STREAK_30',
+ name: '한 달 연속 학습',
+ description: '30일 연속으로 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135810.png',
+ category: 'STREAK',
+ threshold: 30,
+ progress: 5,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'WORDS_100',
+ name: '단어 수집가',
+ description: '100개의 단어를 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232688.png',
+ category: 'WORDS_LEARNED',
+ threshold: 100,
+ progress: 85,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'WORDS_500',
+ name: '단어 전문가',
+ description: '500개의 단어를 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232691.png',
+ category: 'WORDS_LEARNED',
+ threshold: 500,
+ progress: 85,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'WORDS_1000',
+ name: '단어 마스터',
+ description: '1000개의 단어를 학습했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/2232/2232696.png',
+ category: 'WORDS_LEARNED',
+ threshold: 1000,
+ progress: 85,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'PERFECT_SCORE',
+ name: '완벽주의자',
+ description: '테스트에서 만점을 받았습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176293.png',
+ category: 'PERFECT_TEST',
+ threshold: 1,
+ progress: 1,
+ earned: true,
+ earnedAt: '2024-12-10T14:20:00Z',
+ },
+ {
+ badgeType: 'TEST_10',
+ name: '테스트 도전자',
+ description: '10회의 테스트를 완료했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176366.png',
+ category: 'TESTS_COMPLETED',
+ threshold: 10,
+ progress: 7,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'ACCURACY_90',
+ name: '정확도 달인',
+ description: '전체 정확도 90%를 달성했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3176/3176271.png',
+ category: 'ACCURACY',
+ threshold: 90,
+ progress: 78,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'GAME_FIRST_PLAY',
+ name: '첫 게임',
+ description: '첫 게임에 참여했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612569.png',
+ category: 'GAMES_PLAYED',
+ threshold: 1,
+ progress: 1,
+ earned: true,
+ earnedAt: '2024-12-08T16:45:00Z',
+ },
+ {
+ badgeType: 'GAME_10_WINS',
+ name: '게임 10승',
+ description: '게임에서 10번 1등을 했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612530.png',
+ category: 'GAMES_WON',
+ threshold: 10,
+ progress: 3,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'QUICK_GUESSER',
+ name: '번개 정답',
+ description: '5초 내에 정답을 맞췄습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612557.png',
+ category: 'QUICK_GUESSES',
+ threshold: 1,
+ progress: 0,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'PERFECT_DRAWER',
+ name: '완벽한 출제자',
+ description: '출제 시 전원이 정답을 맞췄습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3612/3612537.png',
+ category: 'PERFECT_DRAWS',
+ threshold: 1,
+ progress: 0,
+ earned: false,
+ earnedAt: null,
+ },
+ {
+ badgeType: 'MASTER',
+ name: '학습 마스터',
+ description: '모든 업적을 달성했습니다',
+ imageUrl: 'https://cdn-icons-png.flaticon.com/512/3135/3135706.png',
+ category: 'ALL_BADGES',
+ threshold: 1,
+ progress: 0,
+ earned: false,
+ earnedAt: null,
+ },
+]
+
+// ============================================
+// API with Mock fallback
+// ============================================
+
+const withMock = (apiCall, mockData) => {
+ if (USE_MOCK) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve(mockData), 500)
+ })
+ }
+ return apiCall().catch(() => mockData)
+}
+
+/**
+ * Badge API Service
+ */
+export const badgeService = {
+ // GET /badges - 전체 배지 목록 (획득 여부, 진행도 포함)
+ getAll: () =>
+ withMock(
+ () => badgeApi.get('/badges'),
+ {
+ badges: mockBadges,
+ totalCount: mockBadges.length,
+ earnedCount: mockBadges.filter((b) => b.earned).length,
+ }
+ ),
+
+ // GET /badges/earned - 획득한 배지만 조회
+ getEarned: () =>
+ withMock(
+ () => badgeApi.get('/badges/earned'),
+ {
+ badges: mockBadges.filter((b) => b.earned),
+ count: mockBadges.filter((b) => b.earned).length,
+ }
+ ),
+}
+
+export default badgeService
diff --git a/src/domains/vocab/pages/StatsPage.jsx b/src/domains/vocab/pages/StatsPage.jsx
index e2fb7a2..be57bdc 100644
--- a/src/domains/vocab/pages/StatsPage.jsx
+++ b/src/domains/vocab/pages/StatsPage.jsx
@@ -32,6 +32,7 @@ import {
VOICE_TYPES,
} from '../constants/vocabConstants'
import { useTranslation } from '../../../contexts/SettingsContext'
+import { BadgeSection } from '../../badge'
const TEMP_USER_ID = import.meta.env.VITE_TEMP_USER_ID || 'user1'
@@ -495,7 +496,7 @@ export default function StatsPage() {
{/* 취약 단어 */}
-
+
{t('stats.weakWordsTop10')}
@@ -514,6 +515,9 @@ export default function StatsPage() {
playingWordId={playingWordId}
/>
+
+ {/* 배지 섹션 */}
+
)
}
diff --git a/src/i18n/translations.js b/src/i18n/translations.js
index e96c160..15b5bcf 100644
--- a/src/i18n/translations.js
+++ b/src/i18n/translations.js
@@ -349,6 +349,32 @@ export const translations = {
poor: '더 연습해요',
},
},
+
+ // Badge
+ badge: {
+ title: '획득한 배지',
+ subtitle: '학습 목표를 달성하고 배지를 모아보세요!',
+ progress: '전체 진행률',
+ earned: '획득',
+ notEarned: '미획득',
+ earnedAt: '획득일',
+ progressLabel: '진행도',
+ allCollected: '축하합니다! 모든 배지를 획득했습니다!',
+ noBadges: '배지가 없습니다',
+ categories: {
+ FIRST_STUDY: '첫 시작',
+ STREAK: '연속 학습',
+ WORDS_LEARNED: '단어 학습',
+ PERFECT_TEST: '완벽한 테스트',
+ TESTS_COMPLETED: '테스트 완료',
+ ACCURACY: '정확도',
+ GAMES_PLAYED: '게임 참여',
+ GAMES_WON: '게임 승리',
+ QUICK_GUESSES: '빠른 정답',
+ PERFECT_DRAWS: '완벽한 출제',
+ ALL_BADGES: '마스터',
+ },
+ },
},
en: {
@@ -696,6 +722,32 @@ export const translations = {
poor: 'Keep practicing',
},
},
+
+ // Badge
+ badge: {
+ title: 'Earned Badges',
+ subtitle: 'Achieve learning goals and collect badges!',
+ progress: 'Overall Progress',
+ earned: 'Earned',
+ notEarned: 'Not earned',
+ earnedAt: 'Earned on',
+ progressLabel: 'Progress',
+ allCollected: 'Congratulations! You collected all badges!',
+ noBadges: 'No badges available',
+ categories: {
+ FIRST_STUDY: 'First Steps',
+ STREAK: 'Streak',
+ WORDS_LEARNED: 'Words Learned',
+ PERFECT_TEST: 'Perfect Test',
+ TESTS_COMPLETED: 'Tests Completed',
+ ACCURACY: 'Accuracy',
+ GAMES_PLAYED: 'Games Played',
+ GAMES_WON: 'Games Won',
+ QUICK_GUESSES: 'Quick Guesser',
+ PERFECT_DRAWS: 'Perfect Drawer',
+ ALL_BADGES: 'Master',
+ },
+ },
},
}