diff --git a/docs/company-interviews.md b/docs/company-interviews.md new file mode 100644 index 00000000..cc696326 --- /dev/null +++ b/docs/company-interviews.md @@ -0,0 +1,64 @@ +# 기업 선택·생성 복구·피드백 + +기준: 2026-10-19 사용자 제공 「기업 데이터셋 구현 명세 — 00. 공통 계약」·「01. Web」. +기반 커밋: `34ac21b` (기업 선택 임시 화면, PR #163), 원격 `dev` 확인: `6f10536`. +이슈: #166. 의존 PR #163을 먼저 병합한다. + +## 화면과 API + +`/main/setting/interview` 또는 `/main/setting/interview/multi` → `/main/setting/interview/company?draftId=` → `/main/interview/:id?draftId=`. + +사전 설정은 검증·초안 저장·다음 단계 이동만 한다. 최종 시작에서 기존 페르소나 저장 로직으로 생성 DTO를 만들고, 선택한 경우에만 `companyId`를 추가한다. 면접 생성은 `submitSetupDraft` 한 곳에서만 호출한다. + +- 목록: `GET /api/companies`. 검색 300ms 디바운스·최대 30자, 1자는 q 생략, 전체 분류는 category 생략. 요청 취소와 취소 여부 검사로 이전 결과가 현재 결과를 덮지 않는다. +- 단건 복구: `GET /api/interviews/get?interviewId=…`. 사용자·면접 ID 확인 후 종료 상태를 처리한다. +- 준비: 기존 `GET /api/questions/tailor?interviewId=…`, `POST /api/interviews/:id`, `POST /api/interviews/:id/preparation/retry`. READY와 chatDelivered를 확인한 뒤 진행 화면에 진입한다. 준비 실패로 create를 다시 호출하지 않는다. +- 기업 배지: 단건 면접 API의 snapshot 배지를 진행 화면에 전달한다. 이력과 피드백도 API 배지만 사용한다. + +## 초안과 제출 + +Zustand 메모리 + 현재 탭 sessionStorage의 `repit:interview-setup::`에 저장한다. 초기화·복원·변경 시 설정 enum·면접관·역할·중복·safe integer·UUID·제출 본문을 검사하고 알려진 필드만 보관한다. 토큰·문서 바이트·답변 원문을 저장하지 않는다. 해당 사용자의 로그아웃·사용자 변경 시 이 기능의 초안만 제거한다. + +미제출 초안은 2시간 후 만료한다. 생성 ID가 있는 초안과 결과 불명인 제출은 TTL로 제거하지 않는다. 불명인 요청을 만료시키고 새 키로 생성하면 중복 면접이 될 수 있기 때문이다. 완료 면접은 피드백으로, 중단된 면접은 명시적 새 면접 안내로 복구한다. + +최종 본문과 UUID `Idempotency-Key`를 **POST 전에 저장**한다. 저장 실패 시 POST하지 않는다. 연타·같은 탭에서 재진입한 제출은 같은 promise를 공유한다. 타임아웃·네트워크 실패·409는 본문과 키를 보존하며 설정 편집을 잠근다. 새로고침의 SUBMITTING/UNKNOWN도 같은 본문·키로만 재전송한다. COMPANY_UNAVAILABLE/COMPANY_CONTEXT_UNAVAILABLE 또는 확정된 동기 검증 실패는 선택을 유지하고 편집으로 돌아간다. + +기술 가정: 생성 요청 타임아웃은 30초, 조회는 15초, 준비 확인은 기존 런타임과 같은 최대 20분·기본 1초/질문 대기 3초 폴링을 재사용한다. 서버 retryAfterMs보다 빨리 조회하지 않으며, 대기 초과 시 수동 상태 확인을 제공한다. 새 면접은 draftId 없는 명시적 설정 진입에서 시작한다. 예전 완료 초안을 자동으로 쓰지 않는다. + +## 기업 코멘트 + +기업 선택 없음은 섹션을 숨긴다. company가 있으면서 null·유효하지 않은 companyFit이면 ‘기업 관점 코멘트를 제공하지 못했습니다’를 표시한다. 점수·그래프·합격 예측을 추가하지 않는다. + +공통 상한(총평 300자, 목록별 3항목, factId 32자, focus 20자, 근거/제안 150자)은 Unicode code point로 검사한다. 긴 결과를 잘라 이어 붙이지 않는다. 같은 목록의 factId는 첫 유효 항목을 사용한다. 문항 ID는 `String(feedbacks[].questionId)`와 불투명 문자열로 비교하고 표시 번호는 실제 피드백 배열 순서를 쓴다. 유효 문항이 없는 항목은 숨기며, 원래 있던 항목이 전부 제거되면 총평도 숨긴다. 처음부터 두 목록이 빈 정상 결과는 총평만 표시한다. + +종합·상세 화면과 기존 PDF 캡처 영역에 같은 기업 섹션을 포함한다. 링크는 `questionId` query로 실제 문항·답변을 선택하며 꼬리질문도 동일하게 처리한다. + +## 검증 + +```sh +npm run lint +npm run build +node --test tests/*.test.mjs +PLAYWRIGHT_MODULE_PATH=/absolute/path/to/playwright/index.mjs INTERVIEW_TEST_ORIGIN=http://127.0.0.1:5174 node tests/company-setup.browser.mjs +``` + +Playwright는 기존 브라우저 검사처럼 외부 설치 경로를 지정할 수 있으며 새 패키지는 추가하지 않는다. `COMPANY_SCREENSHOT_DIR`로 화면 저장 위치를 지정한다. + +기업 검사는 API 응답 대역을 사용한다. 초안 훼손·만료·소유자 변경·저장 실패, SOLO/MULTI 기업 유무, 최종 연타, SUBMITTING/UNKNOWN 새로고침, 같은 본문·키 유지, 검색 응답 역전, 목록 0개/장애, 검색 밖/숨김 기업 유지, 모바일·키보드, 준비 실패 재시도·완료 복구, snapshot 이름·꼬리질문·PDF를 검사한다. + +## 서버 선행 조건과 검증 한계 + +로컬 API 소스의 기업 목록·snapshot 배지·companyFit DTO와 기존 준비 endpoint를 확인했다. 다만 현재 확인한 API `InterviewController`에는 Idempotency-Key 처리 코드가 없다. 공통 계약의 **서버 중복 방지 구현과 계약 테스트를 먼저 적용해야 한다**. Web의 고정 키 재전송만으로 서버 중복 생성이 방지되는 것은 아니다. + +공개·검수 완료 기업 필터링, 숨김·snapshot 불가 오류 코드, 배포된 API/Analysis/Chat의 수신·저장·실제 사용도 서버 계약 테스트와 실제 연동으로 확인해야 한다. 이번 Web 작업에서는 실제 서버에 면접을 생성하거나 배포하지 않았다. 검수 데이터 완성·실제 모델 정확도·서버 실연동을 완료한 것으로 보지 않는다. + + +## 확인 결과 + +- `npm run lint`: 오류 0, 기존 router.tsx의 사용하지 않는 eslint-disable 경고 1개. +- `npm run build`: 통과 (TypeScript project build 포함). +- `node --test tests/*.test.mjs`: 32개 통과. +- 기업 설정 브라우저: 18개 시나리오 통과. +- 기존 면접 복구: 10개, 피드백 폴링: 11개, 녹화·종료·업로드: 22개 시나리오 통과. +- 녹화 검사의 기존 설정 버튼명 2곳을 현재 화면의 ‘텍스트 답변’·‘영상/음성 답변’에 맞췄다. +- 자체 리뷰: 생성 호출 단일화, POST 이전 저장, 소유자 변경 시 정리, 서버 준비 상태 확인, 문자열 문항 연결을 점검했다. diff --git a/docs/feedback-regrade.md b/docs/feedback-regrade.md new file mode 100644 index 00000000..0730fbdc --- /dev/null +++ b/docs/feedback-regrade.md @@ -0,0 +1,46 @@ +# 실패한 피드백 재채점 + +- 이슈: #153 +- 기준: `dev` `bb60220` (#147의 재채점 수정 요청 후속) +- 범위: 종합·상세 피드백의 FAILED 재시도. 서버 계약 변경 없음. + +## 사용자 흐름 + +1. 피드백 화면 진입 시 기존 결과를 조회하고, 결과가 없으면 생성을 요청한다. +2. FAILED의 "다시 시도"는 `POST /api/feedbacks?interviewId=…`로 재채점을 요청한다. +3. 접수 중에는 버튼을 비활성화하고 "요청 중…"을 표시한다. +4. PENDING으로 접수되면 기존 5초 폴링을 새 5분 기한으로 시작한다. +5. 완료되면 결과를 표시하고, 실패하면 다시 시도할 수 있다. + +409 응답은 다른 요청이 먼저 접수됐거나 결과가 이미 완성된 경우로 보고 한 번 조회한다. +조회 결과가 PENDING이면 폴링하고, SUCCEEDED이면 결과를 표시한다. +조회 결과가 없으면(404) FAILED를 유지하고 상태 확인 실패 문구를 표시한다. 다시 누르면 재채점을 요청한다. +그 밖의 재요청 오류는 FAILED를 유지하면서 새 오류 메시지를 우선 표시한다. + +## 요청과 화면 수명 + +- 최초 조회·생성의 중복 방지와 POST의 중복 방지를 각각 유지한다. +- 최초 생성과 명시적 재채점은 같은 POST 요청 함수를 사용한다. +- 같은 면접의 POST가 진행 중이면 기존 Promise를 공유한다. +- 빠른 연속 클릭은 훅에서도 즉시 차단한다. +- 면접 변경·재조회·화면 이탈로 무효가 된 응답은 화면에 반영하지 않는다. + +면접 정보 조회 오류의 재시도와 피드백 목록 재조회는 기존 동작을 유지한다. +5분 지연 상태의 버튼도 우선 재조회하며, API가 FAILED로 정리한 뒤 다시 눌러야 재채점된다. + +## 검증 + +```sh +npm run lint +npm run build +# 별도 터미널에서 npm run dev 실행 후 +node tests/feedback-polling.browser.mjs +``` + +외부 Playwright는 `PLAYWRIGHT_MODULE_PATH`, 브라우저 실행 파일은 +`BROWSER_EXECUTABLE_PATH`, 서버 주소는 `INTERVIEW_TEST_ORIGIN`으로 지정할 수 있다. +API는 모의 응답으로 검증하며 실제 분석 서버를 호출하지 않는다. + +브라우저 검증 범위: 종합·상세 재채점 완료, 409 후 진행 중·완료·결과 없음 분기, +500 후 오류 표시·재요청, 중복 클릭·요청 중 버튼 잠금, 최초 생성, +접수 응답 FAILED, 면접 전환 뒤 늦은 응답 무시, 기존 폴링·기한·화면 이탈. diff --git a/docs/feedback-visibility.md b/docs/feedback-visibility.md new file mode 100644 index 00000000..a2aca289 --- /dev/null +++ b/docs/feedback-visibility.md @@ -0,0 +1,47 @@ +# 종합 피드백 점수·섹션 표시 (#168) + +`scoreBreakdown.axes`를 축 이름으로 연결합니다. 순서는 의도 충족(INTENT), 깊이(DEPTH), 구체성(SPECIFICITY), 정확성(ACCURACY)이며, 종합 점수는 기존 `totalScore`를 그대로 씁니다. + +- 0점은 0점입니다. `score:null, weight:null`이 명시된 축은 “해당 없음”, 누락·잘못된 값·중복 축은 “미제공”입니다. +- 옛 응답의 의도 점수만 `intentAlignmentScore`로 보완합니다. 해당 없음은 덮어쓰지 않습니다. +- 분석 완료 결과에 고정 “분석 대기”를 만들지 않습니다. 점수 없는 결과를 조회한다고 재채점하지 않습니다. + +| 유형 | 답변 방식 | 면접관별 코멘트 | 음성 영역 | +|---|---|---|---| +| SOLO | TEXT | 숨김 | 숨김·음성 요청 없음 | +| SOLO | VOICE | 숨김 | 표시 | +| MULTI | TEXT | 표시 | 숨김·음성 요청 없음 | +| MULTI | VOICE | 표시 | 표시 | + +면접 유형은 면접 조회의 mode 우선, 누락 시 피드백 mode를 씁니다. 둘 다 없으면 면접관별 섹션을 숨깁니다. 빈 personas 개수로 유형을 추측하지 않습니다. + +답변 방식은 [API 이슈 #107](https://github.com/Alpha-origin/Repit-api-server/issues/107)의 `answerMode: TEXT | VOICE | null` 계약입니다. 새 제출에만 설정 값을 추가하며 저장된 UNKNOWN/SUBMITTING 본문·요청키에는 새 필드를 주입하지 않습니다. 생성·재진입에서도 서버 값을 보존합니다. + +기존 null 기록은 `/api/interviews/{id}/audio-analysis`의 녹음 답변 목록을 보조 근거로 씁니다. 완료 응답의 빈 녹음 목록은 섹션을 숨기지만 기록 자체를 TEXT로 보정하지 않습니다. 업로드가 전부 실패한 옛 VOICE 기록도 구분할 수 없는 한계가 있습니다. 조회 오류는 텍스트로 단정하지 않고 재확인 버튼을 표시합니다. + +음성 조회는 PENDING에만 5초 간격·최대 5분 진행하며 화면 이탈·ID 변경 시 취소합니다. 완료/부분/결과 없음/실패/조회 오류를 구분합니다. 원시 지표에서 임의 100점 점수를 만들지 않고 음성 점수·코멘트는 준비 중으로 안내합니다. 본문 채점은 음성 조회를 기다리지 않습니다. 키워드·신뢰성·PDF 기능은 TEXT에서도 유지합니다. + +## 검증 (2026-10-19) + +- `node --test tests/*.test.mjs`: 40개 통과. +- `npm run lint`: 오류 0, 기존 router.tsx unused eslint-disable 경고 1개. +- `npm run build`: 통과. +- 실제 브라우저 + 독립 HTTP fixture: SOLO/MULTI × TEXT/VOICE, 새로고침, null 기록의 녹음 있음/없음, 503 및 재확인, PENDING→READY 자동 갱신, PENDING 도중 이탈 확인. +- fixture 요청 기록: TEXT 4201·4203의 음성 API 0회, VOICE 4202·4204 각 1회, 오류 4207 재확인 포함 2회, PENDING→READY 4208은 2회, 화면 이탈한 4209는 1회에서 중단. +- SOLO TEXT PDF 다운로드 후 Poppler로 A4 1쪽 렌더링: 네 축과 키워드·신뢰성 유지, 면접관·음성 섹션 없음, 겹침·잘림 없음. +- 자체 리뷰: 축 null/0 구분, 필드 없는 구형 제출 본문 불변, effect 취소와 stale 응답 차단, PDF의 숨김 조건 확인. + +fixture 실행: + +```sh +node tests/feedback-visibility-fixture.mjs +# http://127.0.0.1:5183/__feedback-fixture +``` + +실제 서버·유료 모델을 사용하지 않는 테스트 데이터입니다. 실서버를 연결한 별도 포트에서 기존 56번 결과를 열었으나 로그인 화면으로 이동하여 **수정 후 실서버 재면접·실모델 연동은 미검증**입니다. 실행 중 localhost:5173과 DB를 변경하지 않았습니다. + +선행 Web #167, API #106·#107 변경 통합 후 실제 환경에서 새 텍스트 면접을 확인해야 합니다. 점수 생성·가중치·Analysis·Chat 계약은 변경하지 않습니다. + +![SOLO TEXT 화면](images/feedback-visibility/solo-text.png) + +![내보낸 PDF 렌더링](images/feedback-visibility/solo-text-pdf.png) diff --git a/docs/images/company-interviews/desktop.png b/docs/images/company-interviews/desktop.png new file mode 100644 index 00000000..1a3637a8 Binary files /dev/null and b/docs/images/company-interviews/desktop.png differ diff --git a/docs/images/company-interviews/feedback.png b/docs/images/company-interviews/feedback.png new file mode 100644 index 00000000..9b5a56b6 Binary files /dev/null and b/docs/images/company-interviews/feedback.png differ diff --git a/docs/images/company-interviews/mobile.png b/docs/images/company-interviews/mobile.png new file mode 100644 index 00000000..26afd5dc Binary files /dev/null and b/docs/images/company-interviews/mobile.png differ diff --git a/docs/images/feedback-visibility/solo-text-pdf.png b/docs/images/feedback-visibility/solo-text-pdf.png new file mode 100644 index 00000000..76f72c93 Binary files /dev/null and b/docs/images/feedback-visibility/solo-text-pdf.png differ diff --git a/docs/images/feedback-visibility/solo-text.png b/docs/images/feedback-visibility/solo-text.png new file mode 100644 index 00000000..590c4665 Binary files /dev/null and b/docs/images/feedback-visibility/solo-text.png differ diff --git a/docs/interview-recording.md b/docs/interview-recording.md new file mode 100644 index 00000000..02ef40f5 --- /dev/null +++ b/docs/interview-recording.md @@ -0,0 +1,85 @@ +# 면접 녹화와 업로드 + +## 답변 방식 + +1:1·다중 면접 모두 사전설정에서 `음성/영상 면접` 또는 `단순 텍스트 면접`을 선택한다. +선택은 `PreparedInterviewData.answerMode`로 라우터 상태에 보관하며, 서버의 `SOLO`/`MULTI` 면접 구성과는 별개다. +면접 내부에서는 전환할 수 없다. 텍스트 면접은 카메라·마이크 권한을 요청하거나 녹화하지 않는다. +음성/영상 면접에서 권한을 거부하면 답변 시작이 비활성화된다. 종료 후 사전설정에서 텍스트 면접을 선택할 수 있다. + +## 파일과 수명 + +- 면접 준비와 카메라·마이크 준비가 모두 완료되면 전체 영상·음성 녹화를 한 번 시작한다. +- 각 답변 시작 시 오디오 트랙만 복제하고 당시 질문 번호를 보관한다. +- 답변 완료 버튼은 음성 파일 확정 → 업로드 시작 → 전사 답변 제출 순서다. +- 210초 자동 완료도 동일 경로를 사용하며 `endReason=timeout`을 보낸다. 분석 서버의 기본 길이 제한은 240초다. +- 그만두기·헤더 메뉴·뒤로가기·로그아웃은 진행 중 답변을 `interrupted`로 확정하고 전체 영상을 마무리한다. +- HTTP·소켓 정상 완료는 같은 종료 Promise를 공유한다. 전체 녹화 업로드와 장치 해제는 한 번만 실행한다. +- 답변은 WebM/MP4(Ogg 대체), 전체 영상은 MP4/WebM 중 지원 형식으로 녹화한다. 파일의 MIME과 확장자는 실제 컨테이너를 따른다. 답변 음성의 MP4 컨테이너 확장자는 `.m4a`다. +- 원본 오디오는 재인코딩하지 않는다. 요청 비트레이트는 음성 128kbps, 영상 1Mbps다. +- 복제 트랙은 녹화 정지·오류 시 해제한다. 원본 트랙은 세션 종료·페이지 해제 시 해제한다. + +`POST /api/interviews/{interviewId}/recordings`의 multipart 필드: + +| kind | 필드 | +| --- | --- | +| `ANSWER` | `file`, `kind`, `questionId`, `endReason` | +| `FULL_INTERVIEW` | `file`, `kind` | + +업로드는 기존 인증 axios 클라이언트를 사용한다. 전체 영상에는 `questionId`와 `endReason`을 보내지 않는다. + +## 업로드 상태 + +앱 범위의 작업 큐가 이동 이후에도 진행·완료·실패를 표시한다. 성공하거나 사용자가 폐기한 파일은 메모리 참조를 해제한다. +실패한 파일은 메모리에 보관하고 사용자가 다시 업로드하거나 폐기할 수 있다. 자동 재시도는 하지 않는다. +로그아웃은 세션 종료와 진행 중 업로드를 기다린 뒤 토큰을 지운다. 실패 파일이 있으면 인증을 유지하고 복구·폐기를 안내한다. + +녹화 중, 파일 확정 중 또는 저장되지 않은 파일이 있는 동안 새로고침·탭 닫기에 `beforeunload` 경고를 요청한다. +브라우저 강제 종료 이후의 파일 복구는 제공하지 않는다. 장시간 전체 영상도 업로드 전까지 메모리에 보관한다. +서버가 파일 업로드에 멱등 키를 제공하지 않으므로, 서버 저장 이후 응답만 유실된 요청을 사용자가 재시도하면 중복 파일이 생길 수 있다. +전체 영상의 분석·다시보기 활용 정책과 사용자 조회 API는 별도 작업이다. + +## 음성 분석 피드백 연결 조건 + +현재 로컬 API의 `AudioAnalysisController`는 `/api/analyses/audio/callback` 저장만 제공한다. +사용자 조회 엔드포인트를 추측하거나 Analysis 서버에 브라우저가 직접 접근하지 않는다. +API 서버에서 면접 소유권을 검사하는 조회 API와 질문·답변·녹음 식별자 계약을 제공한 뒤 피드백 훅·위젯을 연결한다. + +조회 계약은 다음 사항을 포함해야 한다: + +- 전체 및 답변별 `PENDING`, `READY`, `PARTIAL`, `UNAVAILABLE`, `FAILED` 상태. +- 질문·답변·녹음 식별자와 최신 분석 요청 식별자. +- 말 속도, 침묵, 속도 변동, 간투사·반복 후보, 항목별 측정 불가 이유 및 오류. +- `PARTIAL`은 일부 결과가 제공된 **완료 상태**다. 폴링은 `PENDING`에만 적용한다. +- 음향적 말더듬기 검출은 현재 제공되지 않는다. 유창성 후보에 임의 점수를 붙이지 않는다. + +## 검증 + +`npm run lint`, `npm run build`를 실행한다. +브라우저 회귀 검증은 `npm run dev` 실행 후, 사용 가능한 Playwright 런타임과 Chromium으로 아래 스크립트를 실행한다. +프로젝트 패키지는 추가하지 않았다. 외부 런타임을 사용할 때 `PLAYWRIGHT_MODULE_PATH`에 해당 `playwright/index.mjs` 절대 경로를 지정한다. + +```sh +node tests/interview-recording.browser.mjs +node tests/interview-persona.browser.mjs +node tests/question-audio.browser.mjs +``` + +`INTERVIEW_TEST_ORIGIN`으로 개발 서버 주소를 변경할 수 있다(기본 `http://127.0.0.1:5173`). +서버·소켓·전사는 모의하며 Chromium의 실제 MediaRecorder와 가상 카메라·마이크로 파일을 생성한다. +1:1·다중 답변 완료, 자동 완료, 진행 중 종료, 헤더 이동, 뒤로가기, 로그아웃, HTTP·소켓 동시 완료, +업로드 실패·재시도·파일 폐기, 장치 권한 거부, 텍스트의 장치 미요청을 검증한다. +준비 실패·권한 거부·녹화 시작 실패에는 새로고침 경고가 없고, 녹화·파일 확정·미저장 업로드 중에는 경고가 유지되며 업로드 완료·폐기 후 해제되는 것도 확인한다. +실제 API/S3 및 실장치 호환성 검증은 별도로 필요하다. + +질문 음성은 Chat 오디오 조회 API(long-poll, wait=20)로 받은 HTTPS URL을 재생한다. +음성 면접은 질문마다 한 번 자동 재생하고 텍스트 면접은 `질문 듣기`로 재생한다. +답변 녹음 시작·답변 제출 성공·종료 시 정지하며, 정지한 질문의 늦은 음성은 자동 재생하지 않는다. +자동 재생이 브라우저에 차단되면 버튼으로 재생할 수 있고, 서버/파일 실패는 같은 안내 문구를 표시한다. +응답 시간은 질문 도착이 아닌 녹음 시작 또는 텍스트 첫 입력부터 계산한다. + +`interview-persona.browser.mjs`는 실제 설정 화면의 SOLO 면접관 5명·프리셋·직접 조합, MULTI 전공 없음, 저장 요청 번호·성별·슬롯 순서·저장 실패를 검증한다. +`question-audio.browser.mjs`는 HTTPS 가상 저장소의 무음 WAV를 실제 Audio로 재생한다. +자동/수동 재생, 입력 중 재생 유지, 응답 시간, PENDING/QUEUED/GENERATING, 실패·404·재시도, +180초 상한, 자동 재생 차단, 질문 전환·종료 취소, 늦은 응답·play Promise를 검증한다. +재시도·상한 시나리오의 타이머만 단축하며, 운영 서버·외부 합성 서비스에는 요청하지 않는다. diff --git a/docs/interviewer5-image.md b/docs/interviewer5-image.md new file mode 100644 index 00000000..a4c9ffb8 --- /dev/null +++ b/docs/interviewer5-image.md @@ -0,0 +1,12 @@ +# 한민석 면접관 이미지 + +- 파일: `src/shared/img/interview-page/interviewer5.svg` +- 제작: Codex 내장 이미지 생성 도구. 도구가 모델 버전 선택·확인을 제공하지 않아 2.5 사용 여부는 확인하지 않음. +- 생성한 새 가상 인물이며, 기존 면접관 이미지의 베이지색 실내·자연광·비즈니스 복장 분위기에 맞춤. +- 기존 카드와 같은 1211×546 SVG 컨테이너에 JPEG를 내장. SOLO·MULTI가 동일 템플릿을 사용함. + +## 생성 프롬프트 + +```text +Use case: photorealistic-natural. Asset type: a single interviewer portrait for the Korean Re:pit mock interview web app. Create one brand-new fictional Korean male interviewer named Han Minseok, around 36–40 years old, with a distinct face, short neatly trimmed black hair brushed subtly to one side (not center-parted curtain hair), no glasses, clean-shaven, calm attentive expression and a very slight welcoming closed-mouth smile. He is seated in a modern interview room, wearing a dark charcoal business blazer over a light blue open-collar dress shirt, no tie. Natural Korean professional editorial photography, realistic skin texture, restrained retouching, soft diffuse window light from camera-left, warm light beige-gray softly blurred wall background. Match a cohesive corporate interview photo catalog with dark suits and muted beige interiors. Wide landscape composition with aspect ratio 1211:546, approximately 2.22:1; one person only, centered horizontally, facing almost forward with a slight natural three-quarter turn, visible from mid-chest upward. Entire head and hair must fit with some breathing room above; both shoulders visible, face occupying approximately 45 percent of image height. Keep face within the central third so responsive card crops remain usable. Camera at eye level, 70mm portrait lens look, moderate depth of field. Output a full-bleed photograph edge to edge. No collage, no split panels, no border, no white bands, no typography, no name label, no logo, no watermark, no exaggerated beauty-filter look. +``` diff --git a/docs/profile-preparation.md b/docs/profile-preparation.md new file mode 100644 index 00000000..eb9cf1f0 --- /dev/null +++ b/docs/profile-preparation.md @@ -0,0 +1,38 @@ +# 자료 분석 상태와 면접 준비 검증 + +마이페이지 저장은 최초 상태 조회 중, 확인된 `PENDING`, 재분석 요청 중에 차단한다. +최초 조회가 실패하면 오류 안내와 재조회 버튼을 표시하면서 자료 저장을 허용한다. +저장 성공 시 응답의 `jobId`로 분석 중 표시와 폴링을 시작한다. +이미 `PENDING`으로 확인된 분석은 이후 조회가 실패해도 저장 차단을 유지한다. +면접 설정은 조회 오류 중에 시작을 허용하지 않는다. + +면접 준비 완료는 `preparationStatus === "READY"`로 판단한다. +`WAITING` 기본 폴링 간격은 3초, 그 밖은 1초이며 서버의 `retryAfterMs`가 더 길면 그 간격을 따른다. +현재 서버는 `retryAfterMs`에 null을 반환하므로 기본 간격을 사용한다. +최신 원질문 개편 계약은 종합 데이터 분석과 질문 대기를 모두 포함하도록 클라이언트 상한을 약 20분으로 정정했다. +따라서 이전 명세의 180초로 줄이지 않고, 서버의 `FAILED` 판정과 클라이언트의 20분 안전 상한을 유지한다. + +## 실행 + +`npm run lint`, `npm run build`와 개발 서버를 실행한 뒤 아래 회귀 검증을 실행한다. +프로젝트에 테스트 패키지를 추가하지 않았으며, 외부 Playwright 런타임은 `PLAYWRIGHT_MODULE_PATH`로 지정할 수 있다. + +```sh +node tests/profile-preparation.browser.mjs +node tests/interview-recording.browser.mjs +``` + +개발 서버 주소는 `INTERVIEW_TEST_ORIGIN`으로 지정한다(기본 `http://127.0.0.1:5173`). +자료 분석 검증은 다음 경로를 브라우저에서 확인한다. + +- 최초 조회 중 저장 차단, 조회 실패 후 인증된 자료 업로드, 분석 중에서 완료로 복구. +- 완료 자료 변경의 확인·취소와 확인 후 저장, 분석 중 폴링 오류에도 저장 차단 유지. +- 실패 사유 4종의 복구 버튼과 재분석 요청. +- 1:1·다면의 완료·자료 유무·분석 중·실패·조회 오류별 시작 허용 여부와 재조회 복구. +- 1:1·다면의 면접 시작 404 안내와 마이페이지 링크. +- 질문 대기 안내, 서버 지정 폴링 간격, 준비 완료, 재시도 요청 실패 후 다시 재시도. +- 재시도 불가능한 실패, 안전 타임아웃, 화면 이탈 후 폴링 중지. + +테스트는 API·소켓을 모의한다. 자료 분석 5초 폴링은 테스트 안에서 100ms로, +20분 안전 타임아웃은 해당 시나리오에서만 200ms로 줄인다. 면접 준비 폴링 간격은 실제 시간으로 검증한다. +실제 서버 분석 완료와 업로드 저장, 실장치 녹화 호환성은 별도 검증이 필요하다. diff --git a/docs/tts-web-implementation.md b/docs/tts-web-implementation.md new file mode 100644 index 00000000..8863dc14 --- /dev/null +++ b/docs/tts-web-implementation.md @@ -0,0 +1,49 @@ +# Web TTS 구현 계획 + +관련 이슈: #132 (기존 #129). 기준: `origin/dev df13a06`, 2026-10-14 fetch. +요구사항: 작업 폴더 `Doc/tts-impl-spec/07-web-작업-정리.md`, 공통 계약 00, Web 명세 04, 카탈로그 명세 06. + +## 확정 범위와 순서 + +1. W-A: 공통 VoiceIndex·성별 카탈로그를 정의하고 SOLO/MULTI 저장 요청에 번호를 연결한다. 잘못된 MULTI 성별과 미사용 성별 함수를 정리한다. 단독 PR로 배포할 수 있다. +2. W-B: 기존 chatInstance로 인증을 유지하는 오디오 long-poll API·파서를 추가한다. 20초 대기, 30초 클라이언트 타임아웃, 취소/404/형식 오류를 구분한다. +3. W-C: 질문·세션별 요청/Audio 수명, 메모리 자동 재생 이력, 정지 후 억제, 사용자 재생 및 재생 차단 상태를 구현한다. 오류는 1·2·4초 뒤 재시도하고 전체 대기는 180초로 제한한다. +4. W-D~F: 세션 훅·종료·녹음·제출에 연결하고 응답 시간을 녹음 시작/텍스트 첫 입력부터 계산한다. 직접 합성 훅·프리셋·스타일 전달을 삭제하고 질문/면접관 강조·녹화·업로드 흐름을 유지한다. +5. W-G: 저장 요청 검증과 실제 브라우저 오디오·녹화 회귀를 실행한다. lint/build 및 자체 리뷰 뒤 커밋·push·PR한다. + +## 기술 가정 + +- 브랜치: `codex/132-tts-voice-index`, 후속 `codex/132-tts-audio`. 후속 PR은 첫 브랜치를 기준으로 만들어 리뷰 범위를 분리한다. +- 07의 호환성 보완대로 QUEUED/GENERATING은 PENDING으로 접는다. 04의 정상 응답 검증에 대한 명시적 확장이다. PENDING 수신은 연속 오류 횟수를 초기화한다. +- READY URL은 HTTPS만 허용한다. 브라우저 회귀도 HTTPS 가상 저장소 URL을 라우팅하여 무음 WAV를 제공한다(07의 data URL 예시는 HTTPS 파서와 충돌하므로 사용하지 않는다). +- 응답 questionId가 요청과 다르면 형식 오류로 재시도한다. 다른 질문의 음성이 섞이지 않게 한다. +- 전체 대기 상한은 통신 오류·대기 중 요청에도 적용하며 정지 시 진행 중 play Promise도 무효화한다. 질문 번호 0은 null과 구분한다. +- 텍스트 첫 입력 시각은 공백이 아닌 첫 글자 입력 때 기록한다(07의 trim 기준). 삭제 후 다시 입력해도 최초 시각을 유지한다. +- 패키지를 추가하지 않는다. 기존 외부 Playwright 런타임과 Chromium을 사용한다. +- 원격 코드 재확인: SOLO의 interview-content에는 음성 버튼이 없다. W-E는 MULTI 버튼 상태 변경과 함께 SOLO에 같은 문구·상태의 버튼/실패 안내를 추가하여 두 모드 수동 재생 요구를 충족한다. 공통 오디오 컨트롤로 표시를 일치시킨다. + +## 검증 기준 + +- SOLO 1~4 / MULTI 5명 저장 본문 voiceIndex·gender, 슬롯 순서 변경, 저장 오류 시 진행 차단 +- 음성 자동 1회 / 텍스트 수동, 재재생 시 재조회 없음, 답변 시작 전후 정지 및 늦은 READY 억제 +- PENDING·QUEUED·GENERATING, 404, 서버 FAILED, 형식/통신 오류 재시도, 180초 상한, 자동 재생 차단/파일 오류 +- 질문 전환·종료·언마운트 요청 취소/Audio 정리, 늦은 응답·play 완료 무효화 +- 응답 시간에서 질문 청취 제외, 첫 입력/녹음 시각 기준, 기존 녹화·업로드 회귀 +- `npm run lint`, `npm run build`, 브라우저 회귀, 자체 리뷰 + +## 배포와 후속 작업 + +W-A 이후 W-B~G 순서. 오디오 전환 PR은 Chat 운영 설정·실제 READY/S3 URL 확인 전 draft로 둔다. +실환경 W1~4·W9의 성별·말투·선합성은 운영 통합 검증이 필요하다. +W-H의 키·프록시·환경변수 정리는 운영 안정화 이후 별도 배포한다(D10). 후속 이슈 #134에서 시작 조건과 작업을 추적한다. 운영 값을 문서에 복사하지 않는다. +Chat 비종료 응답 정규화·운영 CORS·최종 음성 튜닝은 Web 밖의 후속 작업이며 이번 코드 변경 범위에 포함하지 않는다. + +## 구현·검증 결과 (2026-10-14) + +- W-A: 구현 완료, PR #133 (`codex/132-tts-voice-index` → dev). 설정 화면 저장 검증 7개 통과. +- W-B~G: 구현 완료, draft PR #135 (`codex/132-tts-audio` → `codex/132-tts-voice-index`). #133 병합 뒤 base를 dev로 변경한다. Chat API·재생 훅·세션·시간·공통 버튼·직접 합성 제거 및 브라우저 회귀 추가. +- `npm run lint` / `npm run build` 통과. 기존 router.tsx의 미사용 eslint-disable 경고 1개가 남아 있다. +- 기존 녹화/업로드 브라우저 회귀 22개, 오디오 회귀 23개 통과. 실제 HTTPS Audio 재생과 SOLO/MULTI 화면 캡처를 확인했다. +- 자체 리뷰: 저장 매핑, 취소/재시도/대기 상한, 세션·질문·재생 Promise 경쟁, 응답 시간, 녹화 정리를 확인했다. 질문 번호 불일치·형식 오류를 거부하고 대기 상한 이후 READY도 재생하지 않는다. +- 브라우저의 ElevenLabs/Supertone 요청 0건. src에 speechSynthesis·삭제한 훅·ttsProvider·질문 도착 기준 시간 참조가 없다. 키·프록시는 D10에 따라 보존했다. +- 실환경 음성 성별·말투·선합성·Chat 운영 READY/S3 검증은 미실행. 오디오 전환 PR의 운영 배포 선행 조건이다. diff --git a/index.html b/index.html index 3cb7bdf4..cc458e0c 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + repit diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 00000000..839cbfec --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/layout/main-layout/index.tsx b/src/app/layout/main-layout/index.tsx index c69964d4..f59f28ff 100644 --- a/src/app/layout/main-layout/index.tsx +++ b/src/app/layout/main-layout/index.tsx @@ -1,11 +1,14 @@ import BackGroundPage from "@/widgets/main-page/background-page"; import Header from "@/shared/components/header/index"; +import { useSessionCheck } from "@/shared/model/useSessionCheck"; import { Outlet, useLocation } from "react-router-dom"; import * as S from "./style"; const MainLayout = () => { const { pathname } = useLocation(); + useSessionCheck(); const normalizedPathname = pathname.toLowerCase().replace(/\/+$/, ""); + const isFeedbackDetailRoute = normalizedPathname.startsWith("/main/feedback/detail/"); const isInterviewRoute = normalizedPathname === "/main/interview" || normalizedPathname.startsWith("/main/interview/"); @@ -13,9 +16,9 @@ const MainLayout = () => { return ( - +
- + diff --git a/src/app/layout/main-layout/style.ts b/src/app/layout/main-layout/style.ts index d2ea84b2..12703454 100644 --- a/src/app/layout/main-layout/style.ts +++ b/src/app/layout/main-layout/style.ts @@ -2,13 +2,16 @@ import styled from "styled-components"; interface RouteLayoutProps { $isInterviewRoute: boolean; + $isFeedbackDetailRoute?: boolean; } export const Page = styled.main` position: relative; width: 100%; height: 100vh; + height: 100dvh; min-height: 100vh; + min-height: 100dvh; box-sizing: border-box; overflow: hidden; `; @@ -18,32 +21,34 @@ export const Content = styled.div` z-index: 1; width: 100%; height: 100vh; + height: 100dvh; min-height: 100vh; + min-height: 100dvh; display: grid; grid-template-rows: auto minmax(0, 1fr); - row-gap: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "1rem")}; + row-gap: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? 0 : "1rem")}; box-sizing: border-box; @media (max-width: 56rem) { - row-gap: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "0.75rem")}; + row-gap: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? 0 : "0.75rem")}; } `; export const OutletArea = styled.div` display: grid; grid-template-rows: minmax(0, 1fr); - align-items: ${({ $isInterviewRoute }) => ($isInterviewRoute ? "stretch" : "start")}; + align-items: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? "stretch" : "start")}; justify-items: stretch; min-height: 0; - padding-bottom: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "0.5rem")}; + padding-bottom: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? 0 : "0.5rem")}; box-sizing: border-box; overflow-x: hidden; - overflow-y: ${({ $isInterviewRoute }) => ($isInterviewRoute ? "hidden" : "auto")}; + overflow-y: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? "hidden" : "auto")}; scrollbar-width: none; -ms-overflow-style: none; @media (max-width: 56rem) { - padding-bottom: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "1rem")}; + padding-bottom: ${({ $isInterviewRoute, $isFeedbackDetailRoute }) => ($isInterviewRoute || $isFeedbackDetailRoute ? 0 : "1rem")}; } &::-webkit-scrollbar { diff --git a/src/app/main.tsx b/src/app/main.tsx index 44bf9843..c4215429 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,3 +1,4 @@ +import "@/features/interview-page/setting-company/model/draftStore"; import { createRoot } from "react-dom/client"; import { ThemeProvider } from "styled-components"; import { RouterProvider } from "react-router-dom"; @@ -6,6 +7,8 @@ import { theme } from "@/app/styles/theme"; import { Suspense } from "react"; import Loading from "@/shared/components/loading"; +import RecordingUploads from "@/widgets/recording-uploads"; + import router from "./router.tsx"; createRoot(document.getElementById("root")!).render( @@ -13,6 +16,7 @@ createRoot(document.getElementById("root")!).render( }> + , ); diff --git a/src/app/router.tsx b/src/app/router.tsx index aed9c3bf..e1742228 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -31,6 +31,9 @@ const SettingInterview = lazy( const SettingMultiInterview = lazy( () => import("@/pages/interview-page/setting-multi-interview") ); +const SettingCompany = lazy( + () => import("@/pages/interview-page/setting-company") +); // Feedback const FeedbackListPage = lazy( @@ -113,6 +116,10 @@ const router = createBrowserRouter([ path: "setting/interview/multi", element: , }, + { + path: "setting/interview/company", + element: , + }, ], }, { diff --git a/src/app/styles/theme.ts b/src/app/styles/theme.ts index 0fabbf16..e88930c3 100644 --- a/src/app/styles/theme.ts +++ b/src/app/styles/theme.ts @@ -24,6 +24,11 @@ export const theme = { blue: "rgba(0, 102, 255, 0.25)", }, }, + layout: { + contentMaxWidth: "88rem", + contentPadding: "clamp(1rem, 2.5vw, 2rem)", + mobileBreakpoint: "42rem", + }, spacing: { xs: "0.25rem", sm: "0.5rem", diff --git a/src/features/feedback-page/api/getAudioAnalysis.ts b/src/features/feedback-page/api/getAudioAnalysis.ts new file mode 100644 index 00000000..84e6b9f1 --- /dev/null +++ b/src/features/feedback-page/api/getAudioAnalysis.ts @@ -0,0 +1,16 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; + +export type AudioAnalysisStatus = "PENDING" | "READY" | "PARTIAL" | "UNAVAILABLE" | "FAILED"; +export interface AudioAnalysis { + status: AudioAnalysisStatus; + answers: unknown[]; +} + +export const getAudioAnalysis = async (interviewId: number, signal: AbortSignal): Promise => { + const { data } = await apiInstance.get(`/api/interviews/${interviewId}/audio-analysis`, { signal, timeout: 15_000 }); + const result = data?.data; + if (data?.success !== true || !result || + !["PENDING", "READY", "PARTIAL", "UNAVAILABLE", "FAILED"].includes(result.status) || + !Array.isArray(result.answers)) throw new Error("음성 분석 정보를 확인하지 못했습니다."); + return { status: result.status, answers: result.answers }; +}; diff --git a/src/features/feedback-page/api/getInterviewAnswers.ts b/src/features/feedback-page/api/getInterviewAnswers.ts new file mode 100644 index 00000000..9494fef1 --- /dev/null +++ b/src/features/feedback-page/api/getInterviewAnswers.ts @@ -0,0 +1,36 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; + +export interface InterviewAnswer { + answerId: number; + interview: number; + question: string | number; + userId: number; + responseTime: number; + content: string; + createdAt: string; +} + +interface InterviewAnswersResponse { + success: boolean; + data?: InterviewAnswer[]; +} + +export const getInterviewAnswers = async (interviewId: number) => { + if (!Number.isSafeInteger(interviewId) || interviewId <= 0) { + throw new Error("답변을 조회할 면접 정보가 없습니다."); + } + + const response = await apiInstance.get( + "/api/answer/getAll", + { params: { interviewId } }, + ); + + if (!response.data.success || !Array.isArray(response.data.data)) { + throw new Error( + extractErrorMessage(response.data, "면접 답변을 불러오지 못했습니다."), + ); + } + + return response.data.data.filter((answer) => answer.interview === interviewId); +}; diff --git a/src/features/feedback-page/api/getInterviewStatus.ts b/src/features/feedback-page/api/getInterviewStatus.ts new file mode 100644 index 00000000..ebd3f557 --- /dev/null +++ b/src/features/feedback-page/api/getInterviewStatus.ts @@ -0,0 +1,30 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; + +const GET_INTERVIEW_URL = "/api/interviews/get"; + +const readStatus = (value: unknown) => { + if (!value || typeof value !== "object") { + return null; + } + + const { status } = value as { status?: unknown }; + return typeof status === "string" ? status : null; +}; + +export const getInterviewStatus = async ( + interviewId: number, + { signal, timeout }: { signal?: AbortSignal; timeout?: number } = {}, +) => { + if (!Number.isSafeInteger(interviewId) || interviewId <= 0) { + throw new Error("조회할 면접 정보가 없습니다."); + } + + const response = await apiInstance.get(GET_INTERVIEW_URL, { + params: { interviewId }, + signal, + timeout, + }); + const payload = response.data as { data?: unknown } | null; + + return readStatus(payload?.data) ?? readStatus(payload); +}; diff --git a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts index 6703c3cc..4722a683 100644 --- a/src/features/feedback-page/feedback-list/api/getAllInterviews.ts +++ b/src/features/feedback-page/feedback-list/api/getAllInterviews.ts @@ -1,7 +1,10 @@ +import type { CompanyBadge } from "@/features/interview-page/company/model/type"; +import axios from "axios"; import { apiInstance, ensureAccessToken, } from "@/shared/api/axiosInstance"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; export interface InterviewPersona { id?: number; @@ -17,10 +20,12 @@ export interface InterviewPersona { } export interface InterviewSummary { + company?: CompanyBadge | null; id?: number; interviewId?: number; userId?: number; - mode?: "SOLO" | "MULTI"; + mode?: "SOLO" | "MULTI" | null; + answerMode?: "TEXT" | "VOICE" | null; personaId?: number | null; personaIds?: number[]; persona?: InterviewPersona | null; @@ -44,7 +49,7 @@ export interface FrequentWord { } export interface QuestionFeedback { - questionId?: string; + questionId?: string | number; personaId?: number; questionContent?: string; intention?: string; @@ -67,11 +72,22 @@ export interface FeedbackPersona { questionCount?: number; } +export interface ScoreBreakdown { + scoringVersion?: string; + axes?: { axis: string; score: number | null; weight: number | null }[]; + consistencyScore?: number | null; +} + export interface FeedbackData { + mode?: "SOLO" | "MULTI" | null; + scoreBreakdown?: ScoreBreakdown | null; + company?: CompanyBadge | null; + companyFit?: unknown; feedbackId?: number; interviewId?: number; sessionId?: string; style?: string; + level?: string; status?: string; totalScore?: number; intentAlignmentScore?: number; @@ -93,6 +109,16 @@ interface FeedbackApiResponse { data?: FeedbackData; } +interface FeedbackAcceptedResponse { + jobId?: string; + sessionId?: string; + status?: string; + message?: string; +} + +const feedbackRequests = new Map>(); +const feedbackLookups = new Map>(); + const isFeedbackApiResponse = ( payload: FeedbackData | FeedbackApiResponse, ): payload is FeedbackApiResponse => "data" in payload; @@ -153,20 +179,83 @@ export const getAllFeedbacks = async () => { return normalizeFeedbacks(response.data); }; -export const getFeedback = async (interviewId: number) => { +export const getFeedback = async (interviewId: number, signal?: AbortSignal) => { if (!Number.isInteger(interviewId) || interviewId <= 0) { throw new Error("피드백을 조회할 interviewId가 없습니다."); } const authorizationHeader = await ensureAccessToken(); - const response = await apiInstance.get< - FeedbackData | FeedbackApiResponse - >(GET_FEEDBACK_URL, { - params: { interviewId }, - headers: { - Authorization: authorizationHeader, - }, + try { + const response = await apiInstance.get< + FeedbackData | FeedbackApiResponse + >(GET_FEEDBACK_URL, { + params: { interviewId }, + headers: { + Authorization: authorizationHeader, + }, + signal, + }); + + return normalizeFeedback(response.data); + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return null; + } + + throw error; + } +}; + +export const requestFeedback = (interviewId: number): Promise => { + const existingRequest = feedbackRequests.get(interviewId); + + if (existingRequest) { + return existingRequest; + } + + const request = (async (): Promise => { + const response = await apiInstance.post>( + GET_FEEDBACK_URL, + undefined, + { params: { interviewId } }, + ); + + if (!response.data.success || !response.data.data) { + throw new Error( + extractErrorMessage(response.data, "피드백 생성을 요청하지 못했습니다."), + ); + } + + return { + interviewId, + sessionId: response.data.data.sessionId, + status: response.data.data.status === "FAILED" ? "FAILED" : "PENDING", + errorMessage: response.data.data.status === "FAILED" + ? response.data.data.message + : undefined, + }; + })().finally(() => { + feedbackRequests.delete(interviewId); + }); + + feedbackRequests.set(interviewId, request); + return request; +}; + +export const getOrRequestFeedback = (interviewId: number) => { + const existingRequest = feedbackRequests.get(interviewId) ?? feedbackLookups.get(interviewId); + + if (existingRequest) { + return existingRequest; + } + + const request = (async () => { + const feedback = await getFeedback(interviewId); + return feedback ?? requestFeedback(interviewId); + })().finally(() => { + feedbackLookups.delete(interviewId); }); - return normalizeFeedback(response.data); + feedbackLookups.set(interviewId, request); + return request; }; diff --git a/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts b/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts new file mode 100644 index 00000000..ec5ae5b9 --- /dev/null +++ b/src/features/feedback-page/feedback-list/model/sortFeedbackList.ts @@ -0,0 +1,108 @@ +import type { FeedbackListItem } from "@/widgets/feedback-page/feedback-list/type"; + +export type FeedbackListSortKey = "date" | "score"; +export type FeedbackListSortOrder = "asc" | "desc"; + +export interface FeedbackListSort { + key: FeedbackListSortKey; + order: FeedbackListSortOrder; +} + +export const DEFAULT_FEEDBACK_LIST_SORT: FeedbackListSort = { + key: "date", + order: "desc", +}; + +export const FEEDBACK_LIST_SORT_KEY_LABELS: Record = { + date: "날짜", + score: "종합 점수", +}; + +const SORT_ORDER_LABELS: Record> = { + date: { desc: "최신순", asc: "오래된 순" }, + score: { desc: "높은 점수순", asc: "낮은 점수순" }, +}; + +export const getFeedbackListSortLabel = ({ key, order }: FeedbackListSort) => + SORT_ORDER_LABELS[key][order]; + +const isSortKey = (value: string | null): value is FeedbackListSortKey => + value === "date" || value === "score"; + +const isSortOrder = (value: string | null): value is FeedbackListSortOrder => + value === "asc" || value === "desc"; + +// 쿼리가 없거나 값이 틀리면 기본값(날짜 · 내림차순)을 쓴다. +export const readFeedbackListSort = ( + searchParams: URLSearchParams, +): FeedbackListSort => { + const key = searchParams.get("sort"); + const order = searchParams.get("order"); + + return isSortKey(key) && isSortOrder(order) + ? { key, order } + : DEFAULT_FEEDBACK_LIST_SORT; +}; + +// 기본값이면 sort·order 쿼리를 붙이지 않는다. +export const writeFeedbackListSort = ( + searchParams: URLSearchParams, + sort: FeedbackListSort, +) => { + const next = new URLSearchParams(searchParams); + + if ( + sort.key === DEFAULT_FEEDBACK_LIST_SORT.key && + sort.order === DEFAULT_FEEDBACK_LIST_SORT.order + ) { + next.delete("sort"); + next.delete("order"); + } else { + next.set("sort", sort.key); + next.set("order", sort.order); + } + + return next; +}; + +const getCreatedTime = (item: FeedbackListItem) => { + const time = item.createdAt ? Date.parse(item.createdAt) : Number.NaN; + + return Number.isNaN(time) ? 0 : time; +}; + +const getScore = (item: FeedbackListItem) => + typeof item.totalScore === "number" && Number.isFinite(item.totalScore) + ? item.totalScore + : null; + +export const sortFeedbackList = ( + items: readonly T[], + { key, order }: FeedbackListSort, +): T[] => { + const direction = order === "asc" ? 1 : -1; + const latestFirst = (left: T, right: T) => + getCreatedTime(right) - getCreatedTime(left); + + if (key === "date") { + return [...items].sort( + (left, right) => direction * (getCreatedTime(left) - getCreatedTime(right)), + ); + } + + // 점수가 없는 항목은 방향과 관계없이 맨 뒤, 그 안과 동점은 최신순. + return [...items].sort((left, right) => { + const leftScore = getScore(left); + const rightScore = getScore(right); + + if (leftScore === null || rightScore === null) { + if (leftScore !== rightScore) { + return leftScore === null ? 1 : -1; + } + + return latestFirst(left, right); + } + + return direction * (leftScore - rightScore) || latestFirst(left, right); + }); +}; diff --git a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts index 397c1d61..40b94592 100644 --- a/src/features/feedback-page/feedback-list/model/useFeedbackList.ts +++ b/src/features/feedback-page/feedback-list/model/useFeedbackList.ts @@ -1,3 +1,4 @@ +import { readCompanyBadge } from "@/features/interview-page/company/model/type"; import { useCallback, useEffect, useState } from "react"; import { @@ -6,13 +7,13 @@ import { type FeedbackData, } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; import { - getCareerLabel, getFormattedDate, getInterviewStatusLabel, getInterviewStyleLabel, getInterviewModeLabel, getInterviewId, getInterviewerName, + getLevelLabel, sortInterviewsByCreatedAt, } from "@/features/feedback-page/model/interviewDisplay"; import { extractErrorMessage } from "@/shared/api/errorMessage"; @@ -67,7 +68,9 @@ const getListItems = ( return { id: targetInterviewId, + companyName: readCompanyBadge(interview.company)?.displayName, date: getFormattedDate(interview.createdAt), + createdAt: interview.createdAt, imageUrl: interview.persona?.imageUrl ?? getInterviewerImageUrl( @@ -79,7 +82,7 @@ const getListItems = ( styleLabel: getInterviewStyleLabel( feedback?.style ?? interview.persona?.type, ), - levelLabel: getCareerLabel(interview.persona?.career), + levelLabel: getLevelLabel(feedback?.level ?? interview.persona?.level), interviewerName, statusLabel: feedback ? getInterviewStatusLabel(feedback.status) diff --git a/src/features/feedback-page/model/companyFit.ts b/src/features/feedback-page/model/companyFit.ts new file mode 100644 index 00000000..2ed406f8 --- /dev/null +++ b/src/features/feedback-page/model/companyFit.ts @@ -0,0 +1,48 @@ +import type { QuestionFeedback } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; + +export interface CompanyFitItem { + factId: string; + kind: string; + focus: string; + text: string; + questions: { questionId: string; label: string }[]; +} +export interface CompanyFit { + comment: string; + matchedFocus: CompanyFitItem[]; + gaps: CompanyFitItem[]; +} +const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +const text = (value: unknown, max: number): value is string => typeof value === "string" && + [...value.trim()].length >= 1 && [...value.trim()].length <= max; + +export const readCompanyFit = (value: unknown, feedbacks: readonly QuestionFeedback[]): CompanyFit | null => { + if (!record(value) || !text(value.comment, 300) || !Array.isArray(value.matchedFocus) || !Array.isArray(value.gaps) || + value.matchedFocus.length > 3 || value.gaps.length > 3) return null; + const questions = new Map(); + (Array.isArray(feedbacks) ? feedbacks : []).forEach((question, index) => { + if (!record(question) || (typeof question.questionId !== "string" && typeof question.questionId !== "number")) return; + if (question.questionId === undefined || question.questionId === null || !String(question.questionId).trim()) return; + const id = String(question.questionId); + if (!questions.has(id)) questions.set(id, { questionId: id, label: `문항 ${index + 1}` }); + }); + const readItems = (items: unknown[], field: "evidence" | "suggestion") => { + const seen = new Set(); + const valid: CompanyFitItem[] = []; + for (const item of items) { + if (!record(item) || !text(item.factId, 32) || !text(item.focus, 20) || !text(item[field], 150) || + !["BUSINESS", "PROBLEM", "TALENT", "MISSION_VISION", "TECH_STACK"].includes(String(item.kind)) || !Array.isArray(item.questionIds)) continue; + const links = [...new Set(item.questionIds.filter((id): id is string => typeof id === "string" && !!id.trim()))] + .flatMap((id) => questions.get(id) ? [questions.get(id)!] : []); + const factId = item.factId.trim(); + if (!links.length || seen.has(factId)) continue; + seen.add(factId); + valid.push({ factId, kind: String(item.kind), focus: item.focus.trim(), text: String(item[field]).trim(), questions: links }); + } + return valid; + }; + const matchedFocus = readItems(value.matchedFocus, "evidence"); + const gaps = readItems(value.gaps, "suggestion"); + if (value.matchedFocus.length + value.gaps.length > 0 && matchedFocus.length + gaps.length === 0) return null; + return { comment: value.comment.trim(), matchedFocus, gaps }; +}; diff --git a/src/features/feedback-page/model/feedbackAudio.ts b/src/features/feedback-page/model/feedbackAudio.ts new file mode 100644 index 00000000..41962f98 --- /dev/null +++ b/src/features/feedback-page/model/feedbackAudio.ts @@ -0,0 +1,18 @@ +import type { AudioAnalysis } from "../api/getAudioAnalysis"; +import type { FeedbackSpeechAnalysis } from "@/widgets/feedback-page/feedback-overall/type"; + +export const HIDDEN_SPEECH: FeedbackSpeechAnalysis = { visible: false, message: "", canRetry: false }; +export const LOADING_SPEECH: FeedbackSpeechAnalysis = { visible: true, message: "음성 분석 정보를 확인하고 있어요.", canRetry: false }; + +export const buildSpeechAnalysis = (result: AudioAnalysis, answerMode?: "TEXT" | "VOICE" | null): FeedbackSpeechAnalysis => { + if (answerMode === "TEXT") return HIDDEN_SPEECH; + if (answerMode !== "VOICE" && result.status !== "PENDING" && result.answers.length === 0) return HIDDEN_SPEECH; + const messages = { + PENDING: "음성을 분석 중입니다. 완료되면 자동으로 표시됩니다.", + READY: "음성 분석이 완료되었습니다. 음성 점수와 코멘트 제공은 준비 중입니다.", + PARTIAL: "일부 답변의 음성 분석 결과만 있습니다. 음성 점수와 코멘트 제공은 준비 중입니다.", + UNAVAILABLE: "제공할 수 있는 음성 분석 결과가 없습니다.", + FAILED: "음성 분석에 실패했습니다.", + }; + return { visible: true, message: messages[result.status], canRetry: false }; +}; diff --git a/src/features/feedback-page/model/feedbackDisplay.ts b/src/features/feedback-page/model/feedbackDisplay.ts index ec3dd64d..1452f49c 100644 --- a/src/features/feedback-page/model/feedbackDisplay.ts +++ b/src/features/feedback-page/model/feedbackDisplay.ts @@ -1,3 +1,5 @@ +import { buildFeedbackMetrics } from "./feedbackMetrics"; +import type { InterviewAnswer } from "@/features/feedback-page/api/getInterviewAnswers"; import type { FeedbackData, InterviewSummary, @@ -14,15 +16,9 @@ import type { FeedbackDetailTopSectionData, } from "@/widgets/feedback-page/feedback-detail/type"; -const getScore = (score?: number) => - typeof score === "number" && Number.isFinite(score) ? score : 0; - const getText = (value?: string, fallback = "내용이 아직 없습니다.") => value?.trim() || fallback; -const getListText = (items?: string[], fallback = "내용이 아직 없습니다.") => - items && items.length > 0 ? items.join(" ") : fallback; - const getReliabilityLevel = (score: number) => { if (score >= 80) { return "높음"; @@ -38,20 +34,22 @@ const getReliabilityLevel = (score: number) => { const getFeedbackQuestion = ( item: QuestionFeedback, index: number, + answerContent?: string, personaLabel?: string, ) => ({ id: index + 1, + questionId: item.questionId === undefined ? undefined : String(item.questionId), label: `Q${index + 1}`, preview: getText(item.questionContent), - personaLabel, + personaLabel: personaLabel ?? "면접관", question: getText(item.questionContent), intention: getText(item.intention), - myAnswer: getText(item.userAnswer, "작성된 답변이 없습니다."), + myAnswer: getText(item.userAnswer?.trim() || answerContent, "작성된 답변이 없습니다."), modelAnswer: getText(item.modelAnswer), - followUpQuestion: getListText( - item.improvements, - "이 질문에 대한 추가 피드백이 아직 없습니다.", - ), + followUpQuestion: "예상 꼬리질문이 아직 제공되지 않았습니다.", + strengths: item.strengths?.length ? item.strengths : ["이 질문의 장점 분석 결과가 아직 없습니다."], + improvements: item.improvements?.length ? item.improvements : ["이 질문의 보완할 점이 아직 없습니다."], + comment: getText(item.comment, "이 질문에 대한 코멘트가 아직 없습니다."), }); export const getFeedbackStatusLabel = (status?: string) => { @@ -70,112 +68,95 @@ export const getFeedbackStatusLabel = (status?: string) => { return status || "상태 확인 중"; }; +const getOptionalScore = (score?: number) => + typeof score === "number" && Number.isFinite(score) + ? Math.round(Math.min(100, Math.max(0, score))) + : null; + export const buildFeedbackOverallContent = ( feedback: FeedbackData, interview?: InterviewSummary | null, + previousFeedback?: FeedbackData | null, ): { topSection: FeedbackOverallTopSectionData; middleSection: FeedbackOverallMiddleSectionData; bottomSection: FeedbackOverallBottomSectionData; } => { - const totalScore = getScore(feedback.totalScore); - const personaCards = feedback.personas?.map((persona, index) => ({ - title: persona.personaRole?.trim() || `면접관 ${index + 1}`, - highlightText: persona.comment?.trim() || undefined, - bars: [ - { - label: "내 점수", - score: getScore(persona.score), - tone: "primary" as const, - }, - ], - })); - const comparisonCards = - personaCards && personaCards.length > 0 - ? personaCards - : [ - { - title: "이번 면접", - bars: [ - { - label: "내 점수", - score: totalScore, - tone: "primary" as const, - }, - ], - }, - ]; + const totalScore = getOptionalScore(feedback.totalScore); + const reliabilityScore = getOptionalScore(feedback.reliabilityScore); + const summary = getText(feedback.summary, "아직 종합 분석이 완료되지 않았습니다."); + const summarySentences = summary.split(/(?<=[.!?。])\s+/); + const keywords = [...(feedback.frequentWords ?? [])] + .filter((item) => item.word.trim() && Number.isFinite(item.count) && item.count > 0) + .sort((left, right) => right.count - left.count) + .filter((item, index, items) => items.findIndex((other) => other.word === item.word) === index) + .slice(0, 8); + const keywordPositions = [ + { size: "xl", top: "50%", left: "52%" }, + { size: "lg", top: "24%", left: "69%" }, + { size: "md", top: "30%", left: "23%" }, + { size: "md", top: "72%", left: "71%" }, + { size: "sm", top: "31%", left: "86%" }, + { size: "sm", top: "78%", left: "35%" }, + { size: "xs", top: "72%", left: "11%" }, + { size: "xs", top: "15%", left: "41%" }, + ] as const; return { topSection: { tabs: { - overallLabel: "종합피드백", - detailLabel: "상세피드백", + overallLabel: "종합 피드백", + detailLabel: "상세 피드백", activeTab: "overall", }, summary: { title: "종합 점수", score: totalScore, - description: [ - getText(feedback.summary, "아직 종합 분석이 완료되지 않았습니다."), - ], - }, - averageComparison: { - title: "점수 분석", - bars: [ - { - label: "내 점수", - score: totalScore, - tone: "primary", - }, - { - label: "질문 의도 적합도", - score: getScore(feedback.intentAlignmentScore), - tone: "muted", - }, - ], + description: summarySentences.slice(0, 2).map((sentence) => sentence.trim()), }, - comparisonCards, - intentAlignmentScore: getScore(feedback.intentAlignmentScore), + metrics: buildFeedbackMetrics(feedback), + evaluation: summary, }, middleSection: { cards: [ { - title: "강점", - content: getListText(feedback.strengths), + title: "장점", + tone: "positive", + items: feedback.strengths?.length ? feedback.strengths : ["장점 분석 결과가 아직 없습니다."], }, { - title: "보완점", - content: getListText(feedback.improvements), + title: "단점", + tone: "negative", + items: feedback.improvements?.length ? feedback.improvements : ["보완할 점에 대한 분석 결과가 아직 없습니다."], }, ], + comparison: { + previousScore: getOptionalScore(previousFeedback?.totalScore), + currentScore: totalScore, + }, + showPersonaFeedback: (interview?.mode ?? feedback.mode) === "MULTI", personaFeedbacks: (feedback.personas ?? []).map((persona, index) => ({ title: persona.personaRole?.trim() || `면접관 ${index + 1}`, imageUrl: persona.imageUrl ?? (persona.personaId === interview?.persona?.id - ? interview.persona?.imageUrl + ? interview?.persona?.imageUrl : undefined), - comment: - persona.comment?.trim() || "면접관 코멘트가 아직 등록되지 않았습니다.", + comment: persona.comment?.trim() || "면접관 코멘트가 제공되지 않았습니다.", + score: getOptionalScore(persona.score), + hasNoAnswer: persona.answeredCount === 0, })), }, bottomSection: { reliability: { title: "응답 신뢰성", - scoreLabel: `${getScore(feedback.reliabilityScore)}점`, - levelLabel: getReliabilityLevel(getScore(feedback.reliabilityScore)), - }, - questionFit: { - title: "질문 의도 적합도", - content: `${getScore(feedback.intentAlignmentScore)}점 · ${feedback.answeredCount ?? 0}/${feedback.questionCount ?? 0}문항 답변`, + scoreLabel: reliabilityScore === null ? "—" : `${reliabilityScore}%`, + levelLabel: reliabilityScore === null ? "미제공" : getReliabilityLevel(reliabilityScore), }, keywords: { - items: (feedback.frequentWords ?? []).slice(0, 8).map((item, index) => ({ + items: keywords.map((item, index) => ({ text: item.word, - size: index < 2 ? ("xl" as const) : index < 4 ? ("md" as const) : ("sm" as const), - top: ["22%", "68%", "40%", "82%", "16%", "54%", "30%", "74%"][index], - left: ["28%", "64%", "72%", "38%", "78%", "22%", "48%", "84%"][index], + ...keywordPositions[index], })), }, pdfActionLabel: "PDF로 저장", @@ -185,11 +166,20 @@ export const buildFeedbackOverallContent = ( export const buildFeedbackDetailContent = ( feedback: FeedbackData, + answers: readonly InterviewAnswer[], ): { topSection: FeedbackDetailTopSectionData; middleSection: FeedbackDetailMiddleSectionData; bottomSection: FeedbackDetailBottomSectionData; } => { + const answersByQuestionId = new Map( + [...answers] + .sort((left, right) => + (Date.parse(left.createdAt) || 0) - (Date.parse(right.createdAt) || 0) || + left.answerId - right.answerId, + ) + .map((answer) => [String(answer.question), answer.content]), + ); const personaRoleById = new Map( (feedback.personas ?? []).flatMap((persona, index) => typeof persona.personaId === "number" @@ -205,18 +195,19 @@ export const buildFeedbackDetailContent = ( detailLabel: "상세피드백", activeTab: "detail", }, - questionListTitle: "질문별 피드백", + questionListTitle: "상세 피드백", questionTitle: "질문", intentionTitle: "질문 의도", myAnswerTitle: "내 답변", showMyAnswerLabel: "내 답변 보기", hideMyAnswerLabel: "내 답변 숨기기", modelAnswerTitle: "모범 답변", - followUpTitle: "개선 포인트", + followUpTitle: "예상 꼬리질문", questions: (feedback.feedbacks ?? []).map((item, index) => getFeedbackQuestion( item, index, + answersByQuestionId.get(String(item.questionId)), typeof item.personaId === "number" ? personaRoleById.get(item.personaId) ?? "면접관" : undefined, @@ -226,11 +217,13 @@ export const buildFeedbackDetailContent = ( middleSection: { cards: [ { - title: "강점", + title: "장점", + tone: "positive", paragraphs: feedback.strengths ?? ["강점 분석 결과가 아직 없습니다."], }, { - title: "보완점", + title: "단점", + tone: "negative", paragraphs: feedback.improvements ?? ["보완점 분석 결과가 아직 없습니다."], }, ], diff --git a/src/features/feedback-page/model/feedbackMetrics.ts b/src/features/feedback-page/model/feedbackMetrics.ts new file mode 100644 index 00000000..ebd66981 --- /dev/null +++ b/src/features/feedback-page/model/feedbackMetrics.ts @@ -0,0 +1,28 @@ +import type { FeedbackData } from "../feedback-list/api/getAllInterviews"; +import type { FeedbackOverallMetric } from "@/widgets/feedback-page/feedback-overall/type"; + +const AXES = [ + ["INTENT", "의도 충족"], ["DEPTH", "깊이"], + ["SPECIFICITY", "구체성"], ["ACCURACY", "정확성"], +] as const; + +const validScore = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100; + +export const buildFeedbackMetrics = (feedback: FeedbackData): FeedbackOverallMetric[] => { + const axes = Array.isArray(feedback.scoreBreakdown?.axes) ? feedback.scoreBreakdown.axes : []; + return AXES.map(([axis, label]) => { + const entries = axes.filter((entry) => entry?.axis === axis); + if (entries.length === 1) { + const entry = entries[0]; + if (validScore(entry.score)) return { label, score: entry.score }; + if (entry.score === null && entry.weight === null) { + return { label, score: null, emptyLabel: "해당 없음" }; + } + } + if (entries.length === 0 && axis === "INTENT" && validScore(feedback.intentAlignmentScore)) { + return { label, score: feedback.intentAlignmentScore }; + } + return { label, score: null, emptyLabel: "미제공" }; + }); +}; diff --git a/src/features/feedback-page/model/interviewDisplay.ts b/src/features/feedback-page/model/interviewDisplay.ts index 2b59a70c..a4a5a4aa 100644 --- a/src/features/feedback-page/model/interviewDisplay.ts +++ b/src/features/feedback-page/model/interviewDisplay.ts @@ -1,11 +1,11 @@ +import { getJobRoleLabel } from "@/shared/constants/job/jobRoles"; import type { InterviewSummary } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; export const getInterviewId = (interview: InterviewSummary) => interview.interviewId ?? interview.id ?? 0; -const MAJOR_LABELS: Record = { - BACKEND: "백엔드", - FRONTEND: "프론트엔드", +// 서버가 더는 보내지 않지만 이미 저장된 면접에 남아 있는 옛 전공 코드 +const LEGACY_MAJOR_LABELS: Record = { FULLSTACK: "풀스택", MOBILE: "모바일", DEVOPS: "데브옵스", @@ -28,6 +28,7 @@ const STATUS_LABELS: Record = { READY: "대기중", PENDING: "분석중", FAILED: "분석실패", + ABANDONED: "중단됨", }; const getSafeDate = (value?: string) => { @@ -67,7 +68,7 @@ export const getInterviewMajorLabel = (major?: string) => { return "미제공"; } - return MAJOR_LABELS[major] ?? major; + return LEGACY_MAJOR_LABELS[major] ?? getJobRoleLabel(major); }; export const getInterviewTitle = (major?: string) => @@ -95,27 +96,31 @@ export const getInterviewStyleLabel = (type?: string) => { return PERSONA_TYPE_LABELS[type] ?? "성향 미정"; }; -export const getCareerLabel = (career?: number) => { - if (career === undefined || career === null) { - return "미제공"; - } +const LEVEL_LABELS: Record = { + EASY: "쉬움", + NORMAL: "보통", + HARD: "어려움", +}; - if (career <= 1) { - return "쉬움"; - } +export const getLevelLabel = (level?: string | null) => + (level ? LEVEL_LABELS[level] : undefined) ?? "미제공"; - if (career <= 3) { - return "보통"; - } +// SOLO 페르소나 이름은 유일성을 위해 끝에 -{uuid}가 붙어 저장된다. +const PERSONA_NAME_UUID_SUFFIX = + /-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; - return "어려움"; -}; +export const getPersonaDisplayName = (personaName?: string | null) => + personaName?.trim().replace(PERSONA_NAME_UUID_SUFFIX, "").trim() ?? ""; + +export const getInterviewerName = (interview: InterviewSummary) => { + const count = getInterviewerCount(interview); + const personaName = + interview.mode === "MULTI" + ? "" + : getPersonaDisplayName(interview.persona?.personaName); -export const getInterviewerName = (interview: InterviewSummary) => - interview.persona?.personaName?.trim() || - (getInterviewerCount(interview) > 0 - ? `면접관 ${getInterviewerCount(interview)}명` - : "면접관 정보 없음"); + return personaName || (count > 0 ? `면접관 ${count}명` : "면접관 정보 없음"); +}; export const getInterviewStatusLabel = (status?: string) => { if (!status) { diff --git a/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts b/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts new file mode 100644 index 00000000..f08d3f41 --- /dev/null +++ b/src/features/feedback-page/model/useFeedbackAudioAnalysis.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { getAudioAnalysis } from "../api/getAudioAnalysis"; +import { buildSpeechAnalysis, HIDDEN_SPEECH, LOADING_SPEECH } from "./feedbackAudio"; +import type { FeedbackSpeechAnalysis } from "@/widgets/feedback-page/feedback-overall/type"; + +export const useFeedbackAudioAnalysis = ( + interviewId: number | undefined, + answerMode: "TEXT" | "VOICE" | null | undefined, + enabled: boolean, +) => { + const [attempt, setAttempt] = useState(0); + const [result, setResult] = useState<{ key: string; view: FeedbackSpeechAnalysis } | null>(null); + const key = JSON.stringify([interviewId, answerMode ?? null, enabled, attempt]); + const shouldLoad = enabled && Number.isSafeInteger(interviewId) && (interviewId ?? 0) > 0 && answerMode !== "TEXT"; + + useEffect(() => { + if (!shouldLoad || interviewId === undefined) return; + const controller = new AbortController(); + const deadline = Date.now() + 5 * 60_000; + let timer: number; + const update = (view: FeedbackSpeechAnalysis) => { + if (!controller.signal.aborted) setResult({ key, view }); + }; + const poll = async () => { + if (controller.signal.aborted) return; + if (Date.now() >= deadline) { + update({ visible: true, message: "음성 분석이 지연되고 있습니다. 잠시 후 다시 확인해주세요.", canRetry: true }); + return; + } + try { + const data = await getAudioAnalysis(interviewId, controller.signal); + if (controller.signal.aborted) return; + update(buildSpeechAnalysis(data, answerMode)); + if (data.status === "PENDING") timer = window.setTimeout(() => void poll(), Math.min(5_000, Math.max(0, deadline - Date.now()))); + } catch { + update({ visible: true, message: "음성 분석 정보를 확인하지 못했습니다.", canRetry: true }); + } + }; + timer = window.setTimeout(() => void poll(), 0); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [interviewId, answerMode, key, shouldLoad]); + + return { + view: !shouldLoad ? HIDDEN_SPEECH : result?.key === key ? result.view : LOADING_SPEECH, + retry: () => setAttempt((value) => value + 1), + }; +}; diff --git a/src/features/feedback-page/model/useFeedbackInterview.ts b/src/features/feedback-page/model/useFeedbackInterview.ts index 9a072409..35d50d33 100644 --- a/src/features/feedback-page/model/useFeedbackInterview.ts +++ b/src/features/feedback-page/model/useFeedbackInterview.ts @@ -1,8 +1,19 @@ -import { useCallback, useEffect, useState } from "react"; +import axios from "axios"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; import { + getInterviewAnswers, + type InterviewAnswer, +} from "@/features/feedback-page/api/getInterviewAnswers"; +import { getInterviewStatus } from "@/features/feedback-page/api/getInterviewStatus"; + +import { + getAllFeedbacks, getAllInterviews, getFeedback, + getOrRequestFeedback, + requestFeedback, type FeedbackData, type InterviewSummary, } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; @@ -12,19 +23,99 @@ import { } from "@/features/feedback-page/model/interviewDisplay"; import { extractErrorMessage } from "@/shared/api/errorMessage"; -export const useFeedbackInterview = (id?: string) => { +const INTERVIEW_SAVE_CHECK_INTERVAL_MS = 3_000; +const INTERVIEW_SAVE_CHECK_TIMEOUT_MS = 60_000; +const INTERVIEW_SAVE_REQUEST_TIMEOUT_MS = 15_000; + +const isFromCompletion = (state: unknown) => + !!state && typeof state === "object" && + (state as { fromCompletion?: unknown }).fromCompletion === true; + +const isRetryableError = (error: unknown) => { + if (!axios.isAxiosError(error) || axios.isCancel(error)) return false; + const status = error.response?.status; + return !error.response || status === 408 || status === 429 || + (status !== undefined && status >= 500 && status < 600); +}; + +const waitFor = (ms: number, signal: AbortSignal) => new Promise((resolve) => { + const handleAbort = () => { + window.clearTimeout(timeoutId); + resolve(); + }; + const timeoutId = window.setTimeout(() => { + signal.removeEventListener("abort", handleAbort); + resolve(); + }, ms); + signal.addEventListener("abort", handleAbort, { once: true }); +}); + +// 완료 직후에는 Chat → API 저장이 끝나기 전이라 IN_PROGRESS일 수 있어 정해진 시간만 다시 조회한다. +const waitForInterviewCompletion = async (interviewId: number, signal: AbortSignal) => { + const deadline = Date.now() + INTERVIEW_SAVE_CHECK_TIMEOUT_MS; + let status = "IN_PROGRESS"; + let error: unknown = null; + + while (!signal.aborted) { + const remainingTime = deadline - Date.now(); + if (remainingTime <= 0) break; + + await waitFor(Math.min(INTERVIEW_SAVE_CHECK_INTERVAL_MS, remainingTime), signal); + if (signal.aborted) break; + + try { + // 응답이 멈춰도 60초 기한을 크게 넘기지 않도록 요청마다 시간 제한을 둔다. + const requestTimeout = Math.min( + INTERVIEW_SAVE_REQUEST_TIMEOUT_MS, + Math.max(deadline - Date.now(), 1_000), + ); + status = (await getInterviewStatus(interviewId, { signal, timeout: requestTimeout })) ?? status; + if (status !== "IN_PROGRESS") break; + } catch (requestError) { + if (signal.aborted) break; + if (!isRetryableError(requestError)) { + error = requestError; + break; + } + } + } + + return { status, error }; +}; + +export const useFeedbackInterview = (id?: string, includePreviousFeedback = false) => { + const location = useLocation(); + const fromCompletion = isFromCompletion(location.state); + const [answers, setAnswers] = useState([]); + const [answersErrorMessage, setAnswersErrorMessage] = useState(null); const [interview, setInterview] = useState(null); const [feedback, setFeedback] = useState(null); + const [previousFeedback, setPreviousFeedback] = useState(null); const [feedbackErrorMessage, setFeedbackErrorMessage] = useState( null, ); const [isLoading, setIsLoading] = useState(true); + const [isRetryingFeedback, setIsRetryingFeedback] = useState(false); + const [isSavingInterview, setIsSavingInterview] = useState(false); const [errorMessage, setErrorMessage] = useState(null); + const requestIdRef = useRef(0); + const isRetryingFeedbackRef = useRef(false); + const saveCheckControllerRef = useRef(null); const loadInterview = useCallback(async () => { + const requestId = ++requestIdRef.current; const interviewId = Number(id); + saveCheckControllerRef.current?.abort(); + saveCheckControllerRef.current = null; + isRetryingFeedbackRef.current = false; + setIsRetryingFeedback(false); + setIsSavingInterview(false); + setAnswers([]); + setAnswersErrorMessage(null); + setFeedback(null); + setPreviousFeedback(null); - if (!interviewId) { + if (!Number.isSafeInteger(interviewId) || interviewId <= 0) { setInterview(null); setFeedback(null); setErrorMessage("면접 정보를 찾을 수 없습니다."); @@ -36,21 +127,96 @@ export const useFeedbackInterview = (id?: string) => { setErrorMessage(null); setFeedbackErrorMessage(null); - const [interviewsResult, feedbackResult] = await Promise.allSettled([ + const [interviewsResult, historyResult, answersResult] = await Promise.allSettled([ getAllInterviews(), - getFeedback(interviewId), + includePreviousFeedback ? getAllFeedbacks() : Promise.resolve([]), + getInterviewAnswers(interviewId), ]); + if (requestId !== requestIdRef.current) { + return; + } + + if (answersResult.status === "fulfilled") { + setAnswers(answersResult.value); + } else { + setAnswersErrorMessage( + extractErrorMessage(answersResult.reason, "면접 답변을 불러오지 못했습니다."), + ); + } + if (interviewsResult.status === "fulfilled") { const sortedInterviews = sortInterviewsByCreatedAt(interviewsResult.value); - const nextInterview = + let nextInterview = sortedInterviews.find((item) => getInterviewId(item) === interviewId) ?? null; setInterview(nextInterview); + let saveCheckError: unknown = null; + + if (fromCompletion && nextInterview?.status === "IN_PROGRESS") { + const controller = new AbortController(); + saveCheckControllerRef.current = controller; + setIsSavingInterview(true); + const result = await waitForInterviewCompletion(interviewId, controller.signal); + + if (requestId !== requestIdRef.current) { + return; + } + + saveCheckControllerRef.current = null; + setIsSavingInterview(false); + nextInterview = { ...nextInterview, status: result.status }; + saveCheckError = result.error; + setInterview(nextInterview); + } + + if (nextInterview?.createdAt && historyResult.status === "fulfilled") { + const currentTime = new Date(nextInterview.createdAt).getTime(); + const completedFeedbacks = new Map( + historyResult.value + .filter((entry) => + (entry.status === "SUCCEEDED" || entry.status === "COMPLETED") && + typeof entry.totalScore === "number" && Number.isFinite(entry.totalScore), + ) + .map((entry) => [entry.interviewId, entry]), + ); + const priorInterview = sortedInterviews.find((item) => + item.createdAt && new Date(item.createdAt).getTime() < currentTime && + completedFeedbacks.has(getInterviewId(item)), + ); + + if (priorInterview) { + setPreviousFeedback(completedFeedbacks.get(getInterviewId(priorInterview)) ?? null); + } + } if (!nextInterview) { setErrorMessage("면접 정보를 찾을 수 없습니다."); + } else if (saveCheckError) { + setFeedbackErrorMessage( + extractErrorMessage(saveCheckError, "면접 상태를 확인하지 못했습니다."), + ); + } else if (nextInterview.status !== "COMPLETED") { + setFeedbackErrorMessage("아직 완료되지 않은 면접입니다. 면접을 마친 뒤 피드백을 확인해주세요."); + } else { + try { + const nextFeedback = await getOrRequestFeedback(interviewId); + + if (requestId !== requestIdRef.current) { + return; + } + + setFeedback(nextFeedback); + } catch (error) { + if (requestId !== requestIdRef.current) { + return; + } + + setFeedbackErrorMessage( + extractErrorMessage(error, "피드백을 불러오지 못했습니다."), + ); + } } } else { setInterview(null); @@ -62,20 +228,60 @@ export const useFeedbackInterview = (id?: string) => { ); } - if (feedbackResult.status === "fulfilled") { - setFeedback(feedbackResult.value); - } else { - setFeedback(null); - setFeedbackErrorMessage( - extractErrorMessage( - feedbackResult.reason, - "피드백을 불러오지 못했습니다.", - ), - ); + setIsLoading(false); + }, [fromCompletion, id, includePreviousFeedback]); + + const retryFeedback = useCallback(async () => { + if (feedback?.status !== "FAILED") { + return loadInterview(); } - setIsLoading(false); - }, [id]); + if (isRetryingFeedbackRef.current) { + return; + } + + const requestId = requestIdRef.current; + const interviewId = Number(id); + isRetryingFeedbackRef.current = true; + setIsRetryingFeedback(true); + setFeedbackErrorMessage(null); + + try { + const nextFeedback = await requestFeedback(interviewId).catch((error: unknown) => { + if (requestId !== requestIdRef.current) { + return null; + } + + if (axios.isAxiosError(error) && error.response?.status === 409) { + // 다른 재채점의 새 결과가 아직 조회되지 않으면 FAILED를 유지한다. + return getFeedback(interviewId).then((currentFeedback) => { + if (!currentFeedback) { + throw new Error("피드백 상태를 확인하지 못했습니다. 잠시 후 다시 시도해주세요."); + } + + return currentFeedback; + }); + } + + throw error; + }); + + if (requestId === requestIdRef.current) { + setFeedback(nextFeedback); + } + } catch (error) { + if (requestId === requestIdRef.current) { + setFeedbackErrorMessage( + extractErrorMessage(error, "피드백 생성을 다시 요청하지 못했습니다."), + ); + } + } finally { + if (requestId === requestIdRef.current) { + isRetryingFeedbackRef.current = false; + setIsRetryingFeedback(false); + } + } + }, [feedback?.status, id, loadInterview]); useEffect(() => { const timeoutId = window.setTimeout(() => { @@ -84,15 +290,92 @@ export const useFeedbackInterview = (id?: string) => { return () => { window.clearTimeout(timeoutId); + requestIdRef.current += 1; + saveCheckControllerRef.current?.abort(); + saveCheckControllerRef.current = null; }; }, [loadInterview]); + const feedbackStatus = feedback?.status; + + useEffect(() => { + const interviewId = Number(id); + const isPending = feedbackStatus === "PENDING" || feedbackStatus === "IN_PROGRESS"; + + if (isLoading || !isPending || !Number.isSafeInteger(interviewId) || interviewId <= 0) { + return; + } + + const controller = new AbortController(); + const deadline = Date.now() + 5 * 60_000; + const timeoutMessage = "피드백 분석이 지연되고 있습니다. 잠시 후 다시 확인해주세요."; + let timeoutId: number; + + const pollFeedback = async () => { + if (controller.signal.aborted) return; + if (Date.now() >= deadline) { + setFeedbackErrorMessage(timeoutMessage); + return; + } + + try { + const nextFeedback = await getFeedback(interviewId, controller.signal); + + if (controller.signal.aborted) { + return; + } + + if (nextFeedback) { + setFeedback(nextFeedback); + setFeedbackErrorMessage(null); + + if (nextFeedback.status !== "PENDING" && nextFeedback.status !== "IN_PROGRESS") { + return; + } + } + } catch (error) { + if (controller.signal.aborted) return; + setFeedbackErrorMessage( + extractErrorMessage(error, "피드백 분석 상태를 확인하지 못했습니다."), + ); + + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const canRetry = axios.isAxiosError(error) && !axios.isCancel(error) && ( + !error.response || status === 408 || status === 429 || + (status !== undefined && status >= 500 && status < 600) + ); + if (!canRetry) return; + } + + const remainingTime = deadline - Date.now(); + if (remainingTime <= 0) { + setFeedbackErrorMessage(timeoutMessage); + return; + } + + timeoutId = window.setTimeout(() => void pollFeedback(), Math.min(5_000, remainingTime)); + }; + + timeoutId = window.setTimeout(() => void pollFeedback(), 5_000); + + return () => { + controller.abort(); + window.clearTimeout(timeoutId); + }; + }, [id, isLoading, feedbackStatus]); + return { + answers, + answersErrorMessage, errorMessage, feedback, feedbackErrorMessage, interview, + previousFeedback, isLoading, + isRetryingFeedback, + isSavingInterview, refetch: loadInterview, + retryFeedback, }; }; diff --git a/src/features/interview-page/company/api/index.ts b/src/features/interview-page/company/api/index.ts new file mode 100644 index 00000000..3e13337d --- /dev/null +++ b/src/features/interview-page/company/api/index.ts @@ -0,0 +1,29 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; +import type { CompanyCategory, CompanySummary } from "../model/type"; +import { COMPANY_CATEGORY_ORDER } from "../model/companyLabels"; + +export const getCompanies = async ( + query: string, + category: CompanyCategory | "ALL", + signal?: AbortSignal, +): Promise => { + const q = query.trim(); + const response = await apiInstance.get("/api/companies", { + params: { + ...(q.length >= 2 ? { q: q.slice(0, 30) } : {}), + ...(category !== "ALL" ? { category } : {}), + }, + signal, + timeout: 15_000, + }); + const data: unknown = Array.isArray(response.data) ? response.data : response.data?.data; + if (!Array.isArray(data) || !data.every((item) => + item && Number.isSafeInteger(item.companyId) && item.companyId > 0 && + typeof item.displayName === "string" && item.displayName.trim() && + typeof item.legalName === "string" && COMPANY_CATEGORY_ORDER.includes(item.category) && + (item.legalSize === null || ["LARGE", "MID", "SMALL"].includes(item.legalSize)) && + Array.isArray(item.talentKeywords) && item.talentKeywords.every((title: unknown) => typeof title === "string") && + typeof item.dataAsOf === "string", + )) throw new Error("기업 목록 응답을 확인하지 못했습니다."); + return data; +}; diff --git a/src/features/interview-page/company/model/companyLabels.ts b/src/features/interview-page/company/model/companyLabels.ts new file mode 100644 index 00000000..12815700 --- /dev/null +++ b/src/features/interview-page/company/model/companyLabels.ts @@ -0,0 +1,33 @@ +import type { CompanyCategory, CompanyLegalSize } from "./type"; + +export const COMPANY_CATEGORY_LABELS: Record = { + IT_SERVICE: "대기업 IT서비스", + PLATFORM: "빅테크 플랫폼", + FINTECH: "핀테크", + GAME: "게임", + CONSUMER: "커머스·모빌리티", + SECURITY: "보안", +}; + +export const COMPANY_CATEGORY_ORDER: CompanyCategory[] = [ + "IT_SERVICE", + "PLATFORM", + "FINTECH", + "GAME", + "CONSUMER", + "SECURITY", +]; + +export const COMPANY_LEGAL_SIZE_LABELS: Record = { + LARGE: "대기업", + MID: "중견기업", + SMALL: "중소기업", +}; + +export const COMPANY_DISCLAIMER = + "공개 자료를 바탕으로 한 연습용 면접이며, 해당 기업과 무관합니다. 실제 채용 전형이나 기출 문제가 아닙니다."; + +export const formatDataAsOf = (dataAsOf: string) => { + const [year, month, day] = dataAsOf.split("-").map(Number); + return year && month && day ? `${year}년 ${month}월 ${day}일 기준` : ""; +}; diff --git a/src/features/interview-page/company/model/sampleCompanies.ts b/src/features/interview-page/company/model/sampleCompanies.ts new file mode 100644 index 00000000..f054d54c --- /dev/null +++ b/src/features/interview-page/company/model/sampleCompanies.ts @@ -0,0 +1,76 @@ +import type { CompanySummary } from "./type"; + +// 임시 데이터: 기업 목록 API(GET /api/companies)가 붙기 전까지 화면 흐름 확인용으로만 쓴다. +// 실제 회사를 떠올리게 하는 이름을 쓰지 않는다. +export const SAMPLE_COMPANIES: CompanySummary[] = [ + { + companyId: 1, + displayName: "샘플커머스", + legalName: "샘플커머스 주식회사", + aliases: ["샘플마켓", "Sample Commerce"], + category: "CONSUMER", + legalSize: null, + talentKeywords: ["오너십", "고객 관점"], + dataAsOf: "2026-10-15", + }, + { + companyId: 2, + displayName: "샘플페이", + legalName: "샘플페이 주식회사", + aliases: ["Sample Pay"], + category: "FINTECH", + legalSize: null, + talentKeywords: ["정확성", "빠른 실행"], + dataAsOf: "2026-10-15", + }, + { + companyId: 3, + displayName: "샘플게임즈", + legalName: "샘플게임즈 주식회사", + aliases: ["Sample Games"], + category: "GAME", + legalSize: null, + talentKeywords: ["이용자 경험", "협업"], + dataAsOf: "2026-10-15", + }, + { + companyId: 4, + displayName: "샘플클라우드", + legalName: "샘플클라우드시스템 주식회사", + aliases: ["Sample Cloud"], + category: "IT_SERVICE", + legalSize: "LARGE", + talentKeywords: ["신뢰", "도전", "전문성"], + dataAsOf: "2026-10-15", + }, + { + companyId: 5, + displayName: "샘플플랫폼", + legalName: "샘플플랫폼 주식회사", + aliases: ["Sample Platform"], + category: "PLATFORM", + legalSize: "LARGE", + talentKeywords: ["자율과 책임", "데이터 기반"], + dataAsOf: "2026-10-15", + }, + { + companyId: 6, + displayName: "샘플시큐리티", + legalName: "샘플시큐리티 주식회사", + aliases: ["Sample Security"], + category: "SECURITY", + legalSize: "MID", + talentKeywords: ["원칙", "꼼꼼함"], + dataAsOf: "2026-10-15", + }, + { + companyId: 7, + displayName: "샘플모빌리티", + legalName: "샘플모빌리티 주식회사", + aliases: ["Sample Mobility"], + category: "CONSUMER", + legalSize: null, + talentKeywords: ["고객 관점", "빠른 실행"], + dataAsOf: "2026-10-15", + }, +]; diff --git a/src/features/interview-page/company/model/type.ts b/src/features/interview-page/company/model/type.ts new file mode 100644 index 00000000..c808bf32 --- /dev/null +++ b/src/features/interview-page/company/model/type.ts @@ -0,0 +1,33 @@ +export type CompanyCategory = + | "IT_SERVICE" + | "PLATFORM" + | "FINTECH" + | "GAME" + | "CONSUMER" + | "SECURITY"; + +export type CompanyLegalSize = "LARGE" | "MID" | "SMALL"; + +export interface CompanySummary { + companyId: number; + displayName: string; + legalName: string; + aliases?: string[]; + category: CompanyCategory; + legalSize: CompanyLegalSize | null; // null = 미확인. 표시하지 않음 + talentKeywords: string[]; // 인재상 짧은 이름, 최대 3개 + dataAsOf: string; // YYYY-MM-DD +} + +export interface CompanyBadge { + companyId: number; + displayName: string; +} + +export const readCompanyBadge = (value: unknown): CompanyBadge | null => { + if (!value || typeof value !== "object") return null; + const item = value as Record; + return typeof item.companyId === "number" && Number.isSafeInteger(item.companyId) && item.companyId > 0 && + typeof item.displayName === "string" && item.displayName.trim().length > 0 && [...item.displayName.trim()].length <= 30 + ? { companyId: item.companyId, displayName: item.displayName.trim() } : null; +}; diff --git a/src/features/interview-page/interview/api/create-interview/index.ts b/src/features/interview-page/interview/api/create-interview/index.ts index 51bfc22f..c5124e81 100644 --- a/src/features/interview-page/interview/api/create-interview/index.ts +++ b/src/features/interview-page/interview/api/create-interview/index.ts @@ -1,3 +1,5 @@ +import axios from "axios"; +import { readCompanyBadge } from "@/features/interview-page/company/model/type"; import { apiInstance } from "@/shared/api/axiosInstance"; import { @@ -38,7 +40,7 @@ const getPersonaIds = (value: unknown) => { return value.reduce((personaIds, item) => { const personaId = getNumericValue(item); - if (personaId !== null && personaId > 0 && !personaIds.includes(personaId)) { + if (personaId !== null && Number.isSafeInteger(personaId) && personaId > 0 && !personaIds.includes(personaId)) { personaIds.push(personaId); } @@ -120,9 +122,9 @@ const normalizeCreatedInterview = ( if ( !sessionId || - interviewId === null || - userId === null || - (mode === "SOLO" && personaId === null) || + interviewId === null || !Number.isSafeInteger(interviewId) || interviewId <= 0 || + userId === null || !Number.isSafeInteger(userId) || userId <= 0 || + (mode === "SOLO" && (personaId === null || !Number.isSafeInteger(personaId) || personaId <= 0)) || (mode === "MULTI" && personaIds.length === 0) ) { return null; @@ -135,10 +137,13 @@ const normalizeCreatedInterview = ( return { interviewId, + company: readCompanyBadge(sourceRecord?.company), userId, personaId, personaIds: normalizedPersonaIds, mode, + answerMode: sourceRecord?.answerMode === "TEXT" || sourceRecord?.answerMode === "VOICE" + ? sourceRecord.answerMode : null, sessionId, status: isInterviewLifecycleStatus(sourceRecord?.status) ? sourceRecord.status @@ -155,14 +160,20 @@ const normalizeCreatedInterview = ( } satisfies CreatedInterviewData; }; -export const createInterview = async (params: CreateInterviewParams) => { +export const createInterview = async (params: CreateInterviewParams, idempotencyKey: string) => { try { - const response = await apiInstance.post(CREATE_INTERVIEW_URL, params); + const response = await apiInstance.post(CREATE_INTERVIEW_URL, params, { + headers: { "Idempotency-Key": idempotencyKey }, + timeout: 30_000, + }); const responseRecord = getRecord(response.data); if (responseRecord?.success === false) { return { data: null, + statusCode: null, + code: null, + definitive: false, errorMessage: "면접 시작에 실패했습니다.", }; } @@ -172,19 +183,31 @@ export const createInterview = async (params: CreateInterviewParams) => { if (!createdInterview) { return { data: null, + statusCode: null, + code: null, + definitive: false, errorMessage: "면접 세션 정보를 받지 못했습니다.", }; } return { data: createdInterview, + statusCode: null, + code: null, + definitive: false, errorMessage: null, }; } catch (error) { const errorMessage = getErrorMessage(error, "면접 시작에 실패했습니다."); + const status = axios.isAxiosError(error) ? error.response?.status : undefined; + const payload = axios.isAxiosError(error) ? getRecord(error.response?.data) : null; + const code = getTrimmedString(payload?.code); return { data: null, + code, + statusCode: status ?? null, + definitive: status !== undefined && [400, 404, 422].includes(status), errorMessage: errorMessage === "Internal Server Error" ? "면접 시작 서버에서 오류가 발생했습니다. 잠시 후 다시 시도해주세요." @@ -192,3 +215,15 @@ export const createInterview = async (params: CreateInterviewParams) => { }; } }; + +export const getCreatedInterview = async (interviewId: number, params: CreateInterviewParams, signal?: AbortSignal) => { + const response = await apiInstance.get("/api/interviews/get", { params: { interviewId }, signal, timeout: 15_000 }); + const data = normalizeCreatedInterview(response.data, params); + if (!data || data.interviewId !== interviewId) throw new Error("면접 정보를 확인하지 못했습니다."); + // 서버가 N:1 면접관을 기술 면접관 먼저로 재정렬하므로 순서는 비교하지 않는다. + if ("personaIds" in params && (params.personaIds.length !== data.personaIds.length || + params.personaIds.some((id) => !data.personaIds.includes(id)))) { + throw new Error("생성된 면접관 정보가 선택한 설정과 일치하지 않습니다."); + } + return data; +}; diff --git a/src/features/interview-page/interview/api/index.ts b/src/features/interview-page/interview/api/index.ts index f954b0e1..0b2a52fa 100644 --- a/src/features/interview-page/interview/api/index.ts +++ b/src/features/interview-page/interview/api/index.ts @@ -6,6 +6,7 @@ export { export { createInterview } from "./create-interview"; export { createAnswer } from "./create-answer"; export { getCurrentInterviewQuestion } from "./current-question"; +export { getQuestionAudio } from "./question-audio"; export { connectInterviewSocket, disconnectInterviewSocket, @@ -18,15 +19,18 @@ export { export { getInterviewPreparation, prepareInterviewRecord, + retryInterviewPreparation, } from "./prepare-interview-record"; export type { InterviewPreparationData, InterviewPreparationStatus, + InterviewTailorStatus, } from "./prepare-interview-record"; export { quitInterview } from "./quit-interview"; export { savePersona } from "./save-persona"; export { submitInterviewAnswer } from "./submit-answer"; export { uploadInterviewRecording } from "./upload-recording"; +export type { RecordingEndReason, UploadInterviewRecordingParams } from "./upload-recording"; export type { CreateInterviewParams, diff --git a/src/features/interview-page/interview/api/prepare-interview-record/index.ts b/src/features/interview-page/interview/api/prepare-interview-record/index.ts index 5c372366..0bd0a72e 100644 --- a/src/features/interview-page/interview/api/prepare-interview-record/index.ts +++ b/src/features/interview-page/interview/api/prepare-interview-record/index.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { apiInstance } from "@/shared/api/axiosInstance"; import { @@ -9,15 +10,23 @@ import { } from "../shared"; import type { CurrentInterviewQuestion } from "../type"; - const PREPARE_INTERVIEW_RECORD_URL = "/api/interviews"; const PREPARE_INTERVIEW_RECORD_BODY = 0; -const PREPARATION_STATUS_VALUES = [ +const TAILOR_STATUS_VALUES = [ "NOT_REQUESTED", + "WAITING", "PENDING", "SUCCEEDED", "FAILED", ] as const; +const PREPARATION_STATUS_VALUES = [ + "NOT_REQUESTED", + "PREPARING", + "READY", + "FAILED", +] as const; + +export type InterviewTailorStatus = (typeof TAILOR_STATUS_VALUES)[number]; export type InterviewPreparationStatus = (typeof PREPARATION_STATUS_VALUES)[number]; @@ -29,7 +38,10 @@ export interface InterviewPreparationData { jobId: string | null; questions: CurrentInterviewQuestion[]; retryAfterMs: number | null; - status: InterviewPreparationStatus; + status: InterviewTailorStatus | null; + preparationStatus: InterviewPreparationStatus; + failureStage: "QUESTION_GENERATION" | "CHAT_DELIVERY" | null; + retryable: boolean; } const getInterviewId = (payload: unknown) => { @@ -71,7 +83,7 @@ const getRetryAfterMs = (payload: unknown) => { return retryAfterMs !== null && retryAfterMs >= 0 ? retryAfterMs : null; }; -export const prepareInterviewRecord = async (interviewId: number) => { +export const prepareInterviewRecord = async (interviewId: number, signal?: AbortSignal) => { const requestUrl = `${PREPARE_INTERVIEW_RECORD_URL}/${encodeURIComponent( interviewId, )}`; @@ -87,8 +99,11 @@ export const prepareInterviewRecord = async (interviewId: number) => { const response = await apiInstance.post( requestUrl, PREPARE_INTERVIEW_RECORD_BODY, + { signal, timeout: 15_000 }, ); const responseInterviewId = getInterviewId(response.data); + const responseRecord = getRecord(response.data); + const sourceRecord = getRecord(responseRecord?.data) ?? responseRecord; if (responseInterviewId === null || responseInterviewId <= 0) { return { @@ -102,6 +117,8 @@ export const prepareInterviewRecord = async (interviewId: number) => { interviewId: responseInterviewId, jobId: getJobId(response.data) ?? undefined, sessionId: getSessionId(response.data) ?? undefined, + analysisJobId: getTrimmedString(sourceRecord?.analysisJobId) ?? undefined, + status: getTrimmedString(sourceRecord?.status), }, errorMessage: null, }; @@ -114,6 +131,7 @@ export const prepareInterviewRecord = async (interviewId: number) => { return { data: null, errorMessage, + statusCode: axios.isAxiosError(error) ? error.response?.status ?? null : null, }; } }; @@ -124,7 +142,10 @@ const isInterviewPreparationStatus = ( value !== null && PREPARATION_STATUS_VALUES.includes(value as InterviewPreparationStatus); -export const getInterviewPreparation = async (interviewId: number) => { +export const getInterviewPreparation = async ( + interviewId: number, + signal?: AbortSignal, +) => { if (!Number.isInteger(interviewId) || interviewId <= 0) { return { data: null, @@ -135,12 +156,16 @@ export const getInterviewPreparation = async (interviewId: number) => { try { const response = await apiInstance.get("/api/questions/tailor", { params: { interviewId }, + signal, + timeout: 15_000, }); const responseRecord = getRecord(response.data); const sourceRecord = getRecord(responseRecord?.data) ?? responseRecord; const status = getTrimmedString(sourceRecord?.status)?.toUpperCase() ?? null; + const preparationStatus = + getTrimmedString(sourceRecord?.preparationStatus)?.toUpperCase() ?? null; - if (!isInterviewPreparationStatus(status)) { + if (!isInterviewPreparationStatus(preparationStatus)) { return { data: null, errorMessage: "면접 준비 상태를 확인하지 못했습니다.", @@ -171,7 +196,16 @@ export const getInterviewPreparation = async (interviewId: number) => { jobId: getTrimmedString(sourceRecord?.jobId), questions, retryAfterMs: getRetryAfterMs(sourceRecord?.retryAfterMs), - status, + status: TAILOR_STATUS_VALUES.includes(status as InterviewTailorStatus) + ? (status as InterviewTailorStatus) + : null, + preparationStatus, + failureStage: + sourceRecord?.failureStage === "QUESTION_GENERATION" || + sourceRecord?.failureStage === "CHAT_DELIVERY" + ? sourceRecord.failureStage + : null, + retryable: sourceRecord?.retryable === true, } satisfies InterviewPreparationData, errorMessage: null, }; @@ -182,3 +216,22 @@ export const getInterviewPreparation = async (interviewId: number) => { }; } }; + +export const retryInterviewPreparation = async ( + interviewId: number, + signal?: AbortSignal, +) => { + try { + await apiInstance.post( + `/api/interviews/${encodeURIComponent(interviewId)}/preparation/retry`, + undefined, + { signal }, + ); + return { errorMessage: null, statusCode: null }; + } catch (error) { + return { + errorMessage: getErrorMessage(error, "면접 준비를 다시 요청하지 못했습니다."), + statusCode: axios.isAxiosError(error) ? error.response?.status ?? null : null, + }; + } +}; diff --git a/src/features/interview-page/interview/api/question-audio/index.ts b/src/features/interview-page/interview/api/question-audio/index.ts new file mode 100644 index 00000000..7d690d81 --- /dev/null +++ b/src/features/interview-page/interview/api/question-audio/index.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import { chatInstance } from "@/shared/api/axiosInstance"; +import { getErrorMessage, getQuestionAudioResponse } from "../shared"; +import type { QuestionAudioResponse } from "../type"; + +export type GetQuestionAudioResult = + | { data: QuestionAudioResponse; errorMessage: null } + | { data: null; errorMessage: null; aborted: true } + | { data: null; errorMessage: null; notFound: true } + | { data: null; errorMessage: string }; + +export const getQuestionAudio = async ({ sessionId, questionId, signal }: { + sessionId: string; + questionId: number; + signal?: AbortSignal; +}): Promise => { + try { + const response = await chatInstance.get( + `/chat/interviews/${encodeURIComponent(sessionId)}/questions/${questionId}/audio`, + { params: { wait: 20 }, signal, timeout: 30_000 }, + ); + const data = getQuestionAudioResponse(response.data); + if (!data || data.questionId !== questionId) { + return { data: null, errorMessage: "질문 음성 응답 형식이 올바르지 않습니다." }; + } + return { data, errorMessage: null }; + } catch (error) { + if (axios.isCancel(error)) return { data: null, errorMessage: null, aborted: true }; + if (axios.isAxiosError(error) && error.response?.status === 404) { + return { data: null, errorMessage: null, notFound: true }; + } + return { data: null, errorMessage: getErrorMessage(error, "질문 음성을 조회하지 못했습니다.") }; + } +}; diff --git a/src/features/interview-page/interview/api/quit-interview/index.ts b/src/features/interview-page/interview/api/quit-interview/index.ts index 2eee15ad..46305759 100644 --- a/src/features/interview-page/interview/api/quit-interview/index.ts +++ b/src/features/interview-page/interview/api/quit-interview/index.ts @@ -1,3 +1,4 @@ +import axios from "axios"; import { chatInstance } from "@/shared/api/axiosInstance"; import { getErrorMessage, getTrimmedString } from "../shared"; @@ -10,6 +11,7 @@ export const quitInterview = async (sessionId: string) => { return { data: null, errorMessage: "종료할 면접 세션 정보가 없습니다.", + statusCode: null, }; } @@ -21,11 +23,13 @@ export const quitInterview = async (sessionId: string) => { return { data: response.data, errorMessage: null, + statusCode: null, }; } catch (error) { return { data: null, errorMessage: getErrorMessage(error, "면접 종료에 실패했습니다."), + statusCode: axios.isAxiosError(error) ? error.response?.status ?? null : null, }; } }; diff --git a/src/features/interview-page/interview/api/save-persona/index.ts b/src/features/interview-page/interview/api/save-persona/index.ts index 629c2d6d..b741c002 100644 --- a/src/features/interview-page/interview/api/save-persona/index.ts +++ b/src/features/interview-page/interview/api/save-persona/index.ts @@ -32,7 +32,7 @@ const normalizeSavedPersona = ( fallbackData.role, major: (getTrimmedString(sourceRecord?.major) as SavedPersonaData["major"] | null) ?? - fallbackData.major, + null, type: (getTrimmedString(sourceRecord?.type) as SavedPersonaData["type"] | null) ?? fallbackData.type, diff --git a/src/features/interview-page/interview/api/shared.ts b/src/features/interview-page/interview/api/shared.ts index abaa4613..b01ed657 100644 --- a/src/features/interview-page/interview/api/shared.ts +++ b/src/features/interview-page/interview/api/shared.ts @@ -4,6 +4,7 @@ import type { CurrentInterviewQuestion, InterviewProgressStatus, PersonaType, + QuestionAudioResponse, } from "./type"; const PERSONA_TYPES: readonly PersonaType[] = [ @@ -105,6 +106,24 @@ export const getInterviewQuestion = ( }; }; +export const getQuestionAudioResponse = (value: unknown): QuestionAudioResponse | null => { + const record = getRecord(value); + const questionId = getNumericValue(record?.questionId); + const rawStatus = record?.status; + const status = rawStatus === "QUEUED" || rawStatus === "GENERATING" ? "PENDING" : rawStatus; + const url = getTrimmedString(record?.url); + + if (questionId === null || (status !== "READY" && status !== "PENDING" && status !== "FAILED")) return null; + if ((status === "READY" && !url) || (url && !url.startsWith("https://"))) return null; + + return { + questionId, + status, + url, + errorCode: getTrimmedString(record?.errorCode), + }; +}; + export const getInterviewEvent = (value: unknown) => { const responseRecord = getRecord(value); const responseDataRecord = getRecord(responseRecord?.data); diff --git a/src/features/interview-page/interview/api/type/create-interview.ts b/src/features/interview-page/interview/api/type/create-interview.ts index 621366f5..01888bd6 100644 --- a/src/features/interview-page/interview/api/type/create-interview.ts +++ b/src/features/interview-page/interview/api/type/create-interview.ts @@ -1,6 +1,8 @@ +import type { CompanyBadge } from "@/features/interview-page/company/model/type"; +import type { JobRoleCode } from "@/shared/constants/job/jobRoles"; import type { PrepareInterviewQuestion } from "./prepare-interview"; -export type InterviewPersonaMajor = "BACKEND" | "FRONTEND"; +export type InterviewPersonaMajor = JobRoleCode; export type InterviewPersonality = | "FRIENDLY" | "REALISTIC" @@ -9,6 +11,7 @@ export type CreateInterviewPersonaType = InterviewPersonality; export type InterviewPersonaGender = "MALE" | "FEMALE"; export type InterviewPersonaRole = "TECH" | "HR" | "CEO" | "PM" | "DESIGN"; export type InterviewTone = "GENTLE" | "DIRECT" | "PRESSURING"; +export type InterviewAnswerMode = "TEXT" | "VOICE"; export type InterviewMode = "SOLO" | "MULTI"; export type InterviewLifecycleStatus = | "IN_PROGRESS" @@ -16,14 +19,18 @@ export type InterviewLifecycleStatus = | "ABANDONED"; export interface CreateSoloInterviewParams { + companyId?: number; + answerMode?: InterviewAnswerMode | null; personaName: string; - major: InterviewPersonaMajor; + major?: InterviewPersonaMajor; type: CreateInterviewPersonaType; career: number; gender: InterviewPersonaGender; } export interface CreateMultiInterviewParams { + companyId?: number; + answerMode?: InterviewAnswerMode | null; personaIds: number[]; } @@ -32,9 +39,11 @@ export type CreateInterviewParams = | CreateMultiInterviewParams; export interface CreatedInterviewData { + company: CompanyBadge | null; interviewId: number; userId: number; mode: InterviewMode; + answerMode?: InterviewAnswerMode | null; personaId: number | null; personaIds: number[]; sessionId: string; diff --git a/src/features/interview-page/interview/api/type/index.ts b/src/features/interview-page/interview/api/type/index.ts index b1c2617c..8dbc204c 100644 --- a/src/features/interview-page/interview/api/type/index.ts +++ b/src/features/interview-page/interview/api/type/index.ts @@ -6,6 +6,7 @@ export type { CreatedInterviewData, InterviewLifecycleStatus, InterviewMode, + InterviewAnswerMode, InterviewPersonaGender, InterviewPersonaMajor, InterviewPersonaRole, @@ -14,6 +15,7 @@ export type { } from "./create-interview"; export type { CreateAnswerRequest } from "./create-answer"; export type { CurrentInterviewQuestion } from "./current-question"; +export type { QuestionAudioResponse, QuestionAudioServerStatus } from "./question-audio"; export type { InterviewLevel, InterviewProgressStatus, diff --git a/src/features/interview-page/interview/api/type/prepare-interview.ts b/src/features/interview-page/interview/api/type/prepare-interview.ts index ad8ca76a..43b82637 100644 --- a/src/features/interview-page/interview/api/type/prepare-interview.ts +++ b/src/features/interview-page/interview/api/type/prepare-interview.ts @@ -1,3 +1,4 @@ +import type { CompanyBadge } from "@/features/interview-page/company/model/type"; import type { CreateInterviewPersonaType, InterviewPersonaGender, @@ -57,6 +58,9 @@ export interface PrepareInterviewParams { } export interface PreparedInterviewData { + company?: CompanyBadge | null; + analysisJobId?: string; + questionPreparationStatus?: string | null; sessionId: string; interviewId: number; userId: number; @@ -75,5 +79,6 @@ export interface PreparedInterviewData { currentQuestionIndex: number; questions: PrepareInterviewQuestion[]; mode: PreparedInterviewMode; + answerMode?: "voice" | "text"; interviewers?: PreparedInterviewer[]; } diff --git a/src/features/interview-page/interview/api/type/question-audio.ts b/src/features/interview-page/interview/api/type/question-audio.ts new file mode 100644 index 00000000..9da78c2d --- /dev/null +++ b/src/features/interview-page/interview/api/type/question-audio.ts @@ -0,0 +1,8 @@ +export type QuestionAudioServerStatus = "READY" | "PENDING" | "FAILED"; + +export interface QuestionAudioResponse { + questionId: number; + status: QuestionAudioServerStatus; + url: string | null; + errorCode: string | null; +} diff --git a/src/features/interview-page/interview/api/type/save-persona.ts b/src/features/interview-page/interview/api/type/save-persona.ts index f8972a76..8f80db90 100644 --- a/src/features/interview-page/interview/api/type/save-persona.ts +++ b/src/features/interview-page/interview/api/type/save-persona.ts @@ -1,3 +1,4 @@ +import type { VoiceIndex } from "@/shared/constants/interview-page/voice-catalog"; import type { CreateInterviewPersonaType, InterviewPersonaGender, @@ -10,12 +11,12 @@ import type { InterviewLevel } from "./prepare-interview"; export interface SavePersonaParams { personaName: string; role: InterviewPersonaRole; - major: InterviewPersonaMajor | null; type: CreateInterviewPersonaType; tone: InterviewTone; level: InterviewLevel; career: number; gender: InterviewPersonaGender; + voiceIndex?: VoiceIndex; imageUrl: string; description: string; } diff --git a/src/features/interview-page/interview/api/upload-recording/index.ts b/src/features/interview-page/interview/api/upload-recording/index.ts index 1a76e63b..4c9f91cb 100644 --- a/src/features/interview-page/interview/api/upload-recording/index.ts +++ b/src/features/interview-page/interview/api/upload-recording/index.ts @@ -1,31 +1,31 @@ import { apiInstance } from "@/shared/api/axiosInstance"; import { extractErrorMessage } from "@/shared/api/errorMessage"; -interface UploadInterviewRecordingParams { +export type RecordingEndReason = "user" | "timeout" | "interrupted" | "unknown"; + +export type UploadInterviewRecordingParams = { interviewId: number; - questionId?: number | null; file: File; -} +} & ( + | { kind: "ANSWER"; questionId: number; endReason: RecordingEndReason } + | { kind: "FULL_INTERVIEW"; questionId?: never; endReason?: never } +); -export const uploadInterviewRecording = async ({ - interviewId, - questionId, - file, -}: UploadInterviewRecordingParams) => { +export const uploadInterviewRecording = async (params: UploadInterviewRecordingParams) => { const formData = new FormData(); - formData.append("file", file); - - if (questionId != null) { - formData.append("questionId", String(questionId)); + formData.append("file", params.file); + formData.append("kind", params.kind); + if (params.kind === "ANSWER") { + formData.append("questionId", String(params.questionId)); + formData.append("endReason", params.endReason); } try { const response = await apiInstance.post( - `/api/interviews/${encodeURIComponent(interviewId)}/recordings`, + `/api/interviews/${encodeURIComponent(params.interviewId)}/recordings`, formData, - { headers: { "Content-Type": "multipart/form-data" } }, + { timeout: 120_000 }, ); - return { data: response.data.data, errorMessage: null }; } catch (error) { return { diff --git a/src/features/interview-page/interview/model/activeInterviewLifecycle.ts b/src/features/interview-page/interview/model/activeInterviewLifecycle.ts new file mode 100644 index 00000000..24fb02de --- /dev/null +++ b/src/features/interview-page/interview/model/activeInterviewLifecycle.ts @@ -0,0 +1,25 @@ +type CloseInterview = () => Promise; +let closeInterview: CloseInterview | null = null; +let pendingInterviewLeave: (() => Promise) | null = null; + +export const setPendingInterviewLeave = (leave: () => Promise) => { pendingInterviewLeave = leave; }; +export const clearPendingInterviewLeave = () => { pendingInterviewLeave = null; }; +export const resumePendingInterviewLeave = async () => { + const leave = pendingInterviewLeave; + pendingInterviewLeave = null; + if (!leave) return false; + await leave(); + return true; +}; + +export const registerInterviewLifecycle = (close: CloseInterview) => { + closeInterview = close; + return () => { + if (closeInterview === close) { + closeInterview = null; + clearPendingInterviewLeave(); + } + }; +}; + +export const finishActiveInterview = async () => closeInterview ? closeInterview() : true; diff --git a/src/features/interview-page/interview/model/interviewRecordingStore.ts b/src/features/interview-page/interview/model/interviewRecordingStore.ts index 636b3c79..325ad855 100644 --- a/src/features/interview-page/interview/model/interviewRecordingStore.ts +++ b/src/features/interview-page/interview/model/interviewRecordingStore.ts @@ -1,24 +1,73 @@ import { create } from "zustand"; +import { uploadInterviewRecording } from "../api/upload-recording"; +import type { UploadInterviewRecordingParams } from "../api/upload-recording"; -interface InterviewRecordingState { - recordingId: number | null; - videoFile: File | null; - audioFiles: File[]; - begin: (recordingId: number) => void; - setVideoFile: (recordingId: number, file: File) => void; - addAudioFile: (recordingId: number, file: File) => void; +interface RecordingUploadJob { + id: string; + interviewId: number; + kind: UploadInterviewRecordingParams["kind"]; + questionId?: number; + status: "UPLOADING" | "SUCCEEDED" | "FAILED"; + errorMessage: string | null; + canRetry: boolean; } +interface RecordingUploadState { + jobs: RecordingUploadJob[]; +} + +export const useRecordingUploadStore = create(() => ({ jobs: [] })); +const files = new Map(); +const requests = new Map>(); + +const runUpload = (id: string) => { + const params = files.get(id); + if (!params || requests.has(id)) return; + useRecordingUploadStore.setState((state) => ({ + jobs: state.jobs.map((job) => job.id === id ? { ...job, status: "UPLOADING", errorMessage: null } : job), + })); + const request = uploadInterviewRecording(params).then(({ errorMessage }) => { + if (!errorMessage) files.delete(id); + useRecordingUploadStore.setState((state) => ({ + jobs: state.jobs.map((job) => job.id === id ? { + ...job, status: errorMessage ? "FAILED" : "SUCCEEDED", errorMessage, + } : job), + })); + }).finally(() => { requests.delete(id); }); + requests.set(id, request); +}; + +export const enqueueRecordingUpload = (params: UploadInterviewRecordingParams) => { + const id = crypto.randomUUID(); + files.set(id, params); + useRecordingUploadStore.setState((state) => ({ + jobs: [...state.jobs, { + id, interviewId: params.interviewId, kind: params.kind, + ...(params.kind === "ANSWER" ? { questionId: params.questionId } : {}), + status: "UPLOADING", errorMessage: null, canRetry: true, + }], + })); + runUpload(id); +}; + +export const reportRecordingFailure = (params: { + interviewId: number; + kind: UploadInterviewRecordingParams["kind"]; + questionId?: number; + errorMessage: string; +}) => { + useRecordingUploadStore.setState((state) => ({ + jobs: [...state.jobs, { ...params, id: crypto.randomUUID(), status: "FAILED", canRetry: false }], + })); +}; -// 최근 면접 파일을 메모리에만 보관한다. 다음 면접을 시작하면 교체한다. -export const useInterviewRecordingStore = create((set) => ({ - recordingId: null, - videoFile: null, - audioFiles: [], - begin: (recordingId) => set((state) => state.recordingId === recordingId - ? state - : { recordingId, videoFile: null, audioFiles: [] }), - setVideoFile: (recordingId, videoFile) => set((state) => - state.recordingId === recordingId ? { videoFile } : state), - addAudioFile: (recordingId, file) => set((state) => - state.recordingId === recordingId ? { audioFiles: [...state.audioFiles, file] } : state), -})); +export const retryRecordingUpload = (id: string) => { runUpload(id); }; +export const dismissRecordingUpload = (id: string) => { + if (requests.has(id)) return; + files.delete(id); + useRecordingUploadStore.setState((state) => ({ jobs: state.jobs.filter((job) => job.id !== id) })); +}; +export const waitForRecordingUploads = async () => { + while (requests.size) await Promise.all([...requests.values()]); + return files.size === 0; +}; +export const hasUnsavedRecordings = () => files.size > 0; diff --git a/src/features/interview-page/interview/model/interviewSessionContext.tsx b/src/features/interview-page/interview/model/interviewSessionContext.tsx index c78ff2b2..6c472b7c 100644 --- a/src/features/interview-page/interview/model/interviewSessionContext.tsx +++ b/src/features/interview-page/interview/model/interviewSessionContext.tsx @@ -2,23 +2,18 @@ import { type ReactNode } from "react"; import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; import { InterviewSessionContext } from "./interviewSessionContextValue"; -import { - useInterviewSession, - type InterviewTtsProvider, -} from "./useInterviewSession"; +import { useInterviewSession } from "./useInterviewSession"; interface InterviewSessionProviderProps { children: ReactNode; preparedInterview?: PreparedInterviewData | null; - ttsProvider?: InterviewTtsProvider; } export const InterviewSessionProvider = ({ children, preparedInterview, - ttsProvider = "elevenlabs", }: InterviewSessionProviderProps) => { - const session = useInterviewSession(preparedInterview, ttsProvider); + const session = useInterviewSession(preparedInterview); return ( diff --git a/src/features/interview-page/interview/model/mediaRecording.ts b/src/features/interview-page/interview/model/mediaRecording.ts index 9d0af468..5176e7cc 100644 --- a/src/features/interview-page/interview/model/mediaRecording.ts +++ b/src/features/interview-page/interview/model/mediaRecording.ts @@ -16,10 +16,11 @@ export const createMediaRecording = ( stream: MediaStream, mimeType: string, onError: (error: unknown) => void, + options: MediaRecorderOptions = {}, ): MediaRecording => { const release = () => stream.getTracks().forEach((track) => track.stop()); try { - const recorder = new MediaRecorder(stream, { mimeType }); + const recorder = new MediaRecorder(stream, { ...options, mimeType }); const chunks: Blob[] = []; let settled = false; let resolveResult: (blob: Blob | null) => void = () => {}; @@ -64,7 +65,6 @@ export const createMediaRecording = ( if (!settled && recorder.state !== "inactive") { try { recorder.stop(); - release(); } catch (error) { onError(error); finish(null); diff --git a/src/features/interview-page/interview/model/uploadRecordingInBackground.ts b/src/features/interview-page/interview/model/uploadRecordingInBackground.ts deleted file mode 100644 index 168c0cd6..00000000 --- a/src/features/interview-page/interview/model/uploadRecordingInBackground.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { uploadInterviewRecording } from "../api"; - -interface UploadRecordingParams { - interviewId: number | null; - questionId?: number; - file: File | null; -} - -export const uploadRecordingInBackground = ({ - interviewId, - questionId, - file, -}: UploadRecordingParams) => { - if (!file || interviewId == null) { - return; - } - - // 업로드를 기다리면 다음 질문 진행이 영상 크기만큼 늦어질 수 있다. - void uploadInterviewRecording({ interviewId, questionId, file }).then( - ({ errorMessage }) => { - if (errorMessage) { - console.error("녹화 업로드 실패:", errorMessage); - } - }, - ); -}; diff --git a/src/features/interview-page/interview/model/useAnswerRecorder.ts b/src/features/interview-page/interview/model/useAnswerRecorder.ts new file mode 100644 index 00000000..6bd7d97f --- /dev/null +++ b/src/features/interview-page/interview/model/useAnswerRecorder.ts @@ -0,0 +1,4 @@ +import { useMediaRecorder } from "./useMediaRecorder"; + +export const useAnswerRecorder = (cloneAudioTrack: () => MediaStreamTrack | null) => + useMediaRecorder("audio", cloneAudioTrack); diff --git a/src/features/interview-page/interview/model/useElevenLabsTts.ts b/src/features/interview-page/interview/model/useElevenLabsTts.ts deleted file mode 100644 index dd98ccc3..00000000 --- a/src/features/interview-page/interview/model/useElevenLabsTts.ts +++ /dev/null @@ -1,372 +0,0 @@ -import type { QuestionAudioStatus } from "@/widgets/interview-page/interview/type"; -import { useEffect, useRef, useState } from "react"; - -const ELEVENLABS_TTS_API_URL = "/api/elevenlabs/tts"; -const ELEVENLABS_VOICES_API_URL = "/api/elevenlabs/voices"; -const ELEVENLABS_CONFIGURED_VOICE_ID = - import.meta.env.VITE_ELEVENLABS_VOICE_ID?.trim(); -const ELEVENLABS_ENV = import.meta.env as Record; -const ELEVENLABS_VOICE_NAME = "Kelee K - Seoul Narrator"; -const ELEVENLABS_FALLBACK_VOICE_NAME = "Sarah - Mature, Reassuring, Confident"; -const ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"; -const ELEVENLABS_OUTPUT_FORMAT = "mp3_44100_128"; -const ELEVENLABS_SPEECH_SPEED = 1.1; - -const isPlaceholderVoiceId = (voiceId: string | undefined) => - !voiceId || voiceId === "Kelee_K_Voice_ID"; - -const getConfiguredVoiceId = (voiceIndex?: number) => { - if (voiceIndex !== undefined) { - const voiceId = - ELEVENLABS_ENV[`VITE_ELEVENLABS_VOICE_ID${voiceIndex}`]?.trim(); - - if (voiceId) { - return voiceId; - } - } - - return ELEVENLABS_CONFIGURED_VOICE_ID; -}; - -const getVoiceIdFromAccount = async ( - signal: AbortSignal, - voiceName = ELEVENLABS_VOICE_NAME, -) => { - // ElevenLabs의 search 파라미터에 Kelee 이름을 넣으면 다른 음성이 응답에서 - // 제외되므로, 계정 음성 목록 전체에서 지정 음성과 대체 음성을 찾는다. - const searchParams = new URLSearchParams({ page_size: "100" }); - const response = await fetch(`${ELEVENLABS_VOICES_API_URL}?${searchParams}`, { - headers: { - Accept: "application/json", - }, - signal, - }); - - if (!response.ok) { - throw new Error(`ElevenLabs voice list failed with ${response.status}`); - } - - const payload: unknown = await response.json(); - const voices = - payload && - typeof payload === "object" && - "voices" in payload && - Array.isArray(payload.voices) - ? payload.voices - : []; - const matchedVoice = voices.find((voice) => { - if (!voice || typeof voice !== "object") { - return false; - } - - return (voice as { name?: unknown }).name === voiceName; - }); - - const voiceId = - matchedVoice && typeof matchedVoice === "object" - ? (matchedVoice as { voice_id?: unknown }).voice_id - : null; - - if (typeof voiceId === "string" && voiceId.trim()) { - return voiceId.trim(); - } - - if (voiceName !== ELEVENLABS_VOICE_NAME) { - throw new Error(`ElevenLabs voice not found: ${voiceName}.`); - } - - const fallbackVoice = voices.find((voice) => { - if (!voice || typeof voice !== "object") { - return false; - } - - return ( - (voice as { name?: unknown }).name === ELEVENLABS_FALLBACK_VOICE_NAME - ); - }); - const fallbackVoiceId = - fallbackVoice && typeof fallbackVoice === "object" - ? (fallbackVoice as { voice_id?: unknown }).voice_id - : null; - - if (typeof fallbackVoiceId === "string" && fallbackVoiceId.trim()) { - return fallbackVoiceId.trim(); - } - - throw new Error( - `ElevenLabs voice not found: ${ELEVENLABS_VOICE_NAME}. Set VITE_ELEVENLABS_VOICE_ID.`, - ); -}; - -const createSpeechRequest = (text: string) => - JSON.stringify({ - text, - model_id: ELEVENLABS_MODEL_ID, - voice_settings: { - speed: ELEVENLABS_SPEECH_SPEED, - }, - }); - -const requestSpeech = (voiceId: string, text: string, signal: AbortSignal) => - fetch( - `${ELEVENLABS_TTS_API_URL}/${encodeURIComponent(voiceId)}?output_format=${ELEVENLABS_OUTPUT_FORMAT}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: createSpeechRequest(text), - signal, - }, - ); - -const isVoiceAccessRestricted = async (response: Response) => { - if (response.status !== 400) { - return false; - } - - const body = await response.clone().text(); - return ( - body.includes("free_users_not_allowed") || - body.includes("creator tier or above") - ); -}; - -export const useElevenLabsTts = (text: string, voiceIndex?: number) => { - const [status, setStatus] = useState("idle"); - const [errorMessage, setErrorMessage] = useState(null); - const audioRef = useRef(null); - const audioUrlRef = useRef(null); - const speechRef = useRef(null); - const abortControllerRef = useRef(null); - const resolvedVoiceIdRef = useRef(null); - const voiceIdRequestRef = useRef | null>(null); - const configuredVoiceId = getConfiguredVoiceId(voiceIndex); - - const clearAudioUrl = () => { - if (audioUrlRef.current) { - URL.revokeObjectURL(audioUrlRef.current); - audioUrlRef.current = null; - } - }; - - const releaseAudio = () => { - if (!audioRef.current) { - return; - } - - audioRef.current.pause(); - audioRef.current.currentTime = 0; - audioRef.current.onended = null; - audioRef.current = null; - }; - - const releaseSpeech = () => { - if (typeof window === "undefined" || !window.speechSynthesis) { - return; - } - - window.speechSynthesis.cancel(); - speechRef.current = null; - }; - - const stop = () => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - releaseAudio(); - releaseSpeech(); - clearAudioUrl(); - setStatus("idle"); - }; - - useEffect(() => { - resolvedVoiceIdRef.current = isPlaceholderVoiceId(configuredVoiceId) - ? null - : (configuredVoiceId ?? null); - }, [configuredVoiceId]); - - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - releaseAudio(); - releaseSpeech(); - clearAudioUrl(); - }; - }, []); - - const playWithBrowserSpeech = (speechText: string, signal: AbortSignal) => { - if (typeof window === "undefined" || !window.speechSynthesis) { - throw new Error("브라우저 음성 기능을 사용할 수 없습니다."); - } - - return new Promise((resolve, reject) => { - const utterance = new SpeechSynthesisUtterance(speechText); - utterance.lang = "ko-KR"; - utterance.rate = ELEVENLABS_SPEECH_SPEED; - speechRef.current = utterance; - - const finish = () => { - speechRef.current = null; - setStatus("idle"); - resolve(); - }; - - utterance.onend = finish; - utterance.onerror = (event) => { - speechRef.current = null; - - if (signal.aborted || event.error === "canceled") { - resolve(); - return; - } - - setStatus("idle"); - reject(new Error(`브라우저 음성 재생 실패: ${event.error}`)); - }; - - signal.addEventListener( - "abort", - () => { - window.speechSynthesis.cancel(); - finish(); - }, - { once: true }, - ); - - window.speechSynthesis.speak(utterance); - setStatus("playing"); - }); - }; - - const play = async () => { - if (!text.trim() || status === "loading") { - return; - } - - stop(); - setErrorMessage(null); - - const controller = new AbortController(); - abortControllerRef.current = controller; - setStatus("loading"); - - try { - if (!resolvedVoiceIdRef.current) { - if (!isPlaceholderVoiceId(configuredVoiceId)) { - resolvedVoiceIdRef.current = configuredVoiceId; - } else { - const voiceIdRequest = - voiceIdRequestRef.current ?? - getVoiceIdFromAccount(controller.signal); - voiceIdRequestRef.current = voiceIdRequest; - - try { - resolvedVoiceIdRef.current = await voiceIdRequest; - } catch (error) { - voiceIdRequestRef.current = null; - throw error; - } - } - } - - const resolvedVoiceId = resolvedVoiceIdRef.current; - if (!resolvedVoiceId) { - throw new Error("ElevenLabs voice ID를 확인할 수 없습니다."); - } - - let response = await requestSpeech( - resolvedVoiceId, - text, - controller.signal, - ); - - if ( - (await isVoiceAccessRestricted(response)) && - resolvedVoiceIdRef.current !== ELEVENLABS_FALLBACK_VOICE_NAME - ) { - const fallbackVoiceId = await getVoiceIdFromAccount( - controller.signal, - ELEVENLABS_FALLBACK_VOICE_NAME, - ); - - if (fallbackVoiceId !== resolvedVoiceIdRef.current) { - resolvedVoiceIdRef.current = fallbackVoiceId; - response = await requestSpeech( - fallbackVoiceId, - text, - controller.signal, - ); - } - } - - if (!response.ok) { - if (response.status === 401) { - setErrorMessage( - "ElevenLabs 인증에 실패했습니다. API 키와 권한을 확인해주세요.", - ); - await playWithBrowserSpeech(text, controller.signal); - return; - } - - setErrorMessage( - `ElevenLabs TTS 요청에 실패했습니다. (${response.status})`, - ); - throw new Error(`ElevenLabs TTS failed with ${response.status}`); - } - - const contentType = response.headers.get("content-type") ?? ""; - if (!contentType.toLowerCase().startsWith("audio/")) { - setErrorMessage( - "TTS 응답이 오디오가 아닙니다. 프록시 설정을 확인해주세요.", - ); - await playWithBrowserSpeech(text, controller.signal); - return; - } - const audioBlob = await response.blob(); - const audioUrl = URL.createObjectURL(audioBlob); - const audio = new Audio(audioUrl); - - audioRef.current = audio; - audioUrlRef.current = audioUrl; - - audio.onended = () => { - releaseAudio(); - clearAudioUrl(); - setStatus("idle"); - }; - - await audio.play(); - setStatus("playing"); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - return; - } - - releaseAudio(); - clearAudioUrl(); - setStatus("idle"); - if (error instanceof Error && !errorMessage) { - setErrorMessage(error.message); - } - } finally { - abortControllerRef.current = null; - } - }; - - const toggle = () => { - if (status === "playing") { - stop(); - return; - } - - void play(); - }; - - return { - onPlay: play, - status, - errorMessage, - onStop: stop, - onToggle: toggle, - }; -}; diff --git a/src/features/interview-page/interview/model/useInterviewMedia.ts b/src/features/interview-page/interview/model/useInterviewMedia.ts index 090f7368..a808cfb5 100644 --- a/src/features/interview-page/interview/model/useInterviewMedia.ts +++ b/src/features/interview-page/interview/model/useInterviewMedia.ts @@ -50,6 +50,12 @@ export const useInterviewMedia = (enabled: boolean) => { [attachStream], ); + const releaseMedia = useCallback(() => { + streamRef.current?.getTracks().forEach((track) => track.stop()); + streamRef.current = null; + if (videoElementRef.current) videoElementRef.current.srcObject = null; + }, []); + useEffect(() => { let cancelled = false; let currentStream: MediaStream | null = null; @@ -138,6 +144,7 @@ export const useInterviewMedia = (enabled: boolean) => { return { cameraState, + releaseMedia, cloneAudioTrack, cloneVideoTrack, micState, diff --git a/src/features/interview-page/interview/model/useInterviewRecorder.ts b/src/features/interview-page/interview/model/useInterviewRecorder.ts index 77f1d4f2..76e58183 100644 --- a/src/features/interview-page/interview/model/useInterviewRecorder.ts +++ b/src/features/interview-page/interview/model/useInterviewRecorder.ts @@ -1,165 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from "react"; - -import { createMediaRecording, getSupportedRecordingType } from "./mediaRecording"; -import type { MediaRecording } from "./mediaRecording"; - -const VIDEO_MIME_TYPES = [ - 'video/mp4;codecs="avc1.42E01E,mp4a.40.2"', - "video/mp4", -]; - -interface RecordingSession { - capture: MediaRecording; - result: Promise; -} - -const getRecordingErrorMessage = (error: unknown) => { - if (error instanceof DOMException && error.name === "NotAllowedError") { - return "카메라와 마이크 권한을 허용한 뒤 다시 시도해주세요."; - } - - if (error instanceof DOMException && error.name === "NotFoundError") { - return "사용할 수 있는 카메라 또는 마이크를 찾을 수 없습니다."; - } - - return error instanceof Error ? error.message : "영상 녹화를 시작할 수 없습니다."; -}; - -const createRecordingFile = (blob: Blob) => - new File([blob], `interview-${Date.now()}.mp4`, { - type: "video/mp4", - }); +import { useMediaRecorder } from "./useMediaRecorder"; export const useInterviewRecorder = ( cloneVideoTrack: () => MediaStreamTrack | null, cloneAudioTrack: () => MediaStreamTrack | null, -) => { - const [videoFile, setVideoFile] = useState(null); - const [isRecording, setIsRecording] = useState(false); - const [isStarting, setIsStarting] = useState(false); - const [isStopping, setIsStopping] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - const sessionRef = useRef(null); - const startRequestRef = useRef(null); - const isMountedRef = useRef(true); - - const reportError = useCallback((error: unknown) => { - if (isMountedRef.current) { - setErrorMessage(getRecordingErrorMessage(error)); - } - - console.error("인터뷰 영상 녹화 실패:", error); - }, []); - - const startRecording = useCallback(async (): Promise => { - if (!isMountedRef.current || startRequestRef.current || sessionRef.current) { - return false; - } - - const request = Symbol(); - startRequestRef.current = request; - setIsStarting(true); - setErrorMessage(null); - - try { - const mimeType = getSupportedRecordingType(VIDEO_MIME_TYPES); - const videoTrack = cloneVideoTrack(); - const audioTrack = cloneAudioTrack(); - - if (!videoTrack) { - throw new Error("카메라가 준비되지 않았습니다. 카메라 연결과 권한을 확인해주세요."); - } - - if (!audioTrack) { - throw new Error("마이크 연결을 확인한 뒤 다시 시도해주세요."); - } - - const stream = new MediaStream([audioTrack, videoTrack]); - const capture = createMediaRecording(stream, mimeType, reportError); - const result = capture.result - .then((blob) => (blob ? createRecordingFile(blob) : null)) - .catch((error: unknown) => { - reportError(error); - return null; - }) - .finally(() => { - sessionRef.current = null; - - if (isMountedRef.current) { - setIsRecording(false); - setIsStopping(false); - } - }); - - sessionRef.current = { capture, result }; - setVideoFile(null); - setIsRecording(true); - return true; - } catch (error) { - if (startRequestRef.current === request) { - reportError(error); - } - - return false; - } finally { - if (startRequestRef.current === request) { - startRequestRef.current = null; - - if (isMountedRef.current) { - setIsStarting(false); - } - } - } - }, [cloneAudioTrack, cloneVideoTrack, reportError]); - - const stopRecording = useCallback(async (): Promise => { - startRequestRef.current = null; - - if (isMountedRef.current) { - setIsStarting(false); - } - - const session = sessionRef.current; - - if (!session) { - return null; - } - - if (isMountedRef.current) { - setIsRecording(false); - setIsStopping(true); - } - - // 마지막 dataavailable 이후 생성된 파일까지 기다린다. - await session.capture.stop(); - const file = await session.result; - - if (file && isMountedRef.current) { - setVideoFile(file); - } - - return file; - }, []); - - useEffect(() => { - isMountedRef.current = true; - - return () => { - isMountedRef.current = false; - startRequestRef.current = null; - void sessionRef.current?.capture.stop(); - }; - }, []); - - return { - videoFile, - errorMessage, - isRecording, - isStarting, - isStopping, - startRecording, - stopRecording, - }; -}; - -export type InterviewRecorder = ReturnType; +) => useMediaRecorder("video", cloneAudioTrack, cloneVideoTrack); diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index d4c4ab9c..5cdf1c93 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useBlocker, useNavigate } from "react-router-dom"; import { clearActiveInterviewSessionId, @@ -7,7 +7,9 @@ import { getActiveInterviewSessionId, getCurrentInterviewQuestion, getInterviewPreparation, + prepareInterviewRecord, quitInterview, + retryInterviewPreparation, setActiveInterviewSessionId, submitInterviewAnswer, type CurrentInterviewQuestion, @@ -16,14 +18,19 @@ import { } from "@/features/interview-page/interview/api"; import { INTERVIEW_STATUS_MESSAGES, + INTERVIEW_PREPARATION_POLL_INTERVAL_MS, + INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS, + INTERVIEW_PREPARATION_TIMEOUT_MS, } from "@/shared/constants/interview-page/interview"; -import type { InterviewMode } from "@/widgets/interview-page/interview/type"; -import { useElevenLabsTts } from "./useElevenLabsTts"; -import { useSupertoneTts } from "./useSupertoneTts"; +import { useQuestionAudio } from "./useQuestionAudio"; import { useInterviewElapsedTime } from "./useInterviewElapsedTime"; import { useInterviewMedia } from "./useInterviewMedia"; import { useInterviewRecorder } from "./useInterviewRecorder"; +import { useAnswerRecorder } from "./useAnswerRecorder"; +import { enqueueRecordingUpload, reportRecordingFailure } from "./interviewRecordingStore"; +import { clearPendingInterviewLeave, registerInterviewLifecycle, resumePendingInterviewLeave } from "./activeInterviewLifecycle"; +import type { RecordingEndReason } from "../api/upload-recording"; import { useInterviewSocket } from "./useInterviewSocket"; import { useVoiceAnswer } from "./useVoiceAnswer"; import { useVoiceLevel } from "./useVoiceLevel"; @@ -31,10 +38,6 @@ import { useVoiceLevel } from "./useVoiceLevel"; type InterviewCloseReason = "completed" | "quit"; const INTERVIEW_COMPLETED_PATH = "/main/interview/completed"; const AWAITING_RESPONSE_TIMEOUT_MS = 30_000; -const INTERVIEW_PREPARATION_POLL_INTERVAL_MS = 1_000; -const INTERVIEW_PREPARATION_MAX_ATTEMPTS = 120; - -export type InterviewTtsProvider = "elevenlabs" | "supertone"; const buildQuestionKey = (question: CurrentInterviewQuestion | null) => { if (!question) { @@ -235,7 +238,6 @@ const interviewSessionReducer = ( export const useInterviewSession = ( preparedInterview?: PreparedInterviewData | null, - ttsProvider: InterviewTtsProvider = "elevenlabs", ) => { const navigate = useNavigate(); const [session, dispatch] = useReducer( @@ -249,55 +251,32 @@ export const useInterviewSession = ( isChatSessionReady, totalQuestionCount, } = session; - const [mode, setMode] = useState( - preparedInterview?.mode === "MULTI" || ttsProvider === "supertone" - ? "text" - : "voice", - ); + const mode = preparedInterview?.answerMode ?? "voice"; const [isSubmitting, setIsSubmitting] = useState(false); + const [isCompletingVoice, setIsCompletingVoice] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [hasEndedSession, setHasEndedSession] = useState(false); const [isAwaitingNextQuestion, setIsAwaitingNextQuestion] = useState(false); const [preparationError, setPreparationError] = useState(null); + const [answerError, setAnswerError] = useState(null); + const [quitError, setQuitError] = useState(null); + const [isWaitingForQuestions, setIsWaitingForQuestions] = useState( + preparedInterview?.questionPreparationStatus === "WAITING", + ); + const [canRetryPreparation, setCanRetryPreparation] = useState(false); + const [isRetryingPreparation, setIsRetryingPreparation] = useState(false); + const [preparationAttempt, setPreparationAttempt] = useState(0); + const retryRequestRef = useRef(null); const isVoiceMode = mode === "voice"; - const { cameraState, cloneAudioTrack, cloneVideoTrack, micState, micStream, videoRef } = - useInterviewMedia(isVoiceMode); - const recorder = useInterviewRecorder(cloneVideoTrack, cloneAudioTrack); + const { cameraState, cloneAudioTrack, cloneVideoTrack, micState, micStream, videoRef, releaseMedia } = + useInterviewMedia(isVoiceMode && !hasEndedSession); + const fullRecorder = useInterviewRecorder(cloneVideoTrack, cloneAudioTrack); + const recorder = useAnswerRecorder(cloneAudioTrack); + const { stopRecording: stopAnswerRecording, hasPendingRecording: hasPendingAnswerRecording } = recorder; + const { startRecording: startFullRecording, stopRecording: stopFullRecording, + hasPendingRecording: hasPendingFullRecording } = fullRecorder; const voiceLevel = useVoiceLevel(micStream); const voiceAnswer = useVoiceAnswer(); - const multiTtsSpeaker = useMemo(() => { - if (preparedInterview?.mode !== "MULTI") { - return undefined; - } - - const interviewers = preparedInterview.interviewers ?? []; - if (interviewers.length === 0) { - return undefined; - } - - // 질문에 연결된 persona를 우선 사용해야 질문 내용과 면접관 음성이 일치한다. - // personaId가 없는 응답은 대표 면접관으로 대체해 성별이 섞이지 않게 한다. - const speaker = - (currentQuestion?.personaId - ? interviewers.find( - (interviewer) => interviewer.personaId === currentQuestion.personaId, - ) - : undefined) ?? - interviewers.find( - (interviewer) => interviewer.personaId === preparedInterview.personaId, - ) ?? - interviewers[0]; - - if (!speaker) { - return undefined; - } - - return { - personaId: speaker.personaId, - voiceIndex: speaker.voiceIndex ?? interviewers.indexOf(speaker) + 1, - }; - }, [currentQuestion?.personaId, preparedInterview]); - // TTS는 성별이 섞이지 않게 대표 면접관으로 대체하지만, 화면 강조는 질문에 실제로 - // 연결된 면접관에게만 준다. 대체 면접관까지 강조하면 personaId가 빠진 응답에서 - // 1번 면접관이 계속 질문하는 것처럼 보인다. const speakingPersonaId = useMemo(() => { const personaId = currentQuestion?.personaId; @@ -315,22 +294,37 @@ export const useInterviewSession = ( ? personaId : undefined; }, [currentQuestion?.personaId, preparedInterview]); - const elevenLabsTts = useElevenLabsTts( - currentQuestion?.content ?? "", - multiTtsSpeaker?.voiceIndex, - ); - const supertoneTts = useSupertoneTts(currentQuestion?.content ?? ""); - const questionTts = ttsProvider === "elevenlabs" ? elevenLabsTts : supertoneTts; - const sessionId = preparedInterview?.sessionId ?? getActiveInterviewSessionId(); + const [sessionId, setSessionId] = useState(() => preparedInterview?.sessionId ?? getActiveInterviewSessionId()); + const questionAudio = useQuestionAudio({ + sessionId, + questionId: currentQuestion?.questionId ?? null, + enabled: isChatSessionReady && currentQuestion !== null && !hasEndedSession, + autoplay: isVoiceMode, + }); + const controlsRef = useRef({ questionAudio, voiceAnswer }); + useEffect(() => { controlsRef.current = { questionAudio, voiceAnswer }; }); const isSessionClosedRef = useRef(false); - const hasPreparedChatSessionRef = useRef(false); + const closePromiseRef = useRef | null>(null); + const quitPromiseRef = useRef | null>(null); + const hasQuitSucceededRef = useRef(false); + const closeReasonRef = useRef(null); + const hasNavigatedRef = useRef(false); + const fullRecordingStartedRef = useRef(false); + const answerRecordingRef = useRef<{ + question: CurrentInterviewQuestion; + startedAt: number; + } | null>(null); + const answerStopPromiseRef = useRef | null>(null); + const answerTimeoutRef = useRef(null); + const completionRef = useRef<(reason: RecordingEndReason) => Promise>(async () => {}); + const submittingRef = useRef(false); const preparingSessionIdRef = useRef(null); const sessionIdRef = useRef(sessionId); const displayQuestionNumberRef = useRef(displayQuestionNumber); - const autoPlayedQuestionKeyRef = useRef(null); + const currentQuestionKeyRef = useRef(null); const awaitingResponseTimeoutRef = useRef(null); const isCompletingVoiceRef = useRef(false); - const questionStartedAtRef = useRef(0); + const textAnswerStartedAtRef = useRef(0); const canSubmitAnswer = isChatSessionReady && currentQuestion !== null; // 첫 질문이 도착한 시점을 면접 시작으로 본다. const elapsedSeconds = useInterviewElapsedTime(sessionId, canSubmitAnswer); @@ -370,7 +364,6 @@ export const useInterviewSession = ( useEffect(() => { dispatch({ type: "RESET_SESSION", preparedInterview }); isSessionClosedRef.current = false; - hasPreparedChatSessionRef.current = false; }, [preparedInterview]); useEffect(() => { @@ -380,26 +373,30 @@ export const useInterviewSession = ( return; } - if (autoPlayedQuestionKeyRef.current === nextQuestionKey) { + if (currentQuestionKeyRef.current === nextQuestionKey) { return; } - autoPlayedQuestionKeyRef.current = nextQuestionKey; - questionStartedAtRef.current = Date.now(); + currentQuestionKeyRef.current = nextQuestionKey; + textAnswerStartedAtRef.current = 0; // 새 질문(꼬리질문 포함)이 도착했다면 HTTP·소켓 경로와 무관하게 응답 대기를 끝낸다. clearAwaitingResponseTimeout(); setIsAwaitingNextQuestion(false); - void questionTts.onPlay(); }, [ clearAwaitingResponseTimeout, currentQuestion, isChatSessionReady, - questionTts, ]); + useEffect(() => { + if (!isVoiceMode && canSubmitAnswer && voiceAnswer.answerText.trim() && textAnswerStartedAtRef.current === 0) { + textAnswerStartedAtRef.current = Date.now(); + } + }, [canSubmitAnswer, currentQuestion, isVoiceMode, voiceAnswer.answerText]); + const syncCurrentQuestion = useCallback( (nextQuestion: CurrentInterviewQuestion) => { - dispatch({ type: "SYNC_QUESTION", question: nextQuestion }); + if (!isSessionClosedRef.current) dispatch({ type: "SYNC_QUESTION", question: nextQuestion }); }, [dispatch], ); @@ -411,48 +408,157 @@ export const useInterviewSession = ( [dispatch], ); - const endInterviewSession = useCallback( - async ( - shouldNavigateToMain: boolean, - reason: InterviewCloseReason = "quit", - ) => { - const activeSessionId = sessionIdRef.current ?? getActiveInterviewSessionId(); - const nextPath = getInterviewExitPath(reason); - const answeredQuestionCount = displayQuestionNumberRef.current; - - if (!activeSessionId || isSessionClosedRef.current) { - if (shouldNavigateToMain) { - navigate(nextPath, { - state: - reason === "completed" - ? { answeredQuestionCount } - : undefined, - }); - } + const clearAnswerTimeout = useCallback(() => { + if (answerTimeoutRef.current !== null) { + window.clearTimeout(answerTimeoutRef.current); + answerTimeoutRef.current = null; + } + }, []); - return; + const finishAnswerRecording = useCallback((endReason: RecordingEndReason) => { + clearAnswerTimeout(); + const recording = answerRecordingRef.current; + answerRecordingRef.current = null; + if (!recording) return answerStopPromiseRef.current ?? Promise.resolve(); + const pending = stopAnswerRecording().then((file) => { + if (file && preparedInterview?.interviewId != null) { + enqueueRecordingUpload({ + file, interviewId: preparedInterview.interviewId, + kind: "ANSWER", questionId: recording.question.questionId, endReason, + }); + } else if (!file && preparedInterview?.interviewId != null) { + reportRecordingFailure({ + interviewId: preparedInterview.interviewId, kind: "ANSWER", questionId: recording.question.questionId, + errorMessage: "답변 음성 파일을 만들지 못했습니다. 녹화 중 장치 연결과 브라우저 지원 여부를 확인해주세요.", + }); } + }); + answerStopPromiseRef.current = pending; + return pending; + }, [clearAnswerTimeout, preparedInterview?.interviewId, stopAnswerRecording]); + + const saveQuit = useCallback((activeSessionId: string | null) => { + if (!activeSessionId || hasQuitSucceededRef.current) { + hasQuitSucceededRef.current = true; + return Promise.resolve(true); + } + if (!quitPromiseRef.current) { + setIsClosing(true); + quitPromiseRef.current = quitInterview(activeSessionId).then(({ errorMessage, statusCode }) => { + if (errorMessage && statusCode !== 404) { + setQuitError("면접 종료를 저장하지 못했어요. 다시 시도해 주세요."); + return false; + } + hasQuitSucceededRef.current = true; + setQuitError(null); + return true; + }).finally(() => { + quitPromiseRef.current = null; + setIsClosing(false); + }); + } + return quitPromiseRef.current; + }, []); - isSessionClosedRef.current = true; - clearActiveInterviewSessionId(); - if (reason === "quit") { - await quitInterview(activeSessionId); + const endInterviewSession = useCallback( + async (shouldNavigate: boolean, reason: InterviewCloseReason = "quit") => { + if (!closePromiseRef.current) { + isSessionClosedRef.current = true; + setHasEndedSession(true); + closeReasonRef.current = reason; + setIsClosing(true); + retryRequestRef.current?.abort(); + retryRequestRef.current = null; + setIsRetryingPreparation(false); + clearAwaitingResponseTimeout(); + controlsRef.current.questionAudio.onStop(); + controlsRef.current.voiceAnswer.onExitVoiceMode(); + const activeSessionId = sessionIdRef.current ?? getActiveInterviewSessionId(); + // 같은 종료 Promise를 HTTP, 소켓, 메뉴와 뒤로가기가 공유한다. + closePromiseRef.current = (async () => { + try { + await Promise.all([ + finishAnswerRecording("interrupted"), + stopFullRecording().then((file) => { + if (file && preparedInterview?.interviewId != null) { + enqueueRecordingUpload({ file, interviewId: preparedInterview.interviewId, kind: "FULL_INTERVIEW" }); + } else if (!file && fullRecordingStartedRef.current && preparedInterview?.interviewId != null) { + reportRecordingFailure({ + interviewId: preparedInterview.interviewId, kind: "FULL_INTERVIEW", + errorMessage: "전체 영상 파일을 만들지 못했습니다. 녹화 중 장치 연결과 브라우저 지원 여부를 확인해주세요.", + }); + } + }), + ]); + } finally { + releaseMedia(); + if (activeSessionId) disconnectInterviewSocket(activeSessionId); + } + })(); } - - disconnectInterviewSocket(activeSessionId); - - if (shouldNavigateToMain) { - navigate(nextPath, { - state: - reason === "completed" - ? { answeredQuestionCount } - : undefined, + await closePromiseRef.current; + const canLeave = closeReasonRef.current !== "quit" || + await saveQuit(sessionIdRef.current ?? getActiveInterviewSessionId()); + if (!canLeave) return false; + clearActiveInterviewSessionId(); + if (shouldNavigate && closeReasonRef.current === reason && !hasNavigatedRef.current) { + hasNavigatedRef.current = true; + navigate(getInterviewExitPath(reason), { + state: reason === "completed" + ? { answeredQuestionCount: displayQuestionNumberRef.current, interviewId: preparedInterview?.interviewId ?? null } + : undefined, }); } + return true; }, - [getInterviewExitPath, navigate], + [clearAwaitingResponseTimeout, finishAnswerRecording, stopFullRecording, + getInterviewExitPath, navigate, preparedInterview?.interviewId, releaseMedia, saveQuit], ); + const handleLeaveAnyway = () => { + if (quitPromiseRef.current || hasNavigatedRef.current) return; + hasQuitSucceededRef.current = true; + hasNavigatedRef.current = true; + clearPendingInterviewLeave(); + clearActiveInterviewSessionId(); + navigate(getInterviewExitPath("quit")); + }; + + useEffect(() => { + if (!isVoiceMode || !canSubmitAnswer || cameraState !== "ready" || micState !== "ready" || + fullRecordingStartedRef.current || isSessionClosedRef.current) return; + fullRecordingStartedRef.current = startFullRecording(); + }, [cameraState, canSubmitAnswer, startFullRecording, isVoiceMode, micState]); + + const closeLatestRef = useRef(endInterviewSession); + useEffect(() => { closeLatestRef.current = endInterviewSession; }, [endInterviewSession]); + useEffect(() => { + const unregister = registerInterviewLifecycle(() => closeLatestRef.current(false, "quit")); + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasPendingFullRecording() && !hasPendingAnswerRecording()) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warnBeforeUnload); + return () => { + unregister(); + window.removeEventListener("beforeunload", warnBeforeUnload); + clearAnswerTimeout(); + if (!isSessionClosedRef.current) void closeLatestRef.current(false, "quit"); + }; + }, [clearAnswerTimeout, hasPendingFullRecording, hasPendingAnswerRecording]); + + const blocker = useBlocker(({ currentLocation, nextLocation }) => + (!isSessionClosedRef.current || (closeReasonRef.current === "quit" && !hasQuitSucceededRef.current)) && + currentLocation.pathname !== nextLocation.pathname); + useEffect(() => { + if (blocker.state !== "blocked") return; + void endInterviewSession(false, "quit").then((canLeave) => { + if (canLeave) blocker.proceed(); + else blocker.reset(); + }); + }, [blocker, endInterviewSession]); + const handleSocketStatusChange = useCallback( ({ question, @@ -462,6 +568,8 @@ export const useInterviewSession = ( question: CurrentInterviewQuestion | null; status: InterviewProgressStatus | null; }) => { + if (isSessionClosedRef.current) return; + if (question) { syncCurrentQuestion(question); } @@ -486,40 +594,79 @@ export const useInterviewSession = ( }); useEffect(() => { - if (!preparedInterview || !sessionId) { + if (!preparedInterview || !preparedInterview.sessionId || hasEndedSession) { return; } - if (preparingSessionIdRef.current === sessionId) { + const preparationSessionId = preparedInterview.sessionId; + + if (preparingSessionIdRef.current === preparationSessionId) { return; } - preparingSessionIdRef.current = sessionId; + preparingSessionIdRef.current = preparationSessionId; let isCancelled = false; - let attemptCount = 0; + const abortController = new AbortController(); let timeoutId: number | null = null; let lastErrorMessage: string | null = null; + let hasRequestedStart = false; + + const safetyTimeoutId = window.setTimeout(() => { + isCancelled = true; + abortController.abort(); + preparingSessionIdRef.current = null; + setCanRetryPreparation(false); + setPreparationError( + lastErrorMessage ?? "면접 준비 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.", + ); + }, INTERVIEW_PREPARATION_TIMEOUT_MS); const waitForChatSession = async () => { - attemptCount += 1; + if (isCancelled) return; const { data, errorMessage } = await getInterviewPreparation( preparedInterview.interviewId, + abortController.signal, ); - if (isCancelled) { + if (isCancelled || isSessionClosedRef.current) { return; } if (errorMessage || !data) { lastErrorMessage = errorMessage ?? "면접 준비 상태를 확인하지 못했습니다."; + } else { + lastErrorMessage = null; + } + + if (data) { + setIsWaitingForQuestions(data.status === "WAITING"); + } + + if (data?.preparationStatus === "NOT_REQUESTED" && !hasRequestedStart) { + hasRequestedStart = true; + const { data: startedInterview, errorMessage: startError } = await prepareInterviewRecord(preparedInterview.interviewId); + if (isCancelled || isSessionClosedRef.current) return; + if (startError) { + window.clearTimeout(safetyTimeoutId); + preparingSessionIdRef.current = null; + setPreparationError(startError); + setCanRetryPreparation(true); + return; + } + if (startedInterview?.sessionId) { + sessionIdRef.current = startedInterview.sessionId; + setSessionId(startedInterview.sessionId); + setActiveInterviewSessionId(startedInterview.sessionId); + } } - if (data?.chatDelivered) { + if (data?.preparationStatus === "READY") { + window.clearTimeout(safetyTimeoutId); preparingSessionIdRef.current = null; - setActiveInterviewSessionId(sessionId); - sessionIdRef.current = sessionId; - hasPreparedChatSessionRef.current = true; + const readySessionId = sessionIdRef.current ?? preparationSessionId; + setActiveInterviewSessionId(readySessionId); + sessionIdRef.current = readySessionId; const firstQuestion = data.questions[0]; @@ -532,22 +679,22 @@ export const useInterviewSession = ( return; } - if (data?.status === "FAILED") { - preparingSessionIdRef.current = null; - setPreparationError(data.errorMessage ?? "면접 질문 준비에 실패했습니다."); - return; - } - - if (attemptCount >= INTERVIEW_PREPARATION_MAX_ATTEMPTS) { + if (data?.preparationStatus === "FAILED") { + window.clearTimeout(safetyTimeoutId); + setCanRetryPreparation(data.retryable); preparingSessionIdRef.current = null; setPreparationError( - lastErrorMessage ?? - "면접 준비 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.", + data.errorMessage ?? (data.failureStage === "CHAT_DELIVERY" + ? "채팅 면접을 준비하지 못했습니다." + : "면접 질문 준비에 실패했습니다."), ); return; } - const retryAfterMs = data?.retryAfterMs ?? INTERVIEW_PREPARATION_POLL_INTERVAL_MS; + const pollIntervalMs = data?.status === "WAITING" + ? INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS + : INTERVIEW_PREPARATION_POLL_INTERVAL_MS; + const retryAfterMs = Math.max(pollIntervalMs, data?.retryAfterMs ?? 0); timeoutId = window.setTimeout(() => { void waitForChatSession(); @@ -558,77 +705,77 @@ export const useInterviewSession = ( return () => { isCancelled = true; + abortController.abort(); + window.clearTimeout(safetyTimeoutId); if (timeoutId !== null) { window.clearTimeout(timeoutId); } - if (preparingSessionIdRef.current === sessionId) { + if (preparingSessionIdRef.current === preparationSessionId) { preparingSessionIdRef.current = null; } }; - }, [preparedInterview, sessionId, syncCurrentQuestion]); - - useEffect(() => { - return () => { - const activeSessionId = getActiveInterviewSessionId(); - const latestSessionId = sessionIdRef.current; - - if ( - !latestSessionId || - !hasPreparedChatSessionRef.current || - isSessionClosedRef.current || - activeSessionId !== latestSessionId - ) { - return; - } - - clearActiveInterviewSessionId(); - disconnectInterviewSocket(activeSessionId); - }; - }, []); - - const handleModeChange = (nextMode: InterviewMode) => { - if (nextMode === mode) { + }, [preparedInterview, preparationAttempt, syncCurrentQuestion, hasEndedSession]); + + useEffect(() => () => retryRequestRef.current?.abort(), []); + + const handleRetryPreparation = async () => { + if (!preparedInterview || !canRetryPreparation || retryRequestRef.current || isSessionClosedRef.current) return; + const controller = new AbortController(); + retryRequestRef.current = controller; + setIsRetryingPreparation(true); + const { errorMessage, statusCode } = await retryInterviewPreparation( + preparedInterview.interviewId, + controller.signal, + ); + if (controller.signal.aborted || isSessionClosedRef.current) return; + retryRequestRef.current = null; + setIsRetryingPreparation(false); + if (errorMessage && statusCode !== 409) { + setPreparationError(errorMessage); return; } - - questionTts.onStop(); - - if (isVoiceMode) { - voiceAnswer.onExitVoiceMode(); - } - - setMode(nextMode); + setPreparationError(null); + setCanRetryPreparation(false); + setPreparationAttempt((attempt) => attempt + 1); }; const handleStartVoice = async () => { - if (!isChatSessionReady) { - return false; - } - - if (isSubmitting || isAwaitingNextQuestion || !canSubmitAnswer) { - return false; - } - - questionTts.onStop(); - if (!(await recorder.startRecording())) return false; + if (!isVoiceMode || isSessionClosedRef.current || isCompletingVoiceRef.current || + submittingRef.current || isAwaitingNextQuestion || !canSubmitAnswer || + cameraState !== "ready" || micState !== "ready" || !fullRecorder.isRecording) return false; + questionAudio.onStop(); + if (!recorder.startRecording()) return false; + answerRecordingRef.current = { question: currentQuestion, startedAt: Date.now() }; + setAnswerError(null); void voiceAnswer.onStartVoice(); + clearAnswerTimeout(); + answerTimeoutRef.current = window.setTimeout(() => { + void completionRef.current("timeout"); + }, 210_000); return true; }; - const submitAnswer = async (content: string) => { - if (isSubmitting || isAwaitingNextQuestion || !canSubmitAnswer) { + const submitAnswer = async ( + content: string, + submittedQuestion = currentQuestion, + startedAt = 0, + ) => { + if (isSessionClosedRef.current || submittingRef.current || isAwaitingNextQuestion || !canSubmitAnswer || !submittedQuestion) { return; } const trimmedContent = content.trim(); if (!trimmedContent) { + setAnswerError("답변을 인식하지 못했습니다. 다시 답변해주세요."); return; } + submittingRef.current = true; setIsSubmitting(true); + setAnswerError(null); // 제출이 끝나도 다음 질문이 도착할 때까지 대기 상태를 유지한다. setIsAwaitingNextQuestion(true); scheduleAwaitingResponseTimeout(); @@ -642,28 +789,29 @@ export const useInterviewSession = ( return; } - const submittedQuestion = currentQuestion; - const { data, errorMessage } = await submitInterviewAnswer({ sessionId: activeSessionId, - questionId: currentQuestion.questionId, + questionId: submittedQuestion.questionId, responseTime: - questionStartedAtRef.current > 0 + startedAt > 0 ? Math.max( 0, - Math.round((Date.now() - questionStartedAtRef.current) / 1_000), + Math.round((Date.now() - startedAt) / 1_000), ) : 0, content: trimmedContent, }); + if (isSessionClosedRef.current) return; + if (errorMessage) { + setAnswerError(errorMessage); clearAwaitingResponseTimeout(); setIsAwaitingNextQuestion(false); return; } - questionTts.onStop(); + questionAudio.onStop(); voiceAnswer.onClearAnswer(); if (data?.status) { @@ -701,33 +849,35 @@ export const useInterviewSession = ( } } } finally { + submittingRef.current = false; setIsSubmitting(false); } }; - const handleCompleteVoice = async () => { - if (isCompletingVoiceRef.current || isSubmitting || isAwaitingNextQuestion) { - return; - } - + const handleCompleteVoice = async (reason: RecordingEndReason = "user") => { + const recording = answerRecordingRef.current; + if (!recording || isSessionClosedRef.current || isCompletingVoiceRef.current || submittingRef.current || isAwaitingNextQuestion) return; isCompletingVoiceRef.current = true; - + setIsCompletingVoice(true); try { + // 먼저 음성 파일을 확정해 업로드를 시작하고, 녹음 시작 때 보관한 질문으로 제출한다. + await finishAnswerRecording(reason); + if (isSessionClosedRef.current) return; const voiceContent = await voiceAnswer.onCompleteVoice(); - await submitAnswer(voiceContent || voiceAnswer.answerText); + await submitAnswer(voiceContent || voiceAnswer.answerText, recording.question, recording.startedAt); } finally { isCompletingVoiceRef.current = false; + setIsCompletingVoice(false); } }; + useEffect(() => { completionRef.current = handleCompleteVoice; }); const handleSubmitText = async () => { - await submitAnswer(voiceAnswer.answerText); + if (!isVoiceMode) await submitAnswer(voiceAnswer.answerText, currentQuestion, textAnswerStartedAtRef.current); }; - const handleQuitInterview = async () => { - questionTts.onStop(); - voiceAnswer.onExitVoiceMode(); - await endInterviewSession(true, "quit"); + if (!(await endInterviewSession(false, "quit"))) return; + if (!(await resumePendingInterviewLeave())) await endInterviewSession(true, "quit"); }; return { @@ -735,31 +885,40 @@ export const useInterviewSession = ( answerText: voiceAnswer.answerText, cameraState, recorder, + recordingErrorMessage: recorder.errorMessage ?? fullRecorder.errorMessage, + answerError, + quitError, currentQuestion, displayQuestionNumber, elapsedSeconds, interviewId: preparedInterview?.interviewId ?? null, + sessionId, isAwaitingNextQuestion, - isAwaitingResponse: isSubmitting || isAwaitingNextQuestion || recorder.isStopping, + isAwaitingResponse: hasEndedSession || isClosing || isCompletingVoice || isSubmitting || isAwaitingNextQuestion || recorder.isStopping, isSubmitting, + isClosing, + isMediaReady: cameraState === "ready" && micState === "ready" && fullRecorder.isRecording, isInterviewReady: isChatSessionReady, isPreparingInterview: Boolean(preparedInterview) && !isChatSessionReady, - isVoiceStarted: voiceAnswer.isVoiceStarted, + isVoiceStarted: recorder.isRecording, mode, preparationError, - questionAudioStatus: questionTts.status, - questionAudioErrorMessage: - ttsProvider === "elevenlabs" ? elevenLabsTts.errorMessage : null, + canRetryPreparation: canRetryPreparation && !hasEndedSession, + isRetryingPreparation, + isWaitingForQuestions, + onRetryPreparation: handleRetryPreparation, + questionAudioStatus: questionAudio.status, + questionAudioErrorMessage: questionAudio.errorMessage, speakingPersonaId, - ttsSpeakerPersonaId: multiTtsSpeaker?.personaId, onAnswerTextChange: voiceAnswer.onAnswerTextChange, onClearAnswer: voiceAnswer.onClearAnswer, - onCompleteVoice: handleCompleteVoice, - onModeChange: handleModeChange, + onCompleteVoice: () => handleCompleteVoice("user"), onQuitInterview: handleQuitInterview, + onRetryQuit: handleQuitInterview, + onLeaveAnyway: handleLeaveAnyway, onStartVoice: handleStartVoice, onSubmitText: handleSubmitText, - onToggleQuestionAudio: questionTts.onToggle, + onToggleQuestionAudio: questionAudio.onToggle, micState, videoRef, voiceLevel, diff --git a/src/features/interview-page/interview/model/useMediaRecorder.ts b/src/features/interview-page/interview/model/useMediaRecorder.ts new file mode 100644 index 00000000..d132931b --- /dev/null +++ b/src/features/interview-page/interview/model/useMediaRecorder.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { createMediaRecording, getSupportedRecordingType } from "./mediaRecording"; +import type { MediaRecording } from "./mediaRecording"; + +type RecordingKind = "audio" | "video"; +const MIME_TYPES = { + video: ['video/mp4;codecs="avc1.42E01E,mp4a.40.2"', "video/mp4", "video/webm;codecs=vp8,opus", "video/webm"], + audio: ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"], +}; + +export const useMediaRecorder = ( + kind: RecordingKind, + cloneAudioTrack: () => MediaStreamTrack | null, + cloneVideoTrack?: () => MediaStreamTrack | null, +) => { + const [isRecording, setIsRecording] = useState(false); + const [isStarting, setIsStarting] = useState(false); + const [isStopping, setIsStopping] = useState(false); + const [errorMessage, setErrorMessage] = useState(null); + const sessionRef = useRef(null); + const stoppingRef = useRef(false); + const hasPendingRecording = useCallback(() => sessionRef.current !== null || stoppingRef.current, []); + const mountedRef = useRef(true); + const reportError = useCallback((error: unknown) => { + if (mountedRef.current) { + setErrorMessage(error instanceof Error ? error.message : "녹화 중 오류가 발생했습니다."); + } + }, []); + + const startRecording = useCallback(() => { + if (!mountedRef.current || sessionRef.current || stoppingRef.current) return false; + const tracks: MediaStreamTrack[] = []; + setIsStarting(true); + setErrorMessage(null); + try { + const mimeType = getSupportedRecordingType(MIME_TYPES[kind]); + const audioTrack = cloneAudioTrack(); + if (!audioTrack) throw new Error("마이크 연결과 권한을 확인해주세요. 텍스트 면접은 사전설정에서 선택할 수 있습니다."); + tracks.push(audioTrack); + if (kind === "video") { + const videoTrack = cloneVideoTrack?.(); + if (!videoTrack) throw new Error("카메라 연결과 권한을 확인해주세요."); + tracks.push(videoTrack); + } + const capture = createMediaRecording(new MediaStream(tracks), mimeType, reportError, { + audioBitsPerSecond: 128_000, + ...(kind === "video" ? { videoBitsPerSecond: 1_000_000 } : {}), + }); + sessionRef.current = capture; + setIsRecording(true); + void capture.result.then(() => { + if (sessionRef.current === capture) sessionRef.current = null; + if (mountedRef.current) setIsRecording(false); + }); + return true; + } catch (error) { + tracks.forEach((track) => track.stop()); + reportError(error); + return false; + } finally { + setIsStarting(false); + } + }, [cloneAudioTrack, cloneVideoTrack, kind, reportError]); + + const stopRecording = useCallback(async (): Promise => { + const capture = sessionRef.current; + if (!capture) return null; + sessionRef.current = null; + stoppingRef.current = true; + if (mountedRef.current) { + setIsRecording(false); + setIsStopping(true); + } + try { + const blob = await capture.stop(); + if (!blob) return null; + const mimeType = blob.type.split(";")[0]; + const extension = mimeType.endsWith("mp4") ? (kind === "audio" ? "m4a" : "mp4") + : mimeType.endsWith("ogg") ? "ogg" : "webm"; + return new File([blob], `${kind}-${Date.now()}.${extension}`, { type: blob.type }); + } finally { + stoppingRef.current = false; + if (mountedRef.current) setIsStopping(false); + } + }, [kind]); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + void sessionRef.current?.stop(); + }; + }, []); + + return { errorMessage, isRecording, isStarting, isStopping, hasPendingRecording, startRecording, stopRecording }; +}; diff --git a/src/features/interview-page/interview/model/useQuestionAudio.ts b/src/features/interview-page/interview/model/useQuestionAudio.ts new file mode 100644 index 00000000..aedd8b5c --- /dev/null +++ b/src/features/interview-page/interview/model/useQuestionAudio.ts @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { getQuestionAudio } from "../api"; +import type { QuestionAudioStatus } from "@/widgets/interview-page/interview/type"; + +const AUDIO_WAIT_MS = 180_000; +const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]; +// 서버가 long-poll 없이 PENDING을 바로 돌려줘도 요청이 몰리지 않게 하는 최소 간격 +const PENDING_MIN_INTERVAL_MS = 1_000; +const FAILURE_MESSAGE = "음성 재생에 실패했습니다."; + +interface UseQuestionAudioParams { + sessionId: string | null; + questionId: number | null; + enabled: boolean; + autoplay: boolean; +} + +interface AudioOperation { + key: string; + controller: AbortController; + audio: HTMLAudioElement | null; + status: QuestionAudioStatus; + wantsPlayback: boolean; + playAttempt: number; +} + +export const useQuestionAudio = ({ sessionId, questionId, enabled, autoplay }: UseQuestionAudioParams) => { + const [state, setState] = useState<{ key: string | null; status: QuestionAudioStatus }>({ key: null, status: "idle" }); + const operationRef = useRef(null); + const autoplayRef = useRef(autoplay); + const autoPlayedRef = useRef(new Set()); + const suppressedRef = useRef(new Set()); + const key = sessionId !== null && questionId !== null ? `${sessionId}:${questionId}` : null; + + useEffect(() => { autoplayRef.current = autoplay; }, [autoplay]); + + const isCurrent = useCallback((operation: AudioOperation) => + operationRef.current === operation && !operation.controller.signal.aborted, []); + + const updateStatus = useCallback((operation: AudioOperation, next: QuestionAudioStatus) => { + if (!isCurrent(operation)) return; + operation.status = next; + setState({ key: operation.key, status: next }); + }, [isCurrent]); + + const play = useCallback(async (operation: AudioOperation) => { + const audio = operation.audio; + if (!audio || !isCurrent(operation)) return; + const attempt = ++operation.playAttempt; + operation.wantsPlayback = true; + audio.currentTime = 0; + updateStatus(operation, "playing"); + try { + await audio.play(); + if (!isCurrent(operation) || !operation.wantsPlayback) audio.pause(); + } catch (error) { + if (!isCurrent(operation) || attempt !== operation.playAttempt) return; + operation.wantsPlayback = false; + const blocked = error instanceof DOMException && error.name === "NotAllowedError"; + updateStatus(operation, blocked ? "blocked" : "failed"); + } + }, [isCurrent, updateStatus]); + + useEffect(() => { + if (!enabled || sessionId === null || questionId === null || key === null) return; + const controller = new AbortController(); + const operation: AudioOperation = { + key, controller, audio: null, status: "loading", wantsPlayback: false, playAttempt: 0, + }; + operationRef.current = operation; + let retryTimer: number | undefined; + let releaseRetry: (() => void) | undefined; + const startedAt = Date.now(); + const fail = () => { + updateStatus(operation, "failed"); + controller.abort(); + window.clearTimeout(deadline); + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + releaseRetry?.(); + }; + const deadline = window.setTimeout(fail, AUDIO_WAIT_MS); + const run = async () => { + await Promise.resolve(); + updateStatus(operation, "loading"); + let errorCount = 0; + const wait = (delay: number) => new Promise((resolve) => { + releaseRetry = resolve; + retryTimer = window.setTimeout(resolve, delay); + }); + while (isCurrent(operation)) { + if (Date.now() - startedAt >= AUDIO_WAIT_MS) { fail(); return; } + const requestedAt = Date.now(); + const result = await getQuestionAudio({ sessionId, questionId, signal: controller.signal }); + if (!isCurrent(operation) || ("aborted" in result)) return; + if (Date.now() - startedAt >= AUDIO_WAIT_MS) { fail(); return; } + if ("notFound" in result) { + window.clearTimeout(deadline); + updateStatus(operation, "idle"); + return; + } + if (!result.data) { + console.warn("질문 음성 조회 실패", result.errorMessage); + const delay = RETRY_DELAYS_MS[errorCount++]; + if (delay === undefined) { fail(); return; } + await wait(delay); + continue; + } + const data = result.data; + if (data.status === "PENDING") { + errorCount = 0; + const remaining = PENDING_MIN_INTERVAL_MS - (Date.now() - requestedAt); + if (remaining > 0) await wait(remaining); + continue; + } + window.clearTimeout(deadline); + if (data.status === "FAILED") { + console.warn("질문 음성 생성 실패", data.errorCode); + updateStatus(operation, "failed"); + return; + } + const audio = new Audio(data.url!); + operation.audio = audio; + audio.onended = () => { + operation.wantsPlayback = false; + updateStatus(operation, "idle"); + }; + audio.onerror = () => { + operation.wantsPlayback = false; + operation.playAttempt++; + audio.pause(); + updateStatus(operation, "failed"); + }; + updateStatus(operation, "idle"); + if (autoplayRef.current && !suppressedRef.current.has(key) && !autoPlayedRef.current.has(key)) { + autoPlayedRef.current.add(key); + void play(operation); + } + return; + } + }; + void run(); + return () => { + controller.abort(); + window.clearTimeout(deadline); + if (retryTimer !== undefined) window.clearTimeout(retryTimer); + releaseRetry?.(); + operation.wantsPlayback = false; + operation.playAttempt++; + const audio = operation.audio; + if (audio) { + audio.pause(); + audio.onended = null; + audio.onerror = null; + audio.removeAttribute("src"); + audio.load(); + } + if (operationRef.current === operation) operationRef.current = null; + }; + }, [enabled, sessionId, questionId, key, isCurrent, updateStatus, play]); + + const onStop = useCallback(() => { + if (key === null) return; + suppressedRef.current.add(key); + const operation = operationRef.current; + if (!operation || operation.key !== key || !isCurrent(operation)) return; + operation.wantsPlayback = false; + operation.playAttempt++; + if (operation.audio) { + operation.audio.pause(); + operation.audio.currentTime = 0; + } + if (operation.status === "playing" || operation.status === "blocked") updateStatus(operation, "idle"); + }, [key, isCurrent, updateStatus]); + + const onToggle = useCallback(() => { + const operation = operationRef.current; + if (!operation || operation.key !== key || !isCurrent(operation)) return; + if (operation.status === "loading" || operation.status === "failed") return; + if (operation.status === "playing") onStop(); + else void play(operation); + }, [key, isCurrent, onStop, play]); + + const visibleStatus = enabled && key !== null ? (state.key === key ? state.status : "loading") : "idle"; + return { + status: visibleStatus, + errorMessage: visibleStatus === "failed" ? FAILURE_MESSAGE : null, + onToggle, + onStop, + }; +}; diff --git a/src/features/interview-page/interview/model/useSupertoneTts.ts b/src/features/interview-page/interview/model/useSupertoneTts.ts deleted file mode 100644 index 9a47d61c..00000000 --- a/src/features/interview-page/interview/model/useSupertoneTts.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { QuestionAudioStatus } from "@/widgets/interview-page/interview/type"; - -const SUPERTONE_TTS_API_URL = "/api/supertone/tts"; -const SUPERTONE_ANDREW_VOICE_ID = "4653d63d07d5340656b6bc"; -const SUPERTONE_DEFAULT_MODEL = "sona_speech_1"; -const SUPERTONE_DEFAULT_LANGUAGE = "ko"; -const SUPERTONE_DEFAULT_SPEED = 1.15; - -const createSpeechRequest = (text: string) => - JSON.stringify({ - text, - language: SUPERTONE_DEFAULT_LANGUAGE, - model: SUPERTONE_DEFAULT_MODEL, - output_format: "wav", - voice_settings: { - speed: SUPERTONE_DEFAULT_SPEED, - }, - }); - -export const useSupertoneTts = (text: string) => { - const [status, setStatus] = useState("idle"); - const audioRef = useRef(null); - const audioUrlRef = useRef(null); - const abortControllerRef = useRef(null); - - const clearAudioUrl = () => { - if (audioUrlRef.current) { - URL.revokeObjectURL(audioUrlRef.current); - audioUrlRef.current = null; - } - }; - - const releaseAudio = () => { - if (!audioRef.current) { - return; - } - - audioRef.current.pause(); - audioRef.current.currentTime = 0; - audioRef.current.onended = null; - audioRef.current = null; - }; - - const stop = () => { - abortControllerRef.current?.abort(); - abortControllerRef.current = null; - releaseAudio(); - clearAudioUrl(); - setStatus("idle"); - }; - - useEffect(() => { - return () => { - abortControllerRef.current?.abort(); - releaseAudio(); - clearAudioUrl(); - }; - }, []); - - const play = async () => { - if (!text.trim() || status === "loading") { - return; - } - - stop(); - - const controller = new AbortController(); - abortControllerRef.current = controller; - setStatus("loading"); - - try { - const response = await fetch( - `${SUPERTONE_TTS_API_URL}/${SUPERTONE_ANDREW_VOICE_ID}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: createSpeechRequest(text), - signal: controller.signal, - }, - ); - - if (!response.ok) { - throw new Error(`Supertone TTS failed with ${response.status}`); - } - - const audioBlob = await response.blob(); - const audioUrl = URL.createObjectURL(audioBlob); - const audio = new Audio(audioUrl); - - audioRef.current = audio; - audioUrlRef.current = audioUrl; - - audio.onended = () => { - releaseAudio(); - clearAudioUrl(); - setStatus("idle"); - }; - - await audio.play(); - setStatus("playing"); - } catch (error) { - if (error instanceof DOMException && error.name === "AbortError") { - return; - } - - releaseAudio(); - clearAudioUrl(); - setStatus("idle"); - } finally { - abortControllerRef.current = null; - } - }; - - const toggle = () => { - if (status === "playing") { - stop(); - return; - } - - void play(); - }; - - return { - onPlay: play, - status, - onStop: stop, - onToggle: toggle, - }; -}; diff --git a/src/features/interview-page/setting-company/model/draft.ts b/src/features/interview-page/setting-company/model/draft.ts new file mode 100644 index 00000000..8a71cfa1 --- /dev/null +++ b/src/features/interview-page/setting-company/model/draft.ts @@ -0,0 +1,66 @@ +import { MULTI_INTERVIEWER_TEMPLATES, type InterviewerCount } from "@/shared/constants/interview-page/setting-multi-interview"; +import { validateSelection } from "@/features/interview-page/setting-multi-interview/model/selection"; +import type { + InterviewDifficultyOption, + InterviewStyleOption, +} from "@/shared/constants/interview-page/setting-interview"; +import type { + InterviewerPersonality, + InterviewerSlot, + InterviewerTone, +} from "@/shared/constants/interview-page/setting-multi-interview"; + +export const COMPANY_SETTING_PATH = "/main/setting/interview/company"; + +type AnswerMode = "voice" | "text"; + +export interface SoloInterviewDraft { + mode: "SOLO"; + difficulty: InterviewDifficultyOption; + personality: InterviewerPersonality; + tone: InterviewerTone; + interviewerKey: string; + answerMode: AnswerMode; +} + +export interface MultiInterviewDraft { + mode: "MULTI"; + style: InterviewStyleOption; + difficulty: InterviewDifficultyOption; + slots: InterviewerSlot[]; + answerMode: AnswerMode; +} + +export type InterviewDraft = SoloInterviewDraft | MultiInterviewDraft; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const readInterviewDraft = (state: unknown): InterviewDraft | null => { + if (!isRecord(state) || !isRecord(state.draft)) return null; + const draft = state.draft; + if (draft.answerMode !== "voice" && draft.answerMode !== "text") return null; + if (!["쉬움", "보통", "어려움"].includes(String(draft.difficulty))) return null; + const difficulty = draft.difficulty as InterviewDifficultyOption; + const validPersonality = (value: unknown) => ["FRIENDLY", "REALISTIC", "METICULOUS"].includes(String(value)); + const validTone = (value: unknown) => ["GENTLE", "DIRECT", "PRESSURING"].includes(String(value)); + const findInterviewer = (key: unknown) => MULTI_INTERVIEWER_TEMPLATES.find((item) => item.key === key); + if (draft.mode === "SOLO") { + if (!validPersonality(draft.personality) || !validTone(draft.tone) || !findInterviewer(draft.interviewerKey)) return null; + return { mode: "SOLO", difficulty, answerMode: draft.answerMode, + personality: draft.personality as InterviewerPersonality, tone: draft.tone as InterviewerTone, + interviewerKey: String(draft.interviewerKey) }; + } + if (draft.mode !== "MULTI" || !["편함", "일반", "압박"].includes(String(draft.style)) || + !Array.isArray(draft.slots) || draft.slots.length < 2 || draft.slots.length > 4) return null; + const slots: InterviewerSlot[] = []; + for (const slot of draft.slots) { + if (!isRecord(slot) || !isRecord(slot.interviewer) || !validPersonality(slot.personality) || !validTone(slot.tone)) return null; + const interviewer = findInterviewer(slot.interviewer.key); + if (!interviewer || !["TECH", "HR", "CEO", "PM", "DESIGN"].includes(String(slot.role))) return null; + slots.push({ interviewer, role: slot.role as InterviewerSlot["role"], + personality: slot.personality as InterviewerPersonality, tone: slot.tone as InterviewerTone }); + } + if (validateSelection(slots, slots.length as InterviewerCount)) return null; + return { mode: "MULTI", difficulty, style: draft.style as InterviewStyleOption, slots, answerMode: draft.answerMode }; +}; diff --git a/src/features/interview-page/setting-company/model/draftStore.ts b/src/features/interview-page/setting-company/model/draftStore.ts new file mode 100644 index 00000000..c9e5d17f --- /dev/null +++ b/src/features/interview-page/setting-company/model/draftStore.ts @@ -0,0 +1,130 @@ +import { create } from "zustand"; +import { useUserStore } from "@/shared/store/userStore"; +import type { CreateInterviewParams } from "@/features/interview-page/interview/api"; +import { getJobRole } from "@/shared/constants/job/jobRoles"; +import { readInterviewDraft, type InterviewDraft } from "./draft"; + +const PREFIX = "repit:interview-setup:"; +const TTL = 2 * 60 * 60_000; +const uuid = (value: unknown): value is string => typeof value === "string" && /^[\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12}$/i.test(value); +const record = (value: unknown): value is Record => !!value && typeof value === "object" && !Array.isArray(value); +const positiveId = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value > 0; + +export interface SetupDraft { + draftId: string; + ownerKey: string; + mode: InterviewDraft["mode"]; + settings: InterviewDraft; + companyId: number | null; + updatedAt: string; + submission: { + idempotencyKey: string; + requestBody: CreateInterviewParams; + interviewId: number | null; + state: "SUBMITTING" | "UNKNOWN" | "CREATED"; + } | null; +} + +export const currentOwnerKey = () => { + const { id, isLoaded } = useUserStore.getState(); + return isLoaded && positiveId(id) ? String(id) : null; +}; + +const validBody = (body: unknown, draft: SetupDraft): body is CreateInterviewParams => { + if (!record(body) || (draft.companyId === null && "companyId" in body) || (body.companyId !== undefined && body.companyId !== draft.companyId) || + (draft.companyId !== null && body.companyId !== draft.companyId)) return false; + if (body.answerMode !== undefined && body.answerMode !== null && + body.answerMode !== (draft.settings.answerMode === "text" ? "TEXT" : "VOICE")) return false; + if (draft.mode === "MULTI") return Object.keys(body).every((key) => ["personaIds", "companyId", "answerMode"].includes(key)) && + Array.isArray(body.personaIds) && body.personaIds.length === (draft.settings.mode === "MULTI" ? draft.settings.slots.length : 0) && + body.personaIds.every(positiveId) && new Set(body.personaIds).size === body.personaIds.length; + return Object.keys(body).every((key) => ["personaName", "major", "type", "career", "gender", "companyId", "answerMode"].includes(key)) && + typeof body.personaName === "string" && body.personaName.length > 0 && body.personaName.length < 200 && + ["FRIENDLY", "REALISTIC", "METICULOUS"].includes(String(body.type)) && + typeof body.career === "number" && Number.isFinite(body.career) && body.career >= 0 && + ["MALE", "FEMALE"].includes(String(body.gender)) && + (body.major === undefined || getJobRole(String(body.major)) !== undefined); +}; + +export const parseSetupDraft = (value: unknown, ownerKey: string, now = Date.now()): SetupDraft | null => { + if (!record(value) || !uuid(value.draftId) || value.ownerKey !== ownerKey || + (value.companyId !== null && !positiveId(value.companyId)) || typeof value.updatedAt !== "string") return null; + const settings = readInterviewDraft({ draft: value.settings }); + const updatedAt = Date.parse(value.updatedAt); + if (!settings || settings.mode !== value.mode || !Number.isFinite(updatedAt) || updatedAt > now + 60_000) return null; + const draft: SetupDraft = { draftId: value.draftId, ownerKey, mode: settings.mode, settings, + companyId: value.companyId as number | null, updatedAt: value.updatedAt, submission: null }; + if (value.submission !== null) { + const submission = value.submission; + if (!record(submission) || !uuid(submission.idempotencyKey) || + !["SUBMITTING", "UNKNOWN", "CREATED"].includes(String(submission.state)) || + !validBody(submission.requestBody, draft) || + (submission.interviewId !== null && !positiveId(submission.interviewId)) || + (submission.state === "CREATED" ? !positiveId(submission.interviewId) : submission.interviewId !== null)) return null; + draft.submission = { idempotencyKey: submission.idempotencyKey, requestBody: submission.requestBody, + interviewId: submission.interviewId as number | null, state: submission.state as NonNullable["state"] }; + } + // 결과 불명인 제출도 TTL로 버리면 중복 생성될 수 있어 복구할 때까지 유지한다. + return !draft.submission && now - updatedAt > TTL ? null : draft; +}; + +export const useSetupDraftStore = create<{ drafts: Record }>(() => ({ drafts: {} })); +const storageKey = (owner: string, id: string) => `${PREFIX}${owner}:${id}`; + +export const saveSetupDraft = (draft: SetupDraft) => { + if (currentOwnerKey() !== draft.ownerKey) throw new Error("사용자가 변경되었습니다. 사전 설정부터 다시 시작해주세요."); + const validated = parseSetupDraft(draft, draft.ownerKey); + if (!validated) throw new Error("면접 설정을 다시 확인해주세요."); + try { + sessionStorage.setItem(storageKey(draft.ownerKey, draft.draftId), JSON.stringify(validated)); + } catch { + throw new Error("설정을 저장할 수 없습니다. 브라우저 저장 공간을 확인해주세요."); + } + useSetupDraftStore.setState((state) => ({ drafts: { ...state.drafts, [draft.draftId]: validated } })); + return validated; +}; + +export const loadSetupDraft = (id: string | null): SetupDraft | null => { + const owner = currentOwnerKey(); + if (!owner || !uuid(id)) return null; + const key = storageKey(owner, id); + try { + const stored = useSetupDraftStore.getState().drafts[id] ?? JSON.parse(sessionStorage.getItem(key) ?? "null"); + const draft = parseSetupDraft(stored, owner); + if (draft && draft.draftId === id) return draft; + sessionStorage.removeItem(key); + } catch { + try { sessionStorage.removeItem(key); } catch { /* 저장소 접근이 차단된 경우 */ } + } + return null; +}; + +export const upsertSettingsDraft = (settings: InterviewDraft, id: string | null) => { + const ownerKey = currentOwnerKey(); + if (!ownerKey) throw new Error("로그인 정보를 확인한 뒤 다시 시도해주세요."); + const existing = loadSetupDraft(id); + if (existing?.submission) return existing; + return saveSetupDraft({ draftId: existing?.draftId ?? crypto.randomUUID(), ownerKey, + mode: settings.mode, settings, companyId: existing?.companyId ?? null, + updatedAt: new Date().toISOString(), submission: null }); +}; + +export const clearOwnerDrafts = (owner: string) => { + try { + const keys = Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index)); + keys.forEach((key) => { if (key?.startsWith(`${PREFIX}${owner}:`)) sessionStorage.removeItem(key); }); + } catch { /* 메모리 초안도 함께 제거한다. */ } + useSetupDraftStore.setState((state) => ({ drafts: Object.fromEntries(Object.entries(state.drafts).filter(([, draft]) => draft.ownerKey !== owner)) })); +}; + +useUserStore.subscribe((next, previous) => { + if (previous.id !== null && next.id !== previous.id) clearOwnerDrafts(String(previous.id)); + if (!next.isLoaded || next.id === null) return; + // 새로고침 후에도 이전 사용자의 기능 초안만 제거한다. + try { + const keys = Array.from({ length: sessionStorage.length }, (_, index) => sessionStorage.key(index)); + keys.forEach((key) => { + if (key?.startsWith(PREFIX) && !key.startsWith(`${PREFIX}${next.id}:`)) sessionStorage.removeItem(key); + }); + } catch { /* 저장소가 차단되면 제출 전 저장 단계에서 안내한다. */ } +}); diff --git a/src/features/interview-page/setting-company/model/prepareView.ts b/src/features/interview-page/setting-company/model/prepareView.ts new file mode 100644 index 00000000..53b3930e --- /dev/null +++ b/src/features/interview-page/setting-company/model/prepareView.ts @@ -0,0 +1,28 @@ +import type { CreatedInterviewData, PreparedInterviewData } from "@/features/interview-page/interview/api"; +import { MULTI_INTERVIEWER_TEMPLATES, LEVEL_BY_DIFFICULTY } from "@/shared/constants/interview-page/setting-multi-interview"; +import { completeSlot } from "@/features/interview-page/setting-multi-interview/model/selection"; +import type { InterviewDraft } from "./draft"; + +export const buildPreparedView = (settings: InterviewDraft, data: CreatedInterviewData): PreparedInterviewData => { + const interviewers = settings.mode === "MULTI" ? settings.slots.map(completeSlot) : (() => { + const template = MULTI_INTERVIEWER_TEMPLATES.find((item) => item.key === settings.interviewerKey); + if (!template) throw new Error("선택한 면접관을 확인해주세요."); + return [{ ...template, personality: settings.personality, tone: settings.tone }]; + })(); + if (data.mode !== settings.mode || data.personaIds.length !== interviewers.length) throw new Error("면접관 정보를 확인하지 못했습니다."); + const first = interviewers[0]; + return { + interviewId: data.interviewId, userId: data.userId, sessionId: data.sessionId, + company: data.company, mode: data.mode, answerMode: data.answerMode === "TEXT" ? "text" : data.answerMode === "VOICE" ? "voice" : settings.answerMode, + personaId: data.personaIds[0], personaName: first.name, role: first.role, + major: null, type: first.personality, personaType: first.personality, + level: LEVEL_BY_DIFFICULTY[settings.difficulty], career: settings.mode === "SOLO" ? ({ 쉬움: 0, 보통: 3, 어려움: 5 }[settings.difficulty]) : first.career, + gender: first.gender, tone: first.tone, jobId: "", + status: data.status, currentQuestionIndex: data.currentQuestionIndex, questions: [], + interviewers: interviewers.map((item, index) => ({ + personaId: data.personaIds[index], name: item.name, roleLabel: item.roleLabel, + image: item.image, gender: item.gender, voiceIndex: item.voiceIndex, + personaType: item.personality, tone: item.tone, level: LEVEL_BY_DIFFICULTY[settings.difficulty], + })), + }; +}; diff --git a/src/features/interview-page/setting-company/model/submitDraft.ts b/src/features/interview-page/setting-company/model/submitDraft.ts new file mode 100644 index 00000000..33ce79c7 --- /dev/null +++ b/src/features/interview-page/setting-company/model/submitDraft.ts @@ -0,0 +1,50 @@ +import { createInterview } from "@/features/interview-page/interview/api"; +import { buildSoloInterviewPayload } from "@/features/interview-page/setting-interview/model/submitSoloInterview"; +import { buildMultiInterviewPayload } from "@/features/interview-page/setting-multi-interview/model/submitMultiInterview"; +import { currentOwnerKey, loadSetupDraft, saveSetupDraft, type SetupDraft } from "./draftStore"; + +const requests = new Map>(); +export const COMPANY_ERRORS: Record = { + COMPANY_UNAVAILABLE: "현재 선택할 수 없는 기업입니다. 다른 기업을 선택하거나 기업 없이 시작해주세요.", + COMPANY_CONTEXT_UNAVAILABLE: "선택한 기업의 자료를 사용할 수 없습니다. 다른 기업을 선택하거나 기업 없이 시작해주세요.", + IDEMPOTENCY_KEY_REUSED: "다른 설정으로 처리 중인 요청이 있습니다. 기존 제출 결과를 다시 확인해주세요.", +}; + +export const submitSetupDraft = (draftId: string): Promise => { + const existing = requests.get(draftId); + if (existing) return existing; + const request = (async () => { + let draft = loadSetupDraft(draftId); + if (!draft) throw new Error("설정이 만료되었거나 복구할 수 없습니다. 사전 설정부터 다시 시작해주세요."); + if (draft.submission?.interviewId) return draft; + if (!draft.submission) { + const body = await (draft.settings.mode === "SOLO" + ? buildSoloInterviewPayload(draft.settings) : buildMultiInterviewPayload(draft.settings)); + // 페르소나 저장 대기 중 사용자 전환/초안 변경도 확인한다. + const latest = loadSetupDraft(draftId); + if (!latest || currentOwnerKey() !== draft.ownerKey) throw new Error("사용자가 변경되었습니다."); + if (JSON.stringify(latest.settings) !== JSON.stringify(draft.settings) || latest.companyId !== draft.companyId) { + throw new Error("면접 설정이 변경되었습니다. 변경된 설정으로 다시 시작해주세요."); + } + draft = saveSetupDraft({ ...latest, updatedAt: new Date().toISOString(), submission: { + idempotencyKey: crypto.randomUUID(), + requestBody: { ...body, answerMode: latest.settings.answerMode === "text" ? "TEXT" : "VOICE", ...(latest.companyId !== null ? { companyId: latest.companyId } : {}) }, + interviewId: null, state: "SUBMITTING", + } }); + } + const submission = draft.submission!; + // 새로고침에서 SUBMITTING도 같은 본문과 키로만 재전송한다. + const result = await createInterview(submission.requestBody, submission.idempotencyKey); + if (currentOwnerKey() !== draft.ownerKey) throw new Error("사용자가 변경되었습니다."); + if (!result.data) { + const definitive = result.definitive || result.code === "COMPANY_UNAVAILABLE" || result.code === "COMPANY_CONTEXT_UNAVAILABLE"; + saveSetupDraft({ ...draft, submission: definitive ? null : { ...submission, state: "UNKNOWN" } }); + throw new Error(([401, 403].includes(result.statusCode ?? 0) ? result.errorMessage : null) || (result.code && COMPANY_ERRORS[result.code]) || + (definitive ? result.errorMessage ?? "면접 설정을 확인해주세요." : "생성 결과를 확인하지 못했습니다. 같은 요청으로 결과를 다시 확인해주세요.")); + } + if (String(result.data.userId) !== draft.ownerKey) throw new Error("면접 접근 권한을 확인하지 못했습니다."); + return saveSetupDraft({ ...draft, submission: { ...submission, interviewId: result.data.interviewId, state: "CREATED" } }); + })().finally(() => requests.delete(draftId)); + requests.set(draftId, request); + return request; +}; diff --git a/src/features/interview-page/setting-company/model/useCompanySetup.ts b/src/features/interview-page/setting-company/model/useCompanySetup.ts new file mode 100644 index 00000000..512de9ed --- /dev/null +++ b/src/features/interview-page/setting-company/model/useCompanySetup.ts @@ -0,0 +1,199 @@ +import { INTERVIEW_PREPARATION_POLL_INTERVAL_MS, INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS, INTERVIEW_PREPARATION_TIMEOUT_MS } from "@/shared/constants/interview-page/interview"; +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { getCompanies } from "@/features/interview-page/company/api"; +import type { CompanyCategory, CompanySummary } from "@/features/interview-page/company/model/type"; +import { getCreatedInterview } from "@/features/interview-page/interview/api/create-interview"; +import { getInterviewPreparation, prepareInterviewRecord, retryInterviewPreparation } from "@/features/interview-page/interview/api"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; +import { loadSetupDraft, saveSetupDraft, useSetupDraftStore } from "./draftStore"; +import { submitSetupDraft } from "./submitDraft"; +import { buildPreparedView } from "./prepareView"; + +export type CompanyCategoryFilter = CompanyCategory | "ALL"; + +export const useCompanySetup = () => { + const navigate = useNavigate(); + const location = useLocation(); + const draftId = new URLSearchParams(location.search).get("draftId"); + useSetupDraftStore((state) => draftId ? state.drafts[draftId] : undefined); + const draft = loadSetupDraft(draftId); + const [query, setQuery] = useState(""); + const [debouncedQuery, setDebouncedQuery] = useState(""); + const [category, setCategory] = useState("ALL"); + const [companies, setCompanies] = useState([]); + const [selectedCompany, setSelectedCompany] = useState(null); + const [listState, setListState] = useState<"loading" | "success" | "error">("loading"); + const [listError, setListError] = useState(""); + const [listAttempt, setListAttempt] = useState(0); + const [errorMessage, setErrorMessage] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [preparationState, setPreparationState] = useState<"PREPARING" | "PREPARATION_FAILED" | null>(null); + const [canRetryPreparation, setCanRetryPreparation] = useState(false); + const [showMyPageLink, setShowMyPageLink] = useState(false); + const [preparationAttempt, setPreparationAttempt] = useState(0); + const busy = useRef(false); + const mounted = useRef(true); + useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []); + + useEffect(() => { + const timer = window.setTimeout(() => setDebouncedQuery(query.trim()), 300); + return () => window.clearTimeout(timer); + }, [query]); + + const selectedCompanyId = draft?.companyId ?? null; + const queryKey = JSON.stringify([debouncedQuery.length >= 2 ? debouncedQuery : "", category, listAttempt]); + useEffect(() => { + const controller = new AbortController(); + void (async () => { + setListState("loading"); + setListError(""); + try { + const result = await getCompanies(debouncedQuery, category, controller.signal); + if (controller.signal.aborted) return; + setCompanies(result); + setSelectedCompany((previous) => result.find((company) => company.companyId === selectedCompanyId) ?? previous); + setListState("success"); + } catch (error) { + if (controller.signal.aborted) return; + setListState("error"); + setListError(extractErrorMessage(error, "기업 목록을 불러오지 못했습니다.")); + } + })(); + return () => controller.abort(); + }, [debouncedQuery, category, queryKey, selectedCompanyId]); + + const handleStart = async () => { + if (!draftId || busy.current) return; + busy.current = true; + setIsSubmitting(true); + setErrorMessage(""); + try { + await submitSetupDraft(draftId); + } catch (error) { + if (mounted.current) setErrorMessage(extractErrorMessage(error, "면접 생성 결과를 확인하지 못했습니다.")); + } finally { + busy.current = false; + if (mounted.current) setIsSubmitting(false); + } + }; + + // 제출 중 새로고침/재진입에서는 새 본문을 만들지 않고 저장된 요청만 복구한다. + useEffect(() => { + const initial = loadSetupDraft(draftId); + if (!initial?.submission || initial.submission.interviewId) return; + let cancelled = false; + const timer = window.setTimeout(() => { + busy.current = true; + setIsSubmitting(true); + void submitSetupDraft(initial.draftId).catch((error: unknown) => { + if (!cancelled) setErrorMessage(extractErrorMessage(error, "면접 생성 결과를 확인하지 못했습니다.")); + }).finally(() => { + busy.current = false; + if (!cancelled) setIsSubmitting(false); + }); + }, 0); + return () => { cancelled = true; window.clearTimeout(timer); }; + }, [draftId]); + + const interviewId = draft?.submission?.interviewId; + useEffect(() => { + if (!interviewId || !draftId) return; + const controller = new AbortController(); + let timer: number | undefined; + let requestedStart = false; + const deadline = Date.now() + INTERVIEW_PREPARATION_TIMEOUT_MS; + const poll = async () => { + try { + const saved = loadSetupDraft(draftId); + if (!saved?.submission || saved.submission.interviewId !== interviewId) return; + setPreparationState("PREPARING"); + setErrorMessage(""); + setShowMyPageLink(false); + const data = await getCreatedInterview(interviewId, saved.submission.requestBody, controller.signal); + if (controller.signal.aborted) return; + if (String(data.userId) !== saved.ownerKey) throw new Error("면접 접근 권한이 없습니다."); + if (data.status === "COMPLETED") { + void navigate(`/main/feedback/overall/${interviewId}`, { replace: true }); + return; + } + if (data.status === "ABANDONED") throw new Error("종료된 면접입니다. 새 면접을 만들어주세요."); + const result = await getInterviewPreparation(interviewId, controller.signal); + if (controller.signal.aborted) return; + if (!result.data) throw new Error(result.errorMessage ?? "면접 준비 상태를 확인하지 못했습니다."); + const preparation = result.data; + if (preparation.preparationStatus === "READY" && preparation.chatDelivered) { + const preparedInterview = buildPreparedView(saved.settings, data); + void navigate(`/main/interview/${interviewId}?draftId=${draftId}`, { replace: true, state: { preparedInterview } }); + return; + } + if (preparation.preparationStatus === "FAILED") { + setCanRetryPreparation(preparation.retryable); + throw new Error(preparation.errorMessage ?? "면접 준비에 실패했습니다."); + } + if (preparation.preparationStatus === "NOT_REQUESTED" && !requestedStart) { + requestedStart = true; + const started = await prepareInterviewRecord(interviewId, controller.signal); + if (controller.signal.aborted) return; + if (!started.data) { + // 자료가 없어 준비할 수 없으면(404) 마이페이지 안내를 함께 보여 준다. + setShowMyPageLink(started.statusCode === 404); + throw new Error(started.errorMessage ?? "면접 준비 요청을 확인하지 못했습니다."); + } + } + if (Date.now() >= deadline) throw new Error("면접 준비가 지연되고 있습니다. 저장된 면접의 준비 상태를 다시 확인해주세요."); + const interval = preparation.status === "WAITING" + ? INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS : INTERVIEW_PREPARATION_POLL_INTERVAL_MS; + const delay = Math.max(interval, preparation.retryAfterMs ?? 0); + timer = window.setTimeout(() => void poll(), Math.min(delay, Math.max(0, deadline - Date.now()))); + } catch (error) { + if (controller.signal.aborted) return; + setPreparationState("PREPARATION_FAILED"); + setErrorMessage(extractErrorMessage(error, "면접 준비 상태를 확인하지 못했습니다.")); + } + }; + void poll(); + return () => { controller.abort(); window.clearTimeout(timer); }; + }, [interviewId, draftId, preparationAttempt, navigate]); + + const locked = isSubmitting || !!draft?.submission; + const onRetryPreparation = async () => { + if (!interviewId || busy.current) return; + busy.current = true; + setIsSubmitting(true); + try { + if (canRetryPreparation) { + const result = await retryInterviewPreparation(interviewId); + if (result.errorMessage) throw new Error(result.errorMessage); + } + setCanRetryPreparation(false); + setPreparationAttempt((value) => value + 1); + } catch (error) { + setErrorMessage(extractErrorMessage(error, "면접 준비를 다시 요청하지 못했습니다.")); + } finally { busy.current = false; setIsSubmitting(false); } + }; + + return { + hasDraft: !!draft, draft, query, category, companies, selectedCompanyId, + selectedCompany: selectedCompany?.companyId === selectedCompanyId ? selectedCompany : null, + onQueryChange: (value: string) => { if (!locked) setQuery(value.slice(0, 30)); }, + onCategoryChange: (value: CompanyCategoryFilter) => { if (!locked) setCategory(value); }, + selectCompany: (companyId: number | null) => { + if (locked || !draft) return; + try { + saveSetupDraft({ ...draft, companyId, updatedAt: new Date().toISOString() }); + setSelectedCompany(companies.find((company) => company.companyId === companyId) ?? null); + setErrorMessage(""); + } catch (error) { setErrorMessage(extractErrorMessage(error, "기업 선택을 저장하지 못했습니다.")); } + }, + listState, listError, + emptyMessage: debouncedQuery.length >= 2 || category !== "ALL" ? "검색 결과가 없어요" : "현재 등록된 기업이 없습니다. 기업 없이 면접을 시작할 수 있어요.", + onRetryList: () => setListAttempt((value) => value + 1), + errorMessage, showMyPageLink, isSubmitting, locked, preparationState, canRetryPreparation, onRetryPreparation, + onBack: () => { + if (locked || !draft) return; + void navigate(`/main/setting/interview${draft.mode === "MULTI" ? "/multi" : ""}?draftId=${draft.draftId}`); + }, + onStart: handleStart, + }; +}; diff --git a/src/features/interview-page/setting-company/model/useInterviewEntry.ts b/src/features/interview-page/setting-company/model/useInterviewEntry.ts new file mode 100644 index 00000000..37ca69a4 --- /dev/null +++ b/src/features/interview-page/setting-company/model/useInterviewEntry.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; +import { getCreatedInterview } from "@/features/interview-page/interview/api/create-interview"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; +import { loadSetupDraft } from "./draftStore"; + +// 진행 URL의 히스토리 state가 남아 있어도 종료 여부와 기업명은 서버에서 다시 확인한다. +export const useInterviewEntry = ( + prepared: PreparedInterviewData | null, + draftId: string | null, + userId: number | null, + isLoaded: boolean, +) => { + const navigate = useNavigate(); + const key = `${draftId}:${prepared?.interviewId}:${userId}`; + const [entry, setEntry] = useState<{ key: string; data: PreparedInterviewData | null; error: string | null } | null>(null); + useEffect(() => { + if (!draftId || !prepared || !isLoaded || userId !== prepared.userId) return; + const controller = new AbortController(); + void (async () => { + try { + const draft = loadSetupDraft(draftId); + if (!draft?.submission || draft.submission.interviewId !== prepared.interviewId) { + throw new Error("면접 설정을 복구할 수 없습니다. 사전 설정에서 다시 시작해주세요."); + } + const data = await getCreatedInterview(prepared.interviewId, draft.submission.requestBody, controller.signal); + if (controller.signal.aborted) return; + if (data.userId !== userId) throw new Error("면접 접근 권한이 없습니다."); + if (data.status === "COMPLETED") { + void navigate(`/main/feedback/overall/${data.interviewId}`, { replace: true }); + return; + } + if (data.status === "ABANDONED") throw new Error("종료된 면접입니다. 새 면접을 만들어주세요."); + setEntry({ key, data: { ...prepared, answerMode: data.answerMode === "TEXT" ? "text" : data.answerMode === "VOICE" ? "voice" : prepared.answerMode, sessionId: data.sessionId, company: data.company, status: data.status }, error: null }); + } catch (error) { + if (!controller.signal.aborted) setEntry({ key, data: null, error: extractErrorMessage(error, "면접 정보를 확인하지 못했습니다.") }); + } + })(); + return () => controller.abort(); + }, [draftId, prepared, userId, isLoaded, key, navigate]); + return { + isChecking: !!draftId && entry?.key !== key, + prepared: draftId ? (entry?.key === key ? entry.data : null) : prepared, + error: entry?.key === key ? entry.error : null, + }; +}; diff --git a/src/features/interview-page/setting-interview/model/submitSoloInterview.ts b/src/features/interview-page/setting-interview/model/submitSoloInterview.ts new file mode 100644 index 00000000..e86fbd64 --- /dev/null +++ b/src/features/interview-page/setting-interview/model/submitSoloInterview.ts @@ -0,0 +1,70 @@ +import { + savePersona, + type CreateInterviewParams, + type InterviewLevel, + type InterviewPersonaRole, + type SavePersonaParams, +} from "@/features/interview-page/interview/api"; +import type { + SoloInterviewDraft, +} from "@/features/interview-page/setting-company/model/draft"; +import type { InterviewDifficultyOption } from "@/shared/constants/interview-page/setting-interview"; +import { MULTI_INTERVIEWER_TEMPLATES } from "@/shared/constants/interview-page/setting-multi-interview"; +import { getVoiceGender } from "@/shared/constants/interview-page/voice-catalog"; + +const CAREER_BY_DIFFICULTY: Record = { + 쉬움: 0, + 보통: 3, + 어려움: 5, +}; + +const LEVEL_BY_DIFFICULTY: Record = { + 쉬움: "EASY", + 보통: "NORMAL", + 어려움: "HARD", +}; + +const DEFAULT_INTERVIEW_ROLE: InterviewPersonaRole = "TECH"; + +const buildUniquePersonaName = (personaName: string) => + `${personaName}-${crypto.randomUUID()}`; + +export const buildSoloInterviewPayload = async ( + draft: SoloInterviewDraft, +): Promise => { + const { difficulty: selectedDifficulty, interviewerKey, personality, tone } = draft; + const selectedInterviewer = MULTI_INTERVIEWER_TEMPLATES.find( + (interviewer) => interviewer.key === interviewerKey, + ); + + if (!selectedInterviewer) { + throw new Error("선택한 면접관 정보를 찾을 수 없습니다."); + } + + const personaPayload: SavePersonaParams = { + personaName: buildUniquePersonaName(selectedInterviewer.name), + role: DEFAULT_INTERVIEW_ROLE, + level: LEVEL_BY_DIFFICULTY[selectedDifficulty], + type: personality, + career: CAREER_BY_DIFFICULTY[selectedDifficulty], + gender: getVoiceGender(selectedInterviewer.voiceIndex), + voiceIndex: selectedInterviewer.voiceIndex, + tone, + imageUrl: selectedInterviewer.image, + description: selectedInterviewer.description, + }; + + const { data: savedPersona, errorMessage: savePersonaErrorMessage } = + await savePersona(personaPayload); + + if (savePersonaErrorMessage || !savedPersona) { + throw new Error(savePersonaErrorMessage ?? "페르소나 저장에 실패했습니다."); + } + + return { + personaName: personaPayload.personaName, + type: personaPayload.type, + career: personaPayload.career, + gender: personaPayload.gender, + }; +}; diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index c0aa8cab..7a4f8714 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -1,247 +1,110 @@ +import { loadSetupDraft, upsertSettingsDraft } from "@/features/interview-page/setting-company/model/draftStore"; import { useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useInterviewEligibility } from "@/features/profile/model/useInterviewEligibility"; import { - createInterview, - prepareInterviewRecord, - savePersona, - setActiveInterviewSessionId, - type CreateInterviewPersonaType, - type InterviewPersonaGender, - type InterviewLevel, - type InterviewPersonaMajor, - type InterviewPersonaRole, - type InterviewTone, - type SavePersonaParams, -} from "@/features/interview-page/interview/api"; + COMPANY_SETTING_PATH, + type SoloInterviewDraft, +} from "@/features/interview-page/setting-company/model/draft"; import { INTERVIEW_SETTING_DEFAULT_SELECTION, - type InterviewMajorOption, - type InterviewDifficultyOption, - type InterviewerPersonalityOption, - type InterviewerToneOption, + INTERVIEW_STYLE_PRESETS, + findStylePreset, type InterviewSettingSelectHandlers, } from "@/shared/constants/interview-page/setting-interview"; -import InterviewerBackendImage from "@/shared/img/interview-page/interviewer1.svg?url"; -import InterviewerFrontendImage from "@/shared/img/interview-page/interviewer2.svg?url"; - -const TONE_BY_OPTION: Record< - InterviewerToneOption, - InterviewTone -> = { - 부드러운: "GENTLE", - 직설적인: "DIRECT", - 압박하는: "PRESSURING", -}; - -const TYPE_BY_PERSONALITY: Record< - InterviewerPersonalityOption, - CreateInterviewPersonaType -> = { - 친근한: "FRIENDLY", - 현실적인: "REALISTIC", - 꼼꼼한: "METICULOUS", -}; - -const CAREER_BY_DIFFICULTY: Record = { - 쉬움: 0, - 보통: 3, - 어려움: 5, -}; - -const LEVEL_BY_DIFFICULTY: Record = { - 쉬움: "EASY", - 보통: "NORMAL", - 어려움: "HARD", -}; - -const MAJOR_BY_OPTION: Record = { - 프론트엔드: "FRONTEND", - 백엔드: "BACKEND", -}; - -// 면접관 카드 선택이 사라지면서, 이미지와 소개 문구는 전문 분야에서 파생한다. -const INTERVIEWER_IMAGE_BY_MAJOR: Record = { - 프론트엔드: InterviewerFrontendImage, - 백엔드: InterviewerBackendImage, -}; - -const INTERVIEWER_DESCRIPTION_BY_MAJOR: Record = { - 프론트엔드: - "사용자 중심의 사고방식과 시각적 커뮤니케이션 능력을 심층 질문합니다.", - 백엔드: "기술적 역량과 아키텍처 설계 능력을 중점적으로 파악합니다.", -}; - -const DEFAULT_INTERVIEW_GENDER: InterviewPersonaGender = "FEMALE"; -const DEFAULT_INTERVIEW_ROLE: InterviewPersonaRole = "TECH"; - -const buildUniquePersonaName = () => - `맞춤 면접관-${Date.now().toString(36)}`; +import type { + InterviewerPersonality, + InterviewerTone, +} from "@/shared/constants/interview-page/setting-multi-interview"; export const useInterviewSetup = () => { const navigate = useNavigate(); - - const [selectedStyle, setSelectedStyle] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.style, + const location = useLocation(); + // 기업 선택에서 돌아오면 히스토리 state에 남겨 둔 설정값으로 다시 채운다. + const [initialDraft] = useState(() => { + const draft = loadSetupDraft(new URLSearchParams(location.search).get("draftId"))?.settings; + return draft?.mode === "SOLO" ? draft : null; + }); + const [answerMode, setAnswerMode] = useState<"voice" | "text">( + initialDraft?.answerMode ?? "voice", ); + const eligibility = useInterviewEligibility(); const [selectedDifficulty, setSelectedDifficulty] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty, + initialDraft?.difficulty ?? INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty, ); - - const [selectedPersonality, setSelectedPersonality] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.personality, + const [personality, setPersonality] = useState( + initialDraft?.personality ?? INTERVIEW_SETTING_DEFAULT_SELECTION.personality, ); - - const [selectedTone, setSelectedTone] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.tone, + const [tone, setTone] = useState( + initialDraft?.tone ?? INTERVIEW_SETTING_DEFAULT_SELECTION.tone, ); - - const [selectedMajor, setSelectedMajor] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.major, + const [interviewerKey, setInterviewerKey] = useState( + initialDraft?.interviewerKey ?? INTERVIEW_SETTING_DEFAULT_SELECTION.interviewerKey, ); - - const [errorMessage, setErrorMessage] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); + const selectedStyle = findStylePreset({ + difficulty: selectedDifficulty, + personality, + tone, + }); const handleBack = () => navigate(-1); - const handleNext = async () => { - if (isSubmitting) { - return; - } - - setErrorMessage(""); - setIsSubmitting(true); - - const personaPayload: SavePersonaParams = { - personaName: buildUniquePersonaName(), - role: DEFAULT_INTERVIEW_ROLE, - level: LEVEL_BY_DIFFICULTY[selectedDifficulty], - major: MAJOR_BY_OPTION[selectedMajor], - type: TYPE_BY_PERSONALITY[selectedPersonality], - career: CAREER_BY_DIFFICULTY[selectedDifficulty], - gender: DEFAULT_INTERVIEW_GENDER, - tone: TONE_BY_OPTION[selectedTone], - imageUrl: INTERVIEWER_IMAGE_BY_MAJOR[selectedMajor], - description: INTERVIEWER_DESCRIPTION_BY_MAJOR[selectedMajor], - }; - - const { data: savedPersona, errorMessage: savePersonaErrorMessage } = - await savePersona(personaPayload); - - if (savePersonaErrorMessage || !savedPersona) { - setIsSubmitting(false); - setErrorMessage( - savePersonaErrorMessage ?? "페르소나 저장에 실패했습니다.", - ); + // 면접 생성은 다음 단계(기업 선택)의 "면접 시작"에서 한다. + const handleNext = () => { + if (!eligibility.canStart) { return; } - const interviewPayload = { - personaName: personaPayload.personaName, - major: MAJOR_BY_OPTION[selectedMajor], - type: personaPayload.type, - career: personaPayload.career, - gender: personaPayload.gender, + const draft: SoloInterviewDraft = { + mode: "SOLO", + difficulty: selectedDifficulty, + personality, + tone, + interviewerKey, + answerMode, }; - //repit-dp - - const { data, errorMessage: createErrorMessage } = - await createInterview(interviewPayload); - - if (createErrorMessage || !data) { - setIsSubmitting(false); - setErrorMessage(createErrorMessage ?? "면접 시작에 실패했습니다."); - return; - } - - const { - data: preparedInterviewRecord, - errorMessage: prepareInterviewErrorMessage, - } = await prepareInterviewRecord(data.interviewId); - - if (prepareInterviewErrorMessage || !preparedInterviewRecord) { - setIsSubmitting(false); - setErrorMessage( - prepareInterviewErrorMessage ?? "면접 준비에 실패했습니다.", - ); - return; + try { + const saved = upsertSettingsDraft(draft, new URLSearchParams(location.search).get("draftId")); + void Promise.resolve(navigate(`${location.pathname}?draftId=${saved.draftId}`, { replace: true })) + .then(() => navigate(`${COMPANY_SETTING_PATH}?draftId=${saved.draftId}`)); + } catch (error) { + window.alert(error instanceof Error ? error.message : "설정을 저장하지 못했습니다."); } - - const interviewSessionId = - preparedInterviewRecord.sessionId ?? data.sessionId; - const personaId = - data.personaId !== null && data.personaId > 0 - ? data.personaId - : savedPersona.personaId; - setActiveInterviewSessionId(interviewSessionId); - - setIsSubmitting(false); - - navigate(`/main/interview/${data.interviewId}`, { - state: { - preparedInterview: { - sessionId: interviewSessionId, - interviewId: preparedInterviewRecord.interviewId, - userId: data.userId, - personaId, - personaName: personaPayload.personaName, - role: personaPayload.role, - major: personaPayload.major, - type: personaPayload.type, - personaType: personaPayload.type, - level: LEVEL_BY_DIFFICULTY[selectedDifficulty], - career: personaPayload.career, - gender: personaPayload.gender, - tone: personaPayload.tone, - jobId: preparedInterviewRecord.jobId ?? "", - status: data.status === "COMPLETED" ? "COMPLETED" : "IN_PROGRESS", - currentQuestionIndex: data.currentQuestionIndex, - questions: [], - mode: "SOLO", - interviewers: [ - { - personaId, - name: personaPayload.personaName, - roleLabel: "기술 면접관", - image: personaPayload.imageUrl, - }, - ], - }, - interviewSetting: { - style: selectedStyle, - difficulty: selectedDifficulty, - major: selectedMajor, - personality: selectedPersonality, - tone: selectedTone, - }, - }, - }); }; const select: InterviewSettingSelectHandlers = { difficulty: setSelectedDifficulty, - major: setSelectedMajor, - personality: setSelectedPersonality, - style: setSelectedStyle, - tone: setSelectedTone, + interviewer: setInterviewerKey, + personality: setPersonality, + tone: setTone, + style: (value) => { + const preset = INTERVIEW_STYLE_PRESETS[value]; + setSelectedDifficulty(preset.difficulty); + setPersonality(preset.personality); + setTone(preset.tone); + }, }; return { - errorMessage, - isNextDisabled: isSubmitting, - isPortfolioAnalyzing: false, - isSubmitting, + answerMode, + onAnswerModeChange: setAnswerMode, + isNextDisabled: !eligibility.canStart, + isPortfolioAnalyzing: eligibility.isPortfolioAnalyzing, + profileGuidance: eligibility.guidance, + showMyPageLink: eligibility.showMyPageLink, + onRefreshProfile: eligibility.hasLoadError ? eligibility.refetch : undefined, onBack: handleBack, onNext: handleNext, select, selection: { difficulty: selectedDifficulty, - major: selectedMajor, - personality: selectedPersonality, + interviewerKey, + personality, + tone, style: selectedStyle, - tone: selectedTone, }, }; }; diff --git a/src/features/interview-page/setting-multi-interview/model/selection.ts b/src/features/interview-page/setting-multi-interview/model/selection.ts index 2916547e..35c137ae 100644 --- a/src/features/interview-page/setting-multi-interview/model/selection.ts +++ b/src/features/interview-page/setting-multi-interview/model/selection.ts @@ -13,7 +13,6 @@ export const createSlot = (index: number): InterviewerSlot => ({ role: index === 0 ? "TECH" : null, personality: "FRIENDLY", tone: "GENTLE", - major: "BACKEND", }); export const resizeSlots = (slots: InterviewerSlot[], count: InterviewerCount) => @@ -41,9 +40,8 @@ export const validateSelection = (slots: InterviewerSlot[], count: InterviewerCo } if (slots.some((slot) => !PERSONALITY_OPTIONS.some((option) => option.value === slot.personality) || - !TONE_OPTIONS.some((option) => option.value === slot.tone) || - (slot.role === "TECH" && !["BACKEND", "FRONTEND"].includes(slot.major)))) { - return "면접관의 성격, 말투와 전문 분야를 확인해주세요."; + !TONE_OPTIONS.some((option) => option.value === slot.tone))) { + return "면접관의 성격과 말투를 확인해주세요."; } return null; }; @@ -55,7 +53,6 @@ export const completeSlot = (slot: InterviewerSlot) => { ...slot.interviewer, role: slot.role, roleLabel, - major: slot.role === "TECH" ? slot.major : undefined, personality: slot.personality, tone: slot.tone, description: `${roleLabel}의 관점에서 지원자의 경험과 역량을 평가합니다.`, diff --git a/src/features/interview-page/setting-multi-interview/model/submitMultiInterview.ts b/src/features/interview-page/setting-multi-interview/model/submitMultiInterview.ts new file mode 100644 index 00000000..6aa2121f --- /dev/null +++ b/src/features/interview-page/setting-multi-interview/model/submitMultiInterview.ts @@ -0,0 +1,64 @@ +import { savePersona, type CreateInterviewParams, type SavePersonaParams } from "@/features/interview-page/interview/api"; +import type { + MultiInterviewDraft, +} from "@/features/interview-page/setting-company/model/draft"; +import { + LEVEL_BY_DIFFICULTY, + type InterviewerCount, + type InterviewerTemplate, +} from "@/shared/constants/interview-page/setting-multi-interview"; +import { completeSlot, validateSelection } from "./selection"; + +const buildPersonaName = (template: InterviewerTemplate) => `${template.key}-${crypto.randomUUID()}`; + +export const buildMultiInterviewPayload = async ( + draft: MultiInterviewDraft, +): Promise => { + const { difficulty, slots } = draft; + const interviewerCount = slots.length as InterviewerCount; + const validationError = validateSelection(slots, interviewerCount); + if (validationError) { + throw new Error(validationError); + } + const selectedSlots = slots.map(completeSlot); + + const savedPersonaIds: number[] = []; + + for (const template of selectedSlots) { + const personaPayload: SavePersonaParams = { + personaName: buildPersonaName(template), + role: template.role, + type: template.personality, + tone: template.tone, + level: LEVEL_BY_DIFFICULTY[difficulty], + career: template.career, + gender: template.gender, + voiceIndex: template.voiceIndex, + imageUrl: template.image, + description: template.description, + }; + const { data: savedPersona, errorMessage: savePersonaError } = + await savePersona(personaPayload); + + if (savePersonaError || !savedPersona) { + const failureMessage = `${template.roleLabel} 면접관 저장에 실패했습니다.`; + const savedPersonaMessage = + savedPersonaIds.length > 0 + ? ` 이미 저장된 ${savedPersonaIds.length}명은 서버에 남아있을 수 있습니다.` + : ""; + + throw new Error( + `${failureMessage}${savedPersonaMessage}${ + savePersonaError ? ` (${savePersonaError})` : "" + }`, + ); + } + + if (!Number.isInteger(savedPersona.personaId) || savedPersona.personaId <= 0 || savedPersonaIds.includes(savedPersona.personaId)) { + throw new Error("면접관 저장 응답이 올바르지 않습니다."); + } + savedPersonaIds.push(savedPersona.personaId); + } + + return { personaIds: savedPersonaIds }; +}; diff --git a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts index 1d45debb..02735838 100644 --- a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts +++ b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts @@ -1,30 +1,37 @@ -import { useRef, useState } from "react"; -import { useNavigate } from "react-router-dom"; -import { createInterview, prepareInterviewRecord, savePersona, setActiveInterviewSessionId, type SavePersonaParams } from "@/features/interview-page/interview/api"; -import { extractErrorMessage } from "@/shared/api/errorMessage"; +import { loadSetupDraft, upsertSettingsDraft } from "@/features/interview-page/setting-company/model/draftStore"; +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { useInterviewEligibility } from "@/features/profile/model/useInterviewEligibility"; +import { + COMPANY_SETTING_PATH, + type MultiInterviewDraft, +} from "@/features/interview-page/setting-company/model/draft"; import { - LEVEL_BY_DIFFICULTY, type InterviewerCount, type InterviewerSlot, type MultiInterviewSelection, - type InterviewerTemplate, type SlotIndex, + type SlotIndex, } from "@/shared/constants/interview-page/setting-multi-interview"; import { INTERVIEW_SETTING_DEFAULT_SELECTION, type InterviewDifficultyOption, type InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; -import { completeSlot, createSlot, resizeSlots, validateSelection } from "./selection"; - -const buildPersonaName = (template: InterviewerTemplate) => `${template.key}-${crypto.randomUUID()}`; +import { createSlot, resizeSlots, validateSelection } from "./selection"; export const useMultiInterviewSetup = () => { const navigate = useNavigate(); - const [style, setStyle] = useState(INTERVIEW_SETTING_DEFAULT_SELECTION.style); - const [difficulty, setDifficulty] = useState(INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty); + const location = useLocation(); + // 기업 선택에서 돌아오면 히스토리 state에 남겨 둔 설정값으로 다시 채운다. + const [initialDraft] = useState(() => { + const draft = loadSetupDraft(new URLSearchParams(location.search).get("draftId"))?.settings; + return draft?.mode === "MULTI" ? draft : null; + }); + const [answerMode, setAnswerMode] = useState<"voice" | "text">(initialDraft?.answerMode ?? "voice"); + const eligibility = useInterviewEligibility(); + const [style, setStyle] = useState(initialDraft?.style ?? "편함"); + const [difficulty, setDifficulty] = useState(initialDraft?.difficulty ?? INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty); const [activeSlot, setActiveSlot] = useState(0); - const [slots, setSlots] = useState(() => Array.from({ length: 3 }, (_, i) => createSlot(i))); + const [slots, setSlots] = useState(() => initialDraft?.slots ?? Array.from({ length: 3 }, (_, i) => createSlot(i))); const interviewerCount = slots.length as InterviewerCount; const [errorMessage, setErrorMessage] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); - const submittingRef = useRef(false); const updateSlot = (patch: Partial) => { - if (submittingRef.current) return; if (patch.role && (activeSlot === 0 ? patch.role !== "TECH" : patch.role === "TECH")) return; if (patch.role && slots.some((slot, index) => index !== activeSlot && slot.role === patch.role)) { setErrorMessage("같은 역할을 중복 선택할 수 없습니다."); @@ -39,7 +46,6 @@ export const useMultiInterviewSetup = () => { }; const changeCount = (count: InterviewerCount) => { - if (submittingRef.current) return; setSlots((previous) => resizeSlots(previous, count)); setActiveSlot((previous) => Math.min(previous, count - 1) as SlotIndex); setErrorMessage(""); @@ -48,150 +54,43 @@ export const useMultiInterviewSetup = () => { // 면접 스타일은 난이도와 같은 독립 설정값이다. // 면접관별 성격·말투는 슬롯이 직접 들고 있으므로 여기서 건드리지 않는다. const changeStyle = (next: InterviewStyleOption) => { - if (submittingRef.current) return; setStyle(next); setErrorMessage(""); }; - const handleNext = async () => { - if (submittingRef.current) return; + // 면접 생성은 다음 단계(기업 선택)의 "면접 시작"에서 한다. + const handleNext = () => { + if (!eligibility.canStart) return; const validationError = validateSelection(slots, interviewerCount); if (validationError) { setErrorMessage(validationError); return; } - const selectedSlots = slots.map(completeSlot); setErrorMessage(""); + const draft: MultiInterviewDraft = { mode: "MULTI", style, difficulty, slots, answerMode }; try { - setIsSubmitting(true); - submittingRef.current = true; - - const savedPersonaIds: number[] = []; - const savedPersonaLabels: string[] = []; - - for (const template of selectedSlots) { - const personaPayload: SavePersonaParams = { - personaName: buildPersonaName(template), - role: template.role, - major: template.role === "TECH" ? template.major ?? null : null, - type: template.personality, - tone: template.tone, - level: LEVEL_BY_DIFFICULTY[difficulty], - career: template.career, - gender: template.gender, - imageUrl: template.image, - description: template.description, - }; - const { data: savedPersona, errorMessage: savePersonaError } = - await savePersona(personaPayload); - - if (savePersonaError || !savedPersona) { - const failureMessage = `${template.roleLabel} 면접관 저장에 실패했습니다.`; - const savedPersonaMessage = - savedPersonaIds.length > 0 - ? ` 이미 저장된 ${savedPersonaIds.length}명은 서버에 남아있을 수 있습니다.` - : ""; - - throw new Error( - `${failureMessage}${savedPersonaMessage}${ - savePersonaError ? ` (${savePersonaError})` : "" - }`, - ); - } - - if (!Number.isInteger(savedPersona.personaId) || savedPersona.personaId <= 0 || savedPersonaIds.includes(savedPersona.personaId)) { - throw new Error("면접관 저장 응답이 올바르지 않습니다."); - } - savedPersonaIds.push(savedPersona.personaId); - savedPersonaLabels.push(template.roleLabel); - } - - const { data: createdInterview, errorMessage: createError } = - await createInterview({ personaIds: savedPersonaIds }); - - if (createError || !createdInterview) { - throw new Error( - `${createError ?? "MULTI 면접 생성에 실패했습니다."} (저장된 페르소나 ${savedPersonaIds.length}명: ${savedPersonaLabels.join(", ")})`, - ); - } - - if (createdInterview.personaIds.length !== interviewerCount || - !savedPersonaIds.every((id) => createdInterview.personaIds.includes(id))) { - throw new Error("생성된 면접의 면접관 정보가 선택한 설정과 일치하지 않습니다."); - } - - const { data: preparedInterviewRecord, errorMessage: prepareError } = - await prepareInterviewRecord(createdInterview.interviewId); - - if (prepareError || !preparedInterviewRecord) { - throw new Error(prepareError ?? "질문 준비에 실패했습니다."); - } - - const representative = selectedSlots[0]; - const interviewSessionId = - preparedInterviewRecord.sessionId ?? - createdInterview.sessionId; - setActiveInterviewSessionId(interviewSessionId); - - navigate(`/main/interview/${createdInterview.interviewId}`, { - state: { - preparedInterview: { - sessionId: interviewSessionId, - interviewId: preparedInterviewRecord.interviewId, - userId: createdInterview.userId, - personaId: savedPersonaIds[0], - personaName: representative.name, - role: representative.role, - major: representative.major ?? null, - type: representative.personality, - personaType: representative.personality, - level: LEVEL_BY_DIFFICULTY[difficulty], - career: representative.career, - gender: representative.gender, - tone: representative.tone, - jobId: preparedInterviewRecord.jobId ?? "", - status: - createdInterview.status === "COMPLETED" ? "COMPLETED" : "IN_PROGRESS", - currentQuestionIndex: createdInterview.currentQuestionIndex, - questions: [], - mode: "MULTI", - interviewers: selectedSlots.map((interviewer, index) => - ({ - personaId: savedPersonaIds[index], name: interviewer.name, - roleLabel: interviewer.roleLabel, image: interviewer.image, - gender: interviewer.gender, voiceIndex: interviewer.voiceIndex, - personaType: interviewer.personality, tone: interviewer.tone, - level: LEVEL_BY_DIFFICULTY[difficulty], - }), - ), - }, - multiInterview: { - interviewId: createdInterview.interviewId, - sessionId: createdInterview.sessionId, - mode: "MULTI", - personaIds: createdInterview.personaIds, - interviewers: selectedSlots, - style, - difficulty, - }, - }, - }); + const saved = upsertSettingsDraft(draft, new URLSearchParams(location.search).get("draftId")); + void Promise.resolve(navigate(`${location.pathname}?draftId=${saved.draftId}`, { replace: true })) + .then(() => navigate(`${COMPANY_SETTING_PATH}?draftId=${saved.draftId}`)); } catch (error) { - setErrorMessage(extractErrorMessage(error, "MULTI 면접 준비에 실패했습니다.")); - setIsSubmitting(false); - submittingRef.current = false; + setErrorMessage(error instanceof Error ? error.message : "설정을 저장하지 못했습니다."); } }; return { - errorMessage, isPortfolioAnalyzing: false, isSubmitting, - isNextDisabled: isSubmitting, + answerMode, + onAnswerModeChange: setAnswerMode, + errorMessage, isPortfolioAnalyzing: eligibility.isPortfolioAnalyzing, + profileGuidance: eligibility.guidance, + showMyPageLink: eligibility.showMyPageLink, + onRefreshProfile: eligibility.hasLoadError ? eligibility.refetch : undefined, + isNextDisabled: !eligibility.canStart, onBack: () => navigate(-1), onNext: handleNext, updateSlot, select: { count: changeCount, - difficulty: (next: InterviewDifficultyOption) => { if (!submittingRef.current) setDifficulty(next); }, + difficulty: (next: InterviewDifficultyOption) => setDifficulty(next), slot: (slot: SlotIndex) => { - if (!submittingRef.current && slot < slots.length) { setActiveSlot(slot); setErrorMessage(""); } + if (slot < slots.length) { setActiveSlot(slot); setErrorMessage(""); } }, style: changeStyle, }, diff --git a/src/features/my-page/api/getMyPageData.ts b/src/features/my-page/api/getMyPageData.ts deleted file mode 100644 index 2393a116..00000000 --- a/src/features/my-page/api/getMyPageData.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { - authInstance, - apiInstance, - ensureAccessToken, -} from "@/shared/api/axiosInstance"; - -export interface MyPageUser { - id: number; - username: string; - nickname: string; - email: string; - major: string; - provider: string; - role: string; - createAt: string; -} - -interface UserInfoResponse { - success: boolean; - message: string | null; - data?: MyPageUser; -} - -export interface MyPageMetaData { - gitUrls: string[]; - fileUrl: string; - jobRole?: string; -} - -interface MetaDataResponse { - data?: MyPageMetaData; -} - -interface MetaDataUploadResponse { - job_id?: string; - jobId?: string; - status?: string; - message?: string; - data?: MetaDataUploadResponse; -} - -const USER_INFO_URL = "/api/v1/users/me"; -const META_DATA_URL = "/api/v1/metaData/getMetaData"; -const META_DATA_UPLOAD_URL = "/api/v1/metaData/dataUpload"; - -export const getMyPageUser = async () => { - const response = await authInstance.get(USER_INFO_URL); - - if (!response.data.success || !response.data.data) { - throw new Error(response.data.message ?? "유저 정보를 불러오지 못했습니다."); - } - - return response.data.data; -}; - -export const getMyPageMetaData = async () => { - const response = await apiInstance.get( - META_DATA_URL, - ); - const payload = response.data; - - if ("data" in payload && payload.data) { - return payload.data; - } - - return payload as MyPageMetaData; -}; - -interface UploadMyPageMetaDataParams { - file: File; - gitUrls: string[]; - jobRole: string; -} - -export const uploadMyPageMetaData = async ({ - file, - gitUrls, - jobRole, -}: UploadMyPageMetaDataParams) => { - const authorizationHeader = await ensureAccessToken(); - const formData = new FormData(); - formData.append("file", file); - gitUrls.forEach((gitUrl) => { - formData.append("gitUrls", gitUrl); - }); - formData.append("jobRole", jobRole); - - await apiInstance.post( - META_DATA_UPLOAD_URL, - formData, - { - headers: { - Authorization: authorizationHeader, - }, - }, - ); - - return { - gitUrls, - fileUrl: "", - jobRole, - } satisfies MyPageMetaData; -}; diff --git a/src/features/my-page/api/materials.ts b/src/features/my-page/api/materials.ts new file mode 100644 index 00000000..e2712949 --- /dev/null +++ b/src/features/my-page/api/materials.ts @@ -0,0 +1,126 @@ +import axios from "axios"; + +import { apiInstance, ensureAccessToken } from "@/shared/api/axiosInstance"; +import { getJobRole, type JobRoleCode } from "@/shared/constants/job/jobRoles"; + +// 01-overview §4.1 C-1. 목록은 서버가 null 대신 빈 목록으로 준다. +export interface MaterialDocument { + index: number; + type: string; + fileUrl: string; + fileName: string | null; + pageCount: number | null; +} + +export interface MaterialSupplement { + title: string; + documentIndex: number | null; + goal: string; + role: string; + decision: string; + outcome: string; +} + +export interface Materials { + jobRole: JobRoleCode | null; + documents: MaterialDocument[]; + gitUrls: string[]; + supplements: MaterialSupplement[]; +} + +interface MaterialsResponse { + jobRole?: string | null; + documents?: MaterialDocument[] | null; + gitUrls?: string[] | null; + supplements?: MaterialSupplement[] | null; +} + +const MATERIALS_URL = "/api/v1/metaData/materials"; + +export const EMPTY_MATERIALS: Materials = { + jobRole: null, + documents: [], + gitUrls: [], + supplements: [], +}; + +const toMaterials = (payload: MaterialsResponse | null | undefined): Materials => ({ + // 모르는 직무 코드는 직무를 고르지 않은 것으로 본다. + jobRole: (getJobRole(payload?.jobRole)?.code as JobRoleCode | undefined) ?? null, + documents: payload?.documents ?? [], + gitUrls: payload?.gitUrls ?? [], + supplements: payload?.supplements ?? [], +}); + +export const getMaterials = async (signal?: AbortSignal): Promise => { + const response = await apiInstance.get(MATERIALS_URL, { + signal, + timeout: 15_000, + }); + + return toMaterials(response.data); +}; + +// ----- 저장: PUT /api/v1/metaData/materials (multipart) ----- + +export type MaterialDocumentInput = + | { type: string; source: "EXISTING"; fileUrl: string } + | { type: string; source: "NEW"; fileKey: string }; + +export interface MaterialsPayload { + jobRole: JobRoleCode; + documents: MaterialDocumentInput[]; + gitUrls: string[]; + supplements: MaterialSupplement[]; +} + +export interface MaterialsDraft { + payload: MaterialsPayload; + // fileKey("file0"~"file4") → 새로 첨부한 PDF + files: Record; +} + +export type MaterialsAnalysisStatus = "accepted" | "failed" | "skipped"; + +export interface MaterialsSaveResult { + materials: Materials; + // null이면 바뀐 것이 없어 저장·분석을 하지 않았다는 뜻이다. + analysis: { jobId: string | null; status: MaterialsAnalysisStatus } | null; +} + +export interface MaterialError { + field: string; + reason: string; + detail: string | null; +} + +interface MaterialsSaveResponse { + materials?: MaterialsResponse | null; + analysis?: MaterialsSaveResult["analysis"]; +} + +export const putMaterials = async ({ payload, files }: MaterialsDraft): Promise => { + const authorizationHeader = await ensureAccessToken(); + const formData = new FormData(); + // payload는 application/json 파트여야 서버가 JSON으로 읽는다. + formData.append("payload", new Blob([JSON.stringify(payload)], { type: "application/json" })); + Object.entries(files).forEach(([fileKey, file]) => { + formData.append(fileKey, file); + }); + + const response = await apiInstance.put(MATERIALS_URL, formData, { + headers: { Authorization: authorizationHeader }, + }); + + return { + materials: toMaterials(response.data.materials), + analysis: response.data.analysis ?? null, + }; +}; + +// 422 자료 규칙 위반이면 errors 목록을, 아니면 null을 돌려준다. +export const getMaterialErrors = (error: unknown): MaterialError[] | null => { + if (!axios.isAxiosError(error) || error.response?.status !== 422) return null; + const errors = (error.response.data as { errors?: unknown } | undefined)?.errors; + return Array.isArray(errors) ? (errors as MaterialError[]) : null; +}; diff --git a/src/features/my-page/materials/model/materialRules.ts b/src/features/my-page/materials/model/materialRules.ts new file mode 100644 index 00000000..1b02e3e8 --- /dev/null +++ b/src/features/my-page/materials/model/materialRules.ts @@ -0,0 +1,100 @@ +import type { MaterialError } from "@/features/my-page/api/materials"; +import { getDocumentTypeLabel } from "@/shared/constants/job/documentTypes"; + +// 서버 검사(MaterialsValidator)와 같은 한도. 06 §3.3, Web 수정 사항 4.7. +export const MAX_DOCUMENTS = 5; +export const MAX_FILE_BYTES = 50 * 1024 * 1024; +export const MAX_TOTAL_NEW_FILE_BYTES = 200 * 1024 * 1024; +export const MAX_GITHUB_URLS = 10; +export const MAX_SUPPLEMENTS = 3; +export const MAX_SUPPLEMENT_TITLE = 80; +export const MAX_SUPPLEMENT_ITEM = 500; + +// github.com의 저장소 또는 계정 주소만 받는다. 계정 자리에 오는 GitHub 기능 경로는 막는다(Web 수정 사항 4.6). +const GITHUB_URL = /^(https?:\/\/)?(www\.)?github\.com\/([A-Za-z0-9-]+)(\/[A-Za-z0-9._-]+)?\/?$/i; +const GITHUB_RESERVED_PATHS = new Set([ + "about", "account", "apps", "codespaces", "collections", "contact", "copilot", "customer-stories", + "dashboard", "discussions", "enterprise", "enterprises", "events", "explore", "features", "issues", + "join", "login", "logout", "marketplace", "new", "notifications", "organizations", "orgs", "pricing", + "pulls", "readme", "search", "security", "sessions", "settings", "signup", "site", "sponsors", + "stars", "team", "teams", "topics", "trending", +]); + +export const isValidGithubUrl = (url: string) => { + const match = GITHUB_URL.exec(url.trim()); + return Boolean(match) && !GITHUB_RESERVED_PATHS.has(match![3].toLowerCase()); +}; + +// 대소문자·https·www·끝의 /·.git만 다른 주소는 같은 저장소로 본다(서버도 하나로 합친다). +export const getGithubKey = (url: string) => + url.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/^www\./, "").replace(/\/+$/, "").replace(/\.git$/, ""); + +export const isPdfFile = (file: File) => + file.type === "application/pdf" || file.name.toLowerCase().endsWith(".pdf"); + +export const MATERIAL_MESSAGES = { + JOB_ROLE_REQUIRED: "직무를 선택해 주세요", + TOO_MANY_DOCUMENTS: "문서는 5개까지 올릴 수 있어요", + DOC_TYPE_NOT_ALLOWED: "이 직무에서 받지 않는 문서 종류예요", + UNREADABLE_PDF: "PDF 파일을 열 수 없어요. 다시 내보내 주세요", + ENCRYPTED_PDF: "암호가 걸린 PDF는 올릴 수 없어요", + FILE_TOO_LARGE: "파일 하나는 50MB까지예요", + TOTAL_FILE_TOO_LARGE: "새로 첨부한 파일의 합계는 200MB까지예요", + DUPLICATE_FILE: "같은 파일이 이미 있어요", + MATERIALS_CHANGED: "자료 정보가 바뀌었어요. 새로고침 후 다시 저장해 주세요", + GITHUB_REQUIRED: "GitHub 저장소를 1개 이상 등록해 주세요", + GITHUB_NOT_ALLOWED: "이 직무는 GitHub를 받지 않아요", + INVALID_GITHUB_URL: "GitHub 저장소 주소 형식을 확인해 주세요", + TOO_MANY_GITHUB_URLS: "GitHub 주소는 10개까지 등록할 수 있어요", + SUPPLEMENT_INVALID: "보완 입력 내용을 확인해 주세요", + NOT_PDF_FILE: "PDF 파일만 업로드할 수 있습니다.", + SAVE_FAILED: "자료를 저장하지 못했습니다.", + LOAD_FAILED: "저장된 자료를 불러오지 못했어요. 다시 불러와야 저장할 수 있어요", +} as const; + +export const getRequiredDocumentMessage = (typeCodes: readonly string[]) => + `${typeCodes.map(getDocumentTypeLabel).join(", ")} 중 1개 이상 올려 주세요`; + +export const getTooManyPagesMessage = (totalPages: string | null) => + totalPages ? `문서 쪽수 합계가 50쪽을 넘어요 (현재 ${totalPages}쪽)` : "문서 쪽수 합계가 50쪽을 넘어요"; + +// 422 errors[] 한 건을 화면 문구로 바꾼다(06 §3.4, Web 수정 사항 4.4). +export const getMaterialErrorMessage = (error: MaterialError, requiredDocuments: readonly string[]) => { + switch (error.reason) { + case "JOB_ROLE_REQUIRED": + return MATERIAL_MESSAGES.JOB_ROLE_REQUIRED; + case "NO_DOCUMENT": + return getRequiredDocumentMessage(requiredDocuments); + case "REQUIRED_DOC_MISSING": + return getRequiredDocumentMessage(error.detail ? error.detail.split(",") : requiredDocuments); + case "TOO_MANY_DOCUMENTS": + return MATERIAL_MESSAGES.TOO_MANY_DOCUMENTS; + case "DOC_TYPE_NOT_ALLOWED": + return MATERIAL_MESSAGES.DOC_TYPE_NOT_ALLOWED; + case "NOT_PDF": + case "BROKEN_PDF": + return MATERIAL_MESSAGES.UNREADABLE_PDF; + case "ENCRYPTED_PDF": + return MATERIAL_MESSAGES.ENCRYPTED_PDF; + case "FILE_TOO_LARGE": + return error.field === "documents" ? MATERIAL_MESSAGES.TOTAL_FILE_TOO_LARGE : MATERIAL_MESSAGES.FILE_TOO_LARGE; + case "DUPLICATE_FILE": + return MATERIAL_MESSAGES.DUPLICATE_FILE; + case "TOO_MANY_PAGES": + return getTooManyPagesMessage(error.detail); + case "MATERIALS_RESET_REQUIRED": + case "EXISTING_NOT_FOUND": + return MATERIAL_MESSAGES.MATERIALS_CHANGED; + case "GITHUB_REQUIRED": + return MATERIAL_MESSAGES.GITHUB_REQUIRED; + case "GITHUB_NOT_ALLOWED": + return MATERIAL_MESSAGES.GITHUB_NOT_ALLOWED; + case "INVALID_GITHUB_URL": + return error.detail === "TOO_MANY" ? MATERIAL_MESSAGES.TOO_MANY_GITHUB_URLS : MATERIAL_MESSAGES.INVALID_GITHUB_URL; + case "SUPPLEMENT_NOT_ALLOWED": + case "SUPPLEMENT_INVALID": + return MATERIAL_MESSAGES.SUPPLEMENT_INVALID; + default: + return MATERIAL_MESSAGES.SAVE_FAILED; + } +}; diff --git a/src/features/my-page/materials/model/useMaterialsForm.ts b/src/features/my-page/materials/model/useMaterialsForm.ts new file mode 100644 index 00000000..fc3b9ee3 --- /dev/null +++ b/src/features/my-page/materials/model/useMaterialsForm.ts @@ -0,0 +1,634 @@ +import axios from "axios"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ChangeEvent, KeyboardEvent } from "react"; + +import { + EMPTY_MATERIALS, + getMaterialErrors, + getMaterials, + putMaterials, + type MaterialDocumentInput, + type MaterialError, + type Materials, + type MaterialSupplement, +} from "@/features/my-page/api/materials"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; +import { + getGithubRule, + getJobRole, + type JobCategory, + type JobRoleCode, +} from "@/shared/constants/job/jobRoles"; +import { + MATERIAL_MESSAGES, + MAX_DOCUMENTS, + MAX_FILE_BYTES, + MAX_GITHUB_URLS, + MAX_SUPPLEMENTS, + MAX_TOTAL_NEW_FILE_BYTES, + getGithubKey, + getMaterialErrorMessage, + getRequiredDocumentMessage, + isPdfFile, + isValidGithubUrl, +} from "./materialRules"; + +export interface DocumentRow { + id: string; + type: string; + // 저장된 문서는 fileUrl, 새로 첨부한 문서는 file을 가진다. 둘 다 없으면 아직 비어 있는 행이다. + fileUrl: string | null; + fileName: string | null; + pageCount: number | null; + file: File | null; +} + +export interface SupplementRow { + id: string; + title: string; + documentId: string; + goal: string; + role: string; + decision: string; + outcome: string; +} + +export type SupplementField = "title" | "goal" | "role" | "decision" | "outcome"; + +export interface MaterialFormErrors { + job?: string; + documents?: string; + github?: string; + supplements?: string; + documentRows: Record; + githubRows: Record; + supplementRows: Record; +} + +export interface SaveStatus { + tone: "info" | "success" | "warning" | "error"; + text: string; + // 저장은 됐지만 분석 요청이 실패했을 때 "다시 분석"을 띄운다(Web 수정 사항 4.1). + canReanalyze?: boolean; +} + +interface UseMaterialsFormOptions { + isSaveBlocked?: boolean; + requiresChangeConfirmation?: boolean; + onAnalysisStarted?: (jobId: string | null) => void; +} + +interface FormSnapshot { + job: JobRoleCode | null; + documents: DocumentRow[]; + gitUrls: string[]; + supplements: SupplementRow[]; +} + +const EMPTY_ERRORS: MaterialFormErrors = { documentRows: {}, githubRows: {}, supplementRows: {} }; + +let rowSequence = 0; +const nextRowId = (prefix: string) => `${prefix}-${++rowSequence}`; + +const blankDocument = (): DocumentRow => ({ + id: nextRowId("doc"), type: "", fileUrl: null, fileName: null, pageCount: null, file: null, +}); + +const blankSupplement = (): SupplementRow => ({ + id: nextRowId("supp"), title: "", documentId: "", goal: "", role: "", decision: "", outcome: "", +}); + +const isDevelopmentJob = (job: string | null) => !job || getJobRole(job)?.category === "DEVELOPMENT"; +const isFilledDocument = (row: DocumentRow) => Boolean(row.file || row.fileUrl); +const isFilledSupplement = (row: SupplementRow) => + Boolean(row.title || row.documentId || row.goal || row.role || row.decision || row.outcome); + +// 서버 값을 화면 행으로 바꾼다. 직무를 고른 상태면 빈 문서 행 1개를 미리 둔다(시안). +const toSnapshot = (materials: Materials): FormSnapshot => { + const documents = materials.documents.map((document) => ({ + id: nextRowId("doc"), + type: document.type, + fileUrl: document.fileUrl, + fileName: document.fileName, + pageCount: document.pageCount, + file: null, + })); + return { + job: materials.jobRole, + documents: documents.length || !materials.jobRole ? documents : [blankDocument()], + gitUrls: materials.gitUrls, + supplements: materials.supplements.map((supplement) => ({ + id: nextRowId("supp"), + title: supplement.title ?? "", + documentId: supplement.documentIndex === null ? "" : documents[supplement.documentIndex]?.id ?? "", + goal: supplement.goal ?? "", + role: supplement.role ?? "", + decision: supplement.decision ?? "", + outcome: supplement.outcome ?? "", + })), + }; +}; + +const EMPTY_SNAPSHOT: FormSnapshot = { job: null, documents: [], gitUrls: [], supplements: [] }; + +export const useMaterialsForm = (options: UseMaterialsFormOptions = {}) => { + const fileInputRef = useRef(null); + const gitInputRef = useRef(null); + const documentSectionRef = useRef(null); + const attachingRowIdRef = useRef(null); + const savingRef = useRef(false); + const loadRequestRef = useRef(null); + // 저장 요청 중에 고친 내용이 있는지 알기 위해 수정할 때마다 올린다. + const editVersionRef = useRef(0); + + const [server, setServer] = useState(null); + const [syncedServer, setSyncedServer] = useState(null); + const [savedSnapshot, setSavedSnapshot] = useState(EMPTY_SNAPSHOT); + const [category, setCategory] = useState("DEVELOPMENT"); + const [job, setJob] = useState(null); + const [documents, setDocuments] = useState([]); + const [gitUrls, setGitUrls] = useState([]); + const [gitInput, setGitInput] = useState(""); + const [supplements, setSupplements] = useState([]); + const [isEdited, setIsEdited] = useState(false); + const [pendingJob, setPendingJob] = useState(null); + const [errors, setErrors] = useState(EMPTY_ERRORS); + const [saveStatus, setSaveStatus] = useState(null); + const [isSaving, setIsSaving] = useState(false); + const [isChangeConfirmationOpen, setIsChangeConfirmationOpen] = useState(false); + const [isMaterialsLoading, setIsMaterialsLoading] = useState(true); + const [loadError, setLoadError] = useState(""); + + const jobRole = getJobRole(job); + // 저장은 문서·GitHub·보완 입력 전체를 바꾸므로 저장된 값을 읽기 전에는 보내지 않는다. + const isLoadBlocked = isMaterialsLoading || Boolean(loadError); + const githubRule = getGithubRule(job); + const hasSupplements = Boolean(jobRole?.hasSupplement); + + const applySnapshot = (snapshot: FormSnapshot) => { + setJob(snapshot.job); + setCategory(getJobRole(snapshot.job)?.category ?? "DEVELOPMENT"); + setDocuments(snapshot.documents); + setGitUrls(snapshot.gitUrls); + setGitInput(""); + setSupplements(snapshot.supplements); + setErrors(EMPTY_ERRORS); + }; + + // 서버 값이 바뀌면 저장된 값으로 기억하고, 사용자가 아직 입력을 시작하지 않았으면 화면도 채운다. + if (server && server !== syncedServer) { + const snapshot = toSnapshot(server); + setSyncedServer(server); + setSavedSnapshot(snapshot); + if (!isEdited) applySnapshot(snapshot); + } + + // 화면은 그대로 두고 저장된 값만 바꾼다. + const rememberServer = (materials: Materials) => { + const snapshot = toSnapshot(materials); + setServer(materials); + setSyncedServer(materials); + setSavedSnapshot(snapshot); + return snapshot; + }; + + const applyServer = (materials: Materials) => { + applySnapshot(rememberServer(materials)); + setIsEdited(false); + }; + + // replaceEdits: 다시 불러오기는 조회 실패 중에 고친 내용 대신 저장된 값으로 화면을 채운다. + const loadMaterials = useCallback(async (replaceEdits: boolean) => { + loadRequestRef.current?.abort(); + const controller = new AbortController(); + loadRequestRef.current = controller; + setIsMaterialsLoading(true); + setLoadError(""); + + let materials: Materials; + try { + materials = await getMaterials(controller.signal); + } catch (error) { + if (controller.signal.aborted) return; + if (axios.isAxiosError(error) && error.response?.status === 404) { + materials = EMPTY_MATERIALS; + } else { + setLoadError(MATERIAL_MESSAGES.LOAD_FAILED); + setIsMaterialsLoading(false); + return; + } + } + if (controller.signal.aborted) return; + if (replaceEdits) { + applySnapshot(rememberServer(materials)); + setIsEdited(false); + } else { + setServer(materials); + } + setIsMaterialsLoading(false); + }, []); + + useEffect(() => { + void loadMaterials(false); + return () => loadRequestRef.current?.abort(); + }, [loadMaterials]); + + const markEdited = () => { + editVersionRef.current += 1; + setIsEdited(true); + setSaveStatus(null); + }; + + const hasMaterials = documents.some((row) => row.type || isFilledDocument(row)) || + gitUrls.length > 0 || supplements.some(isFilledSupplement); + + // 직무를 바꾼다. 개발 3직무(직무 없음 포함) 사이만 자료를 유지하고, 그 밖은 초기화한다(06 §3.2). + const applyJob = (nextJob: JobRoleCode, keepsMaterials: boolean) => { + setJob(nextJob); + setCategory(getJobRole(nextJob)?.category ?? "DEVELOPMENT"); + setPendingJob(null); + setErrors(EMPTY_ERRORS); + markEdited(); + if (keepsMaterials) { + if (documents.length === 0) setDocuments([blankDocument()]); + if (!getJobRole(nextJob)?.hasSupplement) setSupplements([]); + return; + } + if (nextJob === savedSnapshot.job) { + // 원래 직무로 돌아오면 서버 값을 다시 채운다. + setDocuments(savedSnapshot.documents.length ? savedSnapshot.documents : [blankDocument()]); + setGitUrls(savedSnapshot.gitUrls); + setSupplements(savedSnapshot.supplements); + return; + } + setDocuments([blankDocument()]); + setGitUrls([]); + setSupplements([]); + }; + + const handleCategoryChange = (nextCategory: JobCategory) => { + setCategory(nextCategory); + }; + + const handleJobChange = (value: string) => { + const nextRole = getJobRole(value); + if (!nextRole || nextRole.code === job) return; + const nextJob = nextRole.code as JobRoleCode; + const keepsMaterials = isDevelopmentJob(job) && nextRole.category === "DEVELOPMENT"; + if (keepsMaterials || !hasMaterials) { + applyJob(nextJob, keepsMaterials); + return; + } + setPendingJob(nextJob); + }; + + const handleCancelJobChange = () => { + setPendingJob(null); + setCategory(getJobRole(job)?.category ?? "DEVELOPMENT"); + }; + + const handleConfirmJobChange = () => { + if (pendingJob) applyJob(pendingJob, false); + }; + + // ----- 문서 ----- + + const updateDocument = (rowId: string, patch: Partial) => { + setDocuments((rows) => rows.map((row) => (row.id === rowId ? { ...row, ...patch } : row))); + setErrors((prev) => { + if (!prev.documentRows[rowId]) return prev; + const documentRows = Object.fromEntries( + Object.entries(prev.documentRows).filter(([id]) => id !== rowId), + ); + return { ...prev, documentRows }; + }); + markEdited(); + }; + + const handleDocumentAdd = () => { + if (documents.length >= MAX_DOCUMENTS) return; + setDocuments((rows) => [...rows, blankDocument()]); + markEdited(); + }; + + const handleDocumentRemove = (rowId: string) => { + setDocuments((rows) => rows.filter((row) => row.id !== rowId)); + setSupplements((rows) => rows.map((row) => (row.documentId === rowId ? { ...row, documentId: "" } : row))); + markEdited(); + }; + + const handleDocumentTypeChange = (rowId: string, type: string) => { + updateDocument(rowId, { type }); + }; + + const handleDocumentAttachClick = (rowId: string) => { + const input = fileInputRef.current; + if (!input) return; + attachingRowIdRef.current = rowId; + input.value = ""; + input.click(); + }; + + const handleDocumentFileChange = (event: ChangeEvent) => { + const file = event.target.files?.[0]; + const rowId = attachingRowIdRef.current; + event.target.value = ""; + if (!file || !rowId) return; + + if (!isPdfFile(file)) { + setErrors((prev) => ({ ...prev, documentRows: { ...prev.documentRows, [rowId]: MATERIAL_MESSAGES.NOT_PDF_FILE } })); + return; + } + + updateDocument(rowId, { file, fileName: file.name, fileUrl: null, pageCount: null }); + }; + + // ----- GitHub ----- + + const handleGitInputChange = (event: ChangeEvent) => { + setGitInput(event.target.value); + setErrors((prev) => (prev.github ? { ...prev, github: undefined } : prev)); + }; + + const handleGitAdd = () => { + const nextGitUrl = gitInput.trim(); + if (!nextGitUrl) return; + + if (!isValidGithubUrl(nextGitUrl)) { + setErrors((prev) => ({ ...prev, github: MATERIAL_MESSAGES.INVALID_GITHUB_URL })); + return; + } + + const nextKey = getGithubKey(nextGitUrl); + if (!gitUrls.some((url) => getGithubKey(url) === nextKey)) { + if (gitUrls.length >= MAX_GITHUB_URLS) { + setErrors((prev) => ({ ...prev, github: MATERIAL_MESSAGES.TOO_MANY_GITHUB_URLS })); + return; + } + setGitUrls((urls) => [...urls, nextGitUrl]); + } + setGitInput(""); + markEdited(); + }; + + const handleGitInputKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) return; + event.preventDefault(); + handleGitAdd(); + }; + + const handleGitRemove = (gitUrl: string) => { + setGitUrls((urls) => urls.filter((url) => url !== gitUrl)); + markEdited(); + }; + + // ----- 보완 입력 ----- + + const handleSupplementAdd = () => { + if (supplements.length >= MAX_SUPPLEMENTS) return; + setSupplements((rows) => [...rows, blankSupplement()]); + markEdited(); + }; + + const handleSupplementRemove = (rowId: string) => { + setSupplements((rows) => rows.filter((row) => row.id !== rowId)); + markEdited(); + }; + + const handleSupplementChange = (rowId: string, field: SupplementField | "documentId", value: string) => { + setSupplements((rows) => rows.map((row) => (row.id === rowId ? { ...row, [field]: value } : row))); + markEdited(); + }; + + // ----- 저장 ----- + + const validate = () => { + const nextErrors: MaterialFormErrors = { documentRows: {}, githubRows: {}, supplementRows: {} }; + if (!jobRole) { + nextErrors.job = MATERIAL_MESSAGES.JOB_ROLE_REQUIRED; + return nextErrors; + } + + const filledDocuments = documents.filter(isFilledDocument); + if (filledDocuments.length > MAX_DOCUMENTS) { + nextErrors.documents = MATERIAL_MESSAGES.TOO_MANY_DOCUMENTS; + } else if (!filledDocuments.some((row) => (jobRole.requiredDocuments as readonly string[]).includes(row.type))) { + nextErrors.documents = getRequiredDocumentMessage(jobRole.requiredDocuments); + } + + let totalNewBytes = 0; + filledDocuments.forEach((row) => { + if (!row.file) return; + totalNewBytes += row.file.size; + if (row.file.size > MAX_FILE_BYTES) nextErrors.documentRows[row.id] = MATERIAL_MESSAGES.FILE_TOO_LARGE; + }); + if (!nextErrors.documents && totalNewBytes > MAX_TOTAL_NEW_FILE_BYTES) { + nextErrors.documents = MATERIAL_MESSAGES.TOTAL_FILE_TOO_LARGE; + } + + if (githubRule === "REQUIRED" && gitUrls.length === 0) { + nextErrors.github = MATERIAL_MESSAGES.GITHUB_REQUIRED; + } + if (githubRule !== "HIDDEN") { + gitUrls.forEach((url) => { + if (!isValidGithubUrl(url)) nextErrors.githubRows[url] = MATERIAL_MESSAGES.INVALID_GITHUB_URL; + }); + } + + return nextErrors; + }; + + const hasErrors = (target: MaterialFormErrors) => + Boolean(target.job || target.documents || target.github || target.supplements) || + Object.keys(target.documentRows).length > 0 || + Object.keys(target.githubRows).length > 0 || + Object.keys(target.supplementRows).length > 0; + + const applyServerErrors = ( + serverErrors: MaterialError[], + sent: { documents: string[]; gitUrls: string[]; supplements: string[] }, + ) => { + const requiredDocuments = jobRole?.requiredDocuments ?? []; + const nextErrors: MaterialFormErrors = { documentRows: {}, githubRows: {}, supplementRows: {} }; + const append = (current: string | undefined, message: string) => + current && current !== message ? `${current} ${message}` : message; + + serverErrors.forEach((error) => { + const message = getMaterialErrorMessage(error, requiredDocuments); + const indexed = /^(documents|gitUrls|supplements)\[(\d+)]$/.exec(error.field); + if (indexed) { + const index = Number(indexed[2]); + if (indexed[1] === "documents" && sent.documents[index]) nextErrors.documentRows[sent.documents[index]] = message; + else if (indexed[1] === "gitUrls" && sent.gitUrls[index]) nextErrors.githubRows[sent.gitUrls[index]] = message; + else if (indexed[1] === "supplements" && sent.supplements[index]) nextErrors.supplementRows[sent.supplements[index]] = message; + return; + } + if (error.field === "jobRole") nextErrors.job = message; + else if (error.field === "gitUrls") nextErrors.github = append(nextErrors.github, message); + else if (error.field === "supplements") nextErrors.supplements = append(nextErrors.supplements, message); + else nextErrors.documents = append(nextErrors.documents, message); + }); + + setErrors(nextErrors); + }; + + const submit = async () => { + if (savingRef.current || options.isSaveBlocked || isLoadBlocked || !jobRole) return; + + const filledDocuments = documents.filter(isFilledDocument); + const files: Record = {}; + const documentInputs: MaterialDocumentInput[] = filledDocuments.map((row) => { + if (row.file) { + const fileKey = `file${Object.keys(files).length}`; + files[fileKey] = row.file; + return { type: row.type, source: "NEW", fileKey }; + } + return { type: row.type, source: "EXISTING", fileUrl: row.fileUrl ?? "" }; + }); + const sentGitUrls = githubRule === "HIDDEN" ? [] : gitUrls; + const sentSupplements = hasSupplements ? supplements.filter(isFilledSupplement) : []; + const supplementInputs: MaterialSupplement[] = sentSupplements.map((row) => { + const documentIndex = filledDocuments.findIndex((document) => document.id === row.documentId); + return { + title: row.title, + documentIndex: documentIndex >= 0 ? documentIndex : null, + goal: row.goal, + role: row.role, + decision: row.decision, + outcome: row.outcome, + }; + }); + + savingRef.current = true; + setIsSaving(true); + setSaveStatus(null); + const sentEditVersion = editVersionRef.current; + + try { + const result = await putMaterials({ + payload: { + jobRole: jobRole.code as JobRoleCode, + documents: documentInputs, + gitUrls: sentGitUrls, + supplements: supplementInputs, + }, + files, + }); + + // 저장 뒤에는 다시 조회하지 않고 응답 값으로 화면과 서버 값을 바꾼다(Web 수정 사항 2.3). + // 요청 중에 고친 내용이 있으면 화면은 두고 저장된 값만 바꿔, 다음 저장에서 보내게 한다. + if (editVersionRef.current === sentEditVersion) applyServer(result.materials); + else rememberServer(result.materials); + if (!result.analysis) { + setSaveStatus({ tone: "info", text: "변경된 내용이 없어요" }); + } else if (result.analysis.status === "accepted") { + options.onAnalysisStarted?.(result.analysis.jobId); + setSaveStatus({ tone: "success", text: "저장했어요. 자료 분석을 시작해요" }); + } else if (result.analysis.status === "failed") { + setSaveStatus({ + tone: "warning", + text: "자료는 저장했지만 분석을 시작하지 못했어요. 다시 분석해 주세요", + canReanalyze: true, + }); + } else { + setSaveStatus({ tone: "info", text: "저장했어요. 이 직무의 자료 분석은 준비 중이에요" }); + } + } catch (error) { + const serverErrors = getMaterialErrors(error); + if (serverErrors) { + applyServerErrors(serverErrors, { + documents: filledDocuments.map((row) => row.id), + gitUrls: sentGitUrls, + supplements: sentSupplements.map((row) => row.id), + }); + setSaveStatus({ tone: "error", text: MATERIAL_MESSAGES.SAVE_FAILED }); + } else { + setSaveStatus({ tone: "error", text: extractErrorMessage(error, "자료 저장에 실패했습니다.") }); + } + } finally { + savingRef.current = false; + setIsSaving(false); + } + }; + + const handleSave = async () => { + if (savingRef.current || options.isSaveBlocked || isLoadBlocked) return; + setSaveStatus(null); + const nextErrors = validate(); + setErrors(nextErrors); + if (hasErrors(nextErrors)) return; + // 고친 것이 없으면 질문이 사라진다는 경고 없이 보내고, 서버의 "변경 없음" 응답을 그대로 보여 준다. + if (options.requiresChangeConfirmation && isEdited) { + setIsChangeConfirmationOpen(true); + return; + } + await submit(); + }; + + const handleConfirmChange = async () => { + setIsChangeConfirmationOpen(false); + await submit(); + }; + + const handleCancelEdit = () => { + applySnapshot(savedSnapshot); + setIsEdited(false); + setPendingJob(null); + setSaveStatus(null); + }; + + const focusDocuments = () => { + documentSectionRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + documentSectionRef.current?.querySelector("select, button")?.focus({ preventScroll: true }); + }; + + return { + materials: server, + jobSelection: { + category, + job: job ?? "", + onCategoryChange: handleCategoryChange, + onJobChange: handleJobChange, + }, + jobChange: { + isOpen: pendingJob !== null, + onCancel: handleCancelJobChange, + onConfirm: handleConfirmJobChange, + }, + job, + githubRule, + hasSupplements, + documents, + documentSectionRef, + fileInputRef, + onDocumentAdd: handleDocumentAdd, + onDocumentRemove: handleDocumentRemove, + onDocumentTypeChange: handleDocumentTypeChange, + onDocumentAttachClick: handleDocumentAttachClick, + onDocumentFileChange: handleDocumentFileChange, + gitInput, + gitInputRef, + gitUrls, + onGitAdd: handleGitAdd, + onGitInputChange: handleGitInputChange, + onGitInputKeyDown: handleGitInputKeyDown, + onGitRemove: handleGitRemove, + supplements, + onSupplementAdd: handleSupplementAdd, + onSupplementRemove: handleSupplementRemove, + onSupplementChange: handleSupplementChange, + errors, + saveStatus, + isSaving, + isEdited, + isSaveDisabled: isSaving || isLoadBlocked || Boolean(options.isSaveBlocked), + loadError, + onRetryLoad: () => void loadMaterials(true), + onSave: handleSave, + onCancelEdit: handleCancelEdit, + isChangeConfirmationOpen, + onCancelChange: () => setIsChangeConfirmationOpen(false), + onConfirmChange: handleConfirmChange, + focusDocuments, + }; +}; diff --git a/src/features/my-page/personal-info/api/getMyInfo.ts b/src/features/my-page/personal-info/api/getMyInfo.ts index 648a42a6..7ba9854d 100644 --- a/src/features/my-page/personal-info/api/getMyInfo.ts +++ b/src/features/my-page/personal-info/api/getMyInfo.ts @@ -3,7 +3,7 @@ const GET_MY_INFO_URL = "/api/v1/users/me"; - interface RawUserRecord { + export interface RawUserRecord { id?: number; name?: string; username?: string; @@ -11,7 +11,7 @@ email?: string; } - interface RawUserResponse { + export interface RawUserResponse { success?: boolean; message?: string | null; data?: RawUserRecord; @@ -38,7 +38,7 @@ return payload as RawUserRecord; }; - const normalizeUserMe = ( + export const normalizeUserMe = ( payload: RawUserRecord | RawUserResponse | null | undefined, ): UserMeData => { const record = getRecord(payload); diff --git a/src/features/my-page/personal-info/api/updateMyInfo.ts b/src/features/my-page/personal-info/api/updateMyInfo.ts index 1266202a..d79322bd 100644 --- a/src/features/my-page/personal-info/api/updateMyInfo.ts +++ b/src/features/my-page/personal-info/api/updateMyInfo.ts @@ -1,22 +1,36 @@ import { authInstance } from "@/shared/api/axiosInstance"; import { extractErrorMessage } from "@/shared/api/errorMessage"; + import { + normalizeUserMe, + type RawUserResponse, + } from "@/features/my-page/personal-info/api/getMyInfo"; const UPDATE_MY_INFO_URL = "/api/v1/users/me"; + // 이메일은 로그인 ID라 서버가 수정하지 않는다. export interface UpdateMyInfoParams { name: string; nickname: string; - email: string; } export const updateMyInfo = async (params: UpdateMyInfoParams) => { try { - await authInstance.patch(UPDATE_MY_INFO_URL, params); + const response = await authInstance.patch( + UPDATE_MY_INFO_URL, + { + username: params.name, + nickname: params.nickname, + }, + ); - return { errorMessage: null }; + return { + data: response.data.data ? normalizeUserMe(response.data) : null, + errorMessage: null, + }; } catch (error) { return { + data: null, errorMessage: extractErrorMessage(error, "회원정보 수정에 실패했습니다."), }; } - }; \ No newline at end of file + }; diff --git a/src/features/my-page/personal-info/model/useMyInfo.ts b/src/features/my-page/personal-info/model/useMyInfo.ts index 0fa14898..f429c0ab 100644 --- a/src/features/my-page/personal-info/model/useMyInfo.ts +++ b/src/features/my-page/personal-info/model/useMyInfo.ts @@ -4,10 +4,10 @@ import { getMyInfo } from "@/features/my-page/personal-info/api/getMyInfo"; import { updateMyInfo } from "@/features/my-page/personal-info/api/updateMyInfo"; import { useUserStore } from "@/shared/store/userStore"; +// 이메일은 로그인 ID라 서버가 수정하지 않으므로 편집 대상에서 뺀다. export interface PersonalInfoDraft { name: string; nickname: string; - email: string; } export const useMyInfo = () => { @@ -28,9 +28,8 @@ export const useMyInfo = () => { () => ({ name: draftOverrides.name ?? name, nickname: draftOverrides.nickname ?? nickname, - email: draftOverrides.email ?? email, }), - [draftOverrides, email, name, nickname], + [draftOverrides, name, nickname], ); const isLoading = isFetching && !isLoaded; @@ -80,11 +79,10 @@ export const useMyInfo = () => { const nextDraft = { name: draft.name.trim(), nickname: draft.nickname.trim(), - email: draft.email.trim(), }; - if (!nextDraft.name || !nextDraft.nickname || !nextDraft.email) { - setErrorMessage("이름, 닉네임, 이메일을 모두 입력해주세요."); + if (!nextDraft.name || !nextDraft.nickname) { + setErrorMessage("이름과 닉네임을 모두 입력해주세요."); setSaveMessage(""); return; } @@ -93,22 +91,27 @@ export const useMyInfo = () => { setErrorMessage(""); setSaveMessage(""); - const { errorMessage: saveErrorMessage } = await updateMyInfo(nextDraft); - - setIsSaving(false); + const { data: updatedUser, errorMessage: saveErrorMessage } = + await updateMyInfo(nextDraft); if (saveErrorMessage) { + setIsSaving(false); setErrorMessage(saveErrorMessage); return; } - setUser(nextDraft); + // 서버가 수정된 사용자를 돌려준다. 응답이 비었으면 재조회하고, 그것도 실패하면 입력값으로 갱신한다. + const savedUser = updatedUser ?? (await getMyInfo()).data; + + setIsSaving(false); + setUser(savedUser ?? nextDraft); setDraftOverrides({}); setSaveMessage("개인정보가 저장되었습니다."); }, [draft, isSaving, setUser]); return { draft, + email, errorMessage, isLoading, isSaving, diff --git a/src/features/my-page/portfolio/api/savePortfolio.ts b/src/features/my-page/portfolio/api/savePortfolio.ts deleted file mode 100644 index a1ee8ceb..00000000 --- a/src/features/my-page/portfolio/api/savePortfolio.ts +++ /dev/null @@ -1,47 +0,0 @@ - import { apiInstance } from "@/shared/api/axiosInstance"; - import { extractErrorMessage } from "@/shared/api/errorMessage"; - - const SAVE_PORTFOLIO_URL = "/api/v1/metaData/dataUpload"; - - export interface SavePortfolioParams { - portfolioFile: File | null; - gitLinks: string[]; - jobRole: string; - } - - export const savePortfolio = async ({ - portfolioFile, - gitLinks = [], - jobRole, - }: SavePortfolioParams) => { - try { - if (!portfolioFile) { - return { errorMessage: "포트폴리오 파일을 첨부해 주세요." }; - } - - const formData = new FormData(); - formData.append("file", portfolioFile); - - const validLinks = gitLinks.map((link) => link.trim()).filter(Boolean); - validLinks.forEach((link) => { - const validLink = link.startsWith("http") ? link : `https://${link}`; - formData.append("gitUrls", validLink); - }); - - if (jobRole) { - formData.append("jobRole", jobRole); - } - - await apiInstance.post(SAVE_PORTFOLIO_URL, formData, { - headers: { - "Content-Type": "multipart/form-data", - }, - }); - - return { errorMessage: null }; - } catch (error) { - return { - errorMessage: extractErrorMessage(error, "포트폴리오 저장에 실패했습니다."), - }; - } - }; diff --git a/src/features/my-page/portfolio/model/usePortfolioForm.ts b/src/features/my-page/portfolio/model/usePortfolioForm.ts deleted file mode 100644 index 5ccf5e3f..00000000 --- a/src/features/my-page/portfolio/model/usePortfolioForm.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import type { ChangeEvent, KeyboardEvent } from "react"; - -import { - uploadMyPageMetaData, - type MyPageMetaData, -} from "@/features/my-page/api/getMyPageData"; -import { extractErrorMessage } from "@/shared/api/errorMessage"; - -interface UsePortfolioFormOptions { - initialGitUrls?: string[]; - initialJobRole?: string; - onUploadSuccess?: (metaData: MyPageMetaData) => void; -} - -const normalizeGitUrl = (value: string) => value.trim(); - -export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { - const fileInputRef = useRef(null); - const [portfolioFile, setPortfolioFile] = useState(null); - const [portfolioFileError, setPortfolioFileError] = useState(""); - const [gitInput, setGitInput] = useState(""); - const [gitUrls, setGitUrls] = useState([]); - const [jobRole, setJobRole] = useState(""); - const [jobRoleError, setJobRoleError] = useState(""); - const [isSaving, setIsSaving] = useState(false); - const [saveMessage, setSaveMessage] = useState(""); - const [saveError, setSaveError] = useState(""); - const isGitListEditedRef = useRef(false); - const isJobRoleEditedRef = useRef(false); - - useEffect(() => { - if (!options.initialGitUrls?.length || isGitListEditedRef.current) { - return; - } - - const nextGitUrls = options.initialGitUrls - .map(normalizeGitUrl) - .filter((url, index, urls) => url && urls.indexOf(url) === index); - - setGitUrls(nextGitUrls); - }, [options.initialGitUrls]); - - useEffect(() => { - if (!options.initialJobRole || isJobRoleEditedRef.current) { - return; - } - - setJobRole(options.initialJobRole); - }, [options.initialJobRole]); - - const clearSaveStatus = () => { - setSaveMessage(""); - setSaveError(""); - }; - - const handlePortfolioUploadClick = () => { - const input = fileInputRef.current; - if (!input) return; - input.value = ""; - input.click(); - }; - - const handlePortfolioFileChange = (event: ChangeEvent) => { - const file = event.target.files?.[0]; - clearSaveStatus(); - - if (!file) { - setPortfolioFile(null); - setPortfolioFileError(""); - return; - } - - const isPdfFile = - file.type === "application/pdf" || - file.name.toLowerCase().endsWith(".pdf"); - - if (!isPdfFile) { - setPortfolioFile(null); - setPortfolioFileError("PDF 파일만 업로드할 수 있습니다."); - event.target.value = ""; - return; - } - - setPortfolioFile(file); - setPortfolioFileError(""); - }; - - const handleGitInputChange = (event: ChangeEvent) => { - setGitInput(event.target.value); - clearSaveStatus(); - }; - - const handleGitAdd = () => { - const nextGitUrl = normalizeGitUrl(gitInput); - - if (!nextGitUrl) { - return; - } - - isGitListEditedRef.current = true; - setGitUrls((prevGitUrls) => - prevGitUrls.includes(nextGitUrl) - ? prevGitUrls - : [...prevGitUrls, nextGitUrl], - ); - setGitInput(""); - clearSaveStatus(); - }; - - const handleGitInputKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Enter") { - return; - } - - event.preventDefault(); - handleGitAdd(); - }; - - const handleGitRemove = (gitUrl: string) => { - isGitListEditedRef.current = true; - setGitUrls((prevGitUrls) => prevGitUrls.filter((url) => url !== gitUrl)); - clearSaveStatus(); - }; - - const handleJobRoleChange = (event: ChangeEvent) => { - isJobRoleEditedRef.current = true; - setJobRole(event.target.value); - setJobRoleError(""); - clearSaveStatus(); - }; - - const validateBeforeSave = () => { - let isValid = true; - - if (!portfolioFile) { - setPortfolioFileError("업로드할 PDF 파일을 첨부해주세요."); - isValid = false; - } - - if (gitUrls.length === 0) { - setSaveError("Git 주소를 하나 이상 추가해주세요."); - isValid = false; - } - - if (!jobRole) { - setJobRoleError("직무를 선택해주세요."); - isValid = false; - } - - return isValid; - }; - - const handlePortfolioSave = async () => { - if (isSaving) { - return; - } - - clearSaveStatus(); - - if (!validateBeforeSave() || !portfolioFile) { - return; - } - - setIsSaving(true); - - try { - const nextMetaData = await uploadMyPageMetaData({ - file: portfolioFile, - gitUrls, - jobRole, - }); - - options.onUploadSuccess?.(nextMetaData); - setSaveMessage("포트폴리오가 저장되었습니다."); - } catch (error) { - setSaveError( - extractErrorMessage(error, "포트폴리오 저장에 실패했습니다."), - ); - } finally { - setIsSaving(false); - } - }; - - return { - fileError: portfolioFileError, - fileInputRef, - gitInput, - gitUrls, - isSaving, - jobRole, - jobRoleError, - onGitAdd: handleGitAdd, - onGitInputChange: handleGitInputChange, - onGitInputKeyDown: handleGitInputKeyDown, - onGitRemove: handleGitRemove, - onJobRoleChange: handleJobRoleChange, - onPortfolioFileChange: handlePortfolioFileChange, - onPortfolioSave: handlePortfolioSave, - onPortfolioUploadClick: handlePortfolioUploadClick, - saveError, - saveMessage, - selectedPortfolioFile: portfolioFile, - }; -}; diff --git a/src/features/profile/api/index.ts b/src/features/profile/api/index.ts new file mode 100644 index 00000000..ca5224af --- /dev/null +++ b/src/features/profile/api/index.ts @@ -0,0 +1,51 @@ +import { apiInstance } from "@/shared/api/axiosInstance"; + +export type ProfileStatus = "NONE" | "PENDING" | "COMPLETED" | "FAILED"; +export type ProfileFailureReason = + | "REPO_NOT_PUBLIC" + | "INVALID_MATERIAL" + | "UNREADABLE_DOCUMENT" + | "INSUFFICIENT_EVIDENCE" + | "INTERNAL"; + +export interface LatestProfile { + jobId: string | null; + status: ProfileStatus; + errorStatusCode: number | null; + errorMessage: string | null; + failureReason: ProfileFailureReason | null; + completedAt: string | null; + projectSummary: unknown; +} + +export const getLatestProfile = async (signal?: AbortSignal) => { + const response = await apiInstance.get<{ success: boolean; data: LatestProfile }>( + "/api/v1/ai/profile/latest", + { signal, timeout: 15_000 }, + ); + const profile = response.data.data; + if ( + !response.data.success || !profile || + !["NONE", "PENDING", "COMPLETED", "FAILED"].includes(profile.status) + ) { + throw new Error("자료 분석 상태를 확인하지 못했습니다."); + } + return profile; +}; + +export const generateProfile = async (signal?: AbortSignal) => { + const response = await apiInstance.post<{ + success?: boolean; + message?: string; + jobId?: string; + data?: { jobId?: string }; + }>( + "/api/v1/ai/generate", + undefined, + { signal }, + ); + if (response.data.success === false) { + throw new Error(response.data.message || "자료 분석을 다시 요청하지 못했습니다."); + } + return response.data.data?.jobId ?? response.data.jobId ?? null; +}; diff --git a/src/features/profile/model/materialsRequirement.ts b/src/features/profile/model/materialsRequirement.ts new file mode 100644 index 00000000..1088eb2f --- /dev/null +++ b/src/features/profile/model/materialsRequirement.ts @@ -0,0 +1,24 @@ +import type { Materials } from "@/features/my-page/api/materials"; +import { getGithubRule, getJobRole } from "@/shared/constants/job/jobRoles"; + +// 직무를 고르지 않은 옛 사용자는 개발 직무 규칙(포트폴리오·GitHub 필수)으로 본다(01 가정 A-7). +const LEGACY_REQUIRED_DOCUMENTS = ["PORTFOLIO"] as const; + +export const getRequiredDocuments = (jobRole: string | null | undefined): readonly string[] => + getJobRole(jobRole)?.requiredDocuments ?? LEGACY_REQUIRED_DOCUMENTS; + +// 06-web §4: 직무의 필수 종류 문서 1개 이상, GitHub 필수 직무는 GitHub 1개 이상. +export const checkMaterials = (materials: Materials) => { + const requiredDocuments = getRequiredDocuments(materials.jobRole); + const hasRequiredDocument = materials.documents.some((document) => + requiredDocuments.includes(document.type)); + const hasGithub = materials.gitUrls.some((url) => url.trim()); + const isGithubMissing = getGithubRule(materials.jobRole) === "REQUIRED" && !hasGithub; + + return { + hasAnyMaterial: materials.documents.length > 0 || hasGithub, + hasRequiredDocument, + isGithubMissing, + isSatisfied: hasRequiredDocument && !isGithubMissing, + }; +}; diff --git a/src/features/profile/model/profileCompletedAt.ts b/src/features/profile/model/profileCompletedAt.ts new file mode 100644 index 00000000..1af5cb4d --- /dev/null +++ b/src/features/profile/model/profileCompletedAt.ts @@ -0,0 +1,27 @@ +// 다른 모듈을 가져오지 않는다. tests/profile-completed-at.test.mjs가 이 파일만 트랜스파일해 검사한다. +export const formatProfileCompletedAt = (value: string | null) => { + if (!value) return null; + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,9}))?)?(Z|[+-]\d{2}:\d{2})?$/); + if (!match) return null; + + const [, year, month, day, hour, minute, second, , offset] = match; + const yearNumber = Number(year); + const monthNumber = Number(month); + const leapYear = yearNumber % 4 === 0 && (yearNumber % 100 !== 0 || yearNumber % 400 === 0); + const daysInMonth = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if (yearNumber === 0 || monthNumber < 1 || monthNumber > 12 || + Number(day) < 1 || Number(day) > daysInMonth[monthNumber - 1] || + Number(hour) > 23 || Number(minute) > 59 || Number(second ?? 0) > 59) return null; + + if (!offset) return `${year}.${month}.${day} ${hour}:${minute} 분석 완료`; + if (offset !== "Z" && (Number(offset.slice(1, 3)) > 23 || Number(offset.slice(4)) > 59)) return null; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + const parts = new Intl.DateTimeFormat("en", { + timeZone: "Asia/Seoul", year: "numeric", month: "2-digit", day: "2-digit", + hour: "2-digit", minute: "2-digit", hourCycle: "h23", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((item) => item.type === type)?.value; + return `${part("year")?.padStart(4, "0")}.${part("month")}.${part("day")} ${part("hour")}:${part("minute")} 분석 완료`; +}; diff --git a/src/features/profile/model/profileMessages.ts b/src/features/profile/model/profileMessages.ts new file mode 100644 index 00000000..20c7a269 --- /dev/null +++ b/src/features/profile/model/profileMessages.ts @@ -0,0 +1,42 @@ +import { getDocumentTypeLabel } from "@/shared/constants/job/documentTypes"; +import { getJobRole, type JobRole } from "@/shared/constants/job/jobRoles"; +import type { LatestProfile, ProfileFailureReason } from "../api"; +import { getRequiredDocuments } from "./materialsRequirement"; + +export const PROFILE_FAILURE_MESSAGES: Record = { + REPO_NOT_PUBLIC: "GitHub 저장소가 비공개라 읽을 수 없어요. public으로 바꾼 뒤 다시 저장해 주세요", + INVALID_MATERIAL: "PDF를 열 수 없거나 자료 형식이 올바르지 않아요", + UNREADABLE_DOCUMENT: "필수 문서의 내용을 읽지 못했어요. 원본 파일에서 PDF로 다시 내보내 올려 주세요", + INSUFFICIENT_EVIDENCE: "코드에서 질문 근거를 충분히 찾지 못했어요. 핵심 기능이 담긴 저장소를 추가하거나 포트폴리오를 보강해 주세요", + INTERNAL: "분석 중 문제가 생겼어요", +}; + +const getNonDevInsufficientMessage = (job: JobRole) => { + const documents = [...job.requiredDocuments, ...job.optionalDocuments] + .map(getDocumentTypeLabel).join(", "); + return `자료에서 질문 근거를 충분히 찾지 못했어요. ${documents}처럼 직접 한 일이 드러나는 문서를 더 올려 주세요`; +}; + +const withObjectParticle = (word: string) => { + const syllable = word.charCodeAt(word.length - 1) - 0xac00; + const hasFinalConsonant = syllable >= 0 && syllable <= 11171 && syllable % 28 !== 0; + return `${word}${hasFinalConsonant ? "을" : "를"}`; +}; + +export const getRequiredDocumentGuidance = (jobRole: string | null | undefined) => { + const labels = getRequiredDocuments(jobRole).map(getDocumentTypeLabel); + return labels.length === 1 + ? `${withObjectParticle(labels[0])} 등록해야 면접을 시작할 수 있어요` + : `${labels.join("·")} 중 1개 이상 등록해야 면접을 시작할 수 있어요`; +}; + +export const getProfileFailureMessage = (profile: LatestProfile, jobRole?: string | null) => { + const job = getJobRole(jobRole); + if (profile.failureReason === "INSUFFICIENT_EVIDENCE" && job?.category === "NON_DEVELOPMENT") { + return getNonDevInsufficientMessage(job); + } + return (profile.failureReason && PROFILE_FAILURE_MESSAGES[profile.failureReason]) || + profile.errorMessage || PROFILE_FAILURE_MESSAGES.INTERNAL; +}; + +export { formatProfileCompletedAt } from "./profileCompletedAt"; diff --git a/src/features/profile/model/useInterviewEligibility.ts b/src/features/profile/model/useInterviewEligibility.ts new file mode 100644 index 00000000..e1f6b456 --- /dev/null +++ b/src/features/profile/model/useInterviewEligibility.ts @@ -0,0 +1,90 @@ +import axios from "axios"; +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + EMPTY_MATERIALS, + getMaterials, + type Materials, +} from "@/features/my-page/api/materials"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; +import { checkMaterials } from "./materialsRequirement"; +import { getProfileFailureMessage, getRequiredDocumentGuidance } from "./profileMessages"; +import { useProfileStatus } from "./useProfileStatus"; + +const JOB_SELECTION_GUIDANCE = "마이페이지에서 직무를 선택해 주세요"; + +export const useInterviewEligibility = () => { + const profileStatus = useProfileStatus(); + const [materials, setMaterials] = useState(null); + const [isMaterialsLoading, setIsMaterialsLoading] = useState(true); + const [materialsError, setMaterialsError] = useState(""); + const requestRef = useRef(null); + + const loadMaterials = useCallback(async () => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + setIsMaterialsLoading(true); + try { + const data = await getMaterials(controller.signal); + if (!controller.signal.aborted) { + setMaterials(data); + setMaterialsError(""); + } + } catch (error) { + if (!controller.signal.aborted) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + setMaterials(EMPTY_MATERIALS); + setMaterialsError(""); + } else { + setMaterialsError(extractErrorMessage(error, "저장된 자료를 확인하지 못했습니다.")); + } + } + } finally { + if (!controller.signal.aborted) setIsMaterialsLoading(false); + } + }, []); + + useEffect(() => { + void loadMaterials(); + return () => requestRef.current?.abort(); + }, [loadMaterials]); + + const status = profileStatus.profile?.status; + const isChecking = profileStatus.isLoading || (status === "NONE" && isMaterialsLoading); + const loadError = profileStatus.errorMessage || (status === "NONE" ? materialsError : ""); + const jobRole = materials?.jobRole ?? null; + const requirement = checkMaterials(materials ?? EMPTY_MATERIALS); + const hasMaterials = requirement.isSatisfied; + const canStart = !isChecking && !loadError && + (status === "COMPLETED" || (status === "NONE" && hasMaterials)); + // 직무 없이 옛 자료로 시작하는 사용자에게만 직무 선택을 권한다(01 가정 A-7). + const needsJobSelection = canStart && materials !== null && jobRole === null; + + let guidance = ""; + if (isChecking) guidance = "자료 분석 상태를 확인하고 있어요"; + else if (loadError) guidance = loadError; + else if (status === "PENDING") guidance = "자료 분석이 끝나면 시작할 수 있어요"; + else if (status === "FAILED" && profileStatus.profile) guidance = getProfileFailureMessage(profileStatus.profile, jobRole); + else if (status === "NONE") guidance = hasMaterials + ? "첫 면접은 준비에 몇 분 걸릴 수 있어요" + : !requirement.hasAnyMaterial + ? jobRole ? "마이페이지에서 자료를 등록해 주세요" : "마이페이지에서 직무와 자료를 등록해 주세요" + : !requirement.hasRequiredDocument ? getRequiredDocumentGuidance(jobRole) + : "GitHub 저장소를 등록해야 면접을 시작할 수 있어요"; + // 다른 안내가 있으면 그 문장을 두고, 마이페이지 이동 링크로 직무 선택을 이끈다. + if (needsJobSelection && !guidance) guidance = JOB_SELECTION_GUIDANCE; + + return { + canStart, + guidance, + isPortfolioAnalyzing: status === "PENDING", + showMyPageLink: status === "FAILED" || needsJobSelection || + (status === "NONE" && !isChecking && !loadError && !hasMaterials), + hasLoadError: Boolean(loadError), + refetch: () => { + void profileStatus.refetch(); + void loadMaterials(); + }, + }; +}; diff --git a/src/features/profile/model/useProfileStatus.ts b/src/features/profile/model/useProfileStatus.ts new file mode 100644 index 00000000..d8436fd6 --- /dev/null +++ b/src/features/profile/model/useProfileStatus.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { generateProfile, getLatestProfile, type LatestProfile } from "../api"; +import { extractErrorMessage } from "@/shared/api/errorMessage"; + +export const useProfileStatus = () => { + const [profile, setProfile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [errorMessage, setErrorMessage] = useState(""); + const [isRestarting, setIsRestarting] = useState(false); + const requestRef = useRef(null); + const restartRef = useRef(null); + + const refetch = useCallback(async () => { + requestRef.current?.abort(); + const controller = new AbortController(); + requestRef.current = controller; + try { + const latest = await getLatestProfile(controller.signal); + if (controller.signal.aborted) return; + setProfile(latest); + setErrorMessage(""); + } catch (error) { + if (!controller.signal.aborted) { + setErrorMessage(extractErrorMessage(error, "자료 분석 상태를 확인하지 못했습니다.")); + } + } finally { + if (!controller.signal.aborted) { + requestRef.current = null; + setIsLoading(false); + } + } + }, []); + + const markPending = useCallback((jobId: string | null) => { + requestRef.current?.abort(); + setProfile({ + jobId, + status: "PENDING", + errorStatusCode: null, + errorMessage: null, + failureReason: null, + completedAt: null, + projectSummary: null, + }); + setIsLoading(false); + setErrorMessage(""); + void refetch(); + }, [refetch]); + + useEffect(() => { + void refetch(); + return () => { + requestRef.current?.abort(); + restartRef.current?.abort(); + }; + }, [refetch]); + + useEffect(() => { + if (profile?.status !== "PENDING") return; + const timer = window.setInterval(() => { + if (!requestRef.current || requestRef.current.signal.aborted) void refetch(); + }, 5_000); + return () => window.clearInterval(timer); + }, [profile?.status, refetch]); + + const reanalyze = async () => { + if (restartRef.current || profile?.status === "PENDING") return; + const controller = new AbortController(); + restartRef.current = controller; + setIsRestarting(true); + setErrorMessage(""); + try { + const jobId = await generateProfile(controller.signal); + if (!controller.signal.aborted) markPending(jobId); + } catch (error) { + if (!controller.signal.aborted) { + setErrorMessage(extractErrorMessage(error, "자료 분석을 다시 요청하지 못했습니다.")); + } + } finally { + if (!controller.signal.aborted) { + restartRef.current = null; + setIsRestarting(false); + } + } + }; + + return { profile, isLoading, errorMessage, isRestarting, refetch, markPending, reanalyze }; +}; diff --git a/src/pages/feedback-page/feedback-detail/index.tsx b/src/pages/feedback-page/feedback-detail/index.tsx index 63680d82..3f042485 100644 --- a/src/pages/feedback-page/feedback-detail/index.tsx +++ b/src/pages/feedback-page/feedback-detail/index.tsx @@ -1,10 +1,8 @@ +import CompanyFeedback from "@/widgets/feedback-page/company-feedback"; import { buildFeedbackDetailContent } from "@/features/feedback-page/model/feedbackDisplay"; import { useFeedbackInterview } from "@/features/feedback-page/model/useFeedbackInterview"; import { exportElementToPdf } from "@/shared/utils/exportElementToPdf"; -import FeedbackDetailBottomSection from "@/widgets/feedback-page/feedback-detail/bottom-section"; -import FeedbackDetailMiddleSection from "@/widgets/feedback-page/feedback-detail/middle-section"; -import FeedbackDetailTopSection from "@/widgets/feedback-page/feedback-detail/top-section"; -import FeedbackSummaryCard from "@/widgets/feedback-page/feedback-summary-card"; +import FeedbackDetail from "@/widgets/feedback-page/feedback-detail"; import { useRef, useState } from "react"; import { useParams } from "react-router-dom"; import * as S from "./style"; @@ -12,12 +10,16 @@ import * as S from "./style"; const FeedbackDetailPage = () => { const { id } = useParams(); const { + answers, + answersErrorMessage, errorMessage, feedback, feedbackErrorMessage, interview, isLoading, + isRetryingFeedback, refetch, + retryFeedback, } = useFeedbackInterview(id); const isAnalysisPending = feedback?.status === "PENDING" || feedback?.status === "IN_PROGRESS"; @@ -25,7 +27,7 @@ const FeedbackDetailPage = () => { const hasQuestionFeedback = (feedback?.feedbacks?.length ?? 0) > 0; const content = feedback && !isAnalysisPending && !isAnalysisFailed && hasQuestionFeedback - ? buildFeedbackDetailContent(feedback) + ? buildFeedbackDetailContent(feedback, answers) : null; const panelRef = useRef(null); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); @@ -50,14 +52,13 @@ const FeedbackDetailPage = () => { return ( - {isLoading ? ( 면접 정보를 불러오는 중입니다. - ) : errorMessage || !interview ? ( + ) : errorMessage || answersErrorMessage || !interview ? ( - {errorMessage ?? "면접 정보를 찾을 수 없습니다."} + {errorMessage ?? answersErrorMessage ?? "면접 정보를 찾을 수 없습니다."} void refetch()}> 다시 시도 @@ -66,34 +67,31 @@ const FeedbackDetailPage = () => { {isAnalysisPending - ? "질문별 피드백을 분석 중입니다. 잠시 후 다시 확인해주세요." + ? feedbackErrorMessage ?? "질문별 피드백을 분석 중입니다. 완료되면 자동으로 표시됩니다." : isAnalysisFailed - ? feedback?.errorMessage ?? "피드백 분석에 실패했습니다." + ? feedbackErrorMessage ?? feedback?.errorMessage ?? "피드백 분석에 실패했습니다." : feedback && !hasQuestionFeedback ? "표시할 질문별 피드백이 없습니다." : feedbackErrorMessage ?? "피드백 분석 결과를 불러오지 못했습니다."} - void refetch()}> - 다시 시도 + void (isAnalysisFailed ? retryFeedback() : refetch())} + disabled={isAnalysisFailed && isRetryingFeedback} + > + {isAnalysisFailed && isRetryingFeedback ? "요청 중…" : "다시 시도"} ) : ( - <> - - - - - + } + isDownloadingPdf={isDownloadingPdf} + onDownloadPdf={handleDownloadPdf} + /> )} - ); }; diff --git a/src/pages/feedback-page/feedback-detail/style.ts b/src/pages/feedback-page/feedback-detail/style.ts index b6833e3f..c4724bba 100644 --- a/src/pages/feedback-page/feedback-detail/style.ts +++ b/src/pages/feedback-page/feedback-detail/style.ts @@ -2,42 +2,15 @@ import styled from "styled-components"; export const Page = styled.section` width: 100%; - display: flex; - align-items: flex-start; - justify-content: center; - padding: 1.5rem 1.6rem 1.8rem; - box-sizing: border-box; - - @media (max-width: 48rem) { - padding: 0.8rem 0.9rem 1.1rem; - } -`; - -export const Panel = styled.section` - width: min(100%, 84rem); - display: flex; - flex-direction: column; - gap: 1.2rem; - padding: 1.8rem 1.7rem 1rem; - background: linear-gradient( - 180deg, - rgba(255, 255, 255, 0.84), - rgba(247, 250, 255, 0.95) - ); - border: 0.0625rem solid rgba(217, 227, 245, 0.88); - border-radius: 1rem; - box-shadow: 0 1.6rem 3.5rem rgba(62, 104, 171, 0.08); + height: 100%; + min-height: 0; + overflow: hidden; box-sizing: border-box; - - @media (max-width: 48rem) { - gap: 0.95rem; - padding: 1.1rem 0.95rem 1.2rem; - border-radius: 1.45rem; - } `; export const StateCard = styled.div` - width: 100%; + width: calc(100% - 3rem); + margin: 1.5rem; min-height: 12rem; display: flex; flex-direction: column; diff --git a/src/pages/feedback-page/feedback-overall/index.tsx b/src/pages/feedback-page/feedback-overall/index.tsx index fc5b012f..ac1bb76d 100644 --- a/src/pages/feedback-page/feedback-overall/index.tsx +++ b/src/pages/feedback-page/feedback-overall/index.tsx @@ -1,3 +1,5 @@ +import { useFeedbackAudioAnalysis } from "@/features/feedback-page/model/useFeedbackAudioAnalysis"; +import CompanyFeedback from "@/widgets/feedback-page/company-feedback"; import { buildFeedbackOverallContent } from "@/features/feedback-page/model/feedbackDisplay"; import { useFeedbackInterview } from "@/features/feedback-page/model/useFeedbackInterview"; import FeedbackOverallBottomSection from "@/widgets/feedback-page/feedback-overall/bottom-section"; @@ -15,16 +17,21 @@ const FeedbackOverallPage = () => { feedback, feedbackErrorMessage, interview, + previousFeedback, isLoading, + isRetryingFeedback, + isSavingInterview, refetch, - } = useFeedbackInterview(id); + retryFeedback, + } = useFeedbackInterview(id, true); const isAnalysisPending = feedback?.status === "PENDING" || feedback?.status === "IN_PROGRESS"; const isAnalysisFailed = feedback?.status === "FAILED"; const content = feedback && !isAnalysisPending && !isAnalysisFailed - ? buildFeedbackOverallContent(feedback, interview) + ? buildFeedbackOverallContent(feedback, interview, previousFeedback) : null; + const audio = useFeedbackAudioAnalysis(interview?.interviewId ?? interview?.id, interview?.answerMode, content !== null); const panelRef = useRef(null); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); @@ -51,7 +58,9 @@ const FeedbackOverallPage = () => { {isLoading ? ( - 면접 정보를 불러오는 중입니다. + + {isSavingInterview ? "면접 기록을 저장하고 있어요" : "면접 정보를 불러오는 중입니다."} + ) : errorMessage || !interview ? ( @@ -64,21 +73,28 @@ const FeedbackOverallPage = () => { {isAnalysisPending - ? "피드백을 분석 중입니다. 잠시 후 다시 확인해주세요." + ? feedbackErrorMessage ?? "피드백을 분석 중입니다. 완료되면 자동으로 표시됩니다." : isAnalysisFailed - ? feedback?.errorMessage ?? "피드백 분석에 실패했습니다." + ? feedbackErrorMessage ?? feedback?.errorMessage ?? "피드백 분석에 실패했습니다." : feedbackErrorMessage ?? "피드백 분석 결과를 불러오지 못했습니다."} - void refetch()}> - 다시 시도 + void (isAnalysisFailed ? retryFeedback() : refetch())} + disabled={isAnalysisFailed && isRetryingFeedback} + > + {isAnalysisFailed && isRetryingFeedback ? "요청 중…" : "다시 시도"} ) : ( <> + diff --git a/src/pages/feedback-page/feedback-overall/style.ts b/src/pages/feedback-page/feedback-overall/style.ts index 68121553..ffbe4f8d 100644 --- a/src/pages/feedback-page/feedback-overall/style.ts +++ b/src/pages/feedback-page/feedback-overall/style.ts @@ -1,29 +1,25 @@ import styled from "styled-components"; +import { pageLayout } from "@/shared/styles/pageLayout"; + export const Page = styled.section` - width: 100%; + ${pageLayout} display: flex; align-items: flex-start; justify-content: center; min-height: 100%; - padding: 1.2rem 2rem 2rem; - box-sizing: border-box; - - @media (max-width: 48rem) { - padding: 0.8rem 0.9rem 1.1rem; - } + color: ${({ theme }) => theme.colors.text.default}; `; export const Panel = styled.section` - width: min(100%, 90rem); + width: 100%; display: flex; flex-direction: column; - gap: 1.3rem; + gap: 0.75rem; box-sizing: border-box; @media (max-width: 48rem) { - gap: 1rem; - padding: 0.8rem 0.2rem 1.2rem; + gap: 0.75rem; } `; diff --git a/src/pages/interview-page/interview/index.tsx b/src/pages/interview-page/interview/index.tsx index 07ebaf91..7e715cb9 100644 --- a/src/pages/interview-page/interview/index.tsx +++ b/src/pages/interview-page/interview/index.tsx @@ -1,11 +1,15 @@ -import { useMemo, useState } from "react"; -import { useLocation } from "react-router-dom"; +import { useInterviewEntry } from "@/features/interview-page/setting-company/model/useInterviewEntry"; +import { useUserStore } from "@/shared/store/userStore"; +import { loadSetupDraft } from "@/features/interview-page/setting-company/model/draftStore"; +import { COMPANY_SETTING_PATH } from "@/features/interview-page/setting-company/model/draft"; +import CompanyBadge from "@/widgets/interview-page/company-badge"; +import { useMemo } from "react"; +import { Link, Navigate, useLocation, useParams } from "react-router-dom"; import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; import { InterviewSessionProvider, } from "@/features/interview-page/interview/model/interviewSessionContext"; -import { uploadRecordingInBackground } from "@/features/interview-page/interview/model/uploadRecordingInBackground"; import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; import { INTERVIEW_DEVICE_STATUS_LABELS } from "@/shared/constants/interview-page/interview"; import CameraIcon from "@/shared/img/interview-page/camara.svg?url"; @@ -14,6 +18,7 @@ import Loading from "@/shared/components/loading"; import InterviewCameraView from "@/widgets/interview-page/interview/camera-view"; import InterviewContentView from "@/widgets/interview-page/interview/interview-content"; import InterviewDashboard from "@/widgets/interview-page/interview/interview-dashboard"; +import InterviewQuitError from "@/widgets/interview-page/interview/quit-error"; import * as S from "@/widgets/interview-page/interview/style"; const getPreparedInterviewFromState = (state: unknown) => { @@ -39,16 +44,30 @@ const formatElapsedTime = (elapsedSeconds: number) => { const InterviewPage = () => { const location = useLocation(); + const { id } = useParams(); + const user = useUserStore(); const preparedInterview = useMemo( () => getPreparedInterviewFromState(location.state), [location.state], ); - const ttsProvider = "elevenlabs" as const; + + const draftId = new URLSearchParams(location.search).get("draftId"); + const entry = useInterviewEntry(preparedInterview, draftId, user.id, user.isLoaded); + + if (!user.isLoaded) return ; + if (!preparedInterview || preparedInterview.interviewId !== Number(id) || preparedInterview.userId !== user.id) { + const draft = loadSetupDraft(draftId); + if (draft?.submission?.interviewId === Number(id)) return ; + return 면접 정보를 복구할 수 없습니다. 사전 설정에서 다시 시작해주세요.메인으로 돌아가기; + } + + if (entry.error) return {entry.error}메인으로 돌아가기; + if (entry.isChecking || !entry.prepared) return ; return ( @@ -58,43 +77,45 @@ const InterviewPage = () => { const InterviewPageContent = () => { const interviewSession = useInterviewSessionContext(); const interviewRecorder = interviewSession.recorder; - const [isVoiceAnswering, setIsVoiceAnswering] = useState(false); const elapsedSeconds = interviewSession.elapsedSeconds; const isMultiInterview = interviewSession.preparedInterview?.mode === "MULTI"; const isVoiceMode = interviewSession.mode === "voice"; const isTextMode = interviewSession.mode === "text"; - const isVoiceActionStarted = isVoiceAnswering || interviewSession.isVoiceStarted; + const isVoiceActionStarted = interviewSession.isVoiceStarted; const isQuestionLoading = interviewSession.currentQuestion === null; const isAwaitingResponse = interviewSession.isAwaitingResponse; const isStartActionDisabled = interviewRecorder.isStarting || interviewRecorder.isStopping || !interviewSession.isInterviewReady || + !interviewSession.isMediaReady || isQuestionLoading || isAwaitingResponse; const isCameraReady = interviewSession.cameraState === "ready"; const isMicReady = interviewSession.micState === "ready"; const micLevel = isMicReady ? interviewSession.voiceLevel : 0; - const handleQuitInterview = async () => { - const answeredQuestionId = interviewSession.currentQuestion?.questionId; - const file = await interviewRecorder.stopRecording(); - uploadRecordingInBackground({ - file, - interviewId: interviewSession.interviewId, - questionId: answeredQuestionId, - }); - await interviewSession.onQuitInterview(); - }; + const handleQuitInterview = interviewSession.onQuitInterview; - if (interviewSession.preparationError) { + if (interviewSession.preparationError || (interviewSession.quitError && interviewSession.isPreparingInterview)) { return ( + {interviewSession.preparationError} + {interviewSession.canRetryPreparation ? ( + void interviewSession.onRetryPreparation()} + > + {interviewSession.isRetryingPreparation ? "다시 요청 중…" : "다시 시도"} + + ) : null} void handleQuitInterview()} > 메인으로 돌아가기 @@ -104,55 +125,22 @@ const InterviewPageContent = () => { } if (interviewSession.isPreparingInterview) { - return ; + return ; } if (isMultiInterview) { return ; } - const handleStartVoice = async () => { - if (isAwaitingResponse) { - return; - } - - if (!(await interviewSession.onStartVoice())) return; - setIsVoiceAnswering(true); - }; - - const handleCompleteVoice = async () => { - if (isAwaitingResponse) { - return; - } - - // onCompleteVoice 이후에는 다음 질문으로 바뀔 수 있으므로 먼저 저장한다. - const answeredQuestionId = interviewSession.currentQuestion?.questionId; - - try { - const file = await interviewRecorder.stopRecording(); - uploadRecordingInBackground({ - file, - interviewId: interviewSession.interviewId, - questionId: answeredQuestionId, - }); - await interviewSession.onCompleteVoice(); - } finally { - setIsVoiceAnswering(false); - } - }; - - const handleModeChange = (nextMode: typeof interviewSession.mode) => { - if (nextMode === "text") { - void interviewRecorder.stopRecording(); - setIsVoiceAnswering(false); - } - - interviewSession.onModeChange(nextMode); - }; + const handleStartVoice = interviewSession.onStartVoice; + const handleCompleteVoice = interviewSession.onCompleteVoice; return ( + {isVoiceMode ? ( @@ -161,32 +149,13 @@ const InterviewPageContent = () => { {formatElapsedTime(elapsedSeconds)} - - handleModeChange("text")} - > - 텍스트 - - handleModeChange("voice")} - > - 음성 - - + ) : null} - + {isVoiceMode && ( @@ -199,15 +168,23 @@ const InterviewPageContent = () => { ) : null} - {interviewRecorder.errorMessage ? ( + {interviewSession.recordingErrorMessage ? ( - {interviewRecorder.errorMessage} + {interviewSession.recordingErrorMessage} ) : null} + {interviewSession.answerError ? ( + {interviewSession.answerError} + ) : null} + + {isVoiceMode && (interviewSession.cameraState === "blocked" || interviewSession.micState === "blocked") ? ( + 카메라와 마이크 권한을 허용해주세요. 텍스트 면접은 사전설정에서 선택할 수 있습니다. + ) : null} void handleQuitInterview()} > 그만두기 diff --git a/src/pages/interview-page/setting-company/index.tsx b/src/pages/interview-page/setting-company/index.tsx new file mode 100644 index 00000000..2ce48e05 --- /dev/null +++ b/src/pages/interview-page/setting-company/index.tsx @@ -0,0 +1,94 @@ +import { Link } from "react-router-dom"; +import { COMPANY_DISCLAIMER } from "@/features/interview-page/company/model/companyLabels"; +import { MULTI_INTERVIEWER_TEMPLATES } from "@/shared/constants/interview-page/setting-multi-interview"; +import { useCompanySetup } from "@/features/interview-page/setting-company/model/useCompanySetup"; +import { useUserStore } from "@/shared/store/userStore"; +import Loading from "@/shared/components/loading"; +import ProfileGuidance from "@/widgets/interview-page/profile-guidance"; +import CompanyPicker from "@/widgets/interview-page/company-picker"; +import SettingStepper from "@/widgets/interview-page/setting-stepper"; +import * as Setting from "@/widgets/interview-page/setting-interview/style"; +import * as S from "./style"; + +const SettingCompanyPage = () => { + const setup = useCompanySetup(); + const settings = setup.draft?.settings; + const companyName = setup.selectedCompany?.displayName ?? "선택한 기업"; + const isPreparing = setup.preparationState === "PREPARING"; + const isCreated = !!setup.draft?.submission?.interviewId; + const isUnknown = !!setup.draft?.submission && !isCreated; + const interviewerNames = settings?.mode === "SOLO" + ? MULTI_INTERVIEWER_TEMPLATES.find((item) => item.key === settings.interviewerKey)?.name + : settings?.slots.map((slot) => slot.interviewer?.name).join(", "); + + return ( + + + + {!setup.hasDraft ? ( + +

설정이 만료되었거나 복구할 수 없습니다. 사전 설정부터 다시 시작해주세요.

+ 1:1 사전 설정{" · "} + N:1 사전 설정 +
+ ) : ( + <> + + 기업 선택 (선택) + 기업의 공개 자료를 바탕으로 질문과 피드백을 맞춰 드려요. 고르지 않아도 면접을 진행할 수 있어요. + + + {settings?.mode === "SOLO" ? "1:1 면접" : "N:1 면접"} +

{interviewerNames} · 난이도 {settings?.difficulty} · {settings?.answerMode === "voice" ? "음성 답변" : "텍스트 답변"}

+ 설정 수정 +
+ + 선택한 기업 {setup.selectedCompanyId === null ? "없음" : companyName} + {setup.selectedCompanyId !== null ? setup.selectCompany(null)}>선택 안 함 : null} + + + {setup.listState === "error" ? ( + +

{setup.listError} 선택한 기업은 유지됩니다. 기업 없이 시작하려면 ‘선택 안 함’을 눌러주세요.

+ 기업 목록 다시 불러오기 +
+ ) : null} + {COMPANY_DISCLAIMER} + 기준일은 자료를 수집한 시점이며, 모든 사실의 작성일이나 현재 유효성을 뜻하지 않습니다. + {setup.showMyPageLink ? : null} + {setup.errorMessage ? {setup.errorMessage} : null} + {isPreparing ? 면접을 준비하고 있어요. 준비가 끝나면 면접 화면으로 이동합니다. : null} + {isUnknown && !setup.isSubmitting ? 생성 결과를 확인 중인 요청이 있습니다. 설정을 유지한 채 같은 요청으로 복구합니다. : null} + + 돌아가기 + {isCreated ? ( + void setup.onRetryPreparation()}> + {setup.canRetryPreparation ? "면접 준비 다시 시도" : "준비 상태 다시 확인"} + + ) : ( + void setup.onStart()}> + {setup.isSubmitting ? "생성 결과 확인 중…" : isUnknown ? "같은 요청으로 결과 확인" : setup.selectedCompanyId === null ? "기업 없이 면접 시작" : `${companyName} 관점으로 면접 시작`} + + )} + {isCreated ? 새 면접 만들기 : null} + + + )} +
+
+ ); +}; + +const AuthenticatedCompany = () => { + const { isLoaded, id } = useUserStore(); + return isLoaded ? : ; +}; + +export default AuthenticatedCompany; diff --git a/src/pages/interview-page/setting-company/style.ts b/src/pages/interview-page/setting-company/style.ts new file mode 100644 index 00000000..1d7cba50 --- /dev/null +++ b/src/pages/interview-page/setting-company/style.ts @@ -0,0 +1,102 @@ +import styled from "styled-components"; + +export const Heading = styled.div` + display: flex; + flex-direction: column; + gap: 0.4rem; +`; + +export const Description = styled.p` + margin: 0; + color: #6b7080; + font-size: 0.95rem; + line-height: 1.6; + word-break: keep-all; +`; + +export const Disclaimer = styled.p` + margin: 0; + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + background: #f1f2f4; + color: #5b606c; + font-size: 0.85rem; + line-height: 1.5; + word-break: keep-all; +`; + +export const InfoIcon = styled.span` + width: 1.1rem; + height: 1.1rem; + margin-top: 0.1rem; + display: inline-flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + border-radius: 50%; + background: #4d5570; + color: #ffffff; + font-size: 0.7rem; + font-weight: 700; +`; + +export const Footer = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.75rem 1.5rem; +`; + +export const Summary = styled.p` + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + color: #6b7080; + font-size: 0.95rem; + + strong { + color: #257ee8; + font-weight: 700; + } +`; + +export const Notice = styled.div` + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.surface.muted}; + color: ${({ theme }) => theme.colors.text.default}; + line-height: 1.6; + p { margin: 0 0 ${({ theme }) => theme.spacing.sm}; } +`; + +export const Button = styled.button` + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + border: 1px solid ${({ theme }) => theme.colors.brand.blueLight}; + border-radius: ${({ theme }) => theme.radius.sm}; + color: ${({ theme }) => theme.colors.brand.blue}; + background: ${({ theme }) => theme.colors.white}; + font: inherit; + cursor: pointer; + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +export const StartButton = styled(Button)` + min-height: 3rem; + width: auto; + min-width: min(100%, 16rem); + max-width: 100%; + padding: ${({ theme }) => theme.spacing.md} ${({ theme }) => theme.spacing.lg}; + color: ${({ theme }) => theme.colors.white}; + background: ${({ theme }) => theme.colors.brand.blue}; + border-color: ${({ theme }) => theme.colors.brand.blue}; + font-weight: 700; + line-height: 1.5; + word-break: keep-all; + overflow-wrap: anywhere; + @media (max-width: 34rem) { width: 100%; } +`; diff --git a/src/pages/interview-page/setting-interview/index.tsx b/src/pages/interview-page/setting-interview/index.tsx index 2b1463b2..540af47b 100644 --- a/src/pages/interview-page/setting-interview/index.tsx +++ b/src/pages/interview-page/setting-interview/index.tsx @@ -1,4 +1,12 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { COMPANY_SETTING_PATH } from "@/features/interview-page/setting-company/model/draft"; +import { loadSetupDraft } from "@/features/interview-page/setting-company/model/draftStore"; +import { useUserStore } from "@/shared/store/userStore"; +import Loading from "@/shared/components/loading"; +import PersonaOptions from "@/widgets/interview-page/setting-interview/persona-options"; +import ProfileGuidance from "@/widgets/interview-page/profile-guidance"; import { useInterviewSetup } from "@/features/interview-page/setting-interview/model/useInterviewSetup"; +import AnswerModeOptions from "@/widgets/interview-page/setting-interview/answer-mode-options"; import InterviewerOptions from "@/widgets/interview-page/setting-interview/interviewer-options"; import SettingActions from "@/widgets/interview-page/setting-interview/setting-actions"; import SettingOptions from "@/widgets/interview-page/setting-interview/setting-options"; @@ -14,31 +22,51 @@ const SettingInterviewPage = () => { - + > + + + + + + - {setup.errorMessage ? ( - {setup.errorMessage} + {setup.profileGuidance || setup.showMyPageLink || setup.onRefreshProfile ? ( + ) : null} - {setup.isSubmitting ? ( - - - ) : null}
); }; -export default SettingInterviewPage; +const AuthenticatedSettings = () => { + const { isLoaded, id } = useUserStore(); + const location = useLocation(); + const saved = isLoaded ? loadSetupDraft(new URLSearchParams(location.search).get("draftId")) : null; + if (saved?.submission) return ; + return isLoaded ? : ; +}; + +export default AuthenticatedSettings; diff --git a/src/pages/interview-page/setting-multi-interview/index.tsx b/src/pages/interview-page/setting-multi-interview/index.tsx index 49a33600..094d4edf 100644 --- a/src/pages/interview-page/setting-multi-interview/index.tsx +++ b/src/pages/interview-page/setting-multi-interview/index.tsx @@ -1,3 +1,9 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { COMPANY_SETTING_PATH } from "@/features/interview-page/setting-company/model/draft"; +import { loadSetupDraft } from "@/features/interview-page/setting-company/model/draftStore"; +import { useUserStore } from "@/shared/store/userStore"; +import Loading from "@/shared/components/loading"; +import ProfileGuidance from "@/widgets/interview-page/profile-guidance"; import { useMultiInterviewSetup, } from "@/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup"; @@ -5,6 +11,7 @@ import MultiInterviewerSettings from "@/widgets/interview-page/multi-interviewer import { INTERVIEWER_COUNT_OPTIONS } from "@/shared/constants/interview-page/setting-multi-interview"; import MultiInterviewerSlots from "@/widgets/interview-page/multi-interviewer-slots"; import SettingOptions from "@/widgets/interview-page/setting-interview/setting-options"; +import AnswerModeOptions from "@/widgets/interview-page/setting-interview/answer-mode-options"; import * as S from "@/widgets/interview-page/setting-multi-interview/style"; const SettingMultiInterviewPage = () => { @@ -14,25 +21,28 @@ const SettingMultiInterviewPage = () => { - - - - + + + + 면접관 수 {INTERVIEWER_COUNT_OPTIONS.map((count) => ( - setup.select.count(count)}>{count}명 @@ -40,7 +50,6 @@ const SettingMultiInterviewPage = () => { { slot={setup.selection.slots[setup.selection.activeSlot]} otherSlots={setup.selection.slots.filter((_, index) => index !== setup.selection.activeSlot)} technical={setup.selection.activeSlot === 0} - disabled={setup.isSubmitting} + disabled={false} onChange={setup.updateSlot} /> + {setup.errorMessage ? ( {setup.errorMessage} ) : null} - + 돌아가기 { type="button" onClick={setup.onNext} > - {setup.isSubmitting - ? "시작 중..." - : setup.isPortfolioAnalyzing - ? ( - <> - 분석 중 - - {setup.isSubmitting ? ( - - - ) : null} ); }; -export default SettingMultiInterviewPage; +const AuthenticatedSettings = () => { + const { isLoaded, id } = useUserStore(); + const location = useLocation(); + const saved = isLoaded ? loadSetupDraft(new URLSearchParams(location.search).get("draftId")) : null; + if (saved?.submission) return ; + return isLoaded ? : ; +}; + +export default AuthenticatedSettings; diff --git a/src/pages/main-page/style.ts b/src/pages/main-page/style.ts index df21286a..d0872721 100644 --- a/src/pages/main-page/style.ts +++ b/src/pages/main-page/style.ts @@ -1,20 +1,15 @@ import styled from "styled-components"; -export const Page = styled.main` - height: 100%; - min-height: 0; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - row-gap: 0.9rem; - padding-bottom: 0; - font-family: ${({ theme }) => theme.fontFamily.wavvePado}; +import { pageLayout } from "@/shared/styles/pageLayout"; + +export const Page = styled.div` + ${pageLayout} + display: flex; + flex-direction: column; + gap: clamp(0.75rem, 1.4vw, 1rem); + font-family: ${({ theme }) => theme.fontFamily.pretendard}; button { font-family: inherit; } - - @media (max-width: 72rem) { - height: auto; - grid-template-rows: auto auto; - } `; diff --git a/src/pages/my-page/index.tsx b/src/pages/my-page/index.tsx index 98cc114e..83ae3c7b 100644 --- a/src/pages/my-page/index.tsx +++ b/src/pages/my-page/index.tsx @@ -1,69 +1,76 @@ -import { useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { - getMyPageMetaData, - type MyPageMetaData, -} from "@/features/my-page/api/getMyPageData"; import { useMyInfo } from "@/features/my-page/personal-info/model/useMyInfo"; -import { usePortfolioForm } from "@/features/my-page/portfolio/model/usePortfolioForm"; +import { useMaterialsForm } from "@/features/my-page/materials/model/useMaterialsForm"; +import { useProfileStatus } from "@/features/profile/model/useProfileStatus"; +import ChangeConfirmation from "@/widgets/my-page/portfolio/change-confirmation"; +import JobChangeConfirmation from "@/widgets/my-page/portfolio/job-change-confirmation"; +import ProfileStatusCard from "@/widgets/my-page/profile-status"; import PersonalInfo from "@/widgets/my-page/personal-info"; import Portfolio from "@/widgets/my-page/portfolio"; +import JobSelection from "@/widgets/my-page/job-selection"; import * as S from "./style"; const MyPage = () => { const navigate = useNavigate(); - const [metaData, setMetaData] = useState(null); const personalInfo = useMyInfo(); + const profileStatus = useProfileStatus(); + const isAnalyzing = profileStatus.profile?.status === "PENDING"; - const initialGitUrls = useMemo( - () => metaData?.gitUrls ?? [], - [metaData?.gitUrls], - ); - const portfolioForm = usePortfolioForm({ - initialGitUrls, - initialJobRole: metaData?.jobRole, - onUploadSuccess: setMetaData, + const { + materials, + jobSelection, + jobChange, + isChangeConfirmationOpen, + onCancelChange, + onConfirmChange, + focusDocuments, + ...materialsForm + } = useMaterialsForm({ + isSaveBlocked: profileStatus.isLoading || isAnalyzing || profileStatus.isRestarting, + requiresChangeConfirmation: profileStatus.profile?.status === "COMPLETED", + onAnalysisStarted: profileStatus.markPending, }); - useEffect(() => { - let isActive = true; - - const fetchMetaData = async () => { - try { - const nextMetaData = await getMyPageMetaData(); - - if (isActive) { - setMetaData(nextMetaData); - } - } catch { - if (isActive) { - setMetaData(null); - } - } - }; - - void fetchMetaData(); - - return () => { - isActive = false; - }; - }, []); - - const handleBack = () => { - if (window.history.length > 1) { - navigate(-1); - return; - } - - navigate("/main"); - }; - return ( - - + + 마이페이지 + + 나의 정보와 경험을 채우고, 나에게 맞는 면접을 준비하세요. + + + + + void profileStatus.reanalyze()} + jobSelection={} + > + void profileStatus.refetch()} + onReanalyze={() => void profileStatus.reanalyze()} + onEdit={focusDocuments} + onReplaceFile={focusDocuments} + onGoToInterview={() => navigate("/main")} + isSaving={materialsForm.isSaving} + jobRole={materials?.jobRole} + /> + + + {isChangeConfirmationOpen ? ( + void onConfirmChange()} + /> + ) : null} + {jobChange.isOpen ? ( + + ) : null} ); }; diff --git a/src/pages/my-page/style.ts b/src/pages/my-page/style.ts index 49414186..f6c98a79 100644 --- a/src/pages/my-page/style.ts +++ b/src/pages/my-page/style.ts @@ -1,25 +1,43 @@ import styled from "styled-components"; -export const Page = styled.main` - width: min(calc(100% - 2rem), 57rem); - min-height: 100%; +import { pageLayout } from "@/shared/styles/pageLayout"; + +export const Page = styled.div` + ${pageLayout} + color: ${({ theme }) => theme.colors.text.default}; + + button, input { + font-family: inherit; + } +`; + +export const PageHeader = styled.header` + margin-bottom: ${({ theme }) => theme.spacing.xl}; +`; + +export const PageTitle = styled.h1` + color: #25324a; + font-size: ${({ theme }) => theme.fontSize.xl}; + font-weight: 750; + line-height: 1.4; + letter-spacing: -0.04em; +`; + +export const PageDescription = styled.p` + margin-top: ${({ theme }) => theme.spacing.sm}; + color: #68758a; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.6; + word-break: keep-all; +`; + +export const Content = styled.div` display: grid; - grid-template-columns: 14.75rem minmax(0, 1fr); + grid-template-columns: 18rem minmax(0, 1fr); align-items: start; - gap: 1.5rem; - margin: 0 auto; - padding: 0.2rem 0 2.75rem; - - @media (min-width: 90rem) { - width: min(calc(100% - 4rem), 60.25rem); - grid-template-columns: 15.625rem minmax(0, 1fr); - gap: 1.75rem; - padding-top: 0.5rem; - } + gap: ${({ theme }) => theme.spacing.lg}; @media (max-width: 56rem) { - width: min(calc(100% - 1.8rem), 42rem); grid-template-columns: minmax(0, 1fr); - gap: 1.25rem; } `; diff --git a/src/shared/components/header/index.tsx b/src/shared/components/header/index.tsx index db26c599..f9712ae9 100644 --- a/src/shared/components/header/index.tsx +++ b/src/shared/components/header/index.tsx @@ -1,9 +1,6 @@ -import { - clearActiveInterviewSessionId, - disconnectInterviewSocket, - getActiveInterviewSessionId, - quitInterview, -} from "@/features/interview-page/interview/api"; +import { useEffect, useRef, useState } from "react"; +import { clearPendingInterviewLeave, finishActiveInterview, setPendingInterviewLeave } from "@/features/interview-page/interview/model/activeInterviewLifecycle"; +import { waitForRecordingUploads } from "@/features/interview-page/interview/model/interviewRecordingStore"; import { clearAccessToken } from "@/shared/api/accessToken"; import { useEnsureCurrentUser } from "@/shared/model/useEnsureCurrentUser"; import { useUserStore } from "@/shared/store/userStore"; @@ -14,6 +11,10 @@ import * as S from "./style"; const Header = () => { const navigate = useNavigate(); const location = useLocation(); + const actionRef = useRef(false); + const logoutPendingRef = useRef(false); + const [isLeaving, setIsLeaving] = useState(false); + const [logoutError, setLogoutError] = useState(null); const pathname = location.pathname.toLowerCase().replace(/\/+$/, "") || "/"; useEnsureCurrentUser(); @@ -22,6 +23,10 @@ const Header = () => { const resetUser = useUserStore((state) => state.reset); const displayName = nickname || name || "회원"; + useEffect(() => () => { + if (logoutPendingRef.current) resetUser(); + }, [resetUser]); + const isInterviewPage = pathname === "/main" || pathname.startsWith("/main/interview") || @@ -31,53 +36,55 @@ const Header = () => { pathname.startsWith("/main/feedback/detail/") || pathname.startsWith("/main/feedback/overall/"); const isMypage = pathname === "/main/mypage"; + const isWorkspacePage = pathname === "/main" || isFeedbackPage || isMypage; - const quitActiveInterviewSession = () => { - if (!pathname.startsWith("/main/interview")) { - return; + const handleLeave = async (path: string, logout = false) => { + if (actionRef.current) return; + actionRef.current = true; + setIsLeaving(true); + setLogoutError(null); + try { + setPendingInterviewLeave(() => handleLeave(path, logout)); + if (!(await finishActiveInterview())) return; + clearPendingInterviewLeave(); + if (logout) { + if (!(await waitForRecordingUploads())) { + setLogoutError("업로드에 실패한 파일이 있습니다. 다시 시도하거나 파일을 폐기한 뒤 로그아웃해주세요."); + return; + } + clearAccessToken(); + logoutPendingRef.current = true; + } + await navigate(path); + } finally { + actionRef.current = false; + setIsLeaving(false); } - - const sessionId = getActiveInterviewSessionId(location.state); - - if (!sessionId) { - return; - } - - clearActiveInterviewSessionId(); - disconnectInterviewSocket(sessionId); - void quitInterview(sessionId); - }; - - const handleNavigate = (path: string) => { - quitActiveInterviewSession(); - navigate(path); - }; - - const handleLogout = () => { - quitActiveInterviewSession(); - clearAccessToken(); - resetUser(); - navigate("/login"); }; + const handleNavigate = (path: string) => { void handleLeave(path); }; + const handleLogout = () => { void handleLeave("/login", true); }; return ( - + handleNavigate("/main")} /> handleNavigate("/main")} $active={isInterviewPage} > 면접 handleNavigate("/main/feedback/list")} $active={isFeedbackPage} > 피드백 handleNavigate("/main/mypage")} $active={isMypage} > @@ -86,11 +93,12 @@ const Header = () => { - 로그아웃 + {isLeaving ? "저장 중..." : "로그아웃"} {displayName}님 + {logoutError ? {logoutError} : null} ); }; -export default Header; \ No newline at end of file +export default Header; diff --git a/src/shared/components/header/style.ts b/src/shared/components/header/style.ts index 9a08bcbc..2c2dd1d6 100644 --- a/src/shared/components/header/style.ts +++ b/src/shared/components/header/style.ts @@ -1,10 +1,10 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; interface TopButtonProps { $active: boolean; } -export const Header = styled.header` +const BaseHeader = styled.header` position: relative; z-index: 1; @@ -197,4 +197,70 @@ export const LogoutButton = styled.button` height: 2.8rem; font-size: 0.95rem; } -`; \ No newline at end of file +`; +export const ErrorMessage = styled.p` + grid-column: 1 / -1; + margin: ${({ theme }) => theme.spacing.sm} 0; + color: ${({ theme }) => theme.colors.text.default}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; + +export const Header = styled(BaseHeader)<{ $compact?: boolean }>` + ${({ $compact }) => $compact && css` + min-height: clamp(4.5rem, 8vw, 5.25rem); + grid-template-columns: auto 1fr auto; + padding: 0 clamp(1rem, 3.65vw, 3rem); + gap: clamp(1.5rem, 3.5vw, 3rem); + border-bottom: 1px solid #dfe5ef; + + ${LogoImage} { + height: clamp(3rem, 6vw, 3.75rem); + margin-left: -0.25rem; + justify-self: start; + } + ${TopButtons} { + justify-self: start; + width: auto; + gap: clamp(0.75rem, 2.4vw, 2rem); + margin: 0; + } + ${TopButton} { + min-width: 0; + min-height: 3rem; + flex: none; + padding: 0.875rem; + font-family: inherit; + font-size: 1rem; + &::after { display: none; } + } + ${StatusButtons} { justify-self: end; gap: 0.75rem; } + ${StatusButton} { + max-width: 8rem; + width: auto; + min-width: 5rem; + height: 2.5rem; + padding: 0 0.875rem; + border-radius: 0.5rem; + font-size: 0.875rem; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + display: block; + } + ${LogoutButton} { + width: auto; + min-height: 3rem; + padding: 0 0.5rem; + font-size: 1rem; + } + + @media (max-width: 42rem) { + grid-template-columns: auto minmax(0, 1fr); + gap: 0.25rem 0.5rem; + padding: 0.5rem 1rem 0; + ${TopButtons} { grid-row: 2; grid-column: 1 / -1; justify-self: center; gap: 1rem; } + ${StatusButtons} { grid-row: 1; grid-column: 2; gap: 0.5rem; min-width: 0; } + ${StatusButton} { max-width: 7rem; } + } + `} +`; diff --git a/src/shared/constants/interview-page/interview/index.ts b/src/shared/constants/interview-page/interview/index.ts index fac13315..3af00f2d 100644 --- a/src/shared/constants/interview-page/interview/index.ts +++ b/src/shared/constants/interview-page/interview/index.ts @@ -24,3 +24,7 @@ export const INTERVIEW_DEVICE_STATUS_LABELS = { blocked: '마이크 연결 안 됨', }, } as const; + +export const INTERVIEW_PREPARATION_POLL_INTERVAL_MS = 1_000; +export const INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS = 3_000; +export const INTERVIEW_PREPARATION_TIMEOUT_MS = 20 * 60_000; diff --git a/src/shared/constants/interview-page/setting-interview/index.ts b/src/shared/constants/interview-page/setting-interview/index.ts index 04332669..d30e5d24 100644 --- a/src/shared/constants/interview-page/setting-interview/index.ts +++ b/src/shared/constants/interview-page/setting-interview/index.ts @@ -3,6 +3,7 @@ import type { InterviewSettingSelection, InterviewSettingStyleMeta, InterviewStyleOption, + InterviewStylePreset, } from './type'; export const INTERVIEW_SETTING_OPTION_SECTIONS = [ @@ -20,34 +21,36 @@ export const INTERVIEW_SETTING_OPTION_SECTIONS = [ export const INTERVIEW_SETTING_STYLE_META = { 편함: { - description: '친절하고 편안한 분위기', + description: '쉬움 · 친근한 · 부드러운', icon: 'smile', }, 일반: { - description: '실전과 비슷한 일반 면접 수준', + description: '보통 · 현실적인 · 직설적인', icon: 'neutral', }, 압박: { - description: '긴장감 넘치는 압박 면접', + description: '어려움 · 꼼꼼한 · 강압적인', icon: 'pressure', }, } as const satisfies Record; -export const INTERVIEW_SETTING_INTERVIEWER_SECTION_TITLE = '면접관 설정'; +export const INTERVIEW_STYLE_PRESETS = { + 편함: { difficulty: '쉬움', personality: 'FRIENDLY', tone: 'GENTLE' }, + 일반: { difficulty: '보통', personality: 'REALISTIC', tone: 'DIRECT' }, + 압박: { difficulty: '어려움', personality: 'METICULOUS', tone: 'PRESSURING' }, +} as const satisfies Record; -export const INTERVIEW_SETTING_PERSONALITY_OPTIONS = [ - '친근한', - '현실적인', - '꼼꼼한', -] as const; - -export const INTERVIEW_SETTING_TONE_OPTIONS = [ - '부드러운', - '직설적인', - '압박하는', -] as const; +export const findStylePreset = (value: InterviewStylePreset): InterviewStyleOption | null => + INTERVIEW_SETTING_OPTION_SECTIONS[0].options.find((style) => { + const preset = INTERVIEW_STYLE_PRESETS[style]; + return ( + preset.difficulty === value.difficulty && + preset.personality === value.personality && + preset.tone === value.tone + ); + }) ?? null; -export const INTERVIEW_SETTING_MAJOR_OPTIONS = ['프론트엔드', '백엔드'] as const; +export const SOLO_MAJOR_GUIDANCE = '전문 분야는 마이페이지에서 선택한 직무를 따릅니다.'; export const INTERVIEW_SETTING_ACTION_LABELS = { back: '돌아가기', @@ -56,19 +59,21 @@ export const INTERVIEW_SETTING_ACTION_LABELS = { export const INTERVIEW_SETTING_DEFAULT_SELECTION: InterviewSettingSelection = { difficulty: '쉬움', - major: '프론트엔드', - personality: '친근한', - style: '편함', - tone: '부드러운', + personality: 'FRIENDLY', + tone: 'GENTLE', + interviewerKey: 'interviewer-kim', }; export type { - InterviewMajorOption, InterviewDifficultyOption, - InterviewerPersonalityOption, - InterviewerToneOption, InterviewSettingSelectHandlers, InterviewSettingSelection, + InterviewStylePreset, InterviewSettingStyleIcon, InterviewStyleOption, } from './type'; + +export const INTERVIEW_ANSWER_MODE_OPTIONS = [ + { value: "voice", label: "영상/음성 답변", description: "카메라와 마이크로 답변합니다. 전체 영상과 답변 음성이 저장됩니다. 답변은 210초에 자동 완료됩니다." }, + { value: "text", label: "텍스트 답변", description: "장치 권한 없이 글로 답변합니다. 영상과 음성은 녹화하지 않습니다." }, +] as const; diff --git a/src/shared/constants/interview-page/setting-interview/type.ts b/src/shared/constants/interview-page/setting-interview/type.ts index a645032c..e333f127 100644 --- a/src/shared/constants/interview-page/setting-interview/type.ts +++ b/src/shared/constants/interview-page/setting-interview/type.ts @@ -1,13 +1,13 @@ +import type { + InterviewerPersonality, + InterviewerTemplate, + InterviewerTone, +} from '@/shared/constants/interview-page/setting-multi-interview'; + export type InterviewStyleOption = '편함' | '일반' | '압박'; export type InterviewDifficultyOption = '쉬움' | '보통' | '어려움'; -export type InterviewerPersonalityOption = '친근한' | '현실적인' | '꼼꼼한'; - -export type InterviewerToneOption = '부드러운' | '직설적인' | '압박하는'; - -export type InterviewMajorOption = '프론트엔드' | '백엔드'; - export type InterviewSettingStyleIcon = 'smile' | 'neutral' | 'pressure'; export type InterviewSettingOptionSection = @@ -27,18 +27,20 @@ export interface InterviewSettingStyleMeta { icon: InterviewSettingStyleIcon; } -export interface InterviewSettingSelection { +export interface InterviewStylePreset { difficulty: InterviewDifficultyOption; - major: InterviewMajorOption; - personality: InterviewerPersonalityOption; - style: InterviewStyleOption; - tone: InterviewerToneOption; + personality: InterviewerPersonality; + tone: InterviewerTone; +} + +export interface InterviewSettingSelection extends InterviewStylePreset { + interviewerKey: InterviewerTemplate['key']; } export interface InterviewSettingSelectHandlers { difficulty: (value: InterviewDifficultyOption) => void; - major: (value: InterviewMajorOption) => void; - personality: (value: InterviewerPersonalityOption) => void; + interviewer: (key: InterviewerTemplate['key']) => void; + personality: (value: InterviewerPersonality) => void; style: (value: InterviewStyleOption) => void; - tone: (value: InterviewerToneOption) => void; + tone: (value: InterviewerTone) => void; } diff --git a/src/shared/constants/interview-page/setting-multi-interview/index.ts b/src/shared/constants/interview-page/setting-multi-interview/index.ts index 2165ae88..35ce817d 100644 --- a/src/shared/constants/interview-page/setting-multi-interview/index.ts +++ b/src/shared/constants/interview-page/setting-multi-interview/index.ts @@ -2,6 +2,8 @@ import InterviewerImage1 from "@/shared/img/interview-page/interviewer1.svg?url" import InterviewerImage2 from "@/shared/img/interview-page/interviewer2.svg?url"; import InterviewerImage3 from "@/shared/img/interview-page/interviewer3.svg?url"; import InterviewerImage4 from "@/shared/img/interview-page/interviewer4.svg?url"; +import InterviewerImage5 from "@/shared/img/interview-page/interviewer5.svg?url"; +import { getVoiceGender } from "@/shared/constants/interview-page/voice-catalog"; import type { InterviewerTemplate, @@ -27,10 +29,8 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ voiceIndex: 1, role: "TECH", roleLabel: "기술 면접관", - specialty: "백엔드 아키텍처", - major: "BACKEND", career: 9, - gender: "FEMALE", + gender: getVoiceGender(1), image: InterviewerImage1, tags: ["논리적 사고", "문제 해결"], description: "핵심 내용을 구조적으로 정리하고 깊이 있게 확인합니다.", @@ -41,10 +41,8 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ voiceIndex: 2, role: "TECH", roleLabel: "기술 면접관", - specialty: "프론트엔드 리드", - major: "FRONTEND", career: 8, - gender: "FEMALE", + gender: getVoiceGender(2), image: InterviewerImage2, tags: ["협업", "실행력"], description: "경험을 바탕으로 구체적인 판단과 실행 과정을 확인합니다.", @@ -55,10 +53,8 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ voiceIndex: 3, role: "TECH", roleLabel: "기술 면접관", - specialty: "플랫폼 엔지니어링", - major: "BACKEND", career: 6, - gender: "MALE", + gender: getVoiceGender(3), image: InterviewerImage3, tags: ["분석력", "성장"], description: "현실적인 상황에서의 문제 해결 방식과 성장 가능성을 살펴봅니다.", @@ -69,10 +65,8 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ voiceIndex: 4, role: "TECH", roleLabel: "기술 면접관", - specialty: "프론트엔드 개발", - major: "FRONTEND", career: 8, - gender: "MALE", + gender: getVoiceGender(4), image: InterviewerImage4, tags: ["소통", "조직 적합성"], description: "지원자의 강점과 협업 방식을 다양한 관점에서 확인합니다.", @@ -83,20 +77,14 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ voiceIndex: 5, role: "TECH", roleLabel: "기술 면접관", - specialty: "데이터 엔지니어링", - major: "BACKEND", career: 5, - gender: "MALE", - image: InterviewerImage2, + gender: getVoiceGender(5), + image: InterviewerImage5, tags: ["동기", "적응력"], description: "지원자의 동기와 문제를 끝까지 해결하는 태도를 확인합니다.", }, ] as const satisfies readonly InterviewerTemplate[]; -export const getInterviewerGender = ( - voiceIndex: InterviewerTemplate["voiceIndex"], -) => (voiceIndex <= 2 ? "FEMALE" : "MALE"); - export const INTERVIEWER_COUNT_OPTIONS = [2, 3, 4] as const; export const PERSONALITY_OPTIONS = [ diff --git a/src/shared/constants/interview-page/setting-multi-interview/type.ts b/src/shared/constants/interview-page/setting-multi-interview/type.ts index f20d3f82..a1cab234 100644 --- a/src/shared/constants/interview-page/setting-multi-interview/type.ts +++ b/src/shared/constants/interview-page/setting-multi-interview/type.ts @@ -1,3 +1,4 @@ +import type { VoiceIndex } from "@/shared/constants/interview-page/voice-catalog"; import type { InterviewDifficultyOption, InterviewStyleOption, @@ -15,17 +16,14 @@ export interface InterviewerSlot { role: InterviewerTemplateRole | null; personality: InterviewerPersonality; tone: InterviewerTone; - major: "BACKEND" | "FRONTEND"; } export interface InterviewerTemplate { key: string; name: string; - voiceIndex: 1 | 2 | 3 | 4 | 5; + voiceIndex: VoiceIndex; role: InterviewerTemplateRole; roleLabel: string; - specialty: string; - major?: "BACKEND" | "FRONTEND"; career: number; gender: "MALE" | "FEMALE"; image: string; diff --git a/src/shared/constants/interview-page/voice-catalog.ts b/src/shared/constants/interview-page/voice-catalog.ts new file mode 100644 index 00000000..1634b616 --- /dev/null +++ b/src/shared/constants/interview-page/voice-catalog.ts @@ -0,0 +1,14 @@ +import type { InterviewPersonaGender } from "@/features/interview-page/interview/api/type/create-interview"; + +// Chat 음성 번호와 이미지의 성별을 같은 카탈로그에서 파생한다. +export const VOICE_CATALOG = { + 1: "MALE", + 2: "MALE", + 3: "FEMALE", + 4: "FEMALE", + 5: "MALE", +} as const satisfies Record; + +export type VoiceIndex = keyof typeof VOICE_CATALOG; + +export const getVoiceGender = (voiceIndex: VoiceIndex) => VOICE_CATALOG[voiceIndex]; diff --git a/src/shared/constants/job/categoryLabels.ts b/src/shared/constants/job/categoryLabels.ts new file mode 100644 index 00000000..21b72c8e --- /dev/null +++ b/src/shared/constants/job/categoryLabels.ts @@ -0,0 +1,33 @@ +import type { JobRoleCode } from "./jobRoles"; + +// 01-overview §3.2 기준. 코드는 직무 안에서만 유일하다(automation은 DBA·인프라와 QA에 따로 있음). +// 개발 3직무는 현행 카테고리를 그대로 쓰므로 여기 두지 않는다. +export const CATEGORY_LABELS: Partial>> = { + DATA_ANALYST: { + hypothesis: "문제·가설", data_prep: "수집·전처리", method: "분석 방법", + interpretation: "해석·한계", impact: "의사결정 연결", + }, + DBA_INFRA: { + design: "설계", performance: "성능 개선", operations: "운영·모니터링", + incident: "장애 대응", automation: "배포·자동화", + }, + QA_ENGINEER: { + test_design: "테스트 설계", defect_reporting: "결함 보고", release_judgment: "출시 판단", + automation: "테스트 자동화", quality_process: "품질 프로세스", + }, + SECURITY: { + vuln_principle: "취약점 원리", analysis_process: "분석 과정", mitigation: "대응·완화", + incident_response: "사고 대응", ethics_law: "윤리·법", + }, + PRODUCT_MANAGER: { + problem_definition: "문제 정의", prioritization: "우선순위", metrics: "지표", + collaboration: "협업", retrospective: "회고", + }, + IT_CONSULTANT: { + requirement_analysis: "요구사항 분석", solution_design: "해결안 설계", + client_communication: "고객 커뮤니케이션", delivery: "수행·납품", outcome: "성과", + }, +}; + +export const getCategoryLabel = (jobRole: string | null | undefined, category: string) => + CATEGORY_LABELS[jobRole as JobRoleCode]?.[category] ?? category; diff --git a/src/shared/constants/job/documentTypes.ts b/src/shared/constants/job/documentTypes.ts new file mode 100644 index 00000000..6a801c96 --- /dev/null +++ b/src/shared/constants/job/documentTypes.ts @@ -0,0 +1,30 @@ +// 01-overview §3 / job-family-decisions 「문서 종류 코드」 기준. 서버 코드와 같아야 한다. +export const DOCUMENT_TYPES = [ + { code: "RESUME", label: "이력서" }, + { code: "CAREER_DESCRIPTION", label: "경력기술서" }, + { code: "PORTFOLIO", label: "포트폴리오" }, + { code: "ANALYSIS_REPORT", label: "분석 리포트" }, + { code: "COMPETITION_SUMMARY", label: "대회 결과 요약" }, + { code: "INCIDENT_REPORT", label: "장애 대응 보고서" }, + { code: "TABLE_SPEC", label: "테이블 정의서" }, + { code: "SYSTEM_ARCHITECTURE", label: "시스템 구성도" }, + { code: "TEST_PLAN", label: "테스트 계획서" }, + { code: "TEST_CASE", label: "테스트 케이스" }, + { code: "BUG_REPORT", label: "버그 리포트" }, + { code: "VULNERABILITY_REPORT", label: "취약점 분석 보고서" }, + { code: "CTF_WRITEUP", label: "CTF 풀이" }, + { code: "PRD", label: "기획서(PRD)" }, + { code: "POLICY_DOC", label: "정책서" }, + { code: "REQUIREMENT_SPEC", label: "요구사항 정의서" }, + { code: "SCREEN_SPEC", label: "화면설계서·플로우" }, + { code: "PROPOSAL", label: "제안서" }, + { code: "PROJECT_REPORT", label: "수행·결과 보고서" }, +] as const; + +export type DocumentTypeCode = (typeof DOCUMENT_TYPES)[number]["code"]; + +const DOCUMENT_TYPE_LABELS: Record = Object.fromEntries( + DOCUMENT_TYPES.map(({ code, label }) => [code, label]), +); + +export const getDocumentTypeLabel = (code: string) => DOCUMENT_TYPE_LABELS[code] ?? code; diff --git a/src/shared/constants/job/jobRoles.ts b/src/shared/constants/job/jobRoles.ts new file mode 100644 index 00000000..93b54df0 --- /dev/null +++ b/src/shared/constants/job/jobRoles.ts @@ -0,0 +1,83 @@ +import type { DocumentTypeCode } from "./documentTypes"; + +// 01-overview §3.1 기준. 서버 코드(MAJOR_ 접두사 제외)와 같아야 한다. +export const JOB_CATEGORIES = [ + { value: "DEVELOPMENT", label: "개발" }, + { value: "NON_DEVELOPMENT", label: "비개발" }, +] as const; + +export type JobCategory = (typeof JOB_CATEGORIES)[number]["value"]; +export type JobFamily = "개발" | "데이터·인프라" | "품질·보안" | "기획·비즈니스"; +// REQUIRED: 1개 이상 필수 / OPTIONAL: 입력 가능 / HIDDEN: 영역 없음 +export type GithubRule = "REQUIRED" | "OPTIONAL" | "HIDDEN"; + +export interface JobRole { + code: string; + label: string; + category: JobCategory; + family: JobFamily; + github: GithubRule; + requiredDocuments: readonly DocumentTypeCode[]; + optionalDocuments: readonly DocumentTypeCode[]; + hasSupplement: boolean; +} + +// code를 리터럴로 유지해야 JobRoleCode가 9개 코드 유니온이 된다. +const dev = (code: C, label: string) => + ({ + code, label, category: "DEVELOPMENT", family: "개발", github: "REQUIRED", + requiredDocuments: ["PORTFOLIO"], optionalDocuments: [], hasSupplement: false, + }) as const; + +export const JOB_ROLES = [ + dev("FRONTEND", "프론트엔드"), + dev("BACKEND", "백엔드"), + dev("WEB_PUBLISHER", "웹 퍼블리셔"), + { + code: "DATA_ANALYST", label: "데이터 분석가", category: "NON_DEVELOPMENT", family: "데이터·인프라", + github: "OPTIONAL", requiredDocuments: ["PORTFOLIO", "ANALYSIS_REPORT"], + optionalDocuments: ["COMPETITION_SUMMARY"], hasSupplement: false, + }, + { + code: "DBA_INFRA", label: "DBA·인프라 엔지니어", category: "NON_DEVELOPMENT", family: "데이터·인프라", + github: "OPTIONAL", requiredDocuments: ["PORTFOLIO", "CAREER_DESCRIPTION"], + optionalDocuments: ["INCIDENT_REPORT", "TABLE_SPEC", "SYSTEM_ARCHITECTURE"], hasSupplement: false, + }, + { + code: "QA_ENGINEER", label: "QA 엔지니어", category: "NON_DEVELOPMENT", family: "품질·보안", + github: "OPTIONAL", requiredDocuments: ["TEST_PLAN", "TEST_CASE"], + optionalDocuments: ["PORTFOLIO", "BUG_REPORT"], hasSupplement: false, + }, + { + code: "SECURITY", label: "정보보안", category: "NON_DEVELOPMENT", family: "품질·보안", + github: "OPTIONAL", requiredDocuments: ["VULNERABILITY_REPORT", "CTF_WRITEUP"], + optionalDocuments: ["PORTFOLIO"], hasSupplement: false, + }, + { + code: "PRODUCT_MANAGER", label: "서비스 기획자 (PM·PO)", category: "NON_DEVELOPMENT", family: "기획·비즈니스", + github: "HIDDEN", requiredDocuments: ["PRD", "POLICY_DOC", "REQUIREMENT_SPEC"], + optionalDocuments: ["PORTFOLIO", "SCREEN_SPEC"], hasSupplement: true, + }, + { + code: "IT_CONSULTANT", label: "IT 컨설턴트·기술영업", category: "NON_DEVELOPMENT", family: "기획·비즈니스", + github: "HIDDEN", requiredDocuments: ["CAREER_DESCRIPTION", "PORTFOLIO"], + optionalDocuments: ["PROPOSAL", "PROJECT_REPORT", "REQUIREMENT_SPEC"], hasSupplement: true, + }, +] as const satisfies readonly JobRole[]; + +export type JobRoleCode = (typeof JOB_ROLES)[number]["code"]; + +export const NON_DEVELOPMENT_FAMILIES = ["데이터·인프라", "품질·보안", "기획·비즈니스"] as const; + +export const getJobRole = (code: string | null | undefined): JobRole | undefined => + JOB_ROLES.find((role) => role.code === code); + +export const getJobRolesByCategory = (category: JobCategory): readonly JobRole[] => + JOB_ROLES.filter((role) => role.category === category); + +export const getJobRoleLabel = (code: string | null | undefined) => + getJobRole(code)?.label ?? code ?? ""; + +// 직무를 고르지 않은 옛 사용자는 개발 직무(GitHub 필수)로 본다(01 가정 A-7). +export const getGithubRule = (code: string | null | undefined): GithubRule => + getJobRole(code)?.github ?? "REQUIRED"; diff --git a/src/shared/constants/job/uploadGuides.ts b/src/shared/constants/job/uploadGuides.ts new file mode 100644 index 00000000..003e5822 --- /dev/null +++ b/src/shared/constants/job/uploadGuides.ts @@ -0,0 +1,17 @@ +import type { JobRoleCode } from "./jobRoles"; + +// job-family-decisions 「안내 문구」 기준. 개발 직무와 안내가 없는 직무는 공통 문구만 쓴다. +export const COMMON_UPLOAD_GUIDE = "이미지로 내보낸 PDF는 인식이 어려울 수 있습니다."; + +export const UPLOAD_GUIDES: Partial> = { + DATA_ANALYST: "문제 정의, 데이터, 분석 방법, 결과, 의사결정 연결이 드러나는 리포트를 올려 주세요.", + QA_ENGINEER: "케이스 ID·절차·기대 결과가 있는 테스트 케이스가 좋습니다. 엑셀은 PDF로 내보내서 올려 주세요.", + SECURITY: "발견, 재현, 영향, 대응 순서로 쓴 보고서가 좋습니다. 블로그 글은 \"PDF로 인쇄\"로 올리고, 실제 기관의 취약점과 민감 정보는 가려 주세요.", + PRODUCT_MANAGER: "글 위주 문서(PRD·정책서)를 먼저 올려 주세요. 화면설계서는 보조 자료입니다.", + IT_CONSULTANT: "고객사 기밀 정보는 가려 주세요.", +}; + +export const getUploadGuides = (code: string | null | undefined) => { + const guide = UPLOAD_GUIDES[code as JobRoleCode]; + return guide ? [guide, COMMON_UPLOAD_GUIDE] : [COMMON_UPLOAD_GUIDE]; +}; diff --git a/src/shared/constants/main-page/dash-board/index.ts b/src/shared/constants/main-page/dash-board/index.ts index 25aa88dd..797fbc2b 100644 --- a/src/shared/constants/main-page/dash-board/index.ts +++ b/src/shared/constants/main-page/dash-board/index.ts @@ -51,3 +51,5 @@ export type MainPageInterviewModeId = (typeof MAIN_PAGE_INTERVIEW_MODE_CARDS)[number]["id"]; export const MAIN_PAGE_COMING_SOON_LABEL = "준비중인 기능입니다"; + +export const MAIN_PAGE_GUIDE_ROUTE = "/"; diff --git a/src/shared/constants/my-page/index.ts b/src/shared/constants/my-page/index.ts deleted file mode 100644 index 971cb8f9..00000000 --- a/src/shared/constants/my-page/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const MY_PAGE_PORTFOLIO_JOB_ROLE_OPTIONS = [ - { - value: "frontend", - label: "Frontend", - }, - { - value: "backend", - label: "Backend", - }, -] as const; diff --git a/src/shared/img/interview-page/interviewer5.svg b/src/shared/img/interview-page/interviewer5.svg new file mode 100644 index 00000000..0035c120 --- /dev/null +++ b/src/shared/img/interview-page/interviewer5.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/shared/img/landing-page/Browser.svg b/src/shared/img/landing-page/Browser.svg index c07d160a..f0b4d62a 100644 --- a/src/shared/img/landing-page/Browser.svg +++ b/src/shared/img/landing-page/Browser.svg @@ -1,63 +1,9 @@ - -
- - -
- - - - - - - - - - - - - - - - - - - + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + - + diff --git a/src/shared/model/useSessionCheck.ts b/src/shared/model/useSessionCheck.ts new file mode 100644 index 00000000..0a5aa627 --- /dev/null +++ b/src/shared/model/useSessionCheck.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useLocation } from "react-router-dom"; + +import { getMyInfo } from "@/features/my-page/personal-info/api/getMyInfo"; +import { useUserStore } from "@/shared/store/userStore"; + +// 세션 만료는 API 요청이 401을 받아야 드러나므로, 진입 시 요청이 없는 화면에서도 +// 화면 이동과 탭 복귀 시점에 /me를 호출해 재발급 → 로그인 이동 흐름을 태운다. +export const useSessionCheck = () => { + const { pathname } = useLocation(); + const setUser = useUserStore((state) => state.setUser); + const checkedPathnameRef = useRef(null); + const isCheckingRef = useRef(false); + + const checkSession = useCallback(async () => { + if (isCheckingRef.current) { + return; + } + + isCheckingRef.current = true; + + try { + const { data } = await getMyInfo(); + + if (data) { + setUser(data); + } + } finally { + isCheckingRef.current = false; + } + }, [setUser]); + + useEffect(() => { + const previousPathname = checkedPathnameRef.current; + + if (previousPathname === pathname) { + return; + } + + checkedPathnameRef.current = pathname; + + // 회원 정보가 없는 첫 진입은 헤더의 useEnsureCurrentUser가 이미 /me를 호출한다. + if (previousPathname === null && !useUserStore.getState().isLoaded) { + return; + } + + void checkSession(); + }, [checkSession, pathname]); + + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + void checkSession(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [checkSession]); +}; diff --git a/src/shared/styles/pageLayout.ts b/src/shared/styles/pageLayout.ts new file mode 100644 index 00000000..3b820662 --- /dev/null +++ b/src/shared/styles/pageLayout.ts @@ -0,0 +1,13 @@ +import { css } from "styled-components"; + +export const pageLayout = css` + width: 100%; + max-width: ${({ theme }) => theme.layout.contentMaxWidth}; + margin: 0 auto; + padding: 0 ${({ theme }) => theme.layout.contentPadding} ${({ theme }) => theme.spacing.xl}; + box-sizing: border-box; + + @media (max-width: ${({ theme }) => theme.layout.mobileBreakpoint}) { + padding-inline: ${({ theme }) => theme.spacing.md}; + } +`; diff --git a/src/shared/utils/exportElementToPdf.ts b/src/shared/utils/exportElementToPdf.ts index 36d5bc66..94343d80 100644 --- a/src/shared/utils/exportElementToPdf.ts +++ b/src/shared/utils/exportElementToPdf.ts @@ -16,8 +16,16 @@ export const exportElementToPdf = async ( backgroundColor: "#ffffff", scale: Math.min(window.devicePixelRatio || 1, 2), useCORS: true, - windowWidth: element.scrollWidth, - windowHeight: element.scrollHeight, + windowWidth: Math.max(document.documentElement.clientWidth, element.scrollWidth), + windowHeight: Math.max(document.documentElement.clientHeight, element.scrollHeight), + onclone: (_document, clonedElement) => { + // 스크롤 영역 안의 피드백도 화면 너비를 유지한 채 끝까지 내보낸다. + let parent = clonedElement.parentElement; + while (parent) { + parent.style.overflow = "visible"; + parent = parent.parentElement; + } + }, }); const imageData = canvas.toDataURL("image/png"); diff --git a/src/widgets/feedback-page/company-feedback/index.tsx b/src/widgets/feedback-page/company-feedback/index.tsx new file mode 100644 index 00000000..9d43a26a --- /dev/null +++ b/src/widgets/feedback-page/company-feedback/index.tsx @@ -0,0 +1,40 @@ +import type { FeedbackData, InterviewSummary } from "@/features/feedback-page/feedback-list/api/getAllInterviews"; +import { readCompanyBadge } from "@/features/interview-page/company/model/type"; +import { readCompanyFit, type CompanyFitItem } from "@/features/feedback-page/model/companyFit"; +import * as S from "./style"; + +const CompanyFeedback = ({ feedback, interview }: { feedback: FeedbackData; interview: InterviewSummary | null }) => { + const company = readCompanyBadge(feedback.company === undefined ? interview?.company : feedback.company); + if (!company) return null; + const fit = readCompanyFit(feedback.companyFit, feedback.feedbacks ?? []); + const interviewId = feedback.interviewId ?? interview?.interviewId ?? interview?.id; + const items = (title: string, entries: CompanyFitItem[]) => entries.length ? ( +
+

{title}

+
    {entries.map((entry) => ( +
  • + {entry.focus} +

    {entry.text}

    + {entry.questions.map((question) => ( + + {question.label} 질문·답변 보기 + + ))} +
  • + ))}
+
+ ) : null; + return ( + +

{company.displayName} 관점으로 돌아보기

+

공개 자료를 바탕으로 한 연습용 코멘트입니다.

+ {fit ? <> +

{fit.comment}

+ {items("잘 드러난 점", fit.matchedFocus)} + {items("보완할 점", fit.gaps)} + :

기업 관점 코멘트를 제공하지 못했습니다

} +
+ ); +}; + +export default CompanyFeedback; diff --git a/src/widgets/feedback-page/company-feedback/style.ts b/src/widgets/feedback-page/company-feedback/style.ts new file mode 100644 index 00000000..0545e505 --- /dev/null +++ b/src/widgets/feedback-page/company-feedback/style.ts @@ -0,0 +1,26 @@ +import styled from "styled-components"; +import { Link } from "react-router-dom"; + +export const Section = styled.section` + margin: ${({ theme }) => theme.spacing.lg} 0; + padding: ${({ theme }) => theme.spacing.lg}; + background: ${({ theme }) => theme.colors.surface.muted}; + border: 1px solid ${({ theme }) => theme.colors.brand.blueLight}; + border-radius: ${({ theme }) => theme.radius.md}; + color: ${({ theme }) => theme.colors.text.default}; + line-height: 1.7; + overflow-wrap: anywhere; + h2 { font-size: ${({ theme }) => theme.fontSize.lg}; margin: 0 0 0.5rem; } + h3 { font-size: ${({ theme }) => theme.fontSize.md}; margin: 1rem 0 0.5rem; } + p { margin: 0.25rem 0; } + ul { margin: 0; padding-left: 1.25rem; } + li + li { margin-top: 0.75rem; } +`; + +export const QuestionLink = styled(Link)` + display: inline-block; + margin-right: ${({ theme }) => theme.spacing.md}; + color: ${({ theme }) => theme.colors.brand.blue}; + text-decoration: underline; + &:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +`; diff --git a/src/widgets/feedback-page/feedback-detail/bottom-section/index.tsx b/src/widgets/feedback-page/feedback-detail/bottom-section/index.tsx index 6cfa471c..84550fd3 100644 --- a/src/widgets/feedback-page/feedback-detail/bottom-section/index.tsx +++ b/src/widgets/feedback-page/feedback-detail/bottom-section/index.tsx @@ -2,43 +2,13 @@ import RobotImageAsset from "@/shared/img/feedback-page/Repit-feedback.svg?url"; import * as S from "../style"; import type { FeedbackDetailBottomSectionProps } from "../type"; -const FeedbackDetailBottomSection = ({ - content, - isDownloadingPdf, - onDownloadPdf, -}: FeedbackDetailBottomSectionProps) => { - return ( - - - - - - - - - {content.coachComment} - - - - - - - {isDownloadingPdf ? "PDF 저장 중..." : content.secondaryActionLabel} - - - {content.primaryActionLabel} (준비 중) - - - - ); -}; +const FeedbackDetailBottomSection = ({ content }: FeedbackDetailBottomSectionProps) => ( + + + + + {content.coachComment} + +); export default FeedbackDetailBottomSection; diff --git a/src/widgets/feedback-page/feedback-detail/index.tsx b/src/widgets/feedback-page/feedback-detail/index.tsx new file mode 100644 index 00000000..2df20699 --- /dev/null +++ b/src/widgets/feedback-page/feedback-detail/index.tsx @@ -0,0 +1,89 @@ +import { useSearchParams } from "react-router-dom"; +import { forwardRef, useEffect, useRef, useState } from "react"; +import FeedbackTabs from "@/widgets/feedback-page/feedback-tabs"; +import FeedbackDetailTopSection from "./top-section"; +import FeedbackDetailMiddleSection from "./middle-section"; +import FeedbackDetailBottomSection from "./bottom-section"; +import type { FeedbackDetailProps } from "./type"; +import * as S from "./style"; + +const FeedbackDetail = forwardRef(function FeedbackDetail( + { content, isDownloadingPdf, onDownloadPdf, companySection }, + ref, +) { + const [searchParams, setSearchParams] = useSearchParams(); + const linkedQuestionId = searchParams.get("questionId"); + const [selectedQuestionId, setSelectedQuestionId] = useState(content.topSection.questions[0]?.id); + const selectedQuestion = content.topSection.questions.find((item) => linkedQuestionId !== null && item.questionId === linkedQuestionId) + ?? content.topSection.questions.find((item) => item.id === selectedQuestionId) + ?? content.topSection.questions[0]; + + // 기업 코멘트의 문항 링크로 들어왔을 때만 한 번 스크롤한다. 탭 클릭으로 바뀐 주소에는 반응하지 않는다. + const initialLinkedQuestionId = useRef(linkedQuestionId); + useEffect(() => { + if (initialLinkedQuestionId.current) document.getElementById("feedback-question-content")?.scrollIntoView({ block: "start" }); + }, []); + + if (!selectedQuestion) return null; + + return ( + + + {content.topSection.questionListTitle} + + {content.topSection.questions.map((item) => ( + { + setSelectedQuestionId(item.id); + setSearchParams((previous) => { + const next = new URLSearchParams(previous); + if (item.questionId) next.set("questionId", item.questionId); + else next.delete("questionId"); + return next; + }, { replace: true }); + }} + > + + {item.label} + {item.personaLabel && {item.personaLabel}} + + {item.preview} + + ))} + + + + {content.bottomSection.primaryActionLabel} + + + {isDownloadingPdf ? "PDF 저장 중..." : content.bottomSection.secondaryActionLabel} + + + + + + + + + 상세 피드백 > {selectedQuestion.label} {selectedQuestion.question} + + + + + + {companySection} + + + + + ); +}); + +export default FeedbackDetail; diff --git a/src/widgets/feedback-page/feedback-detail/middle-section/index.tsx b/src/widgets/feedback-page/feedback-detail/middle-section/index.tsx index 48731cff..8dd7b828 100644 --- a/src/widgets/feedback-page/feedback-detail/middle-section/index.tsx +++ b/src/widgets/feedback-page/feedback-detail/middle-section/index.tsx @@ -1,23 +1,25 @@ import * as S from "../style"; import type { FeedbackDetailMiddleSectionProps } from "../type"; -const FeedbackDetailMiddleSection = ({ - content, -}: FeedbackDetailMiddleSectionProps) => { - return ( - - - {content.cards.map((card) => ( - - {card.title} - {card.paragraphs.map((paragraph) => ( - {paragraph} - ))} - +const FeedbackDetailMiddleSection = ({ content, question }: FeedbackDetailMiddleSectionProps) => ( + + {content.cards.map((card) => ( + + + + {card.title} + + {(card.tone === "positive" ? question.strengths : question.improvements).map((paragraph, index) => ( + {paragraph} ))} - - - ); -}; + + ))} + +); export default FeedbackDetailMiddleSection; diff --git a/src/widgets/feedback-page/feedback-detail/style.ts b/src/widgets/feedback-page/feedback-detail/style.ts index ba34ba56..2175810e 100644 --- a/src/widgets/feedback-page/feedback-detail/style.ts +++ b/src/widgets/feedback-page/feedback-detail/style.ts @@ -1,435 +1,351 @@ import styled, { css } from "styled-components"; -interface TabButtonProps { - $active: boolean; -} +const cardSurface = css` + min-width: 0; + border: 1px solid #dcdde0; + border-radius: 0.5rem; + background: rgba(255, 255, 255, 0.7); +`; -interface QuestionItemButtonProps { - $active: boolean; -} +export const DetailLayout = styled.div` + display: grid; + grid-template-columns: 12.5rem minmax(0, 1fr); + width: 100%; + height: 100%; + min-height: 0; + color: ${({ theme }) => theme.colors.text.strong}; -const cardSurface = css` - background: rgba(255, 255, 255, 0.92); - border: 0.0625rem solid rgba(220, 230, 246, 0.96); - border-radius: 0.8rem; - box-shadow: 0 1rem 2.2rem rgba(60, 101, 168, 0.07); + @media (max-width: 44rem) { + display: flex; + flex-direction: column; + overflow-y: auto; + } `; -export const SectionBlock = styled.section` +export const Sidebar = styled.aside` display: flex; flex-direction: column; - gap: 1rem; -`; - -export const SectionShell = styled.div` - padding: 1rem; - border-radius: 1.25rem; - background: linear-gradient( - 180deg, - rgba(234, 242, 255, 0.72), - rgba(243, 247, 255, 0.82) - ); - border: 0.0625rem solid rgba(217, 228, 247, 0.92); - box-shadow: inset 0 0.0625rem 0 rgba(255, 255, 255, 0.55); - - @media (max-width: 48rem) { - padding: 0.8rem; - border-radius: 0.95rem; + gap: 0.75rem; + min-height: 0; + padding: 1.25rem; + border-right: 1px solid #c9cbd0; + background: #f8f9ff; + + @media (max-width: 44rem) { + flex-shrink: 0; + padding: 1rem; + border-right: 0; + border-bottom: 1px solid #dcdde0; } `; -export const TabGroup = styled.div` - width: fit-content; - max-width: 100%; - display: inline-flex; +export const SideTitle = styled.h1` + display: flex; align-items: center; - gap: 0.4rem; - padding: 0.2rem; - background: rgba(255, 255, 255, 0.96); - border-radius: 0.8rem; - box-shadow: 0 0.85rem 2rem rgba(60, 102, 173, 0.08); -`; - -export const TabButton = styled.button` - min-width: 13.2rem; - height: 2.1rem; - padding: 0 0.9rem; - border: none; - border-radius: 0.62rem; - background: ${({ $active }) => - $active ? "linear-gradient(180deg, #2f75e7, #2667da)" : "transparent"}; - color: ${({ $active }) => ($active ? "#ffffff" : "#2f75e7")}; + min-height: 2.375rem; + margin: 0; + padding: 0.5rem 1.375rem; + border-radius: 0.25rem; + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; font-size: 1rem; - font-weight: 700; - cursor: pointer; - - @media (max-width: 40rem) { - min-width: 9rem; - height: 2rem; - font-size: 0.94rem; - } + font-weight: 500; `; -export const TopGrid = styled.div` - display: grid; - grid-template-columns: minmax(20rem, 23rem) minmax(0, 1fr); - gap: 1rem; +export const QuestionList = styled.div` + display: flex; + flex-direction: column; + gap: 0.75rem; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; - @media (max-width: 72rem) { - grid-template-columns: 1fr; + @media (max-width: 44rem) { + flex-direction: row; + overflow-x: auto; + padding: 2px 2px 0.25rem; } `; -export const QuestionListCard = styled.article` - ${cardSurface} - border-radius: 0.95rem; - padding: 1rem; -`; +export const QuestionItemButton = styled.button<{ $active: boolean }>` + flex-shrink: 0; + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid ${({ $active }) => $active ? "#6fa2ff" : "#d0ddf5"}; + border-radius: 0.375rem; + background: ${({ $active, theme }) => $active ? theme.colors.white : "transparent"}; + color: ${({ $active, theme }) => $active ? theme.colors.brand.blue : theme.colors.text.default}; + text-align: left; + cursor: pointer; -export const SideTitle = styled.h3` - margin: 0 0 0.9rem; - color: #2b74e3; - font-size: 1.28rem; - font-weight: 700; -`; + &:focus-visible { outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; outline-offset: -2px; } -export const QuestionList = styled.div` - display: flex; - flex-direction: column; - gap: 0.85rem; + @media (max-width: 44rem) { width: 10rem; } `; -export const QuestionItemButton = styled.button` - width: 100%; - min-height: 5.45rem; +export const QuestionItemHeading = styled.span` display: flex; - align-items: flex-start; - gap: 0.8rem; - padding: 1rem 0.95rem; - border-radius: 0.72rem; - border: ${({ $active }) => - $active - ? "0.125rem solid rgba(47, 117, 231, 0.92)" - : "0.0625rem solid rgba(194, 214, 241, 0.95)"}; - background: #ffffff; - box-shadow: ${({ $active }) => - $active ? "0 0.7rem 1.4rem rgba(47, 117, 231, 0.08)" : "none"}; - text-align: left; - cursor: pointer; + align-items: baseline; + flex-wrap: wrap; + gap: 0.375rem; + margin-bottom: 0.375rem; + color: ${({ theme }) => theme.colors.brand.blue}; `; export const QuestionNumber = styled.span` - min-width: 2.4rem; - color: #2b74e3; - font-size: 1.15rem; - font-weight: 800; - line-height: 1.5; + font-size: 0.875rem; + font-weight: 600; +`; + +export const QuestionPersona = styled.span` + font-size: 0.6875rem; + font-weight: 500; `; export const QuestionPreview = styled.span` display: -webkit-box; - color: #1c1c1c; - font-size: 1.08rem; - font-weight: 500; - line-height: 1.55; - word-break: keep-all; overflow: hidden; - -webkit-line-clamp: 2; + -webkit-line-clamp: 3; -webkit-box-orient: vertical; + font-size: 0.75rem; + line-height: 1.25; + overflow-wrap: anywhere; `; -export const QuestionPersona = styled.span` - display: block; - width: fit-content; - margin: 0 0 0.32rem; - color: #2b74e3; - font-size: 0.82rem; - font-weight: 700; -`; - -export const DetailColumn = styled.div` - display: grid; - grid-template-rows: auto auto auto minmax(8.5rem, 1fr); - gap: 0.95rem; - height: 100%; - align-self: stretch; - align-content: stretch; - - @media (max-width: 72rem) { - grid-template-rows: none; - height: auto; - align-self: start; - align-content: start; - } -`; - -export const QuestionContent = styled.div` +export const SideActions = styled.div` display: flex; flex-direction: column; - width: 100%; - align-items: flex-start; - gap: 0.7rem; - margin-top: 0.58rem; + gap: 0.75rem; + margin-top: auto; + padding-top: 1rem; + + @media (max-width: 44rem) { + flex-direction: row; + padding-top: 0; + > button { flex: 1; } + } `; -export const IntentSection = styled.div` - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 0.78rem; - margin-top: 2.2rem; +const actionButton = css` + min-height: 2.5rem; + padding: 0.5rem; + border-radius: 0.25rem; + font-size: 0.875rem; + font-weight: 500; `; -export const DetailCard = styled.article` - ${cardSurface} - padding: 1.25rem 1.35rem; +export const ReplayButton = styled.button` + ${actionButton} + border: 1px solid #d6e3ff; + background: ${({ theme }) => theme.colors.white}; + color: #555555; + cursor: not-allowed; `; -export const AnswerCard = styled(DetailCard)` - display: flex; - flex-direction: column; - gap: 0.62rem; - min-height: 0; - padding-top: 0.85rem; - padding-bottom: 0.85rem; +export const PdfButton = styled.button` + ${actionButton} + border: 1px solid #a2c6ff; + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; + box-shadow: 0 2px 4px rgba(34, 68, 114, 0.15); + cursor: pointer; + &:disabled { opacity: 0.65; cursor: wait; } + &:focus-visible { outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; outline-offset: 2px; } `; -export const DetailCardTitle = styled.h3` - margin: 0; - color: #2b74e3; - font-size: 1.34rem; - font-weight: 700; -`; +export const DetailScrollArea = styled.div` + min-width: 0; + min-height: 0; + overflow-y: auto; + padding: 0.75rem 2.375rem 1.5rem 1.375rem; -export const DetailCardText = styled.p` - margin: 0; - color: #191919; - font-size: 1.14rem; - font-weight: 500; - line-height: 1.7; - word-break: keep-all; + @media (max-width: 60rem) { padding: 0.75rem 1.25rem 1.5rem; } + @media (max-width: 44rem) { flex-shrink: 0; overflow: visible; padding: 0.75rem 1rem 1.5rem; } `; -export const IntentBadge = styled.span` +export const DetailContent = styled.section` display: flex; - align-items: center; - justify-content: center; - width: fit-content; - padding: 0.5rem 1rem; - border-radius: 0.32rem; - background: linear-gradient(180deg, #2f75e7, #2667da); - color: #ffffff; - font-size: 1rem; - font-weight: 700; + flex-direction: column; + gap: 0.5rem; + width: min(100%, 80rem); + min-width: 0; + margin: 0 auto; `; -export const AnswerHeader = styled.div` +export const HeadingRow = styled.div` display: flex; align-items: center; justify-content: space-between; gap: 1rem; + min-height: 2.125rem; + margin-bottom: 0.25rem; - @media (max-width: 36rem) { - flex-direction: column; - align-items: flex-start; - } + @media (max-width: 54rem) { flex-wrap: wrap; gap: 0.5rem; } `; -export const AnswerToggleButton = styled.button` - min-width: 8.8rem; - height: 2.5rem; - padding: 0 1rem; - flex-shrink: 0; - border-radius: 0.42rem; - border: 0.0625rem solid rgba(213, 223, 239, 0.96); - background: #ffffff; - color: #5b5b5b; - font-size: 1rem; - font-weight: 700; +export const Breadcrumb = styled.p` + min-width: 0; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; - cursor: pointer; -`; + font-size: 0.75rem; + line-height: 1.5; + font-weight: 500; + span { color: ${({ theme }) => theme.colors.brand.blue}; } -export const SummaryShell = styled.section` - padding: 1rem; - border-radius: 1.15rem; - background: linear-gradient( - 180deg, - rgba(209, 222, 247, 0.92), - rgba(223, 232, 250, 0.96) - ); + @media (max-width: 54rem) { width: 100%; } `; -export const SummaryGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - - @media (max-width: 56rem) { - grid-template-columns: 1fr; - } +export const SectionBlock = styled.section` + display: flex; + flex-direction: column; + gap: 0.375rem; `; -export const SummaryCard = styled.article` +export const DetailCard = styled.article` ${cardSurface} - padding: 1.25rem 1.35rem; + min-height: 6.5rem; + padding: 1.125rem 1.375rem; `; -export const SummaryTitle = styled.h3` - margin: 0 0 0.9rem; - color: #2b74e3; - font-size: 1.32rem; +export const DetailCardTitle = styled.h2` + margin: 0 0 0.5rem; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 1.125rem; font-weight: 700; + line-height: 1.3; `; -export const SummaryText = styled.p` +export const DetailCardText = styled.p` margin: 0; - color: #1d1d1d; - font-size: 1.12rem; - font-weight: 500; - line-height: 1.7; - word-break: keep-all; - - & + & { - margin-top: 0.7rem; - } + font-size: 0.875rem; + font-weight: 400; + line-height: 1.3; + white-space: pre-wrap; + overflow-wrap: anywhere; `; -export const BottomBlock = styled.section` - display: flex; - flex-direction: column; - gap: 1rem; -`; - -export const CoachCard = styled.article` - display: flex; - align-items: flex-end; - gap: 1.55rem; - min-height: 11.6rem; - padding: 1.75rem 1.8rem 1.35rem; +export const QuestionCard = styled(DetailCard)` + min-height: 11.75rem; + padding: 1.375rem 2rem 1.75rem; border-radius: 0.75rem; - background: #dcebfe; + background: ${({ theme }) => theme.colors.white}; - @media (max-width: 48rem) { - flex-direction: column; - align-items: flex-start; - gap: 0.8rem; - min-height: auto; - padding: 1rem 1rem 1.15rem; - } + @media (max-width: 44rem) { padding: 1.25rem; } `; -export const CoachIllustration = styled.div` - width: clamp(3.4rem, 6vw, 4.5rem); +export const QuestionText = styled(DetailCardText)` + font-size: 1rem; + line-height: 1.5; + font-weight: 500; +`; + +export const IntentSection = styled.div` display: flex; - align-items: flex-end; - justify-content: center; - flex-shrink: 0; - margin-bottom: 0.15rem; + flex-direction: column; + align-items: flex-start; + gap: 0.625rem; + margin-top: 1.5rem; `; -export const RobotImage = styled.img` - width: 100%; - height: auto; - display: block; +export const IntentBadge = styled.span` + padding: 0.25rem 1rem; + border-radius: 0.25rem; + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.3; `; -export const CoachBubble = styled.div` - position: relative; - flex: 1; - min-height: 9.3rem; - display: flex; - align-items: center; - justify-content: flex-start; - padding: 2rem 4rem; - border-radius: 0.85rem; - background: rgba(255, 255, 255, 0.98); - - @media (max-width: 48rem) { - width: 100%; - min-height: 7.8rem; - padding: 0.7rem 0.95rem 0.85rem; - } +export const AnswerGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.025fr); + grid-template-rows: repeat(2, minmax(6.5rem, auto)); + gap: 0.625rem 0.5rem; + + @media (max-width: 44rem) { grid-template-columns: 1fr; grid-template-rows: auto; } `; -export const CoachBubbleImage = styled.img` - position: absolute; - inset: 0; - width: 100%; - height: 100%; - display: block; - object-fit: fill; - z-index: 0; - pointer-events: none; - user-select: none; +export const ModelAnswerCard = styled(DetailCard)` + grid-column: 2; + grid-row: 1 / 3; + padding: 1.625rem; + @media (max-width: 44rem) { grid-column: auto; grid-row: auto; padding: 1.125rem 1.375rem; } `; -export const CoachTextBox = styled.div` - flex: 1; - position: relative; - z-index: 1; - width: min(100%, 85%); - text-align: left; - @media (max-width: 48rem) { - width: min(100%, 92%); - } +export const SummaryGrid = styled.section` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: hidden; + border: 1px solid #dcdde0; + border-radius: 0.75rem; + @media (max-width: 44rem) { grid-template-columns: 1fr; } `; -export const CoachText = styled.p` - margin: 0; - color: #171717; - max-width: 100%; - font-size: 1.2rem; - font-weight: 550; - line-height: 1.58; +export const SummaryCard = styled.article<{ $tone: "positive" | "negative" }>` + min-width: 0; + min-height: 9.125rem; + padding: 1.625rem 1.75rem; + background: ${({ $tone }) => $tone === "positive" ? "#eaf2fa" : "#f7eeee"}; + & + & { border-left: 1px solid #dcdde0; } + @media (max-width: 44rem) { & + & { border-left: 0; border-top: 1px solid #dcdde0; } } `; -export const ActionRow = styled.div` +export const SummaryTitle = styled.h2<{ $tone: "positive" | "negative" }>` display: flex; - justify-content: flex-end; - gap: 1rem; - padding-top: 0.35rem; + align-items: center; + gap: 0.5rem; + margin: 0 0 1rem; + color: ${({ $tone, theme }) => $tone === "positive" ? theme.colors.brand.blue : "#ff482b"}; + font-size: 1.125rem; + font-weight: 600; +`; - @media (max-width: 40rem) { - flex-direction: column; - } +export const FaceIcon = styled.svg` + width: 1.5rem; + height: 1.5rem; + stroke: currentColor; + stroke-width: 1.3; + stroke-linecap: round; `; -const actionButton = css` - min-width: 12.2rem; - height: 3.1rem; - padding: 0 1.35rem; - border-radius: 0.62rem; - font-size: 1rem; - font-weight: 800; - cursor: pointer; +export const SummaryText = styled(DetailCardText)` + color: ${({ theme }) => theme.colors.text.default}; + & + & { margin-top: 0.5rem; } +`; - @media (max-width: 40rem) { - width: 100%; - } +export const CoachCard = styled.article` + ${cardSurface} + display: flex; + align-items: center; + gap: 1.625rem; + min-height: 7.25rem; + margin-top: 0.375rem; + padding: 1.375rem 2rem; + border-color: #cccccc; + background: ${({ theme }) => theme.colors.white}; + @media (max-width: 44rem) { gap: 1rem; padding: 1.25rem; } `; -export const SecondaryActionButton = styled.button` - ${actionButton} - border: 0.0625rem solid rgba(214, 224, 240, 0.96); - background: #ffffff; - color: #2b74e3; +export const CoachIllustration = styled.div` + width: 4rem; + flex-shrink: 0; + @media (max-width: 44rem) { width: 3rem; } +`; - &:disabled { - cursor: wait; - opacity: 0.72; - } +export const RobotImage = styled.img` + display: block; + width: 100%; + height: auto; `; -export const PrimaryActionButton = styled.button` - ${actionButton} - border: none; - background: linear-gradient(180deg, #2f75e7, #2667da); - color: #ffffff; - - &:disabled { - cursor: not-allowed; - filter: grayscale(0.35); - opacity: 0.62; - } +export const CoachText = styled.p` + min-width: 0; + margin: 0; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 1rem; + line-height: 1.4; + overflow-wrap: anywhere; `; diff --git a/src/widgets/feedback-page/feedback-detail/top-section/index.tsx b/src/widgets/feedback-page/feedback-detail/top-section/index.tsx index a1ad919b..461a6c65 100644 --- a/src/widgets/feedback-page/feedback-detail/top-section/index.tsx +++ b/src/widgets/feedback-page/feedback-detail/top-section/index.tsx @@ -1,148 +1,31 @@ -import { useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; import * as S from "../style"; -import type { - FeedbackDetailQuestionItem, - FeedbackDetailTopSectionProps, -} from "../type"; - -interface QuestionItemProps { - isActive: boolean; - item: FeedbackDetailQuestionItem; - onSelect: (questionId: number) => void; -} - -const QuestionItem = ({ isActive, item, onSelect }: QuestionItemProps) => { - return ( - onSelect(item.id)} - > - {item.label} - - {item.personaLabel ? ( - {item.personaLabel} - ) : null} - {item.preview} - - - ); -}; - -const FeedbackDetailTopSection = ({ - content, -}: FeedbackDetailTopSectionProps) => { - const firstQuestion = content.questions[0]; - const [selectedQuestionId, setSelectedQuestionId] = useState( - firstQuestion?.id ?? 0 - ); - const [isAnswerVisible, setIsAnswerVisible] = useState(false); - const { id } = useParams(); - const navigate = useNavigate(); - const feedbackId = id ?? "1"; - - const selectedQuestion = - content.questions.find((question) => question.id === selectedQuestionId) ?? - firstQuestion; - - if (!selectedQuestion) { - return null; - } - - const handleSelectQuestion = (questionId: number) => { - setSelectedQuestionId(questionId); - setIsAnswerVisible(false); - }; - - return ( - - - navigate(`/main/feedback/overall/${feedbackId}`)} - > - {content.tabs.overallLabel} - - navigate(`/main/feedback/detail/${feedbackId}`)} - > - {content.tabs.detailLabel} - - - - - - - {content.questionListTitle} - - {content.questions.map((item) => ( - - ))} - - - - - - {content.questionTitle} - {selectedQuestion.personaLabel ? ( - {selectedQuestion.personaLabel} 질문 - ) : null} - - {selectedQuestion.question} - - - {content.intentionTitle} - {selectedQuestion.intention} - - - - - - {content.myAnswerTitle} - setIsAnswerVisible((current) => !current)} - > - {isAnswerVisible - ? content.hideMyAnswerLabel - : content.showMyAnswerLabel} - - - {isAnswerVisible && ( - {selectedQuestion.myAnswer} - )} - - - - {content.modelAnswerTitle} - - {selectedQuestion.modelAnswer} - - - - - {content.followUpTitle} - - - {selectedQuestion.followUpQuestion} - - - - - - - - ); -}; +import type { FeedbackDetailTopSectionProps } from "../type"; + +const FeedbackDetailTopSection = ({ content, question }: FeedbackDetailTopSectionProps) => ( + + + {question.label} + {question.question} + + {content.intentionTitle} + {question.intention} + + + + + {content.myAnswerTitle} + {question.myAnswer} + + + {content.modelAnswerTitle} + {question.modelAnswer} + + + {content.followUpTitle} + {question.followUpQuestion} + + + +); export default FeedbackDetailTopSection; diff --git a/src/widgets/feedback-page/feedback-detail/type.ts b/src/widgets/feedback-page/feedback-detail/type.ts index 28f8ac31..e571f0c4 100644 --- a/src/widgets/feedback-page/feedback-detail/type.ts +++ b/src/widgets/feedback-page/feedback-detail/type.ts @@ -1,5 +1,6 @@ export interface FeedbackDetailQuestionItem { id: number; + questionId?: string; label: string; preview: string; personaLabel?: string; @@ -8,6 +9,9 @@ export interface FeedbackDetailQuestionItem { myAnswer: string; modelAnswer: string; followUpQuestion: string; + strengths: readonly string[]; + improvements: readonly string[]; + comment: string; } export interface FeedbackDetailTopSectionData { @@ -30,6 +34,7 @@ export interface FeedbackDetailTopSectionData { export interface FeedbackDetailMiddleSectionData { cards: readonly { title: string; + tone: "positive" | "negative"; paragraphs: readonly string[]; }[]; } @@ -42,14 +47,25 @@ export interface FeedbackDetailBottomSectionData { export interface FeedbackDetailTopSectionProps { content: FeedbackDetailTopSectionData; + question: FeedbackDetailQuestionItem; } export interface FeedbackDetailMiddleSectionProps { content: FeedbackDetailMiddleSectionData; + question: FeedbackDetailQuestionItem; } export interface FeedbackDetailBottomSectionProps { content: FeedbackDetailBottomSectionData; +} + +export interface FeedbackDetailProps { + content: { + topSection: FeedbackDetailTopSectionData; + middleSection: FeedbackDetailMiddleSectionData; + bottomSection: FeedbackDetailBottomSectionData; + }; + companySection?: import("react").ReactNode; isDownloadingPdf: boolean; onDownloadPdf: () => Promise | void; } diff --git a/src/widgets/feedback-page/feedback-list/index.tsx b/src/widgets/feedback-page/feedback-list/index.tsx index 0d2c744a..6b2bd2fa 100644 --- a/src/widgets/feedback-page/feedback-list/index.tsx +++ b/src/widgets/feedback-page/feedback-list/index.tsx @@ -1,16 +1,17 @@ -import { useMemo, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { useMemo } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { + FEEDBACK_LIST_SORT_KEY_LABELS, + getFeedbackListSortLabel, + readFeedbackListSort, + sortFeedbackList, + writeFeedbackListSort, + type FeedbackListSort, + type FeedbackListSortKey, +} from "@/features/feedback-page/feedback-list/model/sortFeedbackList"; import * as S from "./style"; import type { FeedbackListProps } from "./type"; -type SortOrder = "oldest" | "latest"; - -const getDateTime = (date: string) => { - const parsedDate = new Date(date.split("/").join("-")); - - return Number.isNaN(parsedDate.getTime()) ? 0 : parsedDate.getTime(); -}; - const getNormalizedStatusLabel = (statusLabel: string) => statusLabel.replace(/\s/g, ""); @@ -40,37 +41,57 @@ const FeedbackList = ({ onItemClick, }: InternalProps) => { const navigate = useNavigate(); - const [sortOrder, setSortOrder] = useState("oldest"); + const [searchParams, setSearchParams] = useSearchParams(); + const sort = readFeedbackListSort(searchParams); const sortedItems = useMemo( - () => - [...items].sort((left, right) => { - const leftTime = getDateTime(left.date); - const rightTime = getDateTime(right.date); - - return sortOrder === "oldest" - ? leftTime - rightTime - : rightTime - leftTime; - }), - [items, sortOrder], + () => sortFeedbackList(items, readFeedbackListSort(searchParams)), + [items, searchParams], ); + const updateSort = (nextSort: FeedbackListSort) => { + setSearchParams( + (current) => writeFeedbackListSort(current, nextSort), + { replace: true }, + ); + }; return ( {title} - - - setSortOrder(event.target.value as SortOrder) + + + + // 기준을 바꾸면 방향은 내림차순으로 되돌린다. + updateSort({ + key: event.target.value as FeedbackListSortKey, + order: "desc", + }) + } + > + {(Object.keys(FEEDBACK_LIST_SORT_KEY_LABELS) as FeedbackListSortKey[]).map((key) => ( + + ))} + + + + updateSort({ + key: sort.key, + order: sort.order === "desc" ? "asc" : "desc", + }) } > - - - - + {getFeedbackListSortLabel(sort)} + + {isLoading ? ( @@ -117,6 +138,7 @@ const FeedbackList = ({ /> {item.title} + {item.companyName ? {item.companyName} 연습 면접 : null} {item.totalScore !== undefined ? ( 종합 점수 {item.totalScore}점 diff --git a/src/widgets/feedback-page/feedback-list/style.ts b/src/widgets/feedback-page/feedback-list/style.ts index 98ff5e0b..3c8bfc17 100644 --- a/src/widgets/feedback-page/feedback-list/style.ts +++ b/src/widgets/feedback-page/feedback-list/style.ts @@ -1,5 +1,7 @@ import styled, { css } from "styled-components"; +import { pageLayout } from "@/shared/styles/pageLayout"; + interface StatusBadgeProps { $complete: boolean; } @@ -12,21 +14,15 @@ const focusRing = css` `; export const Page = styled.div` - width: 100%; + ${pageLayout} min-height: 100%; display: flex; align-items: flex-start; justify-content: center; - padding: 2rem clamp(1.5rem, 5vw, 5rem) 4rem; - box-sizing: border-box; - - @media (max-width: 56rem) { - padding: 1.25rem 1rem 2rem; - } `; export const Panel = styled.section` - width: min(100%, 80rem); + width: 100%; display: flex; flex-direction: column; align-items: stretch; @@ -91,6 +87,28 @@ export const SortSelect = styled.select` ${focusRing} `; +export const SortControls = styled.div` + display: inline-flex; + align-items: center; + gap: 0.5rem; +`; + +export const SortDirectionButton = styled.button` + min-height: 2rem; + border: 0.0625rem solid rgba(26, 115, 232, 0.32); + border-radius: 0.35rem; + background: transparent; + color: #1a73e8; + cursor: pointer; + font-family: inherit; + font-size: 1rem; + font-weight: 800; + line-height: 1; + padding: 0.3rem 0.6rem; + + ${focusRing} +`; + export const List = styled.div` width: 100%; display: grid; diff --git a/src/widgets/feedback-page/feedback-list/type.ts b/src/widgets/feedback-page/feedback-list/type.ts index 65816e83..c3105855 100644 --- a/src/widgets/feedback-page/feedback-list/type.ts +++ b/src/widgets/feedback-page/feedback-list/type.ts @@ -1,6 +1,8 @@ export interface FeedbackListItem { + companyName?: string; id: number; date: string; + createdAt?: string; imageUrl: string; title: string; styleLabel: string; diff --git a/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx b/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx index 1ae0c1e1..f2226e07 100644 --- a/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx @@ -1,43 +1,42 @@ import * as S from "../style"; -import type { - FeedbackOverallBottomSectionProps, - FeedbackOverallKeywordItem, -} from "../type"; - -interface KeywordCloudProps { - items: readonly FeedbackOverallKeywordItem[]; -} - -const KeywordCloud = ({ items }: KeywordCloudProps) => { - return ( - - - {items.map((item) => ( - - {item.text} - - ))} - - - ); -}; +import type { FeedbackOverallBottomSectionProps } from "../type"; const FeedbackOverallBottomSection = ({ content, + speechAnalysis, + onRetrySpeechAnalysis, isDownloadingPdf = false, onDownloadPdf, }: FeedbackOverallBottomSectionProps) => { - const { keywords, questionFit, reliability } = content; + const { keywords, reliability } = content; return ( - - - + + {speechAnalysis.visible ? ( + + 음성 분석 결과 + {speechAnalysis.message} + {speechAnalysis.canRetry ? 음성 정보 다시 확인 : null} + + ) : null} - + + + {keywords.items.length > 0 ? ( + + {keywords.items.map((item) => ( + + {item.text} + + ))} + + ) : 분석된 키워드가 아직 없습니다.} + + {reliability.title} @@ -45,24 +44,19 @@ const FeedbackOverallBottomSection = ({ {reliability.levelLabel} - - - {questionFit.title} - {questionFit.content} - - {onDownloadPdf ? ( {isDownloadingPdf ? "PDF 저장 중..." : content.pdfActionLabel} ) : null} - + - + ); }; diff --git a/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx b/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx index 87e54f01..d9b0f209 100644 --- a/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/middle-section/index.tsx @@ -5,49 +5,95 @@ import InterviewerImage2 from "@/shared/img/interview-page/interviewer2.svg?url" import InterviewerImage3 from "@/shared/img/interview-page/interviewer3.svg?url"; import InterviewerImage4 from "@/shared/img/interview-page/interviewer4.svg?url"; -const PERSONA_IMAGES = [ - InterviewerImage1, - InterviewerImage2, - InterviewerImage3, - InterviewerImage4, -] as const; +const PERSONA_IMAGES = [InterviewerImage2, InterviewerImage1, InterviewerImage4, InterviewerImage3] as const; + +const FeedbackOverallMiddleSection = ({ content }: FeedbackOverallMiddleSectionProps) => { + const { previousScore, currentScore } = content.comparison; + const difference = previousScore !== null && currentScore !== null + ? Math.round((currentScore - previousScore) * 10) / 10 + : null; -const FeedbackOverallMiddleSection = ({ - content, -}: FeedbackOverallMiddleSectionProps) => { return ( - + - {content.cards.map((card) => ( - - - - {card.title} - - {card.content} - - ))} + + {content.cards.map((card) => ( + + + + {card.title} + + + {card.items.map((item, index) => ( + + + {item} + + ))} + + + ))} + + + + + 지난 면접 대비 + + {difference === null ? ( + 비교할 점수가 아직 없어요. + ) : difference === 0 ? ( + 지난 면접과 같은 점수예요. + ) : ( + <>{difference > 0 ? "+ " : "− "}{Math.abs(difference)}점이 {difference > 0 ? "상승했어요!" : "하락했어요."} + )} + + + + {[ + { label: "이전 면접 점수", score: previousScore, tone: "muted" as const }, + { label: "이번 면접 점수", score: currentScore, tone: "primary" as const }, + ].map((bar) => ( + + + + {bar.score === null ? "—" : `${bar.score}점`} + + + {bar.label} + + ))} + + - - {content.personaFeedbacks.length > 0 ? ( - content.personaFeedbacks.map((persona, index) => ( - - - - {persona.title} - {persona.comment} - - - )) - ) : ( - 면접관별 코멘트가 아직 없습니다. + {content.showPersonaFeedback ? + {content.personaFeedbacks.length > 0 ? content.personaFeedbacks.map((persona, index) => ( + + { + const fallback = PERSONA_IMAGES[index % PERSONA_IMAGES.length]; + if (event.currentTarget.getAttribute("src") !== fallback) event.currentTarget.src = fallback; + }} + /> + + {persona.title} + {persona.comment} + + + {persona.hasNoAnswer ? "담당 답변 없음" : persona.score === null ? "미제공" : `${persona.score}점`} + + + )) : ( + 면접관별 코멘트가 제공되지 않았습니다. )} - - + : null} + ); }; diff --git a/src/widgets/feedback-page/feedback-overall/style.ts b/src/widgets/feedback-page/feedback-overall/style.ts index 42d67e93..576b1583 100644 --- a/src/widgets/feedback-page/feedback-overall/style.ts +++ b/src/widgets/feedback-page/feedback-overall/style.ts @@ -1,164 +1,54 @@ import styled, { css } from "styled-components"; - -interface TabButtonProps { - $active: boolean; -} - -interface BarFillProps { - $score: number; - $tone: "primary" | "muted"; -} - -interface BarValueProps { - $tone: "primary" | "muted"; -} - -interface KeywordWordProps { - $size: "xs" | "sm" | "md" | "xl"; -} - -interface ScoreRingProps { - $score: number; -} - -interface ProgressFillProps { - $score: number; - $tone: "primary" | "muted"; -} - -interface InsightCardProps { - $tone: "positive" | "negative"; -} +import type { FeedbackOverallKeywordItem } from "./type"; const cardSurface = css` - background: rgba(255, 255, 255, 0.9); - border: 0.0625rem solid rgba(223, 232, 247, 0.96); - border-radius: 1.05rem; - box-shadow: 0 1.1rem 2.6rem rgba(61, 102, 171, 0.08); -`; - -const keywordSizeMap = { - xs: css` - font-size: 1.16rem; - font-weight: 300; - opacity: 0.82; - `, - sm: css` - font-size: 1.58rem; - font-weight: 400; - opacity: 0.88; - `, - md: css` - font-size: 2.3rem; - font-weight: 500; - opacity: 0.94; - `, - xl: css` - font-size: 3.75rem; - font-weight: 500; - opacity: 1; - `, -}; - -const getBarHeight = (score: number) => { - const scaledHeight = score * 0.88 + 20; - - return `${Math.min(100, Math.max(28, scaledHeight))}%`; -}; + min-width: 0; + background: ${({ theme }) => theme.colors.white}; + border: 1px solid #dedfe2; + border-radius: 0.375rem; +`; + +const cardShadow = css` + box-shadow: 0 2px 4px rgba(34, 68, 114, 0.07); +`; export const SectionBlock = styled.section` display: flex; flex-direction: column; - gap: 1rem; - - @media (max-width: 48rem) { - gap: 0.8rem; - } -`; - -export const PageHeading = styled.h1` - margin: 0; - color: #4a4a4a; - font-size: 1.2rem; - font-weight: 700; - letter-spacing: -0.04rem; -`; - -export const SectionShell = styled.div` - padding: 1rem; - border-radius: 1.7rem; - background: linear-gradient( - 180deg, - rgba(234, 242, 255, 0.72), - rgba(243, 247, 255, 0.82) - ); - border: 0.0625rem solid rgba(217, 228, 247, 0.92); - box-shadow: inset 0 0.0625rem 0 rgba(255, 255, 255, 0.55); - - @media (max-width: 48rem) { - padding: 0.8rem; - border-radius: 1.25rem; - } + gap: 0.75rem; + min-width: 0; `; -export const BottomSectionShell = styled(SectionShell)` - padding-bottom: 1.4rem; - - @media (max-width: 48rem) { - padding-bottom: 1rem; - } +export const HeadingRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + min-height: 1.875rem; + margin-bottom: -0.5rem; + padding-left: 0.5rem; `; -export const TabGroup = styled.div` - width: fit-content; - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.2rem; - background: rgba(255, 255, 255, 0.96); - border-radius: 1.05rem; - box-shadow: 0 0.85rem 2rem rgba(60, 102, 173, 0.08); -`; - -export const TabButton = styled.button` - min-width: 13.2rem; - height: 2.1rem; - padding: 0 0.9rem; - border: none; - border-radius: 0.85rem; - background: ${({ $active }) => - $active ? "linear-gradient(180deg, #2f75e7, #2667da)" : "transparent"}; - color: ${({ $active }) => ($active ? "#ffffff" : "#2f75e7")}; - font-size: 1rem; +export const PageHeading = styled.h1` + color: #4a4d53; + font-size: 1.125rem; font-weight: 700; - cursor: pointer; - - @media (max-width: 40rem) { - min-width: 9rem; - height: 2rem; - font-size: 0.94rem; - } + letter-spacing: 0.025em; `; export const TopGrid = styled.div` display: grid; - grid-template-columns: minmax(14rem, 0.7fr) minmax(0, 1.35fr); - gap: 1rem; + grid-template-columns: minmax(0, 0.44fr) minmax(0, 1fr); + gap: 0.5rem; - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } -`; - -export const LeftColumn = styled.div` - display: grid; - gap: 1rem; + @media (max-width: 42rem) { grid-template-columns: 1fr; } `; export const ScoreCard = styled.article` ${cardSurface} - min-height: 15rem; - padding: 1.4rem; + ${cardShadow} + min-height: 16rem; + padding: 1.625rem 1rem; display: flex; flex-direction: column; align-items: center; @@ -167,549 +57,503 @@ export const ScoreCard = styled.article` text-align: center; `; -export const SectionLabel = styled.p` - margin: 0; - color: #111111; - font-size: 1.3rem; - font-weight: 700; +export const ScoreRing = styled.div` + position: relative; + width: 9.375rem; + height: 9.375rem; + flex-shrink: 0; + display: grid; + place-items: center; `; -export const MainScore = styled.h2` - margin: 0.35rem 0 0; - color: #2b74e3; - font-size: clamp(2rem, 3vw, 2.8rem); - font-weight: 700; - letter-spacing: -0.08rem; - line-height: 1; +export const ScoreRingGraphic = styled.svg` + position: absolute; + inset: 0; + width: 100%; + height: 100%; + overflow: visible; + color: ${({ theme }) => theme.colors.brand.blue}; `; -export const DescriptionBlock = styled.div` +export const ScoreRingInner = styled.div` display: flex; flex-direction: column; - gap: 0.15rem; + align-items: center; + gap: 0.25rem; `; -export const DescriptionLine = styled.p` - margin: 0; - color: #222222; - font-size: 0.92rem; - font-weight: 500; - line-height: 1.45; +export const ScoreRingLabel = styled.span` + font-size: 0.75rem; + color: ${({ theme }) => theme.colors.text.strong}; `; -export const ScoreRing = styled.div` - width: 9.6rem; - height: 9.6rem; - display: grid; - place-items: center; - border-radius: 50%; - background: ${({ $score }) => - `conic-gradient(#2477e8 ${Math.min(100, Math.max(0, $score))}%, #d9d9d9 0)`}; - position: relative; +export const MainScore = styled.h2` + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 2rem; + font-weight: 500; + line-height: 1; +`; - &::before { - content: ""; - position: absolute; - inset: 0.55rem; - border-radius: 50%; - background: #ffffff; - } +export const ScoreMaximum = styled.span` + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.875rem; `; -export const ScoreRingInner = styled.div` - position: relative; - z-index: 1; - display: flex; - flex-direction: column; - align-items: center; +export const DescriptionBlock = styled.div` + margin-top: 0.125rem; `; -export const ScoreRingLabel = styled.span` - color: #222222; - font-size: 0.9rem; - font-weight: 700; +export const DescriptionLine = styled.p` + font-size: 0.75rem; + line-height: 1.4; + word-break: keep-all; + overflow-wrap: anywhere; `; export const MetricsCard = styled.article` ${cardSurface} - min-height: 15rem; display: flex; flex-direction: column; justify-content: center; - gap: 1.8rem; - padding: 1.6rem 2rem; + gap: 1.375rem; + padding: 2rem; + + @media (max-width: 48rem) { padding: 1.25rem; } `; -export const MetricRow = styled.div` - display: flex; - flex-direction: column; - gap: 0.55rem; +export const TextCardTitle = styled.h3` + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 1rem; + font-weight: 600; + line-height: 1.3; `; -export const MetricHeader = styled.div` +export const SectionCaption = styled.p` + margin-top: 0.1875rem; + font-size: 0.625rem; + line-height: 1.4; +`; + +export const MetricGrid = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0.625rem; + + @media (max-width: 48rem) { grid-template-columns: repeat(2, minmax(0, 1fr)); } +`; + +export const MetricCard = styled.div` + min-width: 0; display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; + flex-direction: column; + gap: 0.375rem; + padding: 0.875rem 0.8125rem; + border: 1px solid #e1e1e1; + border-radius: 0.1875rem; `; export const MetricLabel = styled.span` - color: #333333; - font-size: 0.92rem; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.75rem; font-weight: 500; + line-height: 1.2; `; export const MetricValue = styled.strong` - color: #2477e8; - font-size: 0.95rem; - font-weight: 700; + color: ${({ theme }) => theme.colors.text.strong}; + font-size: 0.875rem; + font-weight: 600; + white-space: nowrap; + line-height: 1.2; `; export const ProgressTrack = styled.div` width: 100%; - height: 0.6rem; + height: 0.25rem; overflow: hidden; - border-radius: 999rem; - background: #e1e1e1; + border-radius: 1rem; + background: #d0e0ff; `; -export const ProgressFill = styled.div` +export const ProgressFill = styled.div<{ $score: number }>` width: ${({ $score }) => `${Math.min(100, Math.max(0, $score))}%`}; height: 100%; border-radius: inherit; - background: ${({ $tone }) => ($tone === "primary" ? "#2477e8" : "#a6a6a6")}; + background: ${({ theme }) => theme.colors.brand.blue}; `; -export const ChartCard = styled.article` - ${cardSurface} - min-height: 12.8rem; - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 0.35rem; - padding: 1rem 1.15rem 0.95rem; +export const TextCardBody = styled.p` + color: ${({ theme }) => theme.colors.text.default}; + font-size: 0.75rem; + line-height: 1.3; + white-space: pre-line; + word-break: keep-all; + overflow-wrap: anywhere; `; -export const CardHeading = styled.h3` - margin: 0; - color: #121212; - font-size: 1.24rem; - font-weight: 700; +export const MiddleGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 2.6fr) minmax(0, 1fr); + gap: 0.75rem; + + @media (max-width: 48rem) { grid-template-columns: 1fr; } `; -export const ComparisonPanel = styled.section` - ${cardSurface} - min-height: 24rem; +export const InsightGroup = styled.div` display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; - padding: 1rem; - background: linear-gradient(180deg, rgba(219, 228, 249, 0.88), rgba(228, 235, 251, 0.94)); + overflow: hidden; + border: 1px solid #dedfe2; + border-radius: 0.375rem; - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } + @media (max-width: 30rem) { grid-template-columns: 1fr; } `; -export const ComparisonCard = styled.article` - ${cardSurface} - min-height: 20rem; - position: relative; - overflow: hidden; - display: flex; - flex-direction: column; - gap: 0.5rem; - padding: 1rem 1.15rem 0.95rem; -`; +export const InsightCard = styled.article<{ $tone: "positive" | "negative" }>` + min-width: 0; + min-height: 14.25rem; + padding: 1.375rem 2rem; + background: ${({ $tone }) => $tone === "positive" ? "#eaf2fb" : "#f7eeee"}; + color: ${({ $tone, theme }) => $tone === "positive" ? theme.colors.brand.blue : "#ff3b21"}; -export const ComparisonHeader = styled.div` - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 0.75rem; -`; + & + & { border-left: 1px solid #dedfe2; } -export const HighlightText = styled.p` - margin: 0; - color: #2b74e3; - font-size: 1.14rem; - font-weight: 550; - text-align: right; + @media (max-width: 36rem) { padding: 1.25rem; } + @media (max-width: 30rem) { + min-height: 0; + & + & { border-left: 0; border-top: 1px solid #dedfe2; } + } `; -export const BarChart = styled.div` - position: relative; - width: 100%; - height: 11.9rem; - margin-top: auto; +export const InsightHeader = styled.div` display: flex; - align-items: end; - justify-content: center; - gap: 1.2rem; - padding: 0 0.45rem 0.1rem; - box-sizing: border-box; - overflow: visible; + align-items: center; + gap: 0.375rem; + margin-bottom: 0.875rem; `; -export const ComparisonBarChart = styled.div` - position: relative; - width: 100%; - flex: 1; - min-height: 15.2rem; - margin-top: 0.35rem; - display: flex; - align-items: end; - justify-content: center; - gap: 1.35rem; - padding: 0 0.45rem 0.18rem; - box-sizing: border-box; - overflow: visible; +export const InsightIcon = styled.svg` + width: 1.375rem; + height: 1.375rem; + fill: none; + stroke: currentColor; + stroke-width: 1.4; + stroke-linecap: round; `; -export const BarColumn = styled.div` - position: relative; - width: min(100%, 6.7rem); - height: 100%; - display: flex; - align-items: center; - justify-content: flex-end; - padding-bottom: 1.35rem; - box-sizing: border-box; +export const InsightTitle = styled.h3` + font-size: 1rem; + font-weight: 600; `; -export const ComparisonBarColumn = styled.div` - position: relative; - width: min(100%, 7.2rem); - height: 100%; +export const InsightList = styled.ol` display: flex; - align-items: center; - justify-content: flex-end; - padding-bottom: 1.55rem; - box-sizing: border-box; + flex-direction: column; + gap: 0.875rem; `; -export const BarTrack = styled.div` - width: 100%; - height: 100%; - display: flex; - align-items: end; +export const InsightItem = styled.li` + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.5rem; `; -export const BarFill = styled.div` - width: 100%; - height: ${({ $score }) => getBarHeight($score)}; - border-radius: 0.6rem 0.6rem 0.18rem 0.18rem; - background: ${({ $tone }) => - $tone === "primary" - ? "linear-gradient(180deg, #2f75e7, #2a6cdd)" - : "linear-gradient(180deg, rgba(228, 234, 246, 0.95), rgba(214, 223, 240, 0.98))"}; - display: flex; - justify-content: center; - align-items: flex-start; - padding-top: 0.75rem; +export const InsightNumber = styled.span` + font-size: 1rem; + line-height: 1.1; + font-variant-numeric: tabular-nums; `; -export const BarValue = styled.span` - color: ${({ $tone }) => ($tone === "primary" ? "#ffffff" : "#2f75e7")}; - font-size: 1.08rem; - font-weight: 700; +export const ComparisonCard = styled.article` + ${cardSurface} + min-height: 14.25rem; + display: flex; + flex-direction: column; + align-items: center; + padding: 1.625rem 1.5rem 1rem; + gap: 0.25rem; `; -export const BarLabel = styled.p` - margin: 0; - position: absolute; - left: 50%; - bottom: 0.1rem; - width: 100%; - transform: translateX(-50%); - color: #2f75e7; - font-size: 0.9rem; - font-weight: 600; +export const ComparisonTitle = styled.h3` + color: #555555; + font-size: 0.875rem; + line-height: 1.3; text-align: center; - line-height: 1.25; - word-break: keep-all; `; -export const MiddleGrid = styled.div` - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.9rem; +export const ComparisonDescription = styled.div` + font-size: 0.8125rem; + white-space: nowrap; +`; - @media (max-width: 64rem) { - grid-template-columns: 1fr; - } +export const ComparisonHighlight = styled.strong` + margin-right: 0.125rem; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 1.25rem; + font-weight: 600; `; -export const InsightCard = styled.article` - min-height: 10rem; - padding: 1.25rem 1.45rem; - border: 0.0625rem solid - ${({ $tone }) => ($tone === "positive" ? "#d4e4f7" : "#f1d4d1")}; - border-radius: 0.35rem; - background: ${({ $tone }) => - $tone === "positive" ? "#edf5ff" : "#fff1f0"}; +export const BarChart = styled.div` + width: 100%; + max-width: 14rem; + height: 8.375rem; + margin-top: auto; + display: flex; + justify-content: center; + gap: 0.875rem; `; -export const InsightHeader = styled.div` +export const BarColumn = styled.div` + min-width: 0; + flex: 1; display: flex; - align-items: center; - gap: 0.45rem; - margin-bottom: 0.85rem; + flex-direction: column; + gap: 0.1875rem; `; -export const InsightIcon = styled.span` - color: #2477e8; - font-size: 1.3rem; - line-height: 1; +export const BarTrack = styled.div` + flex: 1; + display: flex; + align-items: flex-end; `; -export const TextCard = styled.article` - ${cardSurface} - min-height: 12.6rem; - padding: 1.45rem 1.45rem 1.2rem; +export const BarFill = styled.div<{ $score: number; $tone: "primary" | "muted" }>` + width: 100%; + min-height: 1.25rem; + height: ${({ $score }) => `${Math.min(100, Math.max(0, $score))}%`}; + border-radius: 0.3125rem; + background: ${({ $tone, theme }) => $tone === "primary" ? theme.colors.brand.blue : "#edf1f9"}; display: flex; - flex-direction: column; - align-items: flex-start; - justify-content: flex-start; - gap: 0.7rem; - text-align: left; + justify-content: center; + padding-top: 0.625rem; `; -export const TextCardTitle = styled.h3` - margin: 0; - color: #2b74e3; - font-size: 1.3rem; - font-weight: 700; - letter-spacing: -0.03rem; +export const BarValue = styled.span<{ $tone: "primary" | "muted" }>` + color: ${({ $tone, theme }) => $tone === "primary" ? theme.colors.white : theme.colors.brand.blue}; + font-size: 0.625rem; + font-weight: 600; `; -export const TextCardBody = styled.p` - margin: 0; - color: #1b1b1b; - font-size: 0.9rem; - font-weight: 500; - line-height: 1.6; - word-break: keep-all; +export const BarLabel = styled.p` + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.5rem; + line-height: 1.4; + text-align: center; `; export const PersonaFeedbackList = styled.div` display: flex; flex-direction: column; - gap: 0.65rem; - margin-top: 0.1rem; + gap: 0.625rem; `; export const PersonaFeedbackCard = styled.article` ${cardSurface} - min-height: 4.8rem; + ${cardShadow} + min-height: 5rem; display: flex; align-items: center; - gap: 0.9rem; - padding: 0.55rem 0.75rem; - border-radius: 0.35rem; + gap: 0.625rem; + padding: 0.5625rem 0.8125rem 0.5625rem 0.625rem; + + @media (max-width: 36rem) { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.75rem; + } `; export const PersonaImage = styled.img` - width: 3.8rem; - height: 3.8rem; + width: 3.75rem; + height: 3.75rem; flex-shrink: 0; object-fit: cover; - border-radius: 0.2rem; + border-radius: 0.25rem; background: #e8edf5; `; export const PersonaFeedbackBody = styled.div` min-width: 0; - display: flex; - flex-direction: column; - gap: 0.35rem; + flex: 1; `; -export const PersonaFeedbackTitle = styled.strong` - color: #2477e8; - font-size: 0.88rem; - font-weight: 700; +export const PersonaFeedbackTitle = styled.h3` + margin-bottom: 0.375rem; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.8125rem; + font-weight: 600; + line-height: 1.2; `; -export const PersonaFeedbackText = styled.p` - margin: 0; - color: #333333; - font-size: 0.83rem; - line-height: 1.45; - word-break: keep-all; +export const PersonaFeedbackText = styled(TextCardBody)` + color: ${({ theme }) => theme.colors.text.strong}; + line-height: 1.25; `; -export const PersonaEmpty = styled.p` - ${cardSurface} - padding: 1.25rem; - color: #777777; - font-size: 0.9rem; +export const PersonaScore = styled.span` + min-width: 4.625rem; + min-height: 2.375rem; + padding: 0.625rem 1.125rem; + flex-shrink: 0; + border: 1px solid #c4daff; + border-radius: 0.1875rem; + background: #e8f0fe; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 1rem; + font-weight: 600; text-align: center; -`; - -export const BottomGrid = styled.div` - display: grid; - grid-template-columns: minmax(0, 1.45fr) minmax(14rem, 0.62fr); - gap: 0.9rem; - align-items: start; - padding-bottom: 0.4rem; - box-sizing: border-box; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } + white-space: nowrap; - @media (max-width: 48rem) { - padding-bottom: 0.2rem; - } + @media (max-width: 36rem) { grid-column: 2; justify-self: start; } `; -export const BottomLeftColumn = styled.div` - display: grid; - gap: 0.7rem; - align-content: start; +export const EmptyText = styled.p` + color: #687383; + font-size: 0.75rem; + line-height: 1.5; + text-align: center; `; -export const ReliabilityCard = styled.article` +export const PersonaEmpty = styled(EmptyText)` ${cardSurface} - min-height: 5rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.95rem 1.45rem; - - @media (max-width: 40rem) { - flex-direction: column; - align-items: flex-start; - } + padding: 1.5rem; `; -export const ReliabilityBadges = styled.div` - display: flex; - align-items: center; - gap: 0.55rem; -`; - -export const ReliabilityBadge = styled.div` - min-width: 5.2rem; - height: 2.7rem; - padding: 0 1rem; - border: 0.0625rem solid rgba(189, 201, 224, 0.95); - border-radius: 0.55rem; - background: rgba(255, 255, 255, 0.9); - font-family: ${({ theme }) => theme.fontFamily.wavvePado}; - color: rgba(0, 0, 0, 0.72); - font-size: 1.05rem; - font-weight: 100; - letter-spacing: 0.03rem; - transform: scale(1); - transform-origin: center; - display: flex; - align-items: center; - justify-content: center; +export const SpeechRetryButton = styled.button` + align-self: flex-start; + padding: 0.5rem 0.75rem; + border: 1px solid currentColor; + border-radius: 0.375rem; + color: ${({ theme }) => theme.colors.brand.blue}; + background: ${({ theme }) => theme.colors.white}; + font: inherit; + cursor: pointer; + + &:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } `; -export const QuestionCard = styled.article` +export const SpeechCard = styled.article` ${cardSurface} - min-height: 11.25rem; display: flex; flex-direction: column; - gap: 0.8rem; - padding: 1.45rem 1.45rem 1.15rem; + gap: 0.875rem; + padding: 1.5rem 1.75rem; `; -export const KeywordCard = styled.article` - ${cardSurface} - min-height: 20rem; - align-self: start; - padding: 1.35rem 1.4rem; +export const SpeechMetricGrid = styled(MetricGrid)` + gap: 0.5rem; `; -export const KeywordCanvas = styled.div` - width: 100%; - min-height: 17rem; +export const SpeechMetricCard = styled(MetricCard)` + padding: 0.875rem 0.9375rem 0.75rem; + gap: 0.3125rem; +`; + +export const MetricHeader = styled.div` display: flex; - flex-wrap: wrap; - align-content: center; + justify-content: space-between; align-items: center; - justify-content: center; - gap: 1.45rem 1.9rem; - padding: 1.2rem 0.5rem; + flex-wrap: wrap; + gap: 0.25rem; `; -export const KeywordWord = styled.span` - font-family: ${({ theme }) => theme.fontFamily.wavvePado}; - color: #2b74e3; - line-height: 1.08; - letter-spacing: 0; - white-space: nowrap; - transform: rotate(-5deg); - ${({ $size }) => keywordSizeMap[$size]} +export const MetricDescription = styled.p` + margin-top: 0.125rem; + color: #666666; + font-size: 0.5625rem; + line-height: 1.4; + word-break: keep-all; +`; - &:nth-child(2n) { - transform: rotate(6deg) translateY(0.55rem); - } +export const BottomGrid = styled.div` + display: grid; + grid-template-columns: minmax(0, 3.25fr) minmax(0, 1fr); + gap: 0.5rem; - &:nth-child(3n) { - transform: rotate(-9deg) translateY(-0.45rem); - } + @media (max-width: 42rem) { grid-template-columns: 1fr; } +`; - &:nth-child(4n) { - transform: rotate(4deg) translateY(-0.75rem); - } +export const KeywordCard = styled.article` + ${cardSurface} + ${cardShadow} + min-height: 10.625rem; + display: grid; + align-items: center; + padding: 0.75rem; +`; - &:nth-child(5n) { - transform: rotate(-2deg) translateY(0.85rem); - } +export const KeywordCanvas = styled.div` + position: relative; + height: 100%; + min-height: 9rem; +`; - @media (max-width: 48rem) { - transform: rotate(-3deg); +const keywordSizeMap = { xs: "0.625rem", sm: "0.875rem", md: "1.25rem", lg: "1.875rem", xl: "2.75rem" }; - &:nth-child(2n) { - transform: rotate(4deg) translateY(0.35rem); - } +export const KeywordWord = styled.span<{ $size: FeedbackOverallKeywordItem["size"] }>` + position: absolute; + transform: translate(-50%, -50%); + max-width: 22%; + color: ${({ $size, theme }) => $size === "xl" ? theme.colors.brand.blue : $size === "xs" ? "#5274a3" : "#4c8be4"}; + font-size: ${({ $size }) => keywordSizeMap[$size]}; + font-weight: ${({ $size }) => $size === "xs" ? 500 : 600}; + line-height: 1.1; + text-align: center; + overflow-wrap: anywhere; - &:nth-child(3n) { - transform: rotate(-5deg) translateY(-0.3rem); - } + @media (max-width: 30rem) { font-size: ${({ $size }) => $size === "xl" ? "2rem" : keywordSizeMap[$size]}; } +`; - &:nth-child(4n) { - transform: rotate(3deg) translateY(-0.45rem); - } +export const BottomSideColumn = styled.div` + display: flex; + flex-direction: column; + gap: 0.625rem; +`; - &:nth-child(5n) { - transform: rotate(-2deg) translateY(0.5rem); - } +export const ReliabilityCard = styled.article` + ${cardSurface} + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.8125rem; + padding: 1.5rem 1.125rem; +`; - ${({ $size }) => - $size === "xl" && - css` - font-size: 2.7rem; - `} +export const ReliabilityBadges = styled.div` + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.5rem; +`; - ${({ $size }) => - $size === "md" && - css` - font-size: 1.9rem; - `} - } +export const ReliabilityBadge = styled.span` + min-width: 0; + padding: 0.375rem 0.25rem; + border: 1px solid #dedfe2; + border-radius: 0.3125rem; + color: #555555; + font-size: 1rem; + font-weight: 600; + line-height: 1.2; + text-align: center; + white-space: nowrap; `; export const PdfButton = styled.button` + ${cardShadow} width: 100%; - height: 2.8rem; - border: none; - border-radius: 0.35rem; - background: #2477e8; - color: #ffffff; - font-size: 0.92rem; - font-weight: 600; + min-height: 2.5rem; + padding: 0.625rem 1.25rem; + border: 1px solid #bbd3fa; + border-radius: 0.5rem; + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; + font: inherit; + font-size: 0.875rem; + text-align: left; cursor: pointer; - &:disabled { - cursor: wait; - opacity: 0.65; - } + &:hover:not(:disabled) { filter: brightness(0.96); } + &:focus-visible { outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; outline-offset: 3px; } + &:disabled { cursor: wait; opacity: 0.65; } `; diff --git a/src/widgets/feedback-page/feedback-overall/top-section/index.tsx b/src/widgets/feedback-page/feedback-overall/top-section/index.tsx index 070f93f2..9bec3ec7 100644 --- a/src/widgets/feedback-page/feedback-overall/top-section/index.tsx +++ b/src/widgets/feedback-page/feedback-overall/top-section/index.tsx @@ -1,42 +1,34 @@ +import FeedbackTabs from "@/widgets/feedback-page/feedback-tabs"; import * as S from "../style"; import type { FeedbackOverallTopSectionProps } from "../type"; -import { useNavigate, useParams } from "react-router-dom"; -const FeedbackOverallTopSection = ({ - content, -}: FeedbackOverallTopSectionProps) => { - const { averageComparison, summary, tabs } = content; - const { activeTab, detailLabel, overallLabel } = tabs; - const navigate = useNavigate(); - const { id } = useParams(); - const feedbackId = id ?? "1"; +const FeedbackOverallTopSection = ({ content }: FeedbackOverallTopSectionProps) => { + const { evaluation, metrics, summary } = content; return ( - 종합 피드백 - - navigate(`/main/feedback/overall/${feedbackId}`)} - > - {overallLabel} - - navigate(`/main/feedback/detail/${feedbackId}`)} - > - {detailLabel} - - + + 종합 피드백 + + - + + {summary.title} - {summary.score}점 + {summary.score === null ? "—" : `${summary.score}점`} + 100점 @@ -47,17 +39,22 @@ const FeedbackOverallTopSection = ({ - {averageComparison.bars.map((bar) => ( - - - {bar.label} - {bar.score}점 - - - - - - ))} +
+ 종합 점수 + 평가 가능한 항목의 점수와 가중치를 반영한 결과입니다. +
+ + {metrics.map((metric) => ( + + {metric.label} + {metric.score === null ? metric.emptyLabel ?? "미제공" : `${metric.score}점`} + {metric.score !== null ? : null} + + ))} + + {evaluation}
diff --git a/src/widgets/feedback-page/feedback-overall/type.ts b/src/widgets/feedback-page/feedback-overall/type.ts index c171b9b8..e31c806c 100644 --- a/src/widgets/feedback-page/feedback-overall/type.ts +++ b/src/widgets/feedback-page/feedback-overall/type.ts @@ -1,24 +1,21 @@ -export interface FeedbackOverallBarItem { +export interface FeedbackOverallMetric { label: string; - score: number; - tone: "primary" | "muted"; -} - -export interface FeedbackOverallComparisonCard { - title: string; - highlightText?: string; - bars: readonly FeedbackOverallBarItem[]; + score: number | null; + description?: string; + emptyLabel?: "해당 없음" | "미제공"; } export interface FeedbackOverallPersonaFeedback { title: string; comment: string; imageUrl?: string; + score: number | null; + hasNoAnswer: boolean; } export interface FeedbackOverallKeywordItem { text: string; - size: "xs" | "sm" | "md" | "xl"; + size: "xs" | "sm" | "md" | "lg" | "xl"; top: string; left: string; } @@ -31,22 +28,24 @@ export interface FeedbackOverallTopSectionData { }; summary: { title: string; - score: number; + score: number | null; description: readonly string[]; }; - averageComparison: { - title: string; - bars: readonly FeedbackOverallBarItem[]; - }; - comparisonCards: readonly FeedbackOverallComparisonCard[]; - intentAlignmentScore: number; + metrics: readonly FeedbackOverallMetric[]; + evaluation: string; } export interface FeedbackOverallMiddleSectionData { cards: readonly { title: string; - content: string; + tone: "positive" | "negative"; + items: readonly string[]; }[]; + comparison: { + previousScore: number | null; + currentScore: number | null; + }; + showPersonaFeedback: boolean; personaFeedbacks: readonly FeedbackOverallPersonaFeedback[]; } @@ -56,10 +55,6 @@ export interface FeedbackOverallBottomSectionData { scoreLabel: string; levelLabel: string; }; - questionFit: { - title: string; - content: string; - }; keywords: { items: readonly FeedbackOverallKeywordItem[]; }; @@ -74,7 +69,15 @@ export interface FeedbackOverallMiddleSectionProps { content: FeedbackOverallMiddleSectionData; } +export interface FeedbackSpeechAnalysis { + visible: boolean; + message: string; + canRetry: boolean; +} + export interface FeedbackOverallBottomSectionProps { + speechAnalysis: FeedbackSpeechAnalysis; + onRetrySpeechAnalysis: () => void; content: FeedbackOverallBottomSectionData; isDownloadingPdf?: boolean; onDownloadPdf?: () => void; diff --git a/src/widgets/feedback-page/feedback-tabs/index.tsx b/src/widgets/feedback-page/feedback-tabs/index.tsx new file mode 100644 index 00000000..a54e58ca --- /dev/null +++ b/src/widgets/feedback-page/feedback-tabs/index.tsx @@ -0,0 +1,15 @@ +import { useParams } from "react-router-dom"; +import * as S from "./style"; + +const FeedbackTabs = () => { + const { id } = useParams(); + + return ( + + 종합 피드백 + 상세 피드백 + + ); +}; + +export default FeedbackTabs; diff --git a/src/widgets/feedback-page/feedback-tabs/style.ts b/src/widgets/feedback-page/feedback-tabs/style.ts new file mode 100644 index 00000000..568bf4d4 --- /dev/null +++ b/src/widgets/feedback-page/feedback-tabs/style.ts @@ -0,0 +1,37 @@ +import { NavLink } from "react-router-dom"; +import styled from "styled-components"; + +export const TabGroup = styled.nav` + display: inline-flex; + flex-shrink: 0; + padding: 0.1875rem; + border: 1px solid #e1e3e7; + border-radius: 0.375rem; + background: #ededed; +`; + +export const TabLink = styled(NavLink)` + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 1.625rem; + padding: 0.25rem 0.625rem; + border-radius: 0.25rem; + background: transparent; + color: ${({ theme }) => theme.colors.text.default}; + font-size: 0.8125rem; + font-weight: 500; + text-decoration: none; + white-space: nowrap; + + &[aria-current="page"] { + background: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.brand.blue}; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); + } + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 2px; + } +`; diff --git a/src/widgets/interview-page/company-badge/index.tsx b/src/widgets/interview-page/company-badge/index.tsx new file mode 100644 index 00000000..d30fa763 --- /dev/null +++ b/src/widgets/interview-page/company-badge/index.tsx @@ -0,0 +1,10 @@ +import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; +import { readCompanyBadge } from "@/features/interview-page/company/model/type"; +import { Badge } from "./style"; + +const CompanyBadge = () => { + const { preparedInterview } = useInterviewSessionContext(); + const company = readCompanyBadge(preparedInterview?.company); + return company ? {company.displayName} 면접을 가정한 연습 면접 : null; +}; +export default CompanyBadge; diff --git a/src/widgets/interview-page/company-badge/style.ts b/src/widgets/interview-page/company-badge/style.ts new file mode 100644 index 00000000..b6e67025 --- /dev/null +++ b/src/widgets/interview-page/company-badge/style.ts @@ -0,0 +1,11 @@ +import styled from "styled-components"; +export const Badge = styled.p` + margin: 0; + padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; + color: ${({ theme }) => theme.colors.brand.blue}; + background: ${({ theme }) => theme.colors.surface.blueLight}; + border-radius: ${({ theme }) => theme.radius.sm}; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.5; + overflow-wrap: anywhere; +`; diff --git a/src/widgets/interview-page/company-picker/index.tsx b/src/widgets/interview-page/company-picker/index.tsx new file mode 100644 index 00000000..3ce6686f --- /dev/null +++ b/src/widgets/interview-page/company-picker/index.tsx @@ -0,0 +1,131 @@ +import { + COMPANY_CATEGORY_LABELS, + COMPANY_CATEGORY_ORDER, + COMPANY_LEGAL_SIZE_LABELS, + formatDataAsOf, +} from "@/features/interview-page/company/model/companyLabels"; +import type { CompanySummary } from "@/features/interview-page/company/model/type"; +import type { CompanyCategoryFilter } from "@/features/interview-page/setting-company/model/useCompanySetup"; +import * as S from "./style"; +import type { CompanyPickerProps } from "./type"; + +const CATEGORY_FILTERS: { value: CompanyCategoryFilter; label: string }[] = [ + { value: "ALL", label: "전체" }, + ...COMPANY_CATEGORY_ORDER.map((value) => ({ + value, + label: COMPANY_CATEGORY_LABELS[value], + })), +]; + +const getInitial = (displayName: string) => + displayName.replace(/^샘플/, "").charAt(0) || displayName.charAt(0); + +const getMeta = (company: CompanySummary) => { + const category = COMPANY_CATEGORY_LABELS[company.category]; + return company.legalSize + ? `${category} · ${COMPANY_LEGAL_SIZE_LABELS[company.legalSize]}` + : category; +}; + +const CompanyPicker = ({ + query, + onQueryChange, + category, + onCategoryChange, + companies, + selectedCompanyId, + onSelect, + disabled, + listState, + emptyMessage, +}: CompanyPickerProps) => ( + + + + + + {CATEGORY_FILTERS.map((filter) => ( + onCategoryChange(filter.value)} + > + {filter.label} + + ))} + + + + + + onSelect(null)} aria-label="선택 안 함" /> + + — + + 선택 안 함 + 기업 없이 기본 면접으로 진행 + + + + 지금까지와 같은 방식의 연습 면접이에요 + + + {(listState === "success" ? companies : []).map((company) => { + const selected = selectedCompanyId === company.companyId; + return ( + + onSelect(company.companyId)} aria-label={company.displayName} /> + + {getInitial(company.displayName)} + + {company.displayName} + {company.legalName !== company.displayName ? ( + {company.legalName} + ) : null} + + + + + {getMeta(company)} + + + {company.talentKeywords.slice(0, 3).map((keyword) => ( + #{keyword} + ))} + + {formatDataAsOf(company.dataAsOf)} + + + + ); + })} + + {listState === "success" && companies.length === 0 ? ( + {emptyMessage} + ) : null} + + {listState === "loading" ? 기업 목록을 불러오고 있어요 : null} + +); + +export default CompanyPicker; diff --git a/src/widgets/interview-page/company-picker/style.ts b/src/widgets/interview-page/company-picker/style.ts new file mode 100644 index 00000000..eb093f2b --- /dev/null +++ b/src/widgets/interview-page/company-picker/style.ts @@ -0,0 +1,300 @@ +import styled, { css } from "styled-components"; +import type { SelectedProps } from "./type"; + +const focusRing = css` + &:focus-visible { + outline: 0.125rem solid rgba(47, 128, 237, 0.34); + outline-offset: 0.125rem; + } +`; + +export const Container = styled.div` + display: flex; + flex-direction: column; + gap: 1.25rem; + min-width: 0; +`; + +export const Toolbar = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem 1.25rem; +`; + +export const SearchField = styled.label` + flex: 1 1 18rem; + max-width: 26rem; + min-height: 3rem; + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0 1rem; + border: 0.0625rem solid #d5d9e2; + border-radius: 0.5rem; + background: #ffffff; + box-sizing: border-box; + + &:focus-within { + border-color: #3388f7; + } + + @media (max-width: 34rem) { + max-width: none; + } +`; + +export const SearchIcon = styled.span` + position: relative; + width: 1rem; + height: 1rem; + flex-shrink: 0; + + &::before { + content: ""; + position: absolute; + inset: 0 0.2rem 0.2rem 0; + border: 0.125rem solid #8a8f99; + border-radius: 50%; + } + + &::after { + content: ""; + position: absolute; + right: 0; + bottom: 0.05rem; + width: 0.4rem; + height: 0.125rem; + background: #8a8f99; + transform: rotate(45deg); + transform-origin: right center; + } +`; + +export const SearchInput = styled.input` + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + color: #171717; + font-family: inherit; + font-size: 0.95rem; + + &::placeholder { + color: #a1a6b0; + } +`; + +export const ChipGroup = styled.div` + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +`; + +export const Chip = styled.button` + min-height: 2.25rem; + padding: 0.4rem 0.95rem; + border: 0.0625rem solid ${({ $selected }) => ($selected ? "#3388f7" : "#d5d9e2")}; + border-radius: 999rem; + background: ${({ $selected }) => ($selected ? "#eef4ff" : "#ffffff")}; + color: ${({ $selected }) => ($selected ? "#257ee8" : "#3f4450")}; + font-family: inherit; + font-size: 0.875rem; + font-weight: ${({ $selected }) => ($selected ? 700 : 500)}; + cursor: pointer; + + &:disabled { + cursor: not-allowed; + } + + ${focusRing} +`; + +export const Grid = styled.div` + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; + + @media (max-width: 64rem) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media (max-width: 34rem) { + grid-template-columns: 1fr; + } +`; + +export const Card = styled.label` + min-width: 0; + min-height: 9rem; + position: relative; + &:focus-within { outline: 0.125rem solid ${({ theme }) => theme.colors.brand.blue}; outline-offset: 0.125rem; } + &:has(input:disabled) { cursor: not-allowed; } + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border: 0.0625rem solid ${({ $selected }) => ($selected ? "#3388f7" : "#d5d9e2")}; + border-radius: 0.5rem; + background: ${({ $selected }) => ($selected ? "#eef4ff" : "#ffffff")}; + color: #171717; + font-family: inherit; + text-align: left; + cursor: pointer; + transition: + background-color 0.2s ease, + border-color 0.2s ease, + box-shadow 0.2s ease, + transform 0.2s ease; + + &:not(:disabled):hover { + border-color: #3388f7; + box-shadow: 0 0.75rem 1.75rem rgba(38, 111, 224, 0.1); + transform: translateY(-0.0625rem); + } + + &:disabled { + cursor: not-allowed; + } + + ${focusRing} +`; + +export const CardTop = styled.span` + display: grid; + grid-template-columns: 2.5rem minmax(0, 1fr) 1.5rem; + align-items: center; + gap: 0.75rem; +`; + +export const Badge = styled.span` + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 1rem; + font-weight: 700; + background: ${({ $selected, $muted }) => + $muted ? "#f1f2f4" : $selected ? "#257ee8" : "#eef4ff"}; + color: ${({ $selected, $muted }) => + $muted ? "#a1a6b0" : $selected ? "#ffffff" : "#257ee8"}; +`; + +export const Names = styled.span` + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +`; + +export const Name = styled.span` + font-size: 1.05rem; + font-weight: 700; + line-height: 1.35; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const SubName = styled.span` + color: #6b7080; + font-size: 0.8rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const Radio = styled.span` + position: relative; + width: 1.5rem; + height: 1.5rem; + border: 0.125rem solid ${({ $selected }) => ($selected ? "#3388f7" : "#c8cbd0")}; + border-radius: 50%; + box-sizing: border-box; + + &::after { + content: ""; + position: absolute; + display: ${({ $selected }) => ($selected ? "block" : "none")}; + left: 0.34rem; + top: 0.18rem; + width: 0.36rem; + height: 0.62rem; + border: solid #3388f7; + border-width: 0 0.125rem 0.125rem 0; + transform: rotate(45deg); + } +`; + +export const CardNote = styled.span` + color: #6b7080; + font-size: 0.85rem; + line-height: 1.5; +`; + +export const CardBottom = styled.span` + display: flex; + flex-direction: column; + gap: 0.5rem; +`; + +export const Meta = styled.span` + color: #4d5570; + font-size: 0.85rem; + font-weight: 600; +`; + +export const TagRow = styled.span` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +`; + +export const Tags = styled.span` + min-width: 0; + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +`; + +export const Tag = styled.span` + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + box-sizing: border-box; + padding: 0.15rem 0.55rem; + border-radius: 999rem; + background: ${({ $selected }) => ($selected ? "#ffffff" : "#f4f6fa")}; + color: ${({ $selected }) => ($selected ? "#257ee8" : "#4a4f5c")}; + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +`; + +export const AsOf = styled.span` + flex-shrink: 0; + color: #a1a6b0; + font-size: 0.72rem; + white-space: nowrap; +`; + +export const Empty = styled.p` + margin: 0; + align-self: center; + color: #6b7080; + font-size: 0.95rem; +`; + +export const RadioInput = styled.input` + position: absolute; + width: 1px; + height: 1px; + opacity: 0; +`; diff --git a/src/widgets/interview-page/company-picker/type.ts b/src/widgets/interview-page/company-picker/type.ts new file mode 100644 index 00000000..afc3f541 --- /dev/null +++ b/src/widgets/interview-page/company-picker/type.ts @@ -0,0 +1,19 @@ +import type { CompanySummary } from "@/features/interview-page/company/model/type"; +import type { CompanyCategoryFilter } from "@/features/interview-page/setting-company/model/useCompanySetup"; + +export interface CompanyPickerProps { + listState: "loading" | "success" | "error"; + emptyMessage: string; + query: string; + onQueryChange: (value: string) => void; + category: CompanyCategoryFilter; + onCategoryChange: (value: CompanyCategoryFilter) => void; + companies: CompanySummary[]; + selectedCompanyId: number | null; + onSelect: (companyId: number | null) => void; + disabled: boolean; +} + +export interface SelectedProps { + $selected: boolean; +} diff --git a/src/widgets/interview-page/completed/completed-content/index.tsx b/src/widgets/interview-page/completed/completed-content/index.tsx index ee7cc357..e0226023 100644 --- a/src/widgets/interview-page/completed/completed-content/index.tsx +++ b/src/widgets/interview-page/completed/completed-content/index.tsx @@ -15,10 +15,35 @@ const getAnsweredQuestionCount = (state: unknown) => { : null; }; +const getCompletedInterviewId = (state: unknown) => { + if (!state || typeof state !== "object") { + return null; + } + + const { interviewId } = state as { interviewId?: unknown }; + return typeof interviewId === "number" && + Number.isSafeInteger(interviewId) && + interviewId > 0 + ? interviewId + : null; +}; + const InterviewCompletedContent = () => { const navigate = useNavigate(); const location = useLocation(); const answeredQuestionCount = getAnsweredQuestionCount(location.state); + const interviewId = getCompletedInterviewId(location.state); + + const handleOpenFeedback = () => { + if (interviewId === null) { + navigate("/main/feedback/list"); + return; + } + + navigate(`/main/feedback/overall/${interviewId}`, { + state: { fromCompletion: true }, + }); + }; return ( @@ -36,7 +61,7 @@ const InterviewCompletedContent = () => { navigate("/main/feedback/list")} + onClick={handleOpenFeedback} > 피드백 보러가기 diff --git a/src/widgets/interview-page/interview/interview-content/index.tsx b/src/widgets/interview-page/interview/interview-content/index.tsx index 2d6a4394..edbd3b06 100644 --- a/src/widgets/interview-page/interview/interview-content/index.tsx +++ b/src/widgets/interview-page/interview/interview-content/index.tsx @@ -1,12 +1,11 @@ import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; import InterviewVisualizerIcon from "@/shared/img/interview-page/Repit-Interview.svg?url"; -import type { InterviewMode } from "../type"; import * as S from "../style"; +import QuestionAudioControl, { QuestionAudioError } from "../question-audio-control"; const TEXT_ANSWER_MAX_LENGTH = 10000; const TEXT_ANSWER_PLACEHOLDER = "이곳에 답변을 입력해주세요. 자신의 경험과 성과를 구체적인 수치와 함께 작성하면 좋은 평가를 받을 수 있습니다."; -const FALLBACK_TOTAL_QUESTION_COUNT = 10; const formatQuestionLabel = ({ currentQuestionNumber, @@ -29,17 +28,13 @@ const formatQuestionLabel = ({ ? questionNumber : `${questionNumber}-${followUpQuestionNumber}`; - return `Question ${questionLabel} / ${String(totalQuestionCount).padStart( - 2, - "0", - )}`; -}; + const totalLabel = + totalQuestionCount > 0 ? String(totalQuestionCount).padStart(2, "0") : "--"; -interface InterviewContentViewProps { - onModeChange: (mode: InterviewMode) => void; -} + return `Question ${questionLabel} / ${totalLabel}`; +}; -const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { +const InterviewContentView = () => { const { answerText, currentQuestion, @@ -53,14 +48,13 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { onAnswerTextChange, onSubmitText, preparedInterview, + totalQuestionCount, voiceLevel, } = useInterviewSessionContext(); const isVoiceMode = mode === "voice"; const isTextMode = mode === "text"; const visualizerLevel = isVoiceStarted ? voiceLevel : 0.22; const isQuestionLoading = currentQuestion === null; - const totalQuestionCount = - preparedInterview?.questions.length || FALLBACK_TOTAL_QUESTION_COUNT; const currentQuestionNumber = Math.max(displayQuestionNumber, 1); const activeInterviewer = preparedInterview?.interviewers?.find( (interviewer) => interviewer.personaId === currentQuestion?.personaId, @@ -83,12 +77,14 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { {question.id} + {activeInterviewer ? ( 현재 면접관 · {activeInterviewer.name} ({activeInterviewer.roleLabel}) ) : null} {question.text} + {isVoiceMode ? (