diff --git a/src/App.jsx b/src/App.jsx
index 861df19..59c2179 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -46,6 +46,7 @@ import LoginPage from './pages/Login'
import SignUpPage from './pages/SignUp'
import { fetchMyProfile } from "./domains/profile/store/profileSlice";
import ProfilePage from './domains/profile/pages/ProfilePage'
+import OPIcPage from './domains/opic/pages/OPIcPage'
function ProtectedRoute({ children }) {
@@ -666,16 +667,6 @@ function Dashboard() {
)
}
-// Placeholder Pages
-function OpicPage() {
- return (
-
- OPIC Practice
- Level-based training
-
- )
-}
-
function ReportsPage() {
const { isKorean } = useSettings()
@@ -1185,7 +1176,7 @@ function App() {
} />
} />
} />
- } />
+ } />
} />
} />
} />
diff --git a/src/api/opicApi.js b/src/api/opicApi.js
new file mode 100644
index 0000000..b30640c
--- /dev/null
+++ b/src/api/opicApi.js
@@ -0,0 +1,13 @@
+import api from './axios'
+
+const OPIC_TIMEOUT = 60000
+
+const opicApi = {
+ get: (url, config) => api.get(url, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data),
+ post: (url, data, config) => api.post(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data),
+ put: (url, data, config) => api.put(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data),
+ patch: (url, data, config) => api.patch(url, data, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data),
+ delete: (url, config) => api.delete(url, { timeout: OPIC_TIMEOUT, ...config }).then(res => res.data),
+}
+
+export default opicApi
\ No newline at end of file
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..365d912
--- /dev/null
+++ b/src/domains/opic/pages/OPIcPage.jsx
@@ -0,0 +1,814 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import {
+ Alert,
+ Box,
+ Button,
+ Card,
+ CardContent,
+ CircularProgress,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ TextField,
+ Typography,
+ useMediaQuery,
+ useTheme,
+ LinearProgress,
+ Chip,
+ Divider,
+ IconButton,
+} 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,
+} from '@mui/icons-material'
+import { useSettings } from '../../../contexts/SettingsContext'
+import { useAuth } from '../../../contexts/AuthContext'
+import { sessionService, uploadAudioToS3 } 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 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([])
+
+ // Upload state
+ const [uploadUrl, setUploadUrl] = useState(null)
+ const [s3Key, setS3Key] = useState(null)
+
+ // Feedback state
+ const [feedback, setFeedback] = useState(null)
+
+ // 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)
+ }
+
+ // Get next question
+ const handleNextQuestion = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const data = await sessionService.getNextQuestion(sessionId)
+
+ // 모든 질문 완료 확인
+ if (data.completed) {
+ // 세션 완료 처리
+ handleCompleteSession()
+ return
+ }
+
+ // 질문 데이터로 화면 업데이트
+ displayQuestion(data) // data.question → data로 수정
+ setQuestionNumber(data.questionNumber || questionNumber + 1)
+ setTotalQuestions(data.totalQuestions || totalQuestions)
+ } 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)
+
+ // Upload to S3
+ await uploadAudioToS3(uploadUrl, recordedBlob)
+ setUploadProgress(50)
+
+ // Submit answer
+ const data = await sessionService.submitAnswer(sessionId, { audioS3Key: s3Key })
+ setFeedback(data)
+ setUploadProgress(100)
+ } catch (err) {
+ console.error('Failed to submit answer:', err)
+ setError(isKorean ? '답변 제출에 실패했습니다' : 'Failed to submit answer')
+ } finally {
+ setLoading(false)
+ setTimeout(() => setUploadProgress(0), 2000)
+ }
+ }
+
+
+
+ // Complete session
+ const handleCompleteSession = async () => {
+ try {
+ setLoading(true)
+ setError(null)
+ const report = await sessionService.complete(sessionId)
+ // TODO: Navigate to report page or display report
+ console.log('Session completed:', report)
+ alert(
+ isKorean
+ ? '세션이 완료되었습니다! 리포트를 확인하세요.'
+ : 'Session completed! Check your report.'
+ )
+ // Reset for new session
+ setSessionId(null)
+ setCurrentQuestion(null)
+ setQuestionNumber(0)
+ setFeedback(null)
+ } catch (err) {
+ console.error('Failed to complete session:', err)
+ setError(isKorean ? '세션 완료에 실패했습니다' : 'Failed to complete session')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ // 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)
+ }
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {isKorean ? 'OPIc 스피킹 테스트' : 'OPIc Speaking Test'}
+
+
+ {isKorean
+ ? 'AI 기반 OPIc 연습 및 피드백'
+ : 'AI-powered English speaking practice & feedback'}
+
+
+
+
+ {/* Main Content */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Session Setup */}
+ {!sessionId && (
+
+
+
+ {isKorean ? 'OPIc 질문 생성' : 'Session Setup'}
+
+
+ {/* 주제 선택 */}
+
+ {isKorean ? '질문 유형' : 'Question Type'}
+
+
+
+ {/* 세부 주제 (소재) 선택 - 업데이트된 목록 사용 */}
+
+ {isKorean ? '주제' : 'Topic'}
+
+
+
+
+
+ * {isKorean
+ ? `프로필에 설정된 나의 레벨(${user?.level})에 맞춰 AI 피드백이 제공됩니다.`
+ : `AI feedback will be tailored to your current level (${user?.level || 'IM2'}).`}
+
+
+
+
+
+ )}
+
+ {/* Question Area */}
+ {sessionId && currentQuestion && (
+ <>
+
+
+
+
+
+
+
+
+ {currentQuestion.questionText}
+
+
+ {currentQuestion.audioUrl && (
+
+
+ {isPlayingQuestion ? (
+
+ ) : (
+
+ )}
+
+
+ {isKorean ? '질문 듣기' : 'Listen to question'}
+
+
+ )}
+
+
+
+ {/* Recording Area */}
+
+
+
+ {isKorean ? '답변 녹음' : 'Record Answer'}
+
+
+
+ : }
+ onClick={toggleRecording}
+ disabled={feedback !== null}
+ sx={{
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ minWidth: 200,
+ py: 1.5,
+ animation: isRecording
+ ? 'pulse 1.5s ease-in-out infinite'
+ : 'none',
+ '@keyframes pulse': {
+ '0%, 100%': { opacity: 1 },
+ '50%': { opacity: 0.6 },
+ },
+ }}
+ >
+ {isRecording
+ ? isKorean
+ ? '녹음 중지'
+ : 'Stop Recording'
+ : isKorean
+ ? '녹음 시작'
+ : 'Start Recording'}
+
+
+ {isRecording && (
+
+ {formatTime(recordingTime)}
+
+ )}
+
+ {recordedUrl && !isRecording && (
+
+
+ {isPlayingRecorded ? : }
+
+
+
+ {isKorean ? '녹음 완료' : 'Recording Ready'}
+
+
+ {isKorean
+ ? '재생하여 확인하세요'
+ : 'Play to review'}
+
+
+
+ )}
+
+
+
+
+ {/* Submit Area */}
+ {recordedBlob && !feedback && (
+
+
+
+ {!uploadUrl ? (
+ }
+ onClick={handleGetUploadUrl}
+ disabled={loading}
+ sx={{
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ py: 1.5,
+ }}
+ >
+ {loading
+ ? isKorean
+ ? '준비 중...'
+ : 'Preparing...'
+ : isKorean
+ ? 'Upload URL 발급'
+ : 'Get Upload URL'}
+
+ ) : (
+ }
+ onClick={handleSubmitAnswer}
+ disabled={loading}
+ sx={{
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ py: 1.5,
+ }}
+ >
+ {loading
+ ? isKorean
+ ? '제출 중...'
+ : 'Submitting...'
+ : isKorean
+ ? '답변 제출'
+ : 'Submit Answer'}
+
+ )}
+
+ {uploadProgress > 0 && (
+
+ )}
+
+
+ )}
+
+ {/* Feedback Area */}
+ {feedback && (
+
+
+
+ {isKorean ? 'AI 피드백' : 'AI Feedback'}
+
+
+ {/* My Answer (STT) */}
+ {feedback.transcript && (
+
+
+ {isKorean ? '내 답변' : 'Your Answer'}
+
+ {feedback.transcript}
+
+ )}
+
+ {/* Corrected Answer */}
+ {feedback.feedback?.correctedAnswer && (
+
+
+ {isKorean ? '교정된 답변' : 'Corrected Answer'}
+
+ {feedback.feedback.correctedAnswer}
+
+ )}
+
+ {/* Model Answer */}
+ {feedback.feedback?.sampleAnswer && (
+
+
+ {isKorean ? '모범 답변' : 'Model Answer'}
+
+ {feedback.feedback.sampleAnswer}
+
+ )}
+
+ {/* Grammar Errors */}
+ {feedback.feedback?.errors && feedback.feedback.errors.length > 0 && (
+
+
+ {isKorean ? '문법 오류' : 'Grammar Errors'}
+
+ {feedback.feedback.errors.map((error, index) => (
+
+ • {error.type}: {error.original} → {error.corrected}
+
+
+ {error.explanation}
+
+
+ ))}
+
+ )}
+
+
+
+
+ {/* Action Buttons */}
+
+ {feedback.hasNextQuestion ? (
+
+ ) : (
+ }
+ onClick={handleCompleteSession}
+ disabled={loading}
+ sx={{
+ borderRadius: '12px',
+ textTransform: 'none',
+ fontWeight: 600,
+ py: 1.5,
+ }}
+ >
+ {loading
+ ? isKorean
+ ? '완료 중...'
+ : 'Completing...'
+ : isKorean
+ ? '세션 완료'
+ : 'Complete Session'}
+
+ )}
+
+
+
+ )}
+ >
+ )}
+
+
+ )
+}
diff --git a/src/domains/opic/services/opicService.js b/src/domains/opic/services/opicService.js
new file mode 100644
index 0000000..e98ac9f
--- /dev/null
+++ b/src/domains/opic/services/opicService.js
@@ -0,0 +1,112 @@
+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
+ },
+
+ /**
+ * 답변 제출 (STT + AI 피드백)
+ * @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
+ },
+
+ /**
+ * 세션 완료
+ * @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 default {
+ sessionService,
+ uploadAudioToS3,
+}
\ 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