diff --git a/package-lock.json b/package-lock.json index 7c0c646..a46262d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", @@ -4061,6 +4062,11 @@ "node": ">= 0.6" } }, + "node_modules/event-source-polyfill": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/event-source-polyfill/-/event-source-polyfill-1.0.31.tgz", + "integrity": "sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==" + }, "node_modules/eventemitter2": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-5.0.1.tgz", diff --git a/package.json b/package.json index 03f3bac..b3db69e 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", "eslint-plugin-simple-import-sort": "^12.1.1", + "event-source-polyfill": "^1.0.31", "lucide-react": "^0.545.0", "prettier": "^3.6.2", "react": "^19.0.0", diff --git a/src/apis/myPage.ts b/src/apis/myPage.ts index f0c3f84..d532cea 100644 --- a/src/apis/myPage.ts +++ b/src/apis/myPage.ts @@ -1,6 +1,7 @@ import type { TDeleteRequest, TDeleteResponse, + TInterviewSummaryResponse, TMyFeedbackDetailResponse, TMyFeedbackRequest, TMyFeedbackResponse, @@ -10,6 +11,9 @@ import type { TPatchProfileRequest, TPatchProfileResponse, TPatchRequest, + TQuestionAnswersResponse, + TQuestionFeedbackResponse, + TRandomQuestionsResponse, } from '@/types/myPage'; import { axiosInstance } from '@/apis/axiosInstance'; @@ -46,3 +50,24 @@ export const putProfile = async (param: TPatchProfileRequest): Promise('/api/myarchive/myprofile', param); return data; }; + +// 나의 면접 상세 API +export const getInterviewSummary = async (interviewId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/${interviewId}/summary`); + return data; +}; + +export const getQuestionAnswers = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/answers`); + return data; +}; + +export const getQuestionFeedback = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/feedback`); + return data; +}; + +export const getRandomQuestions = async (questionId: number): Promise => { + const { data } = await axiosInstance.get(`/api/myarchive/myinterviews/questions/${questionId}/random-questions`); + return data; +}; diff --git a/src/components/RandomQuestion.tsx b/src/components/RandomQuestion.tsx index 29d63d4..e4878ef 100644 --- a/src/components/RandomQuestion.tsx +++ b/src/components/RandomQuestion.tsx @@ -1,16 +1,26 @@ import { useEffect, useRef, useState } from 'react'; +import { + getRandomQuestion, + subscribeToNotifications, + uploadFeedbackRecordingAndGetResult, + type IRandomQuestion, + type IRandomNotificationPayload, +} from '@/services/randomQuestionApi'; +import clockFrog from '@/assets/clockFrog.svg'; + +type TNotification = IRandomNotificationPayload; +const MAX_TIME = 180; // 팝업 질문 제한 시간(초) export default function RandomQuestion() { const [showPopup, setShowPopup] = useState(false); - const [currentQuestion, setCurrentQuestion] = useState(0); + const [notification, setNotification] = useState(null); + const [questionDetail, setQuestionDetail] = useState(null); + const [loadingQuestion, setLoadingQuestion] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); - // 질문 데이터 - const questions = [ - { id: 1, main: '메인질문', sub: '간단히 자기소개를 해주세요.' }, - { id: 2, main: '메인질문', sub: '이 직무를 선택한 이유는 무엇인가요?' }, - { id: 3, main: '메인질문', sub: '본인의 강점은 무엇이라고 생각하나요?' }, - { id: 4, main: '메인질문', sub: '입사 후 목표는 무엇인가요?' }, - ]; + // ===== 타이머 상태 ===== + const [remainingTime, setRemainingTime] = useState(MAX_TIME); + const countdownTimerRef = useRef(null); // ===== 녹음 상태 ===== const [isRecording, setIsRecording] = useState(false); @@ -22,6 +32,7 @@ export default function RandomQuestion() { const audioChunksRef = useRef([]); const streamRef = useRef(null); const recordTimerRef = useRef(null); + const latestAudioBlobRef = useRef(null); // ===== 재생 상태 ===== const audioRef = useRef(null); @@ -29,19 +40,142 @@ export default function RandomQuestion() { const [playbackTime, setPlaybackTime] = useState(0); const [playbackDuration, setPlaybackDuration] = useState(0); - // ===== 팝업 랜덤 등장 ===== + // 제출 중 상태 + const [isSubmitting, setIsSubmitting] = useState(false); + + // ===== SSE & 요청 취소 컨트롤 ===== + const esRef = useRef(null); + const reconnectTimerRef = useRef(null); + const attemptsRef = useRef(0); + const fetchAbortRef = useRef(null); // ✅ 이전 요청 취소용 + + const clearReconnectTimer = () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + }; + + const scheduleReconnect = (why: string) => { + if (reconnectTimerRef.current) return; + const wait = Math.min(30000, 1000 * Math.pow(2, attemptsRef.current)); + attemptsRef.current += 1; + console.warn(`[SSE] reconnect in ${wait}ms (${why})`); + reconnectTimerRef.current = window.setTimeout(() => { + reconnectTimerRef.current = null; + openSSE(); + }, wait); + }; + + const closeSSE = () => { + clearReconnectTimer(); + if (esRef.current) { + try { + esRef.current.close(); + } catch {} + esRef.current = null; + } + }; + + const fetchRandomQuestion = async (peerFeedbackId: number) => { + // 이전 요청 취소 + fetchAbortRef.current?.abort(); + fetchAbortRef.current = new AbortController(); + + setLoadingQuestion(true); + setErrorMessage(null); + + try { + console.log('[RQ] fetch start', { peerFeedbackId }); + const q = await getRandomQuestion(peerFeedbackId, { + noCache: true, // ✅ 캐시 우회 + signal: fetchAbortRef.current.signal, // ✅ 이전 요청 취소 대응 + }); + console.log('[RQ] fetch ok', q); + setQuestionDetail(q); + } catch (err: any) { + if (err?.name === 'CanceledError' || err?.name === 'AbortError') { + console.log('[RQ] fetch aborted'); + } else { + console.error('[RQ] fetch error', err); + setErrorMessage('팝업 질문을 불러오지 못했습니다.'); + } + } finally { + setLoadingQuestion(false); + } + }; + + const handleMessage = async (event: MessageEvent) => { + // 서버가 보내는 ping/keepalive 등 비 JSON은 무시 + try { + const parsed = JSON.parse(event.data); + if (!parsed || typeof parsed !== 'object' || parsed.peerFeedbackId == null) { + return; + } + const data = parsed as TNotification; + + // 새 팝업 초기화 + setNotification(data); + setShowPopup(true); + setQuestionDetail(null); + setRecordedAudio((prev) => { + if (prev) URL.revokeObjectURL(prev); + return null; + }); + latestAudioBlobRef.current = null; + setRecordingTime(0); + setRemainingTime(MAX_TIME); + + await fetchRandomQuestion(Number(data.peerFeedbackId)); + } catch { + // 무시(keepalive) + } + }; + + const openSSE = () => { + closeSSE(); + attemptsRef.current = 0; + + const es = subscribeToNotifications(handleMessage, (errorEvt) => { + console.error('SSE 연결 오류:', errorEvt); + scheduleReconnect('onerror'); + }); + + (es as any).onopen = () => { + attemptsRef.current = 0; + console.log('[SSE] opened'); + }; + + esRef.current = es; + }; + useEffect(() => { - const timeout: number = window.setTimeout( - () => { - const randomIndex = Math.floor(Math.random() * questions.length); - setCurrentQuestion(randomIndex); - setShowPopup(true); - }, - Math.random() * 5000 + 3000, - ); // 3~8초 - - return () => clearTimeout(timeout); - }, [questions.length]); + const onVis = () => { + if (document.visibilityState === 'visible') { + openSSE(); + } else { + closeSSE(); + } + }; + openSSE(); + document.addEventListener('visibilitychange', onVis); + return () => { + document.removeEventListener('visibilitychange', onVis); + closeSSE(); + fetchAbortRef.current?.abort(); + if (recordedAudio) URL.revokeObjectURL(recordedAudio); + if (recordTimerRef.current) clearInterval(recordTimerRef.current); + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // ===== 유틸 ===== const formatTime = (s: number) => { @@ -51,14 +185,50 @@ export default function RandomQuestion() { return `${m}:${r.toString().padStart(2, '0')}`; }; - const navigateTo = (path: string) => { - try { - window.history.pushState({}, '', path); - window.dispatchEvent(new PopStateEvent('popstate')); - } catch { - window.location.href = path; + // ===== 팝업 전체 제한시간 타이머 ===== + useEffect(() => { + if (!showPopup) { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + return; } - }; + countdownTimerRef.current = window.setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1) { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => { + if (countdownTimerRef.current) { + clearInterval(countdownTimerRef.current); + countdownTimerRef.current = null; + } + }; + }, [showPopup]); + + // 시간 종료 시 부가 처리 + useEffect(() => { + if (!showPopup || remainingTime > 0) return; + if (isRecording && mediaRecorderRef.current) { + try { + mediaRecorderRef.current.stop(); + } catch {} + setIsRecording(false); + setIsPausedRec(false); + } + if (audioRef.current) audioRef.current.pause(); + }, [remainingTime, showPopup, isRecording]); + + const isTimeOver = remainingTime <= 0; // ===== 녹음 타이머 ===== useEffect(() => { @@ -78,11 +248,16 @@ export default function RandomQuestion() { // ===== 녹음 제어 ===== const startRecording = async () => { + if (isTimeOver) { + alert('시간이 종료되어 더 이상 녹음할 수 없습니다.'); + return; + } try { if (recordedAudio) { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); } + latestAudioBlobRef.current = null; setPlaybackTime(0); setPlaybackDuration(0); setIsPlaying(false); @@ -97,17 +272,14 @@ export default function RandomQuestion() { mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) audioChunksRef.current.push(e.data); }; - mediaRecorder.onstop = () => { const mime = mediaRecorder.mimeType || 'audio/webm'; const blob = new Blob(audioChunksRef.current, { type: mime }); + latestAudioBlobRef.current = blob; const url = URL.createObjectURL(blob); setRecordedAudio(url); - - // 스트림 종료 stream.getTracks().forEach((t) => t.stop()); streamRef.current = null; - setIsRecording(false); setIsPausedRec(false); }; @@ -145,6 +317,10 @@ export default function RandomQuestion() { }; const handleRetry = () => { + if (isTimeOver) { + alert('시간이 종료되어 다시 녹음할 수 없습니다.'); + return; + } if (audioRef.current) { audioRef.current.pause(); audioRef.current.currentTime = 0; @@ -152,13 +328,13 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); setPlaybackDuration(0); - if (recordedAudio) { URL.revokeObjectURL(recordedAudio); setRecordedAudio(null); } setRecordingTime(0); - startRecording(); + latestAudioBlobRef.current = null; + void startRecording(); }; // ===== 재생 제어 ===== @@ -179,7 +355,6 @@ export default function RandomQuestion() { useEffect(() => { const el = audioRef.current; if (!el) return; - const onLoadedMeta = () => { setPlaybackDuration(Math.floor(isFinite(el.duration) ? el.duration : 0)); setPlaybackTime(Math.floor(el.currentTime || 0)); @@ -191,15 +366,12 @@ export default function RandomQuestion() { setIsPlaying(false); setPlaybackTime(0); }; - el.addEventListener('loadedmetadata', onLoadedMeta); el.addEventListener('timeupdate', onTimeUpdate); el.addEventListener('play', onPlay); el.addEventListener('pause', onPause); el.addEventListener('ended', onEnded); - if (el.readyState >= 1) onLoadedMeta(); - return () => { el.removeEventListener('loadedmetadata', onLoadedMeta); el.removeEventListener('timeupdate', onTimeUpdate); @@ -209,44 +381,45 @@ export default function RandomQuestion() { }; }, [recordedAudio]); - // ===== 팝업 제어/페이지 이동 ===== + // ===== 팝업 닫기 ===== const handleClose = () => { if (isRecording) stopRecording(); if (audioRef.current) audioRef.current.pause(); setShowPopup(false); }; - const handleGoToInterview = () => { - if (isRecording) stopRecording(); - if (audioRef.current) audioRef.current.pause(); - navigateTo('/upload'); - setShowPopup(false); - }; - const handleNext = () => { - if (currentQuestion < questions.length - 1) { - setCurrentQuestion((i) => i + 1); - } else { - // 마지막이면 면접 페이지로 이동 (기존 동작 유지) - handleGoToInterview(); + // ===== 답변 제출 ===== + const handleSubmit = async () => { + if (isTimeOver) { + alert('시간이 종료되어 답변을 제출할 수 없습니다.'); + return; + } + if (!questionDetail?.question?.questionId) { + alert('질문 정보를 불러오지 못했습니다.'); + return; + } + if (!latestAudioBlobRef.current) { + alert('먼저 답변을 녹음해주세요.'); + return; } - }; - // 정리 - useEffect(() => { - return () => { - if (recordedAudio) URL.revokeObjectURL(recordedAudio); - if (recordTimerRef.current) clearInterval(recordTimerRef.current); - if (streamRef.current) { - streamRef.current.getTracks().forEach((t) => t.stop()); - streamRef.current = null; - } - }; - }, [recordedAudio]); + try { + setIsSubmitting(true); + const feedback = await uploadFeedbackRecordingAndGetResult(questionDetail.question.questionId, latestAudioBlobRef.current); + alert(`AI 피드백이 도착했어요.\n\n${feedback.aiFeedback}`); + setShowPopup(false); + } catch (err) { + console.error('랜덤 팝업 답변 제출 실패:', err); + alert('답변 제출에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } finally { + setIsSubmitting(false); + } + }; if (!showPopup) return null; - const progress = ((currentQuestion + 1) / questions.length) * 100; const playbackPercent = playbackDuration > 0 ? Math.min(100, Math.max(0, (playbackTime / playbackDuration) * 100)) : 0; + const progress = 100; return (
@@ -258,49 +431,64 @@ export default function RandomQuestion() { - {/* 질문 카드 */} -

- {questions[currentQuestion].id}. ({questions[currentQuestion].main}) -

-

- {questions[currentQuestion].id}-1. {questions[currentQuestion].sub} + {/* 헤더 - 알림 정보 */} +

+ {notification ? `${notification.jobName} · ${notification.interviewName} · 질문 ${notification.questionNumber}번` : '랜덤 팝업 질문'}

- - {/* 이미지 */} -
- 면접관 +

랜덤 팝업 질문이 도착했어요 🔔

+ + {/* 질문/맥락 */} + {loadingQuestion ? ( +

질문을 불러오는 중입니다...

+ ) : errorMessage ? ( +

{errorMessage}

+ ) : questionDetail ? ( + <> +
+

맥락이 되는 질문

+

{questionDetail.context.questionText}

+ {questionDetail.context.sttText &&

{questionDetail.context.sttText}

} +
+

{questionDetail.question.questionText}

+ + ) : ( +

질문 정보를 불러오지 못했습니다.

+ )} + +
+ 면접관
- {/* 질문 진행바 */} +

+ {remainingTime > 0 ? `답변 가능 시간이 ${remainingTime}초 남았습니다.` : '시간이 종료되었습니다.'} +

+
-

- 질문 {currentQuestion + 1} / {questions.length} -

+

랜덤 팝업 질문

- {/* 녹음/재생 영역 */}
{!recordedAudio ? ( - // === 녹음 UI ===
{!isRecording ? ( - // 시작 버튼 (마이크 아이콘) ) : ( <> - {/* 일시정지/재개 */} - - {/* 녹음 시간 */} {formatTime(recordingTime)} - - {/* 정지 */} -
- {formatTime(playbackTime)} / {formatTime(playbackDuration)} -
)}
- {/* 버튼 그룹 */}
@@ -393,7 +582,6 @@ export default function RandomQuestion() { .border-coral-500 { border-color: #ff7f66; } .hover\\:bg-coral-50:hover { background-color: #fff5f5; } .hover\\:bg-coral-600:hover { background-color: #ff6b52; } - .hover\\:text-coral-500:hover { color: #ff7f66; } `}
); diff --git a/src/components/myPage/interviewCard.tsx b/src/components/myPage/interviewCard.tsx index 1f66c97..c04a78e 100644 --- a/src/components/myPage/interviewCard.tsx +++ b/src/components/myPage/interviewCard.tsx @@ -1,106 +1,91 @@ import React, { useEffect, useState } from 'react'; import useDeleteInterview from '@/hooks/useDeleteInterview'; -import usePatchInterview from '@/hooks/usePatchInterview'; +import { getInterviewSummary, getRandomQuestions } from '@/apis/myPage'; import Delete from '@/assets/delete.svg?react'; -import Edit from '@/assets/edit.svg?react'; import Frog from '@/assets/frog.svg?react'; -import Check from '@/assets/o.svg?react'; type TCardProps = { id: number; title: string; + createdAt?: string; onClick?: () => void; }; -export default function InterviewCard({ id, title, onClick }: TCardProps) { - const { mutate: updateTitle } = usePatchInterview(); +export default function InterviewCard({ id, title, createdAt, onClick }: TCardProps) { const { mutate: deleteInterview } = useDeleteInterview(); - const [isEditing, setIsEditing] = useState(false); - const [localTitle, setLocalTitle] = useState(title); + const [hasRandomQuestions, setHasRandomQuestions] = useState(false); + // 랜덤 질문 여부 확인 useEffect(() => { - setLocalTitle(title); - }, [title]); + const checkRandomQuestions = async () => { + try { + // 1. 면접 상세 정보 조회 + const summary = await getInterviewSummary(id); + + if (summary.result.questionCards && summary.result.questionCards.length > 0) { + // 2. 첫 번째 질문의 랜덤 질문 조회 + const firstQuestionId = summary.result.questionCards[0].questionId; + const randomQuestionsData = await getRandomQuestions(firstQuestionId); + + // 3. 랜덤 질문이 있으면 true + if (randomQuestionsData.result && randomQuestionsData.result.length > 0) { + setHasRandomQuestions(true); + } + } + } catch (error) { + // 에러 발생 시 무시 (랜덤 질문 없음으로 처리) + console.log('랜덤 질문 확인 중 오류:', error); + } + }; - const handleEditClick = (e: React.MouseEvent) => { - e.stopPropagation(); - setIsEditing(true); - }; - - const confirmEdit = () => { - setIsEditing(false); - if (localTitle === title) { - console.log('제목 안 바뀜. PATCH 안 보냄'); - return; - } - - console.log('PATCH 보냄', { id, localTitle }); - updateTitle({ interviewId: id, title: localTitle }); - }; - - const handleInputKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - e.preventDefault(); - confirmEdit(); - } - if (e.key === 'Escape') { - e.preventDefault(); - setLocalTitle(title); - setIsEditing(false); - } - }; + void checkRandomQuestions(); + }, [id]); const handleDeleteClick = (e: React.MouseEvent) => { e.stopPropagation(); deleteInterview({ sessionId: id }); }; + // 날짜 포맷팅 + const formatDate = (dateString?: string) => { + if (!dateString) return null; + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}년 ${month}월 ${day}일`; + }; + + const formattedDate = formatDate(createdAt); + return (
-
- -
- {isEditing ? ( -
- setLocalTitle(e.target.value)} - onBlur={() => setIsEditing(false)} - onKeyDown={handleInputKeyDown} - onClick={(e) => e.stopPropagation()} - /> - -
- ) : ( - <> -

{title}

- - - )} -
-
- + {/* 삭제 버튼 */} + + {/* 날짜, 랜덤 질문 뱃지 */} +
+ {formattedDate && {formattedDate}} + {hasRandomQuestions && ( + 랜덤질문 + )} +
+ + {/* 메인 콘텐츠 */} +
+ +
+ {/* 희망직군 (수정 불가) */} +

{title}

+
+
); } diff --git a/src/components/myPage/randomQuestionCard.tsx b/src/components/myPage/randomQuestionCard.tsx new file mode 100644 index 0000000..1fae218 --- /dev/null +++ b/src/components/myPage/randomQuestionCard.tsx @@ -0,0 +1,43 @@ +import OrangeFrog from '@/assets/orangeFrog.svg?react'; + +type TRandomQuestionCardProps = { + questionText: string; + createdAt: string; + jobRole?: string; + onClick?: () => void; +}; + +export default function RandomQuestionCard({ questionText, createdAt, jobRole, onClick }: TRandomQuestionCardProps) { + // 날짜 포맷팅 + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const year = date.getFullYear(); + const month = date.getMonth() + 1; + const day = date.getDate(); + return `${year}년 ${month}월 ${day}일`; + }; + + const formattedDate = formatDate(createdAt); + + return ( +
+ {/* 랜덤 질문 뱃지 */} +
+ 랜덤질문 +
+ + {/* 메인 콘텐츠 */} +
+ +
+

{formattedDate}

+ {jobRole &&

{jobRole}

} +

{questionText}

+
+
+
+ ); +} diff --git a/src/hooks/useGetInterviewSummary.ts b/src/hooks/useGetInterviewSummary.ts new file mode 100644 index 0000000..da0bb4d --- /dev/null +++ b/src/hooks/useGetInterviewSummary.ts @@ -0,0 +1,9 @@ +import { getInterviewSummary } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetInterviewSummary(interviewId: number) { + const { data, isLoading, error } = useCoreQuery(['getInterviewSummary', interviewId], () => getInterviewSummary(interviewId), { + enabled: !!interviewId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionAnswers.ts b/src/hooks/useGetQuestionAnswers.ts new file mode 100644 index 0000000..9c051e0 --- /dev/null +++ b/src/hooks/useGetQuestionAnswers.ts @@ -0,0 +1,9 @@ +import { getQuestionAnswers } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionAnswers(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionAnswers', questionId], () => getQuestionAnswers(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetQuestionFeedback.ts b/src/hooks/useGetQuestionFeedback.ts new file mode 100644 index 0000000..cc7c243 --- /dev/null +++ b/src/hooks/useGetQuestionFeedback.ts @@ -0,0 +1,9 @@ +import { getQuestionFeedback } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetQuestionFeedback(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getQuestionFeedback', questionId], () => getQuestionFeedback(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/hooks/useGetRandomQuestions.ts b/src/hooks/useGetRandomQuestions.ts new file mode 100644 index 0000000..84e90e1 --- /dev/null +++ b/src/hooks/useGetRandomQuestions.ts @@ -0,0 +1,9 @@ +import { getRandomQuestions } from '@/apis/myPage'; +import { useCoreQuery } from '@/hooks/customQuery'; + +export default function useGetRandomQuestions(questionId: number | null) { + const { data, isLoading, error } = useCoreQuery(['getRandomQuestions', questionId], () => getRandomQuestions(questionId!), { + enabled: !!questionId, + }); + return { data, isLoading, error }; +} diff --git a/src/pages/Interview/feedback_result.tsx b/src/pages/Interview/feedback_result.tsx index 6a8bb9f..af97f27 100644 --- a/src/pages/Interview/feedback_result.tsx +++ b/src/pages/Interview/feedback_result.tsx @@ -27,13 +27,39 @@ export default function FeedbackResult() { navigate('/upload'); return; } + const fetchFeedback = async () => { try { setIsLoading(true); + console.log('📊 최종 피드백 조회:', sessionId); + const response = await getFinalFeedback(sessionId); - setFeedbackData(response); - const states = response.feedbacks.map((_, idx: number) => ({ id: idx + 1, showAnswer: false })); - setQuestionStates(states); + + console.log('✅ 피드백 응답:', response); + + // feedbackProgressStatus 확인 + if (response.feedbackProgressStatus === 'WORKING') { + // 피드백 생성 중 - 5초 후 재시도 + setTimeout(fetchFeedback, 5000); + return; + } + + if (response.feedbackProgressStatus === 'FAILED') { + setError('피드백 생성에 실패했습니다.'); + setIsLoading(false); + return; + } + + if (response.interviewSummary) { + setFeedbackData(response); + + // 질문 상태 초기화 + const states = response.interviewSummary.questionSummaries.map((_, idx) => ({ + id: idx + 1, + showAnswer: false, + })); + setQuestionStates(states); + } } catch (err) { console.error('❌ 피드백 조회 실패:', err); setError('피드백을 불러오는데 실패했습니다.'); @@ -41,6 +67,7 @@ export default function FeedbackResult() { setIsLoading(false); } }; + void fetchFeedback(); }, [navigate, sessionId]); @@ -64,7 +91,7 @@ export default function FeedbackResult() { ); } - if (error || !feedbackData) { + if (error || !feedbackData || !feedbackData.interviewSummary) { return (
@@ -79,61 +106,69 @@ export default function FeedbackResult() { ); } - const { feedbacks, totalQuestions, timeoutCount } = feedbackData; + const { interviewSummary } = feedbackData; return (

- 총 {totalQuestions}문항에 대한 최종 피드백 + {interviewSummary.interviewTitle}에 대한 최종 + 피드백

- {timeoutCount > 0 && ( + {interviewSummary.timeoutQuestionNumber > 0 && (

시간 초과로 답변하지 못한 질문{' '} - {timeoutCount}개 + {interviewSummary.timeoutQuestionNumber}개

)}
- {feedbacks.map((item, index: number) => { + {interviewSummary.questionSummaries.map((summary, index) => { const isShowingAnswer = questionStates.find((q) => q.id === index + 1)?.showAnswer || false; - const isPositive = item.feedbackType === 'positive'; - const feedbackTypeLabel = isPositive ? 'AI 피드백(긍정)' : 'AI 피드백(개선)'; - const hasAnswer = !!item.answer && item.answer.trim().length > 0; + + // AI 피드백과 셀프 피드백 중 표시할 것 선택 + const feedbackText = summary.aiFeedback || summary.selfFeedback; + const feedbackType = summary.aiFeedback ? 'AI 피드백' : summary.selfFeedback ? '셀프 피드백' : '피드백 없음'; + + // 답변 텍스트 (Q&A 턴에서 ANSWER만 추출) + const answerTurns = summary.qnaTurns.filter((turn) => turn.turn === 'ANSWER'); + const hasAnswer = answerTurns.length > 0; return ( -
+
+ {/* 카드 헤더 */}

- {index + 1}. {item.question} + {summary.questionNumber}. {summary.rootQuestion}

-

{feedbackTypeLabel}

+

{feedbackType}

+ {/* 카드 내용 (스크롤 가능) */}
{isShowingAnswer ? ( hasAnswer ? (
-
-

답변:

-

{item.answer}

-
+ {summary.qnaTurns.map((turn, turnIndex) => ( +
+

{turn.turn === 'QUESTION' ? '질문:' : '답변:'}

+

{turn.content}

+
+ ))}
) : ( -

{item.timeout ? '시간 초과로 답변하지 못했습니다.' : '답변이 제공되지 않았습니다.'}

+

시간 초과로 답변하지 못했습니다.

) - ) : item.feedback ? ( -

{item.feedback}

+ ) : feedbackText ? ( +

{feedbackText}

) : (

피드백이 생성되지 않았습니다.

)}
+ {/* 버튼 */}
); diff --git a/src/pages/Interview/main_answer.tsx b/src/pages/Interview/main_answer.tsx index 9ca8d29..1ed0bf6 100644 --- a/src/pages/Interview/main_answer.tsx +++ b/src/pages/Interview/main_answer.tsx @@ -5,6 +5,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; import type { IQuestion } from '@/services/interviewApi'; import { sendTimeout, uploadRecordingAndGetNext } from '@/services/interviewApi'; +import clockFrog from '@/assets/clockFrog.svg'; export default function AnswerQuestion() { const navigate = useNavigate(); @@ -41,7 +42,6 @@ export default function AnswerQuestion() { const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); const latestAudioBlobRef = useRef(null); - const timerRef = useRef(null); // 재생 const audioRef = useRef(null); @@ -74,11 +74,11 @@ export default function AnswerQuestion() { setRecordedAudioUrl(null); latestAudioBlobRef.current = null; setRecordingTime(0); - setRemainingTime(180); + setRemainingTime(180); // 다음 질문용 기본 180초 setRetryCount(1); } - /** ---------------- 녹음 중지 / 타임아웃 핸들러 (deps 안전) ---------------- */ + /** ---------------- 녹음 중지 ---------------- */ const stopRecording = useCallback(() => { if (mediaRecorderRef.current && isRecording) { try { @@ -89,52 +89,67 @@ export default function AnswerQuestion() { setIsRecording(false); setIsPaused(false); } - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } }, [isRecording]); - const handleTimeout = useCallback(async (questionId: string) => { + /** ---------------- 시간초과 처리 ---------------- */ + const handleTimeout = async (questionId: string) => { try { - await sendTimeout(questionId); - // 현재 스펙에선 sendTimeout이 다음 질문을 주지 않으므로 종료 처리 + const next = await sendTimeout(questionId); // ← 다음 질문 시도 resetForNext(); - setShowCompleteModal(true); + applyNext(next); // ← next가 있으면 다음으로, 없으면 내부에서 완료 모달 } catch (e) { console.error('시간초과 처리 실패:', e); - alert('시간초과 처리에 실패했습니다.'); + alert('시간이 초과되었습니다. 다음 질문으로 넘어갑니다.'); } - }, []); + }; - /** ---------------- 타이머 ---------------- */ + /** ---------------- 녹음 시간 타이머 (녹음 중일 때만 증가) ---------------- */ useEffect(() => { if (isRecording && !isPaused) { - timerRef.current = window.setInterval(() => { + const id = window.setInterval(() => { setRecordingTime((prev) => prev + 1); - setRemainingTime((prev) => { - if (prev <= 1) { - stopRecording(); - if (currentQuestion?.questionId) { - void handleTimeout(currentQuestion.questionId); - } - return 0; - } - return prev - 1; - }); }, 1000); - } else if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; + + return () => { + clearInterval(id); + }; } + // 녹음 중이 아니면 타이머 없음 + return undefined; + }, [isRecording, isPaused]); + + /** ---------------- 180초 카운트다운 (질문 뜨는 순간부터 시작) ---------------- */ + useEffect(() => { + // 질문이 없거나, 이미 완료 모달이 떠 있으면 타이머 돌리지 않음 + if (!currentQuestion || showCompleteModal) return; + + // 질문이 화면에 노출되는 순간부터 1초마다 remainingTime 감소 + const id = window.setInterval(() => { + setRemainingTime((prev) => { + if (prev <= 1) { + clearInterval(id); + + // 녹음 중이면 강제로 정지 + if (isRecording) { + stopRecording(); + } + + if (currentQuestion?.questionId) { + void handleTimeout(currentQuestion.questionId); + } + + return 0; + } + return prev - 1; + }); + }, 1000); + + // 질문이 바뀌거나, 컴포넌트 언마운트 시 타이머 정리 return () => { - if (timerRef.current) { - clearInterval(timerRef.current); - timerRef.current = null; - } + clearInterval(id); }; - }, [isRecording, isPaused, currentQuestion?.questionId, stopRecording, handleTimeout]); + }, [currentQuestion?.questionId, isRecording, stopRecording, handleTimeout, showCompleteModal]); /** ---------------- 녹음 제어 ---------------- */ const startRecording = async () => { @@ -172,9 +187,8 @@ export default function AnswerQuestion() { setIsPaused(false); setRecordedAudioUrl(null); - // 타이머 초기화 + // 녹음 시간은 새로 시작 setRecordingTime(0); - setRemainingTime(180); } catch (error) { console.error('마이크 접근 오류:', error); alert('마이크 접근 권한이 필요합니다.'); @@ -351,7 +365,7 @@ export default function AnswerQuestion() { {/* 캐릭터 이미지 */}
- 면접관 + 면접관
{/* 타이머 & 녹음 컨트롤 */} diff --git a/src/pages/Interview/question_loading.tsx b/src/pages/Interview/question_loading.tsx index b19f35c..fbc59f5 100644 --- a/src/pages/Interview/question_loading.tsx +++ b/src/pages/Interview/question_loading.tsx @@ -1,11 +1,12 @@ -import { useEffect } from 'react'; +// src/pages/Interview/question_loading.tsx +import { useEffect, useRef } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import InterviewLayout from '@/layouts/InterviewLayout'; import type { ICreateInterviewSessionResponse } from '@/services/interviewApi'; import { createInterviewSession } from '@/services/interviewApi'; -const ANSWER_ROUTE = '/main-answer'; // 프로젝트 라우트에 맞게 조정 +const ANSWER_ROUTE = '/main-answer'; type TLocationState = { fileName?: string; @@ -18,7 +19,14 @@ export default function QuestionLoading() { const navigate = useNavigate(); const location = useLocation() as { state?: TLocationState }; + // ✅ 부트스트랩이 중복 실행되는 것을 막기 위한 ref 가드 + const bootstrappedRef = useRef(false); + useEffect(() => { + // 이미 실행된 적 있으면 다시 실행하지 않음 + if (bootstrappedRef.current) return; + bootstrappedRef.current = true; + const bootstrap = async () => { try { const fileName = location.state?.fileName ?? '자소서'; @@ -32,14 +40,14 @@ export default function QuestionLoading() { return; } - // 스펙에 맞게 요청 + // 스펙에 맞게 세션 생성 요청 const resp: ICreateInterviewSessionResponse = await createInterviewSession({ resumeKey, jobTitle, interviewType, }); - // 성공 → 답변 페이지로 이동 (필요값 전달) + // ✅ 성공 → 답변 페이지로 이동 navigate(ANSWER_ROUTE, { replace: true, state: { @@ -60,7 +68,8 @@ export default function QuestionLoading() { }; void bootstrap(); - }, [location.state, navigate]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [navigate]); // location.state는 초기 한 번만 쓰고, 재실행을 막기 위해 deps에서 제외 return ( @@ -83,8 +92,13 @@ export default function QuestionLoading() { ); diff --git a/src/pages/Interview/upload.tsx b/src/pages/Interview/upload.tsx index 6f9baa5..ad8fefb 100644 --- a/src/pages/Interview/upload.tsx +++ b/src/pages/Interview/upload.tsx @@ -4,6 +4,7 @@ import { Upload } from 'lucide-react'; import InterviewLayout from '@/layouts/InterviewLayout'; import { uploadResume } from '@/services/interviewApi'; +import orangeFrog from '@/assets/orangeFrog.svg'; const ALLOWED_EXTENSIONS = ['.pdf', '.docx']; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -148,7 +149,7 @@ export default function MyInterview() {
- 리뷰캐릭터 + 리뷰캐릭터