-
- )}
+ )
+ })
+ )}
+
+
+ )}
{/* 입력 영역 */}
{
color: 'white',
width: 32,
height: 32,
- '&:hover': {bgcolor: 'primary.dark'},
- '&:disabled': {bgcolor: 'grey.300'},
+ '&:hover': { bgcolor: 'primary.dark' },
+ '&:disabled': { bgcolor: 'grey.300' },
}}
>
- {sendingMessage ? :
- }
+ {sendingMessage ? :
+ }
>
diff --git a/src/domains/opic/constants/opicConstants.js b/src/domains/opic/constants/opicConstants.js
new file mode 100644
index 0000000..0faa114
--- /dev/null
+++ b/src/domains/opic/constants/opicConstants.js
@@ -0,0 +1,319 @@
+/**
+ * OPIc domain constants
+ * Based on grammarConstants.js pattern
+ */
+
+// ============================================
+// 주제 타입
+// ============================================
+export const OPIC_TOPICS = {
+ DESCRIPTION: 'DESCRIPTION',
+ HABIT: 'HABIT',
+ PAST_EXPERIENCE: 'PAST_EXPERIENCE',
+ COMPARISON: 'COMPARISON',
+ ROLE_PLAY: 'ROLE_PLAY',
+ ISSUE: 'ISSUE',
+}
+
+
+export const OPIC_TOPIC_LABELS = {
+ DESCRIPTION: { ko: '단순 묘사', en: 'Description' },
+ HABIT: { ko: '습관/경향', en: 'Habit' },
+ PAST_EXPERIENCE: { ko: '과거 경험', en: 'Past Experience' },
+ COMPARISON: { ko: '비교', en: 'Comparison' },
+ ROLE_PLAY: { ko: '롤플레이', en: 'Role Play' },
+ ISSUE: { ko: '사회 이슈/심화', en: 'Social Issues' },
+}
+
+// 주제별 아이콘
+export const OPIC_TOPIC_ICONS = {
+ DESCRIPTION: '🏠',
+ HABIT: '🔄',
+ PAST_EXPERIENCE: '📅',
+}
+
+// 주제별 색상
+export const OPIC_TOPIC_COLORS = {
+ DESCRIPTION: '#3b82f6',
+ HABIT: '#22c55e',
+ PAST_EXPERIENCE: '#f97316',
+}
+
+// ============================================
+// 세부 주제
+// ============================================
+export const OPIC_SUBTOPICS = [
+ { value: 'BANKS', labelKo: '은행', labelEn: 'Banks' },
+ { value: 'BARS', labelKo: '술집/바', labelEn: 'Bars' },
+ { value: 'CAFES', labelKo: '카페', labelEn: 'Cafes' },
+ { value: 'CONCERTS', labelKo: '콘서트', labelEn: 'Concerts' },
+ { value: 'FAMILY', labelKo: '가족', labelEn: 'Family' },
+ { value: 'FURNITURE', labelKo: '가구', labelEn: 'Furniture' },
+ { value: 'GAMES', labelKo: '게임', labelEn: 'Games' },
+ { value: 'GYM', labelKo: '헬스장', labelEn: 'Gym' },
+ { value: 'HEALTH', labelKo: '건강', labelEn: 'Health' },
+ { value: 'HOLIDAYS', labelKo: '명절/휴일', labelEn: 'Holidays' },
+ { value: 'HOMES', labelKo: '집', labelEn: 'Homes' },
+ { value: 'HOTEL', labelKo: '호텔', labelEn: 'Hotel' },
+ { value: 'INTERNET', labelKo: '인터넷', labelEn: 'Internet' },
+ { value: 'MOVIES', labelKo: '영화', labelEn: 'Movies' },
+ { value: 'MUSIC', labelKo: '음악', labelEn: 'Music' },
+ { value: 'PARKS', labelKo: '공원', labelEn: 'Parks' },
+ { value: 'PHONE', labelKo: '전화기', labelEn: 'Phone' },
+ { value: 'RECYCLING', labelKo: '재활용', labelEn: 'Recycling' },
+ { value: 'RESTAURANTS', labelKo: '식당', labelEn: 'Restaurants' },
+ { value: 'SHOPPING', labelKo: '쇼핑', labelEn: 'Shopping' },
+ { value: 'TECHNOLOGY', labelKo: '기술', labelEn: 'Technology' },
+ { value: 'TRAVEL', labelKo: '여행', labelEn: 'Travel' },
+ { value: 'VACATION', labelKo: '휴가', labelEn: 'Vacation' },
+ { value: 'WEATHER', labelKo: '날씨', labelEn: 'Weather' },
+ { value: 'FREESTYLE', labelKo: '돌발/자유', labelEn: 'Freestyle' },
+ { value: 'GENERAL', labelKo: '일반', labelEn: 'General' },
+];
+
+// ============================================
+// 레벨
+// ============================================
+export const OPIC_LEVELS = {
+ IM1: 'IM1',
+ IM2: 'IM2',
+ IM3: 'IM3',
+ IH: 'IH',
+ AL: 'AL',
+}
+
+export const OPIC_LEVEL_LABELS = {
+ IM1: { ko: 'IM1 (중급 하)', en: 'IM1 (Intermediate Mid 1)' },
+ IM2: { ko: 'IM2 (중급 중)', en: 'IM2 (Intermediate Mid 2)' },
+ IM3: { ko: 'IM3 (중급 상)', en: 'IM3 (Intermediate Mid 3)' },
+ IH: { ko: 'IH (중상급)', en: 'IH (Intermediate High)' },
+ AL: { ko: 'AL (고급)', en: 'AL (Advanced Low)' },
+}
+
+// 레벨별 색상
+export const OPIC_LEVEL_COLORS = {
+ IM1: '#22c55e',
+ IM2: '#3b82f6',
+ IM3: '#8b5cf6',
+ IH: '#f97316',
+}
+
+// 레벨별 배경 색상
+export const OPIC_LEVEL_BG_COLORS = {
+ IM1: '#f0fdf4',
+ IM2: '#eff6ff',
+ IM3: '#f5f3ff',
+ IH: '#fff7ed',
+}
+
+// ============================================
+// UI 번역 (한국어)
+// ============================================
+export const OPIC_UI_KO = {
+ // 헤더
+ title: 'OPIc 스피킹 테스트',
+ subtitle: 'AI 기반 영어 스피킹 연습 및 피드백',
+
+ // 세션 설정
+ sessionSetup: '세션 설정',
+ topic: '주제',
+ subTopic: '세부 주제',
+ targetLevel: '목표 레벨',
+ start: '시작하기',
+
+ // 질문
+ question: '질문',
+ listenQuestion: '질문 듣기',
+
+ // 녹음
+ recordAnswer: '답변 녹음',
+ startRecording: '녹음 시작',
+ stopRecording: '녹음 중지',
+ recording: '녹음 중...',
+ recordingReady: '녹음 완료',
+ playToReview: '재생하여 확인하세요',
+ reRecord: '다시 녹음',
+
+ // 제출
+ getUploadUrl: 'Upload URL 발급',
+ preparing: '준비 중...',
+ submitAnswer: '답변 제출',
+ submitting: '제출 중...',
+
+ // 피드백
+ aiFeedback: 'AI 피드백',
+ yourAnswer: '내 답변',
+ correctedAnswer: '교정된 답변',
+ modelAnswer: '모범 답변',
+ grammarErrors: '문법 오류',
+
+ // 네비게이션
+ nextQuestion: '다음 질문',
+ completeSession: '세션 완료',
+ completing: '완료 중...',
+
+ // 결과
+ sessionCompleted: '세션이 완료되었습니다!',
+ checkReport: '리포트를 확인하세요.',
+ overallScore: '종합 점수',
+ totalQuestions: '총 질문 수',
+ answeredQuestions: '답변한 질문',
+
+ // 에러
+ errorCreateSession: '세션 생성에 실패했습니다',
+ errorGetQuestion: '다음 질문을 불러오는데 실패했습니다',
+ errorStartRecording: '녹음을 시작할 수 없습니다',
+ errorGetUploadUrl: 'Upload URL 발급에 실패했습니다',
+ errorSubmitAnswer: '답변 제출에 실패했습니다',
+ errorCompleteSession: '세션 완료에 실패했습니다',
+
+ // 마이크 권한
+ micPermissionRequired: '마이크 권한이 필요합니다',
+ micPermissionDenied: '마이크 권한이 거부되었습니다',
+}
+
+// ============================================
+// UI 번역 (영어)
+// ============================================
+export const OPIC_UI_EN = {
+ // 헤더
+ title: 'OPIc Speaking Test',
+ subtitle: 'AI-powered English speaking practice & feedback',
+
+ // 세션 설정
+ sessionSetup: 'Session Setup',
+ topic: 'Topic',
+ subTopic: 'Sub Topic',
+ targetLevel: 'Target Level',
+ start: 'Start Session',
+
+ // 질문
+ question: 'Question',
+ listenQuestion: 'Listen to question',
+
+ // 녹음
+ recordAnswer: 'Record Answer',
+ startRecording: 'Start Recording',
+ stopRecording: 'Stop Recording',
+ recording: 'Recording...',
+ recordingReady: 'Recording Ready',
+ playToReview: 'Play to review',
+ reRecord: 'Re-record',
+
+ // 제출
+ getUploadUrl: 'Get Upload URL',
+ preparing: 'Preparing...',
+ submitAnswer: 'Submit Answer',
+ submitting: 'Submitting...',
+
+ // 피드백
+ aiFeedback: 'AI Feedback',
+ yourAnswer: 'Your Answer',
+ correctedAnswer: 'Corrected Answer',
+ modelAnswer: 'Model Answer',
+ grammarErrors: 'Grammar Errors',
+
+ // 네비게이션
+ nextQuestion: 'Next Question',
+ completeSession: 'Complete Session',
+ completing: 'Completing...',
+
+ // 결과
+ sessionCompleted: 'Session completed!',
+ checkReport: 'Check your report.',
+ overallScore: 'Overall Score',
+ totalQuestions: 'Total Questions',
+ answeredQuestions: 'Answered Questions',
+
+ // 에러
+ errorCreateSession: 'Failed to create session',
+ errorGetQuestion: 'Failed to get next question',
+ errorStartRecording: 'Failed to start recording',
+ errorGetUploadUrl: 'Failed to get upload URL',
+ errorSubmitAnswer: 'Failed to submit answer',
+ errorCompleteSession: 'Failed to complete session',
+
+ // 마이크 권한
+ micPermissionRequired: 'Microphone permission required',
+ micPermissionDenied: 'Microphone permission denied',
+}
+
+// ============================================
+// 점수 등급 (grammarConstants 패턴)
+// ============================================
+export const OPIC_SCORE_GRADES = {
+ EXCELLENT: { min: 90, max: 100, label: 'Excellent', labelKo: '훌륭해요!' },
+ GOOD: { min: 70, max: 89, label: 'Good', labelKo: '잘했어요!' },
+ FAIR: { min: 50, max: 69, label: 'Fair', labelKo: '괜찮아요' },
+ POOR: { min: 0, max: 49, label: 'Needs Practice', labelKo: '더 연습해요' },
+}
+
+// 점수에 따른 등급 반환
+export const getOpicScoreGrade = (score) => {
+ if (score >= 90) return OPIC_SCORE_GRADES.EXCELLENT
+ if (score >= 70) return OPIC_SCORE_GRADES.GOOD
+ if (score >= 50) return OPIC_SCORE_GRADES.FAIR
+ return OPIC_SCORE_GRADES.POOR
+}
+
+// 점수에 따른 색상 반환
+export const getOpicScoreColor = (score) => {
+ if (score >= 90) return '#059669'
+ if (score >= 70) return '#3b82f6'
+ if (score >= 50) return '#f97316'
+ return '#ef4444'
+}
+
+// ============================================
+// 설정값
+// ============================================
+export const OPIC_CONFIG = {
+ DEFAULT_TOTAL_QUESTIONS: 12,
+ MAX_RECORDING_TIME: 120, // 2분
+ MIN_RECORDING_TIME: 5, // 5초
+ AUDIO_MIME_TYPE: 'audio/webm',
+ FALLBACK_MIME_TYPE: 'audio/mp4',
+}
+
+// ============================================
+// 헬퍼 함수 (오류 수정됨)
+// ============================================
+
+// 언어에 따른 주제 라벨 반환
+export const getTopicLabel = (topic, isKorean) => {
+ const labelObj = OPIC_TOPIC_LABELS[topic];
+ return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : topic;
+}
+
+// 언어에 따른 세부주제 라벨 반환
+export const getSubtopicLabel = (subtopicValue, isKorean) => {
+ const found = OPIC_SUBTOPICS.find(item => item.value === subtopicValue);
+ if (!found) return subtopicValue;
+ return isKorean ? found.labelKo : found.labelEn;
+}
+
+// 언어에 따른 레벨 라벨 반환
+export const getLevelLabel = (level, isKorean) => {
+ const labelObj = OPIC_LEVEL_LABELS[level];
+ return labelObj ? (isKorean ? labelObj.ko : labelObj.en) : level;
+}
+
+// 녹음 시간 포맷 (mm:ss)
+export const formatRecordingTime = (seconds) => {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+}
+
+export default {
+ OPIC_TOPICS,
+ OPIC_TOPIC_LABELS,
+ OPIC_TOPIC_ICONS,
+ OPIC_SUBTOPICS,
+ OPIC_LEVELS,
+ OPIC_LEVEL_LABELS,
+ getTopicLabel,
+ getSubtopicLabel,
+ getLevelLabel,
+ formatRecordingTime,
+}
\ No newline at end of file
diff --git a/src/domains/opic/pages/OPIcPage.jsx b/src/domains/opic/pages/OPIcPage.jsx
new file mode 100644
index 0000000..af50e00
--- /dev/null
+++ b/src/domains/opic/pages/OPIcPage.jsx
@@ -0,0 +1,704 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ CircularProgress,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ TextField,
+ Typography,
+ useMediaQuery,
+ useTheme,
+ LinearProgress,
+ Chip,
+ Divider,
+ IconButton,
+ Grid,
+ Paper
+} from '@mui/material'
+import {
+ RecordVoiceOver as VoiceIcon,
+ Mic as MicIcon,
+ Stop as StopIcon,
+ PlayArrow as PlayIcon,
+ Pause as PauseIcon,
+ CheckCircle as CheckIcon,
+ Send as SendIcon,
+ UploadFile as UploadIcon,
+ VolumeUp as SpeakerIcon,
+ Home as HomeIcon,
+ TrendingUp as TrendingUpIcon,
+ Warning as WarningIcon,
+ Lightbulb as LightbulbIcon,
+ Email as EmailIcon
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import { useAuth } from '../../../contexts/AuthContext'
+import { sessionService, uploadAudioToS3, pollForAnswerResult } from '../services/opicService'
+import {
+ OPIC_TOPICS,
+ OPIC_TOPIC_LABELS,
+ OPIC_SUBTOPICS,
+} from '../constants/opicConstants'
+
+export default function OPIcPage() {
+ const { t, isKorean } = useSettings()
+ const { user } = useAuth()
+ const theme = useTheme()
+ const navigate = useNavigate()
+ const isMobile = useMediaQuery(theme.breakpoints.down('md'))
+
+ // Session state
+ const [sessionId, setSessionId] = useState(null)
+ const [sessionSettings, setSessionSettings] = useState({
+ topic: OPIC_TOPICS.DESCRIPTION,
+ subTopic: 'HOMES',
+ })
+
+ // Question state
+ const [currentQuestion, setCurrentQuestion] = useState(null)
+ const [questionNumber, setQuestionNumber] = useState(0)
+ const [totalQuestions, setTotalQuestions] = useState(12)
+
+ // Recording state
+ const [isRecording, setIsRecording] = useState(false)
+ const [recordedBlob, setRecordedBlob] = useState(null)
+ const [recordedUrl, setRecordedUrl] = useState(null)
+ const [mediaRecorder, setMediaRecorder] = useState(null)
+ const [recordingTime, setRecordingTime] = useState(0)
+ const audioChunksRef = useRef([])
+ const [processingStatus, setProcessingStatus] = useState(null)
+
+ // Upload state
+ const [uploadUrl, setUploadUrl] = useState(null)
+ const [s3Key, setS3Key] = useState(null)
+
+ // Feedback state
+ const [feedback, setFeedback] = useState(null)
+
+ // Report state
+ const [sessionReport, setSessionReport] = useState(null)
+ const [showReport, setShowReport] = useState(false)
+
+ // UI state
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [uploadProgress, setUploadProgress] = useState(0)
+
+ // Audio playback state
+ const [isPlayingQuestion, setIsPlayingQuestion] = useState(false)
+ const [isPlayingRecorded, setIsPlayingRecorded] = useState(false)
+ const questionAudioRef = useRef(null)
+ const recordedAudioRef = useRef(null)
+
+ // Recording timer
+ useEffect(() => {
+ let interval
+ if (isRecording) {
+ interval = setInterval(() => {
+ setRecordingTime((prev) => prev + 1)
+ }, 1000)
+ } else {
+ setRecordingTime(0)
+ }
+ return () => clearInterval(interval)
+ }, [isRecording])
+
+ // Format recording time
+ const formatTime = (seconds) => {
+ const mins = Math.floor(seconds / 60)
+ const secs = seconds % 60
+ return `${mins}:${secs.toString().padStart(2, '0')}`
+ }
+
+ // Create session
+ const handleCreateSession = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const userTargetLevel = user?.level || 'IM2';
+
+ const requestData = {
+ topic: sessionSettings.topic,
+ subTopic: sessionSettings.subTopic,
+ targetLevel: userTargetLevel
+ };
+
+ const data = await sessionService.create(requestData)
+
+ console.log("✅ 백엔드 응답 데이터:", data);
+
+ setSessionId(data.sessionId)
+ const firstQuestionData = data.question || data.questionResponse || data.firstQuestion;
+
+ if (firstQuestionData) {
+ displayQuestion(firstQuestionData)
+ setQuestionNumber(1)
+ } else {
+ console.error("❌ 질문 데이터가 응답에 없습니다:", data);
+ setError("서버 응답에서 질문 데이터를 찾을 수 없습니다.");
+ }
+
+ if (data.totalQuestions) {
+ setTotalQuestions(data.totalQuestions)
+ }
+ } catch (err) {
+ console.error('Failed to create session:', err)
+ setError(isKorean ? '세션 생성에 실패했습니다' : 'Failed to create session')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Display question
+ const displayQuestion = (questionData) => {
+ setCurrentQuestion(questionData)
+ setFeedback(null)
+ setRecordedBlob(null)
+ setRecordedUrl(null)
+ setUploadUrl(null)
+ setS3Key(null)
+ setProcessingStatus(null)
+ }
+
+ // Get next question
+ const handleNextQuestion = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const data = await sessionService.getNextQuestion(sessionId)
+
+ // 모든 질문 완료 확인
+ if (data.completed) {
+ // 세션 완료 처리
+ await handleCompleteSession()
+ return
+ }
+
+ if (data.question) {
+ displayQuestion(data.question)
+
+ // 질문 번호 업데이트 (백엔드에서 오는 questionNumber가 있으면 사용, 없으면 수동 계산)
+ setQuestionNumber(data.question.questionNumber || questionNumber + 1)
+ } else {
+ console.error("❌ 질문 데이터를 찾을 수 없습니다:", data)
+ setError(isKorean ? '질문 데이터를 불러오지 못했습니다' : 'Failed to load question data')
+ }
+ } catch (err) {
+ console.error('Failed to get next question:', err)
+ setError(isKorean ? '다음 질문을 불러오는데 실패했습니다' : 'Failed to get next question')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Toggle recording
+ const toggleRecording = async () => {
+ if (!isRecording) {
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ const recorder = new MediaRecorder(stream, {
+ mimeType: MediaRecorder.isTypeSupported('audio/webm')
+ ? 'audio/webm'
+ : 'audio/mp4',
+ })
+
+ audioChunksRef.current = []
+
+ recorder.ondataavailable = (event) => {
+ if (event.data.size > 0) {
+ audioChunksRef.current.push(event.data)
+ }
+ }
+
+ recorder.onstop = () => {
+ const blob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
+ setRecordedBlob(blob)
+ const url = URL.createObjectURL(blob)
+ setRecordedUrl(url)
+ stream.getTracks().forEach((track) => track.stop())
+ }
+
+ recorder.start()
+ setMediaRecorder(recorder)
+ setIsRecording(true)
+ } catch (err) {
+ console.error('Failed to start recording:', err)
+ setError(isKorean ? '녹음을 시작할 수 없습니다' : 'Failed to start recording')
+ }
+ } else {
+ if (mediaRecorder) {
+ mediaRecorder.stop()
+ setMediaRecorder(null)
+ setIsRecording(false)
+ }
+ }
+ }
+
+ // Get upload URL
+ const handleGetUploadUrl = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const data = await sessionService.getUploadUrl(sessionId)
+ setUploadUrl(data.uploadUrl)
+ setS3Key(data.s3Key)
+ } catch (err) {
+ console.error('Failed to get upload URL:', err)
+ setError(isKorean ? 'Upload URL 발급에 실패했습니다' : 'Failed to get upload URL')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // Submit answer
+ const handleSubmitAnswer = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ setUploadProgress(0)
+ setProcessingStatus(isKorean ? 'S3에 업로드 중...' : 'Uploading to S3...')
+
+ // 1. S3 업로드
+ await uploadAudioToS3(uploadUrl, recordedBlob)
+ setUploadProgress(20)
+ setProcessingStatus(isKorean ? '답변 제출 중...' : 'Submitting...')
+
+ // 2. 답변 제출 (비동기 처리 시작 요청)
+ const submitResult = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key })
+ setUploadProgress(40)
+ setProcessingStatus(isKorean ? 'AI가 분석 중...' : 'AI is analyzing...')
+
+ // 3. 폴링으로 결과 대기 (백엔드 완료될 때까지 반복 확인)
+ const result = await pollForAnswerResult(sessionId, submitResult.questionIndex, {
+ onProgress: ({ attempt }) => {
+ // 진행 상황에 따라 프로그레스 바를 조금씩 채움 (40% ~ 90%)
+ setUploadProgress(prev => Math.min(prev + 1, 90))
+ }
+ })
+
+ // 4. 최종 결과 세팅
+ setFeedback(result)
+ setUploadProgress(100)
+ setProcessingStatus(null)
+
+ } catch (err) {
+ console.error('Failed to submit answer:', err)
+ setError(isKorean ? '분석 중 오류가 발생했습니다. 다시 시도해주세요.' : 'Analysis failed. Please try again.')
+ } finally {
+ setLoading(false)
+ setTimeout(() => setUploadProgress(0), 2000)
+ }
+ }
+
+
+
+ // Complete session
+ const handleCompleteSession = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const report = await sessionService.complete(sessionId)
+
+ const reportData = report.data || report;
+
+ // 리포트 데이터 저장 및 화면 전환
+ setSessionReport(reportData)
+ setShowReport(true)
+ } catch (err) {
+ console.error('Failed to complete session:', err)
+ setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const handleStartNewSession = () => {
+ setSessionId(null)
+ setCurrentQuestion(null)
+ setQuestionNumber(0)
+ setFeedback(null)
+ setShowReport(false)
+ setSessionReport(null)
+ setRecordedBlob(null)
+ setRecordedUrl(null)
+ setUploadUrl(null)
+ setS3Key(null)
+ }
+
+ // Navigate to reports page
+ const handleGoToReports = () => {
+ navigate('/reports')
+ }
+
+ // Play question audio
+ const handlePlayQuestionAudio = () => {
+ if (questionAudioRef.current) {
+ if (isPlayingQuestion) {
+ questionAudioRef.current.pause()
+ setIsPlayingQuestion(false)
+ } else {
+ questionAudioRef.current.play()
+ setIsPlayingQuestion(true)
+ }
+ }
+ }
+
+ // Play recorded audio
+ const handlePlayRecordedAudio = () => {
+ if (recordedAudioRef.current) {
+ if (isPlayingRecorded) {
+ recordedAudioRef.current.pause()
+ setIsPlayingRecorded(false)
+ } else {
+ recordedAudioRef.current.play()
+ setIsPlayingRecorded(true)
+ }
+ }
+ }
+
+ const getLevelColor = (level) => {
+ const colors = {
+ 'IM1': '#22c55e', 'IM2': '#3b82f6', 'IM3': '#8b5cf6',
+ 'IH': '#f97316', 'AL': '#ef4444'
+ };
+ return colors[level] || '#3b82f6';
+ }
+
+ // 결과 리포트 화면
+ if (showReport && sessionReport) {
+ return (
+
+
+
+ {/* Header: Score & Level */}
+
+
+ {isKorean ? '테스트 결과 리포트' : 'TEST REPORT'}
+
+
+
+ {/* Level */}
+
+
+ {sessionReport.estimatedLevel}
+
+ LEVEL
+
+
+
+
+ {/* Score */}
+
+
+ {sessionReport.overallScore}
+
+ SCORE
+
+
+
+ {/* Overall Feedback */}
+
+
+ {sessionReport.feedback}
+
+
+
+
+
+
+ {/* Details Grid */}
+
+ {[
+ { title: isKorean ? "잘한 점" : "Strengths", items: sessionReport.strengths, color: "#16a34a", icon: TrendingUpIcon },
+ { title: isKorean ? "아쉬운 점" : "Weaknesses", items: sessionReport.weaknesses, color: "#ea580c", icon: WarningIcon },
+ { title: isKorean ? "학습 추천" : "Tips", items: sessionReport.recommendations, color: "#7c3aed", icon: LightbulbIcon }
+ ].map((section, idx) => (
+
+
+
+
+
+ {section.title}
+
+
+
+ {section.items && section.items.length > 0 ? (
+ section.items.map((item, i) => (
+
+ {item}
+
+ ))
+ ) : (
+ -
+ )}
+
+
+
+ ))}
+
+
+ {/* Email Notification */}
+
+ }
+ label={isKorean ? "결과가 이메일로 발송되었습니다." : "Report sent to your email."}
+ variant="outlined"
+ size="small"
+ sx={{ borderColor: '#e2e8f0', color: '#94a3b8', fontSize: '0.8rem' }}
+ />
+
+
+
+ {/* Bottom Actions */}
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+ {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'}
+ {isKorean ? 'AI 기반 OPIc 연습 및 피드백' : 'AI-powered English speaking practice'}
+
+
+
+ {/* Main Content */}
+
+ {error && {error}}
+
+ {/* --- [1] Session Setup --- */}
+ {!sessionId && (
+
+
+ {isKorean ? 'OPIc 질문 생성' : 'Session Setup'}
+
+
+ {isKorean ? '질문 유형' : 'Question Type'}
+
+
+
+
+ {isKorean ? '주제' : 'Topic'}
+
+
+
+
+ * {isKorean ? `프로필에 설정된 나의 레벨(${user?.level || 'IM2'})에 맞춰 AI 피드백이 제공됩니다.` : `AI feedback tailored to level (${user?.level || 'IM2'}).`}
+
+
+
+
+
+ )}
+
+ {/* --- [2] Question Area --- */}
+ {sessionId && currentQuestion && (
+ <>
+
+
+
+
+
+ {/* 질문 카드 */}
+
+
+ {currentQuestion.questionText}
+ {currentQuestion.audioUrl && (
+
+
+ {isPlayingQuestion ? : }
+
+ {isKorean ? '질문 듣기' : 'Listen'}
+
+ )}
+
+
+
+ {/* 녹음 카드 */}
+
+
+ {isKorean ? '답변 녹음' : 'Record Answer'}
+
+ : } onClick={toggleRecording} disabled={feedback !== null} sx={{ borderRadius: '12px', minWidth: 200, py: 1.5, animation: isRecording ? 'pulse 1.5s infinite' : 'none', '@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.6 } } }}>
+ {isRecording ? (isKorean ? '녹음 중지' : 'Stop') : (isKorean ? '녹음 시작' : 'Start Recording')}
+
+ {isRecording && {formatTime(recordingTime)}}
+ {recordedUrl && !isRecording && (
+
+
+ {isPlayingRecorded ? : }
+
+
+ {isKorean ? '녹음 완료' : 'Recorded'}
+
+
+ )}
+
+
+
+
+ {/* 제출 버튼 영역 */}
+ {recordedBlob && !feedback && (
+
+
+
+ {!uploadUrl ? (
+ } onClick={handleGetUploadUrl} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? 'Preparing...' : isKorean ? '업로드 준비' : 'Prepare Upload'}
+
+ ) : (
+ } onClick={handleSubmitAnswer} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? (
+
+
+ {processingStatus || (isKorean ? '처리 중...' : 'Processing...')}
+
+ ) : isKorean ? '제출하기' : 'Submit'}
+
+ )}
+
+ {uploadProgress > 0 && (
+
+
+ {processingStatus && (
+
+ {processingStatus}
+
+ )}
+
+ )}
+
+
+ )}
+
+ {/* 피드백 및 다음 질문 버튼 */}
+ {feedback && (
+
+
+ AI Feedback
+
+ {feedback.transcript && (
+
+ {isKorean ? '내 답변' : 'Your Answer'}
+ {feedback.transcript}
+
+ )}
+
+ {feedback.feedback?.correctedAnswer && (
+
+ {isKorean ? '교정된 답변' : 'Corrected'}
+ {feedback.feedback.correctedAnswer}
+
+ )}
+ {feedback.feedback?.errors && feedback.feedback.errors.length > 0 && (
+
+
+ {isKorean ? ' 문법 오류 체크' : 'Grammar Errors'}
+
+ {feedback.feedback.errors.map((error, index) => (
+
+
+ • {error.original} → {error.corrected}
+
+ {error.explanation && (
+
+ └ {error.explanation}
+
+ )}
+
+ ))}
+
+ )}
+
+ {(feedback.feedback?.grammarCorrection || feedback.feedback?.feedback) && (
+
+
+
+
+ {isKorean ? ' AI 학습 팁 & 문법 교정' : 'Tips & Grammar Correction'}
+
+
+
+ {/* grammarCorrection이 있으면 우선 보여주고, 없으면 전체 feedback을 보여줌 */}
+ {feedback.feedback.grammarCorrection || feedback.feedback.feedback}
+
+
+ )}
+
+
+
+
+ {/* 마지막 질문이 아니면 '다음 질문' 버튼, 마지막 질문이면 '세션 완료' 버튼 */}
+ {questionNumber < totalQuestions ? (
+
+ ) : (
+ } onClick={handleCompleteSession} disabled={loading} sx={{ borderRadius: '12px', py: 1.5 }}>
+ {loading ? (isKorean ? '완료 중...' : 'Completing...') : (isKorean ? '결과 보기' : 'View Results')}
+
+ )}
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+}
+
diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js
new file mode 100644
index 0000000..3bff7b5
--- /dev/null
+++ b/src/domains/opic/services/opicService.js
@@ -0,0 +1,159 @@
+import opicApi from '../../../api/opicApi'
+
+/**
+ * OPIc 세션 서비스
+ */
+export const sessionService = {
+ /**
+ * 세션 생성
+ * @param {Object} data - { topic, subTopic }
+ */
+ async create(data) {
+ const response = await opicApi.post('/opic/sessions', data)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to create session')
+ }
+ return response.data
+ },
+
+ /**
+ * 세션 목록 조회
+ */
+ async getList() {
+ const response = await opicApi.get('/opic/sessions')
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get sessions')
+ }
+ return response.data
+ },
+
+ /**
+ * 세션 상세 조회
+ * @param {string} sessionId
+ */
+ async getDetail(sessionId) {
+ const response = await opicApi.get(`/opic/sessions/${sessionId}`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get session detail')
+ }
+ return response.data
+ },
+
+ /**
+ * 다음 질문 조회
+ * @param {string} sessionId
+ */
+ async getNextQuestion(sessionId) {
+ const response = await opicApi.get(`/opic/sessions/${sessionId}/questions/next`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get question')
+ }
+ return response.data
+ },
+
+ /**
+ * 음성 업로드 URL 발급
+ * @param {string} sessionId
+ */
+ async getUploadUrl(sessionId) {
+ const response = await opicApi.get(`/opic/sessions/${sessionId}/upload-url`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get upload URL')
+ }
+ return response.data
+ },
+
+ /**
+ * 답변 제출
+ * @param {string} sessionId
+ * @param {Object} data - { audioS3Key }
+ */
+ async submitAnswer(sessionId, data) {
+ const response = await opicApi.post(`/opic/sessions/${sessionId}/answers`, data)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to submit answer')
+ }
+ return response.data
+ },
+
+ /**
+ * 답변 상태 조회 (폴링용 함수 추가)
+ */
+ async getAnswerStatus(sessionId, questionIndex) {
+ const response = await opicApi.get(`/opic/sessions/${sessionId}/answers/${questionIndex}/status`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to get answer status')
+ }
+ return response.data; // { status, transcript, feedback 등 }
+ },
+
+ /**
+ * 세션 완료
+ * @param {string} sessionId
+ */
+ async complete(sessionId) {
+ const response = await opicApi.post(`/opic/sessions/${sessionId}/complete`)
+ if (!response.isSuccess) {
+ throw new Error(response.message || 'Failed to complete session')
+ }
+ return response.data
+ },
+}
+
+/**
+ * S3 업로드 헬퍼 (presigned URL 사용)
+ * @param {string} uploadUrl - Presigned URL
+ * @param {Blob} audioBlob - 녹음된 오디오
+ */
+export const uploadAudioToS3 = async (uploadUrl, audioBlob) => {
+ const response = await fetch(uploadUrl, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'audio/webm' },
+ body: audioBlob,
+ })
+ if (!response.ok) {
+ throw new Error('Failed to upload audio to S3')
+ }
+ return true
+}
+
+/**
+ * 답변 처리 결과 폴링 헬퍼 함수
+ */
+export const pollForAnswerResult = async (sessionId, questionIndex, options = {}) => {
+ const {
+ maxAttempts = 90,
+ intervalMs = 2000, // 2초 간격
+ onProgress = null // 진행 상태 보고용 콜백
+ } = options
+
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const result = await sessionService.getAnswerStatus(sessionId, questionIndex)
+
+ // 1. 완료 시 결과 반환
+ if (result.status === 'COMPLETED') {
+ return result
+ }
+
+ // 2. 실패 시 에러 발생
+ if (result.status === 'FAILED') {
+ throw new Error(result.message || 'AI 분석 중 오류가 발생했습니다.')
+ }
+
+ // 3. 처리 중일 때 (PROCESSING)
+ if (onProgress) {
+ onProgress({ attempt: attempt + 1, status: result.status })
+ }
+
+ // 대기 후 재시도
+ await new Promise(resolve => setTimeout(resolve, intervalMs))
+ }
+
+ throw new Error('분석 시간이 초과되었습니다. 잠시 후 다시 확인해주세요.')
+}
+
+export default {
+ sessionService,
+ uploadAudioToS3,
+ pollForAnswerResult
+}
\ No newline at end of file
diff --git a/src/domains/profile/pages/ProfilePage.jsx b/src/domains/profile/pages/ProfilePage.jsx
new file mode 100644
index 0000000..2f29b8b
--- /dev/null
+++ b/src/domains/profile/pages/ProfilePage.jsx
@@ -0,0 +1,106 @@
+import { useState, useEffect } from 'react'
+import { Box, Button, Card, CardContent, Container, TextField, Typography, Avatar, Alert } from '@mui/material'
+import { useDispatch, useSelector } from 'react-redux'
+
+// ▼▼▼ [중요] 파일 위치가 바뀌었으니 import 경로도 수정했습니다! ▼▼▼
+// (pages 폴더와 store 폴더가 같은 profile 폴더 안에 형제(sibling) 관계이므로)
+import { updateProfile } from '../store/profileSlice'
+
+const ProfilePage = () => {
+ const dispatch = useDispatch()
+ const { profile, updateLoading } = useSelector((state) => state.profile)
+
+ const [nickname, setNickname] = useState('')
+ const [successMsg, setSuccessMsg] = useState('')
+
+ // 리덕스에 있는 내 정보(profile)가 로드되면 입력창에 채워넣기
+ useEffect(() => {
+ if (profile?.nickname) {
+ setNickname(profile.nickname)
+ }
+ }, [profile])
+
+ const handleSave = async () => {
+ try {
+ // 1. 닉네임 변경 요청 보내기
+ await dispatch(updateProfile({ nickname })).unwrap()
+
+ // 2. 성공 메시지 띄우기
+ setSuccessMsg('닉네임이 변경되었습니다! 로그아웃 후 다시 로그인해주세요.')
+ } catch (error) {
+ alert('변경 실패: ' + error)
+ }
+ }
+
+ return (
+
+
+ 내 프로필 수정
+
+
+
+
+
+ {/* 프로필 이미지 */}
+
+
+ {nickname ? nickname.substring(0, 1).toUpperCase() : 'U'}
+
+
+
+ {/* 이메일 (수정 불가) */}
+
+
+ {/* 닉네임 (수정 가능) */}
+ setNickname(e.target.value)}
+ fullWidth
+ variant="outlined"
+ helperText="채팅방에서 사용할 멋진 닉네임을 지어주세요!"
+ />
+
+ {/* 성공 메시지 */}
+ {successMsg && (
+ {successMsg}
+ )}
+
+ {/* 저장 버튼 */}
+
+
+
+
+
+ )
+}
+
+export default ProfilePage
\ No newline at end of file
diff --git a/src/domains/speaking/services/speakingApi.js b/src/domains/speaking/services/speakingApi.js
deleted file mode 100644
index 4d18b1d..0000000
--- a/src/domains/speaking/services/speakingApi.js
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * Speaking REST API Service
- */
-
-const API_BASE_URL = import.meta.env.VITE_API_URL
-
-export const speakingApi = {
- /**
- * 대화 요청 (음성 또는 텍스트)
- */
- async chat({ sessionId, audio, text, level = 'INTERMEDIATE' }) {
- const token = localStorage.getItem('accessToken')
-
- // sessionId가 null/undefined면 body에서 제외
- const requestBody = {
- ...(sessionId && { sessionId }), // null이 아닐 때만 포함
- ...(audio && { audio }),
- ...(text && { text }),
- level,
- }
-
- console.log('[speakingApi] Request body:', {
- hasSessionId: !!sessionId,
- hasAudio: !!audio,
- hasText: !!text,
- level
- })
-
- const response = await fetch(`${API_BASE_URL}/api/speaking/chat`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`,
- },
- body: JSON.stringify(requestBody),
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.error || '요청 처리에 실패했습니다')
- }
-
- return response.json()
- },
-
- /**
- * 대화 초기화
- */
- async reset(sessionId) {
- const token = localStorage.getItem('accessToken')
-
- const response = await fetch(`${API_BASE_URL}/api/speaking/reset`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'Authorization': `Bearer ${token}`,
- },
- body: JSON.stringify({ sessionId }),
- })
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.error || '초기화에 실패했습니다')
- }
-
- return response.json()
- },
-}
\ No newline at end of file
diff --git a/src/domains/vocab/pages/DailyLearning.jsx b/src/domains/vocab/pages/DailyLearning.jsx
index 0a9a65b..c0d8c86 100644
--- a/src/domains/vocab/pages/DailyLearning.jsx
+++ b/src/domains/vocab/pages/DailyLearning.jsx
@@ -37,7 +37,7 @@ import {useAuth} from '../../../contexts/AuthContext'
import {useThemeMode} from '../../../contexts/ThemeContext'
// 카드 셔플 애니메이션 컴포넌트
-function ShuffleAnimation({count, isKorean}) {
+function ShuffleAnimation({count, isKorean, isDark}) {
return (
)}