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'} + + + + + + {isRecording && ( + + {formatTime(recordingTime)} + + )} + + {recordedUrl && !isRecording && ( + + + {isPlayingRecorded ? : } + + + + {isKorean ? '녹음 완료' : 'Recording Ready'} + + + {isKorean + ? '재생하여 확인하세요' + : 'Play to review'} + + + + )} + + + + + {/* Submit Area */} + {recordedBlob && !feedback && ( + + + + {!uploadUrl ? ( + + ) : ( + + )} + + {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 ? ( + + ) : ( + + )} + +
+
+ )} + + )} +
+
+ ) +} 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