From ffc5bfec66f898604d580a526bfbc1af2e31e53a Mon Sep 17 00:00:00 2001 From: gituser Date: Tue, 29 Sep 2026 17:51:03 +0900 Subject: [PATCH 01/88] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 수정 요청의 이름 키를 name에서 username으로 변경 - 사용하지 않는 getMyPageUser/MyPageUser 제거, 조회는 getMyInfo로 통일 - 저장 성공 후 getMyInfo로 재조회해 스토어 갱신 Co-Authored-By: Claude Opus 5.5 --- src/features/my-page/api/getMyPageData.ts | 29 ------------------- .../my-page/personal-info/api/updateMyInfo.ts | 6 +++- .../my-page/personal-info/model/useMyInfo.ts | 9 ++++-- 3 files changed, 11 insertions(+), 33 deletions(-) diff --git a/src/features/my-page/api/getMyPageData.ts b/src/features/my-page/api/getMyPageData.ts index 2393a116..ba51d245 100644 --- a/src/features/my-page/api/getMyPageData.ts +++ b/src/features/my-page/api/getMyPageData.ts @@ -1,26 +1,8 @@ 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; @@ -39,20 +21,9 @@ interface MetaDataUploadResponse { 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, diff --git a/src/features/my-page/personal-info/api/updateMyInfo.ts b/src/features/my-page/personal-info/api/updateMyInfo.ts index 1266202a..3a4c45c1 100644 --- a/src/features/my-page/personal-info/api/updateMyInfo.ts +++ b/src/features/my-page/personal-info/api/updateMyInfo.ts @@ -11,7 +11,11 @@ export const updateMyInfo = async (params: UpdateMyInfoParams) => { try { - await authInstance.patch(UPDATE_MY_INFO_URL, params); + await authInstance.patch(UPDATE_MY_INFO_URL, { + username: params.name, + nickname: params.nickname, + email: params.email, + }); return { errorMessage: null }; } catch (error) { diff --git a/src/features/my-page/personal-info/model/useMyInfo.ts b/src/features/my-page/personal-info/model/useMyInfo.ts index 0fa14898..b6afd07f 100644 --- a/src/features/my-page/personal-info/model/useMyInfo.ts +++ b/src/features/my-page/personal-info/model/useMyInfo.ts @@ -95,14 +95,17 @@ export const useMyInfo = () => { const { errorMessage: saveErrorMessage } = await updateMyInfo(nextDraft); - setIsSaving(false); - if (saveErrorMessage) { + setIsSaving(false); setErrorMessage(saveErrorMessage); return; } - setUser(nextDraft); + // 저장은 성공했으므로 재조회가 실패하면 입력값으로 대신 갱신한다. + const { data: savedUser } = await getMyInfo(); + + setIsSaving(false); + setUser(savedUser ?? nextDraft); setDraftOverrides({}); setSaveMessage("개인정보가 저장되었습니다."); }, [draft, isSaving, setUser]); From 7fee5bc8995a61dcdf14d8ceb82785b29ac65b4b Mon Sep 17 00:00:00 2001 From: gituser Date: Tue, 29 Sep 2026 18:02:10 +0900 Subject: [PATCH 02/88] =?UTF-8?q?fix:=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=9B=84=20?= =?UTF-8?q?=EB=A9=94=ED=83=80=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=9E=AC?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 업로드 성공 후 getMyPageMetaData로 다시 조회해 화면 상태 갱신 - 재조회 실패 시 입력값으로 대체 Co-Authored-By: Claude Opus 5.5 --- src/features/my-page/portfolio/model/usePortfolioForm.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/features/my-page/portfolio/model/usePortfolioForm.ts b/src/features/my-page/portfolio/model/usePortfolioForm.ts index 5ccf5e3f..4e8b1fcb 100644 --- a/src/features/my-page/portfolio/model/usePortfolioForm.ts +++ b/src/features/my-page/portfolio/model/usePortfolioForm.ts @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { ChangeEvent, KeyboardEvent } from "react"; import { + getMyPageMetaData, uploadMyPageMetaData, type MyPageMetaData, } from "@/features/my-page/api/getMyPageData"; @@ -165,11 +166,15 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { setIsSaving(true); try { - const nextMetaData = await uploadMyPageMetaData({ + const uploadedMetaData = await uploadMyPageMetaData({ file: portfolioFile, gitUrls, jobRole, }); + // 업로드는 성공했으므로 재조회가 실패하면 입력값으로 대신 갱신한다. + const nextMetaData = await getMyPageMetaData().catch( + () => uploadedMetaData, + ); options.onUploadSuccess?.(nextMetaData); setSaveMessage("포트폴리오가 저장되었습니다."); From fbd96e273e5fb998638488651fc4beb218308907 Mon Sep 17 00:00:00 2001 From: gituser Date: Tue, 29 Sep 2026 18:02:10 +0900 Subject: [PATCH 03/88] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A7=81=EB=AC=B4=20=EA=B0=92=EC=9D=84=20?= =?UTF-8?q?FRONTEND,=20BACKEND=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/shared/constants/my-page/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/shared/constants/my-page/index.ts b/src/shared/constants/my-page/index.ts index 971cb8f9..7aa2842f 100644 --- a/src/shared/constants/my-page/index.ts +++ b/src/shared/constants/my-page/index.ts @@ -1,10 +1,10 @@ export const MY_PAGE_PORTFOLIO_JOB_ROLE_OPTIONS = [ { - value: "frontend", + value: "FRONTEND", label: "Frontend", }, { - value: "backend", + value: "BACKEND", label: "Backend", }, ] as const; From 9b46e8c2453882956abcb9bf9dea2194010919b3 Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 30 Sep 2026 12:18:55 +0900 Subject: [PATCH 04/88] feat: check session on route change and tab focus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pages without load-time API calls (interview setup, main, completed) never received a 401 after the session expired, so users stayed on them until the next request. MainLayout now calls /me on /main route changes and when the tab becomes visible, reusing the existing refresh → /login flow. Co-Authored-By: Claude Opus 5.5 --- src/app/layout/main-layout/index.tsx | 2 + src/shared/model/useSessionCheck.ts | 63 ++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 src/shared/model/useSessionCheck.ts diff --git a/src/app/layout/main-layout/index.tsx b/src/app/layout/main-layout/index.tsx index c69964d4..51ee517b 100644 --- a/src/app/layout/main-layout/index.tsx +++ b/src/app/layout/main-layout/index.tsx @@ -1,10 +1,12 @@ 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 isInterviewRoute = normalizedPathname === "/main/interview" || 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]); +}; From 9cf5cacbbd6770647b0f50e7113e49e91ad36244 Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 30 Sep 2026 12:26:38 +0900 Subject: [PATCH 05/88] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=9D=B4=EB=A9=94=EC=9D=BC=EC=9D=84=20=EC=9D=BD?= =?UTF-8?q?=EA=B8=B0=20=EC=A0=84=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 인증 서버 PATCH /users/me가 이메일을 수정하지 않으므로 수정 요청에서 email 제외 - 편집 대상과 필수 입력 검사에서 이메일을 빼고 입력칸을 읽기 전용으로 표시 Co-Authored-By: Claude Opus 5.5 --- .../my-page/personal-info/api/updateMyInfo.ts | 3 +-- src/features/my-page/personal-info/model/useMyInfo.ts | 11 +++++------ src/widgets/my-page/personal-info/index.tsx | 7 ++++--- src/widgets/my-page/personal-info/style.ts | 6 ++++++ 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/src/features/my-page/personal-info/api/updateMyInfo.ts b/src/features/my-page/personal-info/api/updateMyInfo.ts index 3a4c45c1..9b43f34a 100644 --- a/src/features/my-page/personal-info/api/updateMyInfo.ts +++ b/src/features/my-page/personal-info/api/updateMyInfo.ts @@ -3,10 +3,10 @@ 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) => { @@ -14,7 +14,6 @@ await authInstance.patch(UPDATE_MY_INFO_URL, { username: params.name, nickname: params.nickname, - email: params.email, }); return { errorMessage: null }; diff --git a/src/features/my-page/personal-info/model/useMyInfo.ts b/src/features/my-page/personal-info/model/useMyInfo.ts index b6afd07f..f69f27a5 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; } @@ -112,6 +110,7 @@ export const useMyInfo = () => { return { draft, + email, errorMessage, isLoading, isSaving, diff --git a/src/widgets/my-page/personal-info/index.tsx b/src/widgets/my-page/personal-info/index.tsx index 15823978..3e98661c 100644 --- a/src/widgets/my-page/personal-info/index.tsx +++ b/src/widgets/my-page/personal-info/index.tsx @@ -6,6 +6,7 @@ import * as S from "./style"; interface PersonalInfoProps { draft: PersonalInfoDraft; + email: string; errorMessage: string; isLoading: boolean; isSaving: boolean; @@ -16,6 +17,7 @@ interface PersonalInfoProps { const PersonalInfo = ({ draft, + email, errorMessage, isLoading, isSaving, @@ -69,9 +71,8 @@ const PersonalInfo = ({ id="my-page-email" type="email" autoComplete="email" - value={draft.email} - disabled={isSaving} - onChange={(event) => onFieldChange("email", event.target.value)} + value={email} + readOnly /> diff --git a/src/widgets/my-page/personal-info/style.ts b/src/widgets/my-page/personal-info/style.ts index 4fb46558..05c41ca0 100644 --- a/src/widgets/my-page/personal-info/style.ts +++ b/src/widgets/my-page/personal-info/style.ts @@ -99,6 +99,12 @@ export const EditInput = styled.input` cursor: not-allowed; opacity: 0.65; } + + &:read-only { + background-color: #f5f6f9; + color: #8a93a6; + cursor: default; + } `; export const StatusText = styled.p` From 89deb110b327939a0d8f2d8ca5b669dd4a7c2c9c Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 30 Sep 2026 12:26:44 +0900 Subject: [PATCH 06/88] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A0=80=EC=9E=A5=20=ED=9B=84=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EC=8A=A4=ED=86=A0?= =?UTF-8?q?=EC=96=B4=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /users/me가 수정된 사용자를 돌려주므로 재조회 대신 응답으로 갱신 - 응답이 비었을 때만 getMyInfo로 재조회하고, 그마저 실패하면 입력값으로 갱신 - 응답 정규화를 위해 getMyInfo의 normalizeUserMe와 응답 타입을 export Co-Authored-By: Claude Opus 5.5 --- .../my-page/personal-info/api/getMyInfo.ts | 6 ++--- .../my-page/personal-info/api/updateMyInfo.ts | 23 ++++++++++++++----- .../my-page/personal-info/model/useMyInfo.ts | 7 +++--- 3 files changed, 24 insertions(+), 12 deletions(-) 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 9b43f34a..d79322bd 100644 --- a/src/features/my-page/personal-info/api/updateMyInfo.ts +++ b/src/features/my-page/personal-info/api/updateMyInfo.ts @@ -1,5 +1,9 @@ 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"; @@ -11,15 +15,22 @@ export const updateMyInfo = async (params: UpdateMyInfoParams) => { try { - await authInstance.patch(UPDATE_MY_INFO_URL, { - username: params.name, - nickname: params.nickname, - }); + 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 f69f27a5..f429c0ab 100644 --- a/src/features/my-page/personal-info/model/useMyInfo.ts +++ b/src/features/my-page/personal-info/model/useMyInfo.ts @@ -91,7 +91,8 @@ export const useMyInfo = () => { setErrorMessage(""); setSaveMessage(""); - const { errorMessage: saveErrorMessage } = await updateMyInfo(nextDraft); + const { data: updatedUser, errorMessage: saveErrorMessage } = + await updateMyInfo(nextDraft); if (saveErrorMessage) { setIsSaving(false); @@ -99,8 +100,8 @@ export const useMyInfo = () => { return; } - // 저장은 성공했으므로 재조회가 실패하면 입력값으로 대신 갱신한다. - const { data: savedUser } = await getMyInfo(); + // 서버가 수정된 사용자를 돌려준다. 응답이 비었으면 재조회하고, 그것도 실패하면 입력값으로 갱신한다. + const savedUser = updatedUser ?? (await getMyInfo()).data; setIsSaving(false); setUser(savedUser ?? nextDraft); From 14e51838781e8e1caf115944735ed56dbb4d7341 Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 30 Sep 2026 12:26:50 +0900 Subject: [PATCH 07/88] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A7=81=EB=AC=B4=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 직군은 면접 설정의 전문 분야에서만 고르는 A안으로 API·인증 서버가 구현되어 마이페이지 직무 선택을 제거 - 포트폴리오 업로드 요청의 jobRole, MyPageMetaData.jobRole, initialJobRole 복원 로직 제거 - 사용하지 않게 된 직무 옵션 상수와 select 스타일 제거 Co-Authored-By: Claude Opus 5.5 --- src/features/my-page/api/getMyPageData.ts | 5 --- .../portfolio/model/usePortfolioForm.ts | 28 -------------- src/pages/my-page/index.tsx | 1 - src/shared/constants/my-page/index.ts | 10 ----- src/widgets/my-page/portfolio/index.tsx | 35 ----------------- src/widgets/my-page/portfolio/style.ts | 38 ------------------- 6 files changed, 117 deletions(-) delete mode 100644 src/shared/constants/my-page/index.ts diff --git a/src/features/my-page/api/getMyPageData.ts b/src/features/my-page/api/getMyPageData.ts index ba51d245..fbe2b26c 100644 --- a/src/features/my-page/api/getMyPageData.ts +++ b/src/features/my-page/api/getMyPageData.ts @@ -6,7 +6,6 @@ import { export interface MyPageMetaData { gitUrls: string[]; fileUrl: string; - jobRole?: string; } interface MetaDataResponse { @@ -40,13 +39,11 @@ export const getMyPageMetaData = async () => { interface UploadMyPageMetaDataParams { file: File; gitUrls: string[]; - jobRole: string; } export const uploadMyPageMetaData = async ({ file, gitUrls, - jobRole, }: UploadMyPageMetaDataParams) => { const authorizationHeader = await ensureAccessToken(); const formData = new FormData(); @@ -54,7 +51,6 @@ export const uploadMyPageMetaData = async ({ gitUrls.forEach((gitUrl) => { formData.append("gitUrls", gitUrl); }); - formData.append("jobRole", jobRole); await apiInstance.post( META_DATA_UPLOAD_URL, @@ -69,6 +65,5 @@ export const uploadMyPageMetaData = async ({ return { gitUrls, fileUrl: "", - jobRole, } satisfies MyPageMetaData; }; diff --git a/src/features/my-page/portfolio/model/usePortfolioForm.ts b/src/features/my-page/portfolio/model/usePortfolioForm.ts index 4e8b1fcb..b700bb93 100644 --- a/src/features/my-page/portfolio/model/usePortfolioForm.ts +++ b/src/features/my-page/portfolio/model/usePortfolioForm.ts @@ -10,7 +10,6 @@ import { extractErrorMessage } from "@/shared/api/errorMessage"; interface UsePortfolioFormOptions { initialGitUrls?: string[]; - initialJobRole?: string; onUploadSuccess?: (metaData: MyPageMetaData) => void; } @@ -22,13 +21,10 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { 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) { @@ -42,14 +38,6 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { setGitUrls(nextGitUrls); }, [options.initialGitUrls]); - useEffect(() => { - if (!options.initialJobRole || isJobRoleEditedRef.current) { - return; - } - - setJobRole(options.initialJobRole); - }, [options.initialJobRole]); - const clearSaveStatus = () => { setSaveMessage(""); setSaveError(""); @@ -124,13 +112,6 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { clearSaveStatus(); }; - const handleJobRoleChange = (event: ChangeEvent) => { - isJobRoleEditedRef.current = true; - setJobRole(event.target.value); - setJobRoleError(""); - clearSaveStatus(); - }; - const validateBeforeSave = () => { let isValid = true; @@ -144,11 +125,6 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { isValid = false; } - if (!jobRole) { - setJobRoleError("직무를 선택해주세요."); - isValid = false; - } - return isValid; }; @@ -169,7 +145,6 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { const uploadedMetaData = await uploadMyPageMetaData({ file: portfolioFile, gitUrls, - jobRole, }); // 업로드는 성공했으므로 재조회가 실패하면 입력값으로 대신 갱신한다. const nextMetaData = await getMyPageMetaData().catch( @@ -193,13 +168,10 @@ export const usePortfolioForm = (options: UsePortfolioFormOptions = {}) => { gitInput, gitUrls, isSaving, - jobRole, - jobRoleError, onGitAdd: handleGitAdd, onGitInputChange: handleGitInputChange, onGitInputKeyDown: handleGitInputKeyDown, onGitRemove: handleGitRemove, - onJobRoleChange: handleJobRoleChange, onPortfolioFileChange: handlePortfolioFileChange, onPortfolioSave: handlePortfolioSave, onPortfolioUploadClick: handlePortfolioUploadClick, diff --git a/src/pages/my-page/index.tsx b/src/pages/my-page/index.tsx index 98cc114e..be0ac068 100644 --- a/src/pages/my-page/index.tsx +++ b/src/pages/my-page/index.tsx @@ -23,7 +23,6 @@ const MyPage = () => { ); const portfolioForm = usePortfolioForm({ initialGitUrls, - initialJobRole: metaData?.jobRole, onUploadSuccess: setMetaData, }); diff --git a/src/shared/constants/my-page/index.ts b/src/shared/constants/my-page/index.ts deleted file mode 100644 index 7aa2842f..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/widgets/my-page/portfolio/index.tsx b/src/widgets/my-page/portfolio/index.tsx index deb59bcc..532b18ea 100644 --- a/src/widgets/my-page/portfolio/index.tsx +++ b/src/widgets/my-page/portfolio/index.tsx @@ -1,6 +1,5 @@ import type { ChangeEvent, KeyboardEvent, RefObject } from "react"; -import { MY_PAGE_PORTFOLIO_JOB_ROLE_OPTIONS } from "@/shared/constants/my-page"; import MyPageFileImage from "@/shared/img/my-page/Repit-mypage.svg?url"; import * as S from "./style"; @@ -11,14 +10,11 @@ interface PortfolioProps { gitInput: string; gitUrls: string[]; isSaving: boolean; - jobRole: string; - jobRoleError: string; onBack: () => void; onGitAdd: () => void; onGitInputChange: (event: ChangeEvent) => void; onGitInputKeyDown: (event: KeyboardEvent) => void; onGitRemove: (gitUrl: string) => void; - onJobRoleChange: (event: ChangeEvent) => void; onPortfolioFileChange: (event: ChangeEvent) => void; onPortfolioSave: () => void; onPortfolioUploadClick: () => void; @@ -48,14 +44,11 @@ const Portfolio = ({ gitInput, gitUrls, isSaving, - jobRole, - jobRoleError, onBack, onGitAdd, onGitInputChange, onGitInputKeyDown, onGitRemove, - onJobRoleChange, onPortfolioFileChange, onPortfolioSave, onPortfolioUploadClick, @@ -149,34 +142,6 @@ const Portfolio = ({ )} - - 직무 - - - - - - {MY_PAGE_PORTFOLIO_JOB_ROLE_OPTIONS.map((option) => ( - - ))} - - - - - - {jobRoleError && ( - {jobRoleError} - )} - - {(saveMessage || saveError) && ( {saveError || saveMessage} diff --git a/src/widgets/my-page/portfolio/style.ts b/src/widgets/my-page/portfolio/style.ts index e80b90f9..70e6b2b3 100644 --- a/src/widgets/my-page/portfolio/style.ts +++ b/src/widgets/my-page/portfolio/style.ts @@ -354,44 +354,6 @@ export const GitRemoveButton = styled.button` } `; -export const SelectWrapper = styled.div` - position: relative; -`; - -export const Select = styled.select` - appearance: none; - width: 100%; - height: 2.3rem; - padding: 0 2.8rem 0 0.8rem; - border: 0.0625rem solid #d6dce5; - border-radius: 0.45rem; - outline: none; - background-color: #f9fafc; - color: #47516a; - font-size: 0.75rem; - cursor: pointer; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - - @media (min-width: 90rem) { - height: 2.5rem; - } - - &:focus-visible { - border-color: #3b82f6; - box-shadow: 0 0 0 0.14rem rgba(59, 130, 246, 0.12); - } -`; - -export const Arrow = styled.span` - position: absolute; - top: 50%; - right: 0.85rem; - color: #596076; - font-size: 0.85rem; - pointer-events: none; - transform: translateY(-50%); -`; - export const SaveStatus = styled.p<{ $isError: boolean }>` margin: 0.9rem 0 0; color: ${({ $isError }) => ($isError ? "#d64f4f" : "#1976e8")}; From 03c0fa27e5fa7aca114cc2b609534cacf731e30b Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 30 Sep 2026 12:26:57 +0900 Subject: [PATCH 08/88] =?UTF-8?q?chore:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8F=AC=ED=8A=B8=ED=8F=B4?= =?UTF-8?q?=EB=A6=AC=EC=98=A4=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - savePortfolio는 호출하는 곳 없이 jobRole을 보내던 중복 업로드 함수라 제거 - 포트폴리오 업로드는 getMyPageData의 uploadMyPageMetaData로 일원화 Co-Authored-By: Claude Opus 5.5 --- .../my-page/portfolio/api/savePortfolio.ts | 47 ------------------- 1 file changed, 47 deletions(-) delete mode 100644 src/features/my-page/portfolio/api/savePortfolio.ts 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, "포트폴리오 저장에 실패했습니다."), - }; - } - }; From 8a2a7a261b3b2ba6702d1ff34e88aa31bdb82593 Mon Sep 17 00:00:00 2001 From: gituser Date: Sat, 10 Oct 2026 19:51:03 +0900 Subject: [PATCH 09/88] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=20=EB=85=B9?= =?UTF-8?q?=ED=99=94=C2=B7=EC=97=85=EB=A1=9C=EB=93=9C=EC=99=80=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A2=85=EB=A3=8C=20=ED=9D=90=EB=A6=84=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/main.tsx | 3 + .../interview-page/interview/api/index.ts | 1 + .../interview/api/type/prepare-interview.ts | 1 + .../interview/api/upload-recording/index.ts | 30 +- .../model/activeInterviewLifecycle.ts | 11 + .../model/interviewRecordingStore.ts | 89 ++++-- .../interview/model/mediaRecording.ts | 4 +- .../model/uploadRecordingInBackground.ts | 26 -- .../interview/model/useAnswerRecorder.ts | 4 + .../interview/model/useInterviewMedia.ts | 7 + .../interview/model/useInterviewRecorder.ts | 163 +--------- .../interview/model/useInterviewSession.ts | 297 +++++++++++------- .../interview/model/useMediaRecorder.ts | 95 ++++++ src/pages/interview-page/interview/index.tsx | 93 ++---- src/shared/components/header/index.tsx | 64 ++-- src/shared/components/header/style.ts | 8 +- .../interview/interview-content/index.tsx | 29 +- .../interview/interview-dashboard/index.tsx | 116 ++----- src/widgets/recording-uploads/index.tsx | 46 +++ src/widgets/recording-uploads/style.ts | 32 ++ 20 files changed, 550 insertions(+), 569 deletions(-) create mode 100644 src/features/interview-page/interview/model/activeInterviewLifecycle.ts delete mode 100644 src/features/interview-page/interview/model/uploadRecordingInBackground.ts create mode 100644 src/features/interview-page/interview/model/useAnswerRecorder.ts create mode 100644 src/features/interview-page/interview/model/useMediaRecorder.ts create mode 100644 src/widgets/recording-uploads/index.tsx create mode 100644 src/widgets/recording-uploads/style.ts diff --git a/src/app/main.tsx b/src/app/main.tsx index 44bf9843..486b25aa 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -6,6 +6,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 +15,7 @@ createRoot(document.getElementById("root")!).render( }> + , ); diff --git a/src/features/interview-page/interview/api/index.ts b/src/features/interview-page/interview/api/index.ts index f954b0e1..fb6156f2 100644 --- a/src/features/interview-page/interview/api/index.ts +++ b/src/features/interview-page/interview/api/index.ts @@ -27,6 +27,7 @@ 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/type/prepare-interview.ts b/src/features/interview-page/interview/api/type/prepare-interview.ts index ad8ca76a..8bc56668 100644 --- a/src/features/interview-page/interview/api/type/prepare-interview.ts +++ b/src/features/interview-page/interview/api/type/prepare-interview.ts @@ -75,5 +75,6 @@ export interface PreparedInterviewData { currentQuestionIndex: number; questions: PrepareInterviewQuestion[]; mode: PreparedInterviewMode; + answerMode?: "voice" | "text"; interviewers?: PreparedInterviewer[]; } 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..638b1773 --- /dev/null +++ b/src/features/interview-page/interview/model/activeInterviewLifecycle.ts @@ -0,0 +1,11 @@ +type CloseInterview = () => Promise; +let closeInterview: CloseInterview | null = null; + +export const registerInterviewLifecycle = (close: CloseInterview) => { + closeInterview = close; + return () => { + if (closeInterview === close) closeInterview = null; + }; +}; + +export const finishActiveInterview = async () => { await closeInterview?.(); }; 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/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/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..30c6b23f 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, @@ -18,12 +18,15 @@ import { INTERVIEW_STATUS_MESSAGES, } from "@/shared/constants/interview-page/interview"; -import type { InterviewMode } from "@/widgets/interview-page/interview/type"; import { useElevenLabsTts } from "./useElevenLabsTts"; import { useSupertoneTts } from "./useSupertoneTts"; import { useInterviewElapsedTime } from "./useInterviewElapsedTime"; import { useInterviewMedia } from "./useInterviewMedia"; import { useInterviewRecorder } from "./useInterviewRecorder"; +import { useAnswerRecorder } from "./useAnswerRecorder"; +import { enqueueRecordingUpload, reportRecordingFailure } from "./interviewRecordingStore"; +import { registerInterviewLifecycle } from "./activeInterviewLifecycle"; +import type { RecordingEndReason } from "../api/upload-recording"; import { useInterviewSocket } from "./useInterviewSocket"; import { useVoiceAnswer } from "./useVoiceAnswer"; import { useVoiceLevel } from "./useVoiceLevel"; @@ -249,18 +252,20 @@ 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 [isAwaitingNextQuestion, setIsAwaitingNextQuestion] = useState(false); const [preparationError, setPreparationError] = useState(null); + const [answerError, setAnswerError] = useState(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 && !isClosing); + const fullRecorder = useInterviewRecorder(cloneVideoTrack, cloneAudioTrack); + const recorder = useAnswerRecorder(cloneAudioTrack); + const { stopRecording: stopAnswerRecording } = recorder; + const { startRecording: startFullRecording, stopRecording: stopFullRecording } = fullRecorder; const voiceLevel = useVoiceLevel(micStream); const voiceAnswer = useVoiceAnswer(); const multiTtsSpeaker = useMemo(() => { @@ -321,9 +326,23 @@ export const useInterviewSession = ( ); const supertoneTts = useSupertoneTts(currentQuestion?.content ?? ""); const questionTts = ttsProvider === "elevenlabs" ? elevenLabsTts : supertoneTts; + const controlsRef = useRef({ questionTts, voiceAnswer }); + useEffect(() => { controlsRef.current = { questionTts, voiceAnswer }; }); const sessionId = preparedInterview?.sessionId ?? getActiveInterviewSessionId(); const isSessionClosedRef = useRef(false); - const hasPreparedChatSessionRef = useRef(false); + const isFinalizingRef = useRef(false); + const closePromiseRef = useRef | null>(null); + 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); @@ -370,7 +389,6 @@ export const useInterviewSession = ( useEffect(() => { dispatch({ type: "RESET_SESSION", preparedInterview }); isSessionClosedRef.current = false; - hasPreparedChatSessionRef.current = false; }, [preparedInterview]); useEffect(() => { @@ -389,17 +407,18 @@ export const useInterviewSession = ( // 새 질문(꼬리질문 포함)이 도착했다면 HTTP·소켓 경로와 무관하게 응답 대기를 끝낸다. clearAwaitingResponseTimeout(); setIsAwaitingNextQuestion(false); - void questionTts.onPlay(); + if (isVoiceMode && !isSessionClosedRef.current) void questionTts.onPlay(); }, [ clearAwaitingResponseTimeout, currentQuestion, isChatSessionReady, + isVoiceMode, questionTts, ]); const syncCurrentQuestion = useCallback( (nextQuestion: CurrentInterviewQuestion) => { - dispatch({ type: "SYNC_QUESTION", question: nextQuestion }); + if (!isSessionClosedRef.current) dispatch({ type: "SYNC_QUESTION", question: nextQuestion }); }, [dispatch], ); @@ -411,48 +430,115 @@ 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]); - 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; + closeReasonRef.current = reason; + setIsClosing(true); + isFinalizingRef.current = true; + clearAwaitingResponseTimeout(); + controlsRef.current.questionTts.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: "전체 영상 파일을 만들지 못했습니다. 녹화 중 장치 연결과 브라우저 지원 여부를 확인해주세요.", + }); + } + }), + ]); + if (activeSessionId && reason === "quit") await quitInterview(activeSessionId); + } finally { + isFinalizingRef.current = false; + releaseMedia(); + clearActiveInterviewSessionId(); + if (activeSessionId) disconnectInterviewSocket(activeSessionId); + } + })(); } - - disconnectInterviewSocket(activeSessionId); - - if (shouldNavigateToMain) { - navigate(nextPath, { - state: - reason === "completed" - ? { answeredQuestionCount } - : undefined, + await closePromiseRef.current; + if (shouldNavigate && closeReasonRef.current === reason && !hasNavigatedRef.current) { + hasNavigatedRef.current = true; + navigate(getInterviewExitPath(reason), { + state: reason === "completed" ? { answeredQuestionCount: displayQuestionNumberRef.current } : undefined, }); } }, - [getInterviewExitPath, navigate], + [clearAwaitingResponseTimeout, finishAnswerRecording, stopFullRecording, + getInterviewExitPath, navigate, preparedInterview?.interviewId, releaseMedia], ); + useEffect(() => { + if (!isVoiceMode || !canSubmitAnswer || cameraState !== "ready" || micState !== "ready" || + fullRecordingStartedRef.current || isSessionClosedRef.current) return; + fullRecordingStartedRef.current = true; + 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 (!isVoiceMode || (isSessionClosedRef.current && !isFinalizingRef.current)) 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, isVoiceMode]); + + const blocker = useBlocker(({ currentLocation, nextLocation }) => + !isSessionClosedRef.current && currentLocation.pathname !== nextLocation.pathname); + useEffect(() => { + if (blocker.state !== "blocked") return; + void endInterviewSession(false, "quit").then(() => blocker.proceed()); + }, [blocker, endInterviewSession]); + const handleSocketStatusChange = useCallback( ({ question, @@ -462,6 +548,8 @@ export const useInterviewSession = ( question: CurrentInterviewQuestion | null; status: InterviewProgressStatus | null; }) => { + if (isSessionClosedRef.current) return; + if (question) { syncCurrentQuestion(question); } @@ -507,7 +595,7 @@ export const useInterviewSession = ( preparedInterview.interviewId, ); - if (isCancelled) { + if (isCancelled || isSessionClosedRef.current) { return; } @@ -519,7 +607,6 @@ export const useInterviewSession = ( preparingSessionIdRef.current = null; setActiveInterviewSessionId(sessionId); sessionIdRef.current = sessionId; - hasPreparedChatSessionRef.current = true; const firstQuestion = data.questions[0]; @@ -569,66 +656,41 @@ export const useInterviewSession = ( }; }, [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) { - return; - } - - questionTts.onStop(); - - if (isVoiceMode) { - voiceAnswer.onExitVoiceMode(); - } - - setMode(nextMode); - }; - const handleStartVoice = async () => { - if (!isChatSessionReady) { - return false; - } - - if (isSubmitting || isAwaitingNextQuestion || !canSubmitAnswer) { - return false; - } - + if (!isVoiceMode || isSessionClosedRef.current || isCompletingVoiceRef.current || + submittingRef.current || isAwaitingNextQuestion || !canSubmitAnswer || + cameraState !== "ready" || micState !== "ready" || !fullRecorder.isRecording) return false; questionTts.onStop(); - if (!(await recorder.startRecording())) return false; + if (!recorder.startRecording()) return false; + answerRecordingRef.current = { question: currentQuestion, startedAt: questionStartedAtRef.current }; + 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 = questionStartedAtRef.current, + ) => { + 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,22 +704,23 @@ 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; @@ -701,50 +764,53 @@ 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); - }; - - const handleQuitInterview = async () => { - questionTts.onStop(); - voiceAnswer.onExitVoiceMode(); - await endInterviewSession(true, "quit"); + if (!isVoiceMode) await submitAnswer(voiceAnswer.answerText); }; + const handleQuitInterview = () => endInterviewSession(true, "quit"); return { answerStatus: isVoiceMode ? voiceAnswer.voiceStatus : INTERVIEW_STATUS_MESSAGES.text, answerText: voiceAnswer.answerText, cameraState, recorder, + recordingErrorMessage: recorder.errorMessage ?? fullRecorder.errorMessage, + answerError, currentQuestion, displayQuestionNumber, elapsedSeconds, interviewId: preparedInterview?.interviewId ?? null, isAwaitingNextQuestion, - isAwaitingResponse: isSubmitting || isAwaitingNextQuestion || recorder.isStopping, + isAwaitingResponse: 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, @@ -754,8 +820,7 @@ export const useInterviewSession = ( ttsSpeakerPersonaId: multiTtsSpeaker?.personaId, onAnswerTextChange: voiceAnswer.onAnswerTextChange, onClearAnswer: voiceAnswer.onClearAnswer, - onCompleteVoice: handleCompleteVoice, - onModeChange: handleModeChange, + onCompleteVoice: () => handleCompleteVoice("user"), onQuitInterview: handleQuitInterview, onStartVoice: handleStartVoice, onSubmitText: handleSubmitText, 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..33f782bd --- /dev/null +++ b/src/features/interview-page/interview/model/useMediaRecorder.ts @@ -0,0 +1,95 @@ +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 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, startRecording, stopRecording }; +}; diff --git a/src/pages/interview-page/interview/index.tsx b/src/pages/interview-page/interview/index.tsx index 07ebaf91..6bc6c794 100644 --- a/src/pages/interview-page/interview/index.tsx +++ b/src/pages/interview-page/interview/index.tsx @@ -1,11 +1,10 @@ -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { useLocation } 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"; @@ -47,6 +46,7 @@ const InterviewPage = () => { return ( @@ -58,34 +58,25 @@ 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) { return ( @@ -111,44 +102,8 @@ const InterviewPageContent = () => { 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 ( @@ -161,32 +116,13 @@ const InterviewPageContent = () => { {formatElapsedTime(elapsedSeconds)} - - handleModeChange("text")} - > - 텍스트 - - handleModeChange("voice")} - > - 음성 - - + ) : null} - + {isVoiceMode && ( @@ -199,15 +135,22 @@ 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/shared/components/header/index.tsx b/src/shared/components/header/index.tsx index db26c599..e8fd401b 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 { useRef, useState } from "react"; +import { finishActiveInterview } 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,9 @@ import * as S from "./style"; const Header = () => { const navigate = useNavigate(); const location = useLocation(); + const actionRef = useRef(false); + const [isLeaving, setIsLeaving] = useState(false); + const [logoutError, setLogoutError] = useState(null); const pathname = location.pathname.toLowerCase().replace(/\/+$/, "") || "/"; useEnsureCurrentUser(); @@ -32,33 +32,29 @@ const Header = () => { pathname.startsWith("/main/feedback/overall/"); const isMypage = pathname === "/main/mypage"; - 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 { + await finishActiveInterview(); + if (logout) { + if (!(await waitForRecordingUploads())) { + setLogoutError("업로드에 실패한 파일이 있습니다. 다시 시도하거나 파일을 폐기한 뒤 로그아웃해주세요."); + return; + } + clearAccessToken(); + resetUser(); + } + 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 ( @@ -66,18 +62,21 @@ const Header = () => { handleNavigate("/main")} $active={isInterviewPage} > 면접 handleNavigate("/main/feedback/list")} $active={isFeedbackPage} > 피드백 handleNavigate("/main/mypage")} $active={isMypage} > @@ -86,9 +85,10 @@ const Header = () => { - 로그아웃 + {isLeaving ? "저장 중..." : "로그아웃"} {displayName}님 + {logoutError ? {logoutError} : null} ); }; diff --git a/src/shared/components/header/style.ts b/src/shared/components/header/style.ts index 9a08bcbc..987fb968 100644 --- a/src/shared/components/header/style.ts +++ b/src/shared/components/header/style.ts @@ -197,4 +197,10 @@ 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}; +`; diff --git a/src/widgets/interview-page/interview/interview-content/index.tsx b/src/widgets/interview-page/interview/interview-content/index.tsx index 2d6a4394..6de4309f 100644 --- a/src/widgets/interview-page/interview/interview-content/index.tsx +++ b/src/widgets/interview-page/interview/interview-content/index.tsx @@ -1,6 +1,5 @@ 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"; const TEXT_ANSWER_MAX_LENGTH = 10000; @@ -35,11 +34,7 @@ const formatQuestionLabel = ({ )}`; }; -interface InterviewContentViewProps { - onModeChange: (mode: InterviewMode) => void; -} - -const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { +const InterviewContentView = () => { const { answerText, currentQuestion, @@ -107,26 +102,8 @@ const InterviewContentView = ({ onModeChange }: InterviewContentViewProps) => { {mode === "text" ? ( - - onModeChange("text")} - > - 텍스트 - - onModeChange("voice")} - > - 음성 - - + 텍스트 답변 + const InterviewDashboard = () => { const interview = useInterviewSessionContext(); const recorder = interview.recorder; - const [isVoiceAnswering, setIsVoiceAnswering] = useState(false); const elapsedSeconds = interview.elapsedSeconds; const sessionId = interview.preparedInterview?.sessionId; const memoKey = useMemo(() => getMemoKey(sessionId), [sessionId]); @@ -37,10 +35,10 @@ const InterviewDashboard = () => { const isQuestionLoading = interview.currentQuestion === null; const isQuestionSpeaking = interview.questionAudioStatus === "playing"; const speakingPersonaId = interview.speakingPersonaId; - const lastSpeakingPersonaIdRef = useRef(undefined); + const [lastSpeakingPersonaId, setLastSpeakingPersonaId] = useState(undefined); // 질문이 도착한 뒤 다음 질문이 올 때까지 같은 면접관을 강조한다. // 질문 로딩 구간에는 personaId가 비므로 직전 발화자를 유지해 강조가 끊기지 않게 한다. - const activePersonaId = speakingPersonaId ?? lastSpeakingPersonaIdRef.current; + const activePersonaId = speakingPersonaId ?? lastSpeakingPersonaId; const interviewers = interview.preparedInterview?.interviewers ?? []; const activeInterviewer = interviewers.find( (interviewer) => interviewer.personaId === activePersonaId, @@ -55,24 +53,14 @@ const InterviewDashboard = () => { : `${questionNumber}-${interview.followUpQuestionNumber}`; const isAnswerDisabled = !interview.isInterviewReady || isQuestionLoading || isAwaitingResponse || - recorder.isStarting || recorder.isStopping; + recorder.isStarting || recorder.isStopping || (isVoiceMode && !interview.isMediaReady); const isCameraReady = interview.cameraState === "ready"; const isMicReady = interview.micState === "ready"; const micLevel = isMicReady ? interview.voiceLevel : 0; - useEffect(() => { - lastSpeakingPersonaIdRef.current = undefined; - }, [sessionId]); - - useEffect(() => { - if (speakingPersonaId !== undefined) { - lastSpeakingPersonaIdRef.current = speakingPersonaId; - } - }, [speakingPersonaId]); - - useEffect(() => { - setMemo(memoKey ? window.sessionStorage.getItem(memoKey) ?? "" : ""); - }, [memoKey]); + if (speakingPersonaId !== undefined && speakingPersonaId !== lastSpeakingPersonaId) { + setLastSpeakingPersonaId(speakingPersonaId); + } useEffect(() => { if (memoKey) { @@ -80,55 +68,9 @@ const InterviewDashboard = () => { } }, [memo, memoKey]); - const handleModeChange = (mode: typeof interview.mode) => { - if (mode === "text") { - void recorder.stopRecording(); - setIsVoiceAnswering(false); - } - - interview.onModeChange(mode); - }; - - const handleStartVoice = async () => { - if (isAwaitingResponse) { - return; - } - - if (!(await interview.onStartVoice())) return; - setIsVoiceAnswering(true); - }; - - const handleCompleteVoice = async () => { - if (isAwaitingResponse) { - return; - } - - // onCompleteVoice 이후에는 다음 질문으로 바뀔 수 있으므로 먼저 저장한다. - const answeredQuestionId = interview.currentQuestion?.questionId; - - try { - const file = await recorder.stopRecording(); - uploadRecordingInBackground({ - file, - interviewId: interview.interviewId, - questionId: answeredQuestionId, - }); - await interview.onCompleteVoice(); - } finally { - setIsVoiceAnswering(false); - } - }; - - const handleQuitInterview = async () => { - const answeredQuestionId = interview.currentQuestion?.questionId; - const file = await recorder.stopRecording(); - uploadRecordingInBackground({ - file, - interviewId: interview.interviewId, - questionId: answeredQuestionId, - }); - await interview.onQuitInterview(); - }; + const handleStartVoice = interview.onStartVoice; + const handleCompleteVoice = interview.onCompleteVoice; + const handleQuitInterview = interview.onQuitInterview; return ( @@ -176,26 +118,7 @@ const InterviewDashboard = () => { - - handleModeChange("voice")} - > - 영상/음성 답변 - - handleModeChange("text")} - > - 텍스트 답변 - - + {isVoiceMode ? "음성/영상 답변 · 210초 자동 완료" : "텍스트 답변"} {isVoiceMode ? ( @@ -231,26 +154,29 @@ const InterviewDashboard = () => { /> )} - {recorder.errorMessage ? ( + {interview.recordingErrorMessage ? ( - {recorder.errorMessage} + {interview.recordingErrorMessage} ) : null} + {interview.answerError ? {interview.answerError} : null} + {isVoiceMode && (interview.cameraState === "blocked" || interview.micState === "blocked") ? ( + 카메라와 마이크 권한을 허용해주세요. 텍스트 면접은 사전설정에서 선택할 수 있습니다. + ) : null} 글자 수 {interview.answerText.length} / {TEXT_ANSWER_MAX_LENGTH} - {!isMultiInterview ? ( - void handleQuitInterview()} > 그만두기 - - ) : null} + {isVoiceMode ? ( - isVoiceAnswering || interview.isVoiceStarted ? ( + interview.isVoiceStarted ? ( { + const jobs = useRecordingUploadStore((state) => state.jobs); + useEffect(() => { + const warnBeforeUnload = (event: BeforeUnloadEvent) => { + if (!hasUnsavedRecordings()) return; + event.preventDefault(); + event.returnValue = ""; + }; + window.addEventListener("beforeunload", warnBeforeUnload); + return () => window.removeEventListener("beforeunload", warnBeforeUnload); + }, []); + if (!jobs.length) return null; + return ( + + {jobs.map((job) => ( + +
+ 면접 {job.interviewId} · {job.kind === "ANSWER" ? `질문 ${job.questionId} 음성` : "전체 영상"} + + {job.status === "UPLOADING" ? "업로드 중입니다. 새로고침하거나 탭을 닫지 마세요." + : job.status === "FAILED" ? job.errorMessage : "업로드 완료"} + +
+ {job.status === "FAILED" && job.canRetry && ( + retryRecordingUpload(job.id)}>다시 시도 + )} + {job.status !== "UPLOADING" && ( + dismissRecordingUpload(job.id)}> + {job.status === "FAILED" && job.canRetry ? "파일 폐기" : "닫기"} + + )} +
+ ))} +
+ ); +}; +export default RecordingUploads; diff --git a/src/widgets/recording-uploads/style.ts b/src/widgets/recording-uploads/style.ts new file mode 100644 index 00000000..6d78b3f3 --- /dev/null +++ b/src/widgets/recording-uploads/style.ts @@ -0,0 +1,32 @@ +import styled from "styled-components"; + +export const Panel = styled.aside` + position: fixed; + right: ${({ theme }) => theme.spacing.md}; + bottom: ${({ theme }) => theme.spacing.md}; + z-index: 1000; + width: min(400px, calc(100vw - 2rem)); + max-height: 35vh; + overflow-y: auto; + padding: ${({ theme }) => theme.spacing.md}; + border-radius: ${({ theme }) => theme.radius.md}; + background: ${({ theme }) => theme.colors.surface.background}; + color: ${({ theme }) => theme.colors.text.default}; + box-shadow: 0 4px 24px ${({ theme }) => theme.colors.shadow.blue}; + font-size: ${({ theme }) => theme.fontSize.sm}; +`; +export const Job = styled.div` + & + & { margin-top: ${({ theme }) => theme.spacing.md}; } +`; +export const Message = styled.p` + margin: ${({ theme }) => theme.spacing.sm} 0; +`; +export const Button = styled.button` + margin-right: ${({ theme }) => theme.spacing.sm}; + padding: ${({ theme }) => theme.spacing.xs} ${({ theme }) => theme.spacing.sm}; + border: 0; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blueAction}; + cursor: pointer; +`; From bd2d981f73d16ac12d977b55f10d385f6bd549f3 Mon Sep 17 00:00:00 2001 From: gituser Date: Sat, 10 Oct 2026 19:51:08 +0900 Subject: [PATCH 10/88] =?UTF-8?q?feat:=20=EC=82=AC=EC=A0=84=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=97=90=EC=84=9C=20=EC=9D=8C=EC=84=B1=C2=B7=EC=98=81?= =?UTF-8?q?=EC=83=81=20=EB=B0=8F=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=84=A0=ED=83=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/useInterviewSetup.ts | 4 ++++ .../model/useMultiInterviewSetup.ts | 4 ++++ .../setting-interview/index.tsx | 2 ++ .../setting-multi-interview/index.tsx | 2 ++ .../interview-page/setting-interview/index.ts | 5 +++++ .../setting-options/index.tsx | 21 ++++++++++++++++++- .../setting-interview/setting-options/type.ts | 2 ++ .../interview-page/setting-interview/style.ts | 15 +++++++++++++ 8 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index c0aa8cab..e104d7d0 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -80,6 +80,7 @@ const buildUniquePersonaName = () => export const useInterviewSetup = () => { const navigate = useNavigate(); + const [answerMode, setAnswerMode] = useState<"voice" | "text">("voice"); const [selectedStyle, setSelectedStyle] = useState( INTERVIEW_SETTING_DEFAULT_SELECTION.style, @@ -200,6 +201,7 @@ export const useInterviewSetup = () => { currentQuestionIndex: data.currentQuestionIndex, questions: [], mode: "SOLO", + answerMode, interviewers: [ { personaId, @@ -229,6 +231,8 @@ export const useInterviewSetup = () => { }; return { + answerMode, + onAnswerModeChange: setAnswerMode, errorMessage, isNextDisabled: isSubmitting, isPortfolioAnalyzing: false, 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..b022b319 100644 --- a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts +++ b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts @@ -14,6 +14,7 @@ const buildPersonaName = (template: InterviewerTemplate) => `${template.key}-${c export const useMultiInterviewSetup = () => { const navigate = useNavigate(); + const [answerMode, setAnswerMode] = useState<"voice" | "text">("voice"); const [style, setStyle] = useState(INTERVIEW_SETTING_DEFAULT_SELECTION.style); const [difficulty, setDifficulty] = useState(INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty); const [activeSlot, setActiveSlot] = useState(0); @@ -155,6 +156,7 @@ export const useMultiInterviewSetup = () => { currentQuestionIndex: createdInterview.currentQuestionIndex, questions: [], mode: "MULTI", + answerMode, interviewers: selectedSlots.map((interviewer, index) => ({ personaId: savedPersonaIds[index], name: interviewer.name, @@ -184,6 +186,8 @@ export const useMultiInterviewSetup = () => { }; return { + answerMode, + onAnswerModeChange: setAnswerMode, errorMessage, isPortfolioAnalyzing: false, isSubmitting, isNextDisabled: isSubmitting, onBack: () => navigate(-1), onNext: handleNext, updateSlot, diff --git a/src/pages/interview-page/setting-interview/index.tsx b/src/pages/interview-page/setting-interview/index.tsx index 2b1463b2..bb5c2364 100644 --- a/src/pages/interview-page/setting-interview/index.tsx +++ b/src/pages/interview-page/setting-interview/index.tsx @@ -12,6 +12,8 @@ const SettingInterviewPage = () => { diff --git a/src/pages/interview-page/setting-multi-interview/index.tsx b/src/pages/interview-page/setting-multi-interview/index.tsx index 49a33600..9005488b 100644 --- a/src/pages/interview-page/setting-multi-interview/index.tsx +++ b/src/pages/interview-page/setting-multi-interview/index.tsx @@ -16,6 +16,8 @@ const SettingMultiInterviewPage = () => { ( +const SettingOptions = ({ onSelect, selection, answerMode, onAnswerModeChange }: SettingOptionsProps) => ( + + 답변 방식 + + {INTERVIEW_ANSWER_MODE_OPTIONS.map((option) => ( + onAnswerModeChange(option.value)}> + + {option.label} + {option.description} + + + ))} + + void; onSelect: Pick; selection: Pick; } diff --git a/src/widgets/interview-page/setting-interview/style.ts b/src/widgets/interview-page/setting-interview/style.ts index d2e0b232..f4831ea4 100644 --- a/src/widgets/interview-page/setting-interview/style.ts +++ b/src/widgets/interview-page/setting-interview/style.ts @@ -579,3 +579,18 @@ export const LoadingMessage = styled.p` font-weight: 700; line-height: 1.4; `; + +export const AnswerModeGroup = styled.div` + display: grid; + gap: ${({ theme }) => theme.spacing.sm}; +`; + +export const AnswerModeButton = styled(StyleOptionButton)` + grid-template-columns: minmax(0, 1fr) 1.5rem; + height: auto; + min-height: 5.5rem; + + @media (max-width: 30rem), (max-height: 44rem) { + grid-template-columns: minmax(0, 1fr) 1.35rem; + } +`; From 1e6cbeccf6c9ea979403df8424b9b70df1696000 Mon Sep 17 00:00:00 2001 From: gituser Date: Sat, 10 Oct 2026 19:51:08 +0900 Subject: [PATCH 11/88] =?UTF-8?q?test:=20=EB=A9=B4=EC=A0=91=20=EB=85=B9?= =?UTF-8?q?=ED=99=94=20=ED=9A=8C=EA=B7=80=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99=20=EB=AC=B8=EC=84=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/interview-recording.md | 70 ++++++ tests/interview-recording.browser.mjs | 298 ++++++++++++++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 docs/interview-recording.md create mode 100644 tests/interview-recording.browser.mjs diff --git a/docs/interview-recording.md b/docs/interview-recording.md new file mode 100644 index 00000000..b9b362cc --- /dev/null +++ b/docs/interview-recording.md @@ -0,0 +1,70 @@ +# 면접 녹화와 업로드 + +## 답변 방식 + +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 +``` + +`INTERVIEW_TEST_ORIGIN`으로 개발 서버 주소를 변경할 수 있다(기본 `http://127.0.0.1:5173`). +서버·소켓·전사는 모의하며 Chromium의 실제 MediaRecorder와 가상 카메라·마이크로 파일을 생성한다. +1:1·다중 답변 완료, 자동 완료, 진행 중 종료, 헤더 이동, 뒤로가기, 로그아웃, HTTP·소켓 동시 완료, +업로드 실패·재시도, 장치 권한 거부, 텍스트의 장치 미요청을 검증한다. +실제 API/S3 및 실장치 호환성 검증은 별도로 필요하다. diff --git a/tests/interview-recording.browser.mjs b/tests/interview-recording.browser.mjs new file mode 100644 index 00000000..99274c10 --- /dev/null +++ b/tests/interview-recording.browser.mjs @@ -0,0 +1,298 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE_PATH; +const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : "playwright"); +const origin = process.env.INTERVIEW_TEST_ORIGIN ?? "http://127.0.0.1:5173"; +const browser = await chromium.launch({ headless: true, args: [ + "--use-fake-device-for-media-stream", "--use-fake-ui-for-media-stream", + "--autoplay-policy=no-user-gesture-required", +] }); +const question = (id) => ({ questionId: id, parentId: 0, type: "ORIGINAL", intention: "경험", content: `질문 ${id}: 프로젝트 경험을 설명해주세요.`, personaId: 1 }); +const results = []; +const report = (result) => { results.push(result); console.log(`PASS ${result}`); }; + +async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false } = {}) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.addInitScript(({ timeout, denied, mimeMode }) => { + window.__tracks = []; + window.__captures = []; + window.__sockets = []; + window.__mediaRequests = 0; + const originalClone = MediaStreamTrack.prototype.clone; + MediaStreamTrack.prototype.clone = function () { + const track = originalClone.call(this); + window.__tracks.push(track); + return track; + }; + const originalMedia = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices); + navigator.mediaDevices.getUserMedia = async (constraints) => { + window.__mediaRequests++; + if (denied) throw new DOMException("denied", "NotAllowedError"); + const stream = await originalMedia(constraints); + window.__tracks.push(...stream.getTracks()); + return stream; + }; + const NativeRecorder = MediaRecorder; + window.MediaRecorder = class extends NativeRecorder { + static isTypeSupported(type) { + return (!mimeMode || type.includes(mimeMode)) && NativeRecorder.isTypeSupported(type); + } + constructor(stream, options) { + super(stream, options); + this.__info = { kinds: stream.getTracks().map((track) => track.kind), mimeType: this.mimeType, options, stops: 0 }; + window.__captures.push(this.__info); + } + stop() { this.__info.stops++; super.stop(); } + }; + window.SpeechRecognition = class { + start() { + this.onstart?.(); + setTimeout(() => this.onresult?.({ results: [{ isFinal: true, 0: { transcript: "팀 프로젝트에서 성능을 개선하고 문제를 해결했습니다." } }] }), 20); + } + stop() { this.onend?.(); } + abort() { this.onend?.(); } + }; + window.WebSocket = class extends EventTarget { + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; + readyState = 1; + constructor() { super(); window.__sockets.push(this); } + close() { this.readyState = 3; this.dispatchEvent(new Event("close")); } + send() {} + }; + if (timeout) { + const originalTimeout = window.setTimeout.bind(window); + window.setTimeout = (callback, delay, ...args) => originalTimeout(callback, delay === 210_000 ? 1_200 : delay, ...args); + } + }, { timeout, denied, mimeMode }); + const uploads = []; + const answers = []; + const quits = []; + const releases = []; + let current = question(101); + let shouldFail = fail; + await page.route("**/*", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/") && !pathname.startsWith("/chat/")) return route.continue(); + const json = (body, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname.endsWith("/recordings")) { + assert.match(request.headers()["content-type"], /multipart\/form-data; boundary=/); + assert.match(request.headers().authorization, /^Bearer /); + const body = request.postDataBuffer().toString("latin1"); + const field = (name) => body.match(new RegExp(`name="${name}"\\r\\n\\r\\n([^\\r]+)`))?.[1]; + uploads.push({ kind: field("kind"), questionId: field("questionId"), endReason: field("endReason"), + filename: body.match(/filename="([^"]+)"/)?.[1], type: body.match(/Content-Type: ([^\r]+)/)?.[1] }); + if (delayUploads) await new Promise((resolve) => releases.push(resolve)); + return json(shouldFail ? { message: "테스트 업로드 실패" } : { data: { recordingId: uploads.length } }, shouldFail ? 500 : 201); + } + if (pathname.endsWith("/answers")) { + answers.push(request.postDataJSON()); + current = question(102); + if (socketOnAnswer) await page.evaluate(() => window.__sockets.filter((socket) => socket.readyState === 1).forEach((socket) => socket.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ status: "COMPLETED" }) })))); + return json({ status: complete ? "COMPLETED" : "IN_PROGRESS", question: complete ? undefined : current }); + } + if (pathname.endsWith("/quit")) { quits.push(pathname); return json({ status: "ABANDONED" }); } + if (pathname.endsWith("/question")) return json(current); + if (pathname === "/api/questions/tailor") return json({ data: { status: "SUCCEEDED", chatDelivered: true, questions: [current, question(102)], interviewId: 42 } }); + if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } }); + if (pathname.includes("/elevenlabs/") || pathname.includes("/supertone/")) return json({ message: "TTS mock" }, 503); + if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); + return json({ data: [] }); + }); + await page.goto(origin); + await page.getByRole("button", { name: "면접 시작하기", exact: true }).first().waitFor(); + await page.evaluate(async ({ multi, answerMode }) => { + const { setAccessToken } = await import("/src/shared/api/accessToken.ts"); + setAccessToken("test-only-token"); + const routerUrl = performance.getEntriesByType("resource").map((entry) => entry.name).find((url) => new URL(url).pathname === "/src/app/router.tsx"); + const { default: router } = await import(routerUrl); + window.__router = router; + await router.navigate("/main/interview/42", { state: { preparedInterview: { + sessionId: "test-session", interviewId: 42, userId: 7, personaId: 1, personaName: "테스트 면접관", role: "TECH", major: "FRONTEND", + type: "FRIENDLY", personaType: "FRIENDLY", level: "EASY", career: 0, gender: "FEMALE", tone: "GENTLE", jobId: "test-job", status: "IN_PROGRESS", currentQuestionIndex: 0, + questions: [], mode: multi ? "MULTI" : "SOLO", answerMode, + interviewers: [{ personaId: 1, name: "테스트 면접관", roleLabel: "기술 면접관" }], + } } }); + }, { multi, answerMode }); + const start = page.getByRole("button", { name: multi ? "음성 답변 시작" : "시작하기", exact: true }); + const finish = page.getByRole("button", { name: multi ? "답변 끝내기" : "끝내기", exact: true }); + if (answerMode === "voice" && !denied) { + try { await start.waitFor({ state: "visible" }); } + catch (error) { + console.error("Fixture failed:", page.url(), errors, await page.locator("body").innerText()); + throw error; + } + } + return { page, context, start, finish, uploads, answers, quits, releases, errors, + recover: () => { shouldFail = false; }, + release: () => { for (const resolve of releases.splice(0)) resolve(); }, + }; +} +async function assertReleased(f) { + await f.page.waitForFunction(() => window.__tracks.every((track) => track.readyState === "ended")); + assert.deepEqual(f.errors, []); + const captures = await f.page.evaluate(() => window.__captures); + for (const capture of captures) assert.equal(capture.stops, 1, "each capture stops once"); +} +async function waitUntil(check) { + for (let i = 0; i < 100; i++) { if (check()) return; await new Promise((resolve) => setTimeout(resolve, 50)); } + assert.fail("timed out waiting for network assertion"); +} +try { + for (const multi of [false, true]) { + const f = await fixture({ multi }); + await f.start.click(); + await f.finish.waitFor(); + assert.equal(await f.page.evaluate(() => !window.dispatchEvent(new Event("beforeunload", { cancelable: true }))), true); + await f.page.waitForTimeout(250); + await f.finish.click(); + await waitUntil(() => f.answers.length === 1); + assert.deepEqual(f.uploads.map(({ kind, questionId, endReason }) => ({ kind, questionId, endReason })), [{ kind: "ANSWER", questionId: "101", endReason: "user" }]); + assert.equal(f.answers[0].questionId, 101); + assert.match(f.uploads[0].type, /^audio\//); + assert.match(f.uploads[0].filename, /\.(webm|m4a|ogg)$/); + const captures = await f.page.evaluate(() => window.__captures); + assert.deepEqual(captures[0].kinds.sort(), ["audio", "video"]); + assert.deepEqual(captures[1].kinds, ["audio"]); + assert.equal(captures[0].options.videoBitsPerSecond, 1_000_000); + await f.page.getByRole("button", { name: "그만두기", exact: true }).click(); + await f.page.waitForURL("**/main"); + await waitUntil(() => f.uploads.length === 2); + assert.equal(f.uploads[1].kind, "FULL_INTERVIEW"); + assert.equal(f.uploads[1].questionId, undefined); + assert.equal(f.uploads[1].endReason, undefined); + assert.match(f.uploads[1].type, /^video\//); + assert.ok(f.uploads[1].filename.endsWith(f.uploads[1].type.startsWith("video/mp4") ? ".mp4" : ".webm")); + await assertReleased(f); + await f.context.close(); + report(`${multi ? "MULTI" : "SOLO"}: answer + full capture, multipart contract, track cleanup`); + } + for (const action of ["quit", "header", "back", "logout"]) { + const f = await fixture({ multi: action === "header", delayUploads: action === "logout" || action === "header" }); + await f.start.click(); + await f.finish.waitFor(); + await f.page.waitForTimeout(250); + if (action === "back") await f.page.goBack(); + else await f.page.getByRole("button", { name: action === "quit" ? "그만두기" : action === "header" ? "피드백" : "로그아웃", exact: true }).click(); + await waitUntil(() => f.uploads.length === 2); + assert.equal(f.uploads.find((upload) => upload.kind === "ANSWER").endReason, "interrupted"); + assert.equal(f.quits.length, 1); + if (action === "logout") { + assert.match(f.page.url(), /interview\/42$/); + assert.ok(await f.page.evaluate(async () => (await import("/src/shared/api/accessToken.ts")).getAccessToken())); + f.release(); + await f.page.waitForURL("**/login"); + assert.equal(await f.page.evaluate(async () => (await import("/src/shared/api/accessToken.ts")).getAccessToken()), null); + } else { + await f.page.waitForURL(action === "header" ? "**/main/feedback/list" : action === "back" ? `${origin}/` : "**/main"); + if (action === "header") { + assert.equal(await f.page.getByText("업로드 중입니다.", { exact: false }).count(), 2); + assert.equal(await f.page.evaluate(() => !window.dispatchEvent(new Event("beforeunload", { cancelable: true }))), true); + f.release(); + } + } + await assertReleased(f); + await f.context.close(); + report(`${action}: interrupted audio + full video once; auth preserved until uploads finish`); + } + for (const multi of [false, true]) { + const f = await fixture({ multi, timeout: true }); + await f.start.click(); + await waitUntil(() => f.answers.length === 1); + assert.equal(f.uploads[0].endReason, "timeout"); + await f.start.waitFor(); + await f.page.getByRole("button", { name: "그만두기", exact: true }).click(); + await f.page.waitForURL("**/main"); + await assertReleased(f); + await f.context.close(); + report(`${multi ? "MULTI" : "SOLO"}: 210s automatic completion through shared path`); + } + for (const source of ["http", "socket", "both"]) { + const f = await fixture({ complete: source !== "socket", socketOnAnswer: source === "both", mimeMode: source === "socket" ? "webm" : undefined }); + await f.start.click(); + await f.finish.waitFor(); + await f.page.waitForTimeout(250); + if (source !== "socket") await f.finish.click(); + if (source === "socket") await f.page.evaluate(() => window.__sockets.filter((socket) => socket.readyState === 1).forEach((socket) => socket.dispatchEvent(new MessageEvent("message", { data: JSON.stringify({ status: "COMPLETED" }) })))); + await f.page.waitForURL("**/main/interview/completed"); + await waitUntil(() => f.uploads.length === 2); + assert.equal(f.uploads.filter((upload) => upload.kind === "FULL_INTERVIEW").length, 1); + assert.equal(f.quits.length, 0); + await assertReleased(f); + await f.context.close(); + report(`${source}: completed finalization without duplicate uploads`); + } + const mp4 = await fixture({ mimeMode: "mp4", complete: true }); + await mp4.start.click(); + await mp4.finish.waitFor(); + await mp4.page.waitForTimeout(250); + await mp4.finish.click(); + await mp4.page.waitForURL("**/main/interview/completed"); + await waitUntil(() => mp4.uploads.length === 2); + assert.match(mp4.uploads.find((upload) => upload.kind === "ANSWER").filename, /\.m4a$/); + assert.match(mp4.uploads.find((upload) => upload.kind === "FULL_INTERVIEW").filename, /\.mp4$/); + await assertReleased(mp4); + await mp4.context.close(); + report("MP4-only browser format: audio m4a + video mp4"); + const failed = await fixture({ fail: true }); + await failed.start.click(); + await failed.finish.waitFor(); + await failed.page.waitForTimeout(250); + await failed.page.getByRole("button", { name: "로그아웃", exact: true }).click(); + await failed.page.getByText("업로드에 실패한 파일이 있습니다.", { exact: false }).waitFor(); + assert.match(failed.page.url(), /interview\/42$/); + failed.recover(); + while (await failed.page.getByRole("button", { name: "다시 시도", exact: true }).count()) { + await failed.page.getByRole("button", { name: "다시 시도", exact: true }).first().click(); + } + await waitUntil(() => failed.uploads.length === 4); + await failed.page.getByRole("button", { name: "로그아웃", exact: true }).click(); + await failed.page.waitForURL("**/login"); + await assertReleased(failed); + await failed.context.close(); + report("failed upload: visible failure, retry preserved files, logout deferred"); + for (const multi of [false, true]) { + const f = await fixture({ multi, answerMode: "text", complete: true }); + await f.page.getByRole("textbox").first().fill("프로젝트 경험에 대한 텍스트 답변입니다."); + await f.page.getByRole("button", { name: "제출하기", exact: true }).click(); + await f.page.waitForURL("**/main/interview/completed"); + assert.equal(await f.page.evaluate(() => window.__mediaRequests), 0); + assert.equal(f.uploads.length, 0); + assert.equal(f.answers.length, 1); + assert.deepEqual(f.errors, []); + await f.context.close(); + report(`${multi ? "MULTI" : "SOLO"}: text mode without device permissions or recording`); + } + const denied = await fixture({ denied: true }); + await denied.page.getByText("카메라와 마이크 권한을 허용해주세요.", { exact: false }).waitFor(); + assert.equal(await denied.start.isDisabled(), true); + assert.equal(denied.uploads.length, 0); + await denied.page.getByRole("button", { name: "그만두기", exact: true }).click(); + await denied.page.waitForURL("**/main"); + await denied.context.close(); + report("permission denied: voice blocked, setup text choice remains separate"); + const f = await fixture({ answerMode: "text" }); + await f.page.getByRole("button", { name: "그만두기", exact: true }).click(); + await f.page.waitForURL("**/main"); + for (const path of ["/main/setting/interview", "/main/setting/interview/multi"]) { + await f.page.evaluate((path) => window.__router.navigate(path), path); + const textChoice = f.page.getByRole("button", { name: "단순 텍스트 면접", exact: false }); + await textChoice.click(); + const voiceChoice = f.page.getByRole("button", { name: "음성/영상 면접", exact: false }); + const labelBox = await voiceChoice.locator("span").first().boundingBox(); + assert.ok(labelBox.width > 200, "answer mode text has enough width"); + assert.equal(await textChoice.getAttribute("aria-pressed"), "true"); + await f.page.screenshot({ path: `/tmp/repit-${path.endsWith("multi") ? "multi" : "solo"}-setup.png`, fullPage: true }); + } + await f.context.close(); + report("SOLO + MULTI setup: selectable fixed answer modes"); + console.log(`${results.length} browser scenarios passed`); +} finally { await browser.close(); } From 09a86ee42e6d3f5eaae7499ec96d02b495bd0df4 Mon Sep 17 00:00:00 2001 From: han0911 Date: Thu, 17 Sep 2026 20:32:24 +0900 Subject: [PATCH 12/88] feat: use male voice for pressure interviews --- .../model/interviewSessionContext.tsx | 9 +- .../interview/model/useElevenLabsTts.ts | 146 ++++++++++++++++-- .../interview/model/useInterviewSession.ts | 14 +- src/pages/interview-page/interview/index.tsx | 26 ++++ 4 files changed, 183 insertions(+), 12 deletions(-) diff --git a/src/features/interview-page/interview/model/interviewSessionContext.tsx b/src/features/interview-page/interview/model/interviewSessionContext.tsx index c78ff2b2..7f5115b0 100644 --- a/src/features/interview-page/interview/model/interviewSessionContext.tsx +++ b/src/features/interview-page/interview/model/interviewSessionContext.tsx @@ -1,6 +1,7 @@ import { type ReactNode } from "react"; import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; +import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import { InterviewSessionContext } from "./interviewSessionContextValue"; import { useInterviewSession, @@ -9,16 +10,22 @@ import { interface InterviewSessionProviderProps { children: ReactNode; + interviewStyle?: InterviewStyleOption; preparedInterview?: PreparedInterviewData | null; ttsProvider?: InterviewTtsProvider; } export const InterviewSessionProvider = ({ children, + interviewStyle, preparedInterview, ttsProvider = "elevenlabs", }: InterviewSessionProviderProps) => { - const session = useInterviewSession(preparedInterview, ttsProvider); + const session = useInterviewSession( + preparedInterview, + ttsProvider, + interviewStyle, + ); return ( diff --git a/src/features/interview-page/interview/model/useElevenLabsTts.ts b/src/features/interview-page/interview/model/useElevenLabsTts.ts index dd98ccc3..ad18451d 100644 --- a/src/features/interview-page/interview/model/useElevenLabsTts.ts +++ b/src/features/interview-page/interview/model/useElevenLabsTts.ts @@ -1,3 +1,8 @@ +import type { + InterviewPersonality, + InterviewTone, +} from "../api/type/create-interview"; +import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import type { QuestionAudioStatus } from "@/widgets/interview-page/interview/type"; import { useEffect, useRef, useState } from "react"; @@ -12,6 +17,106 @@ const ELEVENLABS_MODEL_ID = "eleven_multilingual_v2"; const ELEVENLABS_OUTPUT_FORMAT = "mp3_44100_128"; const ELEVENLABS_SPEECH_SPEED = 1.1; +export interface InterviewerSpeechStyle { + interviewStyle?: InterviewStyleOption; + personality?: InterviewPersonality; + tone?: InterviewTone; +} + +interface VoiceSettings { + stability: number; + similarity_boost: number; + style: number; + speed: number; + use_speaker_boost: boolean; +} + +const DEFAULT_VOICE_SETTINGS: VoiceSettings = { + stability: 0.55, + similarity_boost: 0.8, + style: 0.1, + speed: ELEVENLABS_SPEECH_SPEED, + use_speaker_boost: true, +}; + +const TONE_PRESETS: Record< + InterviewTone, + Pick +> = { + GENTLE: { stability: 0.72, style: 0.04, speed: 1.02 }, + DIRECT: { stability: 0.42, style: 0.38, speed: 1.13 }, + // 낮은 안정성 + 강한 style + 빠른 속도로 긴장감과 압박감을 크게 높인다. + PRESSURING: { stability: 0.2, style: 0.82, speed: 1.17 }, +}; + +const INTERVIEW_STYLE_PRESETS: Record< + InterviewStyleOption, + Pick +> = { + 편함: { stability: 0.74, style: 0.04, speed: 1.02 }, + 일반: { stability: 0.55, style: 0.18, speed: 1.1 }, + 압박: { stability: 0.05, style: 1, speed: 1.17 }, +}; + +const PERSONALITY_ADJUSTMENTS: Record< + InterviewPersonality, + Partial> +> = { + FRIENDLY: { style: -0.04, speed: -0.02 }, + REALISTIC: { style: 0.04 }, + METICULOUS: { stability: 0.04, style: 0.02, speed: -0.01 }, +}; + +const clamp = (value: number) => Math.min(1, Math.max(0, value)); + +const TONE_ADJUSTMENTS: Record< + InterviewTone, + Partial> +> = { + GENTLE: { stability: 0.04, style: -0.03, speed: -0.02 }, + DIRECT: { stability: -0.04, style: 0.08, speed: 0.02 }, + PRESSURING: { stability: -0.08, style: 0.12, speed: 0.03 }, +}; + +const getVoiceSettings = ({ + interviewStyle, + personality, + tone, +}: InterviewerSpeechStyle = {}): VoiceSettings => { + const basePreset = interviewStyle + ? INTERVIEW_STYLE_PRESETS[interviewStyle] + : tone + ? TONE_PRESETS[tone] + : DEFAULT_VOICE_SETTINGS; + const personalityAdjustment = personality + ? PERSONALITY_ADJUSTMENTS[personality] + : {}; + const toneAdjustment = tone ? TONE_ADJUSTMENTS[tone] : {}; + + return { + ...DEFAULT_VOICE_SETTINGS, + stability: clamp( + basePreset.stability + + (personalityAdjustment.stability ?? 0) + + (toneAdjustment.stability ?? 0), + ), + style: clamp( + basePreset.style + + (personalityAdjustment.style ?? 0) + + (toneAdjustment.style ?? 0), + ), + speed: Math.min( + 1.2, + Math.max( + 0.8, + basePreset.speed + + (personalityAdjustment.speed ?? 0) + + (toneAdjustment.speed ?? 0), + ), + ), + }; +}; + const isPlaceholderVoiceId = (voiceId: string | undefined) => !voiceId || voiceId === "Kelee_K_Voice_ID"; @@ -98,16 +203,19 @@ const getVoiceIdFromAccount = async ( ); }; -const createSpeechRequest = (text: string) => +const createSpeechRequest = (text: string, voiceSettings: VoiceSettings) => JSON.stringify({ text, model_id: ELEVENLABS_MODEL_ID, - voice_settings: { - speed: ELEVENLABS_SPEECH_SPEED, - }, + voice_settings: voiceSettings, }); -const requestSpeech = (voiceId: string, text: string, signal: AbortSignal) => +const requestSpeech = ( + voiceId: string, + text: string, + voiceSettings: VoiceSettings, + signal: AbortSignal, +) => fetch( `${ELEVENLABS_TTS_API_URL}/${encodeURIComponent(voiceId)}?output_format=${ELEVENLABS_OUTPUT_FORMAT}`, { @@ -115,7 +223,7 @@ const requestSpeech = (voiceId: string, text: string, signal: AbortSignal) => headers: { "Content-Type": "application/json", }, - body: createSpeechRequest(text), + body: createSpeechRequest(text, voiceSettings), signal, }, ); @@ -132,7 +240,11 @@ const isVoiceAccessRestricted = async (response: Response) => { ); }; -export const useElevenLabsTts = (text: string, voiceIndex?: number) => { +export const useElevenLabsTts = ( + text: string, + voiceIndex?: number, + speechStyle?: InterviewerSpeechStyle, +) => { const [status, setStatus] = useState("idle"); const [errorMessage, setErrorMessage] = useState(null); const audioRef = useRef(null); @@ -142,6 +254,7 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { const resolvedVoiceIdRef = useRef(null); const voiceIdRequestRef = useRef | null>(null); const configuredVoiceId = getConfiguredVoiceId(voiceIndex); + const voiceSettings = getVoiceSettings(speechStyle); const clearAudioUrl = () => { if (audioUrlRef.current) { @@ -194,7 +307,11 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { }; }, []); - const playWithBrowserSpeech = (speechText: string, signal: AbortSignal) => { + const playWithBrowserSpeech = ( + speechText: string, + signal: AbortSignal, + speechRate = voiceSettings.speed, + ) => { if (typeof window === "undefined" || !window.speechSynthesis) { throw new Error("브라우저 음성 기능을 사용할 수 없습니다."); } @@ -202,7 +319,7 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { return new Promise((resolve, reject) => { const utterance = new SpeechSynthesisUtterance(speechText); utterance.lang = "ko-KR"; - utterance.rate = ELEVENLABS_SPEECH_SPEED; + utterance.rate = speechRate; speechRef.current = utterance; const finish = () => { @@ -277,6 +394,7 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { let response = await requestSpeech( resolvedVoiceId, text, + voiceSettings, controller.signal, ); @@ -294,12 +412,19 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { response = await requestSpeech( fallbackVoiceId, text, + voiceSettings, controller.signal, ); } } if (!response.ok) { + const responseMessage = await response.clone().text(); + console.error("ElevenLabs TTS response error:", { + status: response.status, + body: responseMessage, + }); + if (response.status === 401) { setErrorMessage( "ElevenLabs 인증에 실패했습니다. API 키와 권한을 확인해주세요.", @@ -311,7 +436,8 @@ export const useElevenLabsTts = (text: string, voiceIndex?: number) => { setErrorMessage( `ElevenLabs TTS 요청에 실패했습니다. (${response.status})`, ); - throw new Error(`ElevenLabs TTS failed with ${response.status}`); + await playWithBrowserSpeech(text, controller.signal); + return; } const contentType = response.headers.get("content-type") ?? ""; diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index 30c6b23f..f28416e9 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -17,6 +17,7 @@ import { import { INTERVIEW_STATUS_MESSAGES, } from "@/shared/constants/interview-page/interview"; +import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import { useElevenLabsTts } from "./useElevenLabsTts"; import { useSupertoneTts } from "./useSupertoneTts"; @@ -36,6 +37,7 @@ 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; +const PRESSURE_SOLO_VOICE_INDEX = 3; export type InterviewTtsProvider = "elevenlabs" | "supertone"; @@ -239,6 +241,7 @@ const interviewSessionReducer = ( export const useInterviewSession = ( preparedInterview?: PreparedInterviewData | null, ttsProvider: InterviewTtsProvider = "elevenlabs", + interviewStyle?: InterviewStyleOption, ) => { const navigate = useNavigate(); const [session, dispatch] = useReducer( @@ -322,7 +325,16 @@ export const useInterviewSession = ( }, [currentQuestion?.personaId, preparedInterview]); const elevenLabsTts = useElevenLabsTts( currentQuestion?.content ?? "", - multiTtsSpeaker?.voiceIndex, + preparedInterview?.mode === "SOLO" && interviewStyle === "압박" + ? PRESSURE_SOLO_VOICE_INDEX + : multiTtsSpeaker?.voiceIndex, + preparedInterview?.mode === "SOLO" + ? { + interviewStyle, + personality: preparedInterview.personaType, + tone: preparedInterview.tone, + } + : undefined, ); const supertoneTts = useSupertoneTts(currentQuestion?.content ?? ""); const questionTts = ttsProvider === "elevenlabs" ? elevenLabsTts : supertoneTts; diff --git a/src/pages/interview-page/interview/index.tsx b/src/pages/interview-page/interview/index.tsx index 6bc6c794..aabadce8 100644 --- a/src/pages/interview-page/interview/index.tsx +++ b/src/pages/interview-page/interview/index.tsx @@ -7,6 +7,7 @@ import { } from "@/features/interview-page/interview/model/interviewSessionContext"; import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; import { INTERVIEW_DEVICE_STATUS_LABELS } from "@/shared/constants/interview-page/interview"; +import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import CameraIcon from "@/shared/img/interview-page/camara.svg?url"; import MicIcon from "@/shared/img/interview-page/mike.svg?url"; import Loading from "@/shared/components/loading"; @@ -29,6 +30,26 @@ const getPreparedInterviewFromState = (state: unknown) => { return preparedInterview as PreparedInterviewData; }; +const getInterviewStyleFromState = ( + state: unknown, +): InterviewStyleOption | undefined => { + if (!state || typeof state !== "object" || !("interviewSetting" in state)) { + return undefined; + } + + const interviewSetting = (state as { interviewSetting?: unknown }).interviewSetting; + + if (!interviewSetting || typeof interviewSetting !== "object") { + return undefined; + } + + const style = (interviewSetting as { style?: unknown }).style; + + return style === "편함" || style === "일반" || style === "압박" + ? style + : undefined; +}; + const formatElapsedTime = (elapsedSeconds: number) => { const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; @@ -42,11 +63,16 @@ const InterviewPage = () => { () => getPreparedInterviewFromState(location.state), [location.state], ); + const interviewStyle = useMemo( + () => getInterviewStyleFromState(location.state), + [location.state], + ); const ttsProvider = "elevenlabs" as const; return ( From 491858b12c2bef93b7468e77c3d5c4de56388a3a Mon Sep 17 00:00:00 2001 From: han0911 Date: Thu, 17 Sep 2026 20:46:54 +0900 Subject: [PATCH 13/88] fix: use interviewer1 for solo pressure interviews --- .../model/useInterviewSetup.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index e104d7d0..658a54c5 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -22,7 +22,7 @@ import { type InterviewerToneOption, type InterviewSettingSelectHandlers, } from "@/shared/constants/interview-page/setting-interview"; -import InterviewerBackendImage from "@/shared/img/interview-page/interviewer1.svg?url"; +import InterviewerOneImage from "@/shared/img/interview-page/interviewer1.svg?url"; import InterviewerFrontendImage from "@/shared/img/interview-page/interviewer2.svg?url"; const TONE_BY_OPTION: Record< @@ -63,7 +63,7 @@ const MAJOR_BY_OPTION: Record = { // 면접관 카드 선택이 사라지면서, 이미지와 소개 문구는 전문 분야에서 파생한다. const INTERVIEWER_IMAGE_BY_MAJOR: Record = { 프론트엔드: InterviewerFrontendImage, - 백엔드: InterviewerBackendImage, + 백엔드: InterviewerOneImage, }; const INTERVIEWER_DESCRIPTION_BY_MAJOR: Record = { @@ -72,7 +72,6 @@ const INTERVIEWER_DESCRIPTION_BY_MAJOR: Record = { 백엔드: "기술적 역량과 아키텍처 설계 능력을 중점적으로 파악합니다.", }; -const DEFAULT_INTERVIEW_GENDER: InterviewPersonaGender = "FEMALE"; const DEFAULT_INTERVIEW_ROLE: InterviewPersonaRole = "TECH"; const buildUniquePersonaName = () => @@ -115,6 +114,14 @@ export const useInterviewSetup = () => { setErrorMessage(""); setIsSubmitting(true); + const isPressureInterview = selectedStyle === "압박"; + const interviewerGender: InterviewPersonaGender = isPressureInterview + ? "MALE" + : "FEMALE"; + const interviewerImage = isPressureInterview + ? InterviewerOneImage + : INTERVIEWER_IMAGE_BY_MAJOR[selectedMajor]; + const personaPayload: SavePersonaParams = { personaName: buildUniquePersonaName(), role: DEFAULT_INTERVIEW_ROLE, @@ -122,9 +129,9 @@ export const useInterviewSetup = () => { major: MAJOR_BY_OPTION[selectedMajor], type: TYPE_BY_PERSONALITY[selectedPersonality], career: CAREER_BY_DIFFICULTY[selectedDifficulty], - gender: DEFAULT_INTERVIEW_GENDER, + gender: interviewerGender, tone: TONE_BY_OPTION[selectedTone], - imageUrl: INTERVIEWER_IMAGE_BY_MAJOR[selectedMajor], + imageUrl: interviewerImage, description: INTERVIEWER_DESCRIPTION_BY_MAJOR[selectedMajor], }; @@ -208,6 +215,7 @@ export const useInterviewSetup = () => { name: personaPayload.personaName, roleLabel: "기술 면접관", image: personaPayload.imageUrl, + gender: personaPayload.gender, }, ], }, From eb5c942a8d4bfa8f533a3ff4f51a6671a0db0b87 Mon Sep 17 00:00:00 2001 From: han0911 Date: Thu, 17 Sep 2026 20:54:56 +0900 Subject: [PATCH 14/88] feat: restore solo interviewer selection UI --- .../interview/model/useInterviewSession.ts | 13 +- .../model/useInterviewSetup.ts | 147 ++++++------ .../setting-interview/index.tsx | 4 +- .../interview-page/setting-interview/index.ts | 54 +++-- .../interview-page/setting-interview/type.ts | 22 +- .../interview-page/setting-interview/data.ts | 45 ++-- .../interviewer-options/index.tsx | 111 ++++----- .../interviewer-options/type.ts | 20 +- .../interview-page/setting-interview/style.ts | 211 ++++++++++++------ .../interview-page/setting-interview/type.ts | 4 - 10 files changed, 325 insertions(+), 306 deletions(-) diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index f28416e9..0c01fdf8 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -37,7 +37,7 @@ 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; -const PRESSURE_SOLO_VOICE_INDEX = 3; +const DEFAULT_SOLO_VOICE_INDEX = 1; export type InterviewTtsProvider = "elevenlabs" | "supertone"; @@ -323,11 +323,16 @@ export const useInterviewSession = ( ? personaId : undefined; }, [currentQuestion?.personaId, preparedInterview]); + const soloTtsVoiceIndex = + preparedInterview?.mode === "SOLO" + ? preparedInterview.interviewers?.[0]?.voiceIndex + : undefined; const elevenLabsTts = useElevenLabsTts( currentQuestion?.content ?? "", - preparedInterview?.mode === "SOLO" && interviewStyle === "압박" - ? PRESSURE_SOLO_VOICE_INDEX - : multiTtsSpeaker?.voiceIndex, + soloTtsVoiceIndex ?? + (preparedInterview?.mode === "SOLO" && interviewStyle === "압박" + ? DEFAULT_SOLO_VOICE_INDEX + : multiTtsSpeaker?.voiceIndex), preparedInterview?.mode === "SOLO" ? { interviewStyle, diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index 658a54c5..8b789d68 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -7,8 +7,8 @@ import { savePersona, setActiveInterviewSessionId, type CreateInterviewPersonaType, - type InterviewPersonaGender, type InterviewLevel, + type InterviewPersonaGender, type InterviewPersonaMajor, type InterviewPersonaRole, type InterviewTone, @@ -16,31 +16,27 @@ import { } from "@/features/interview-page/interview/api"; import { INTERVIEW_SETTING_DEFAULT_SELECTION, - type InterviewMajorOption, + INTERVIEW_SETTING_INTERVIEWERS, type InterviewDifficultyOption, - type InterviewerPersonalityOption, - type InterviewerToneOption, + type InterviewerId, type InterviewSettingSelectHandlers, + type InterviewStyleOption, } from "@/shared/constants/interview-page/setting-interview"; -import InterviewerOneImage 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", +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"; + +const TYPE_BY_STYLE: Record = { + 편함: "FRIENDLY", + 일반: "REALISTIC", + 압박: "METICULOUS", }; -const TYPE_BY_PERSONALITY: Record< - InterviewerPersonalityOption, - CreateInterviewPersonaType -> = { - 친근한: "FRIENDLY", - 현실적인: "REALISTIC", - 꼼꼼한: "METICULOUS", +const TONE_BY_STYLE: Record = { + 편함: "GENTLE", + 일반: "DIRECT", + 압박: "PRESSURING", }; const CAREER_BY_DIFFICULTY: Record = { @@ -55,27 +51,27 @@ const LEVEL_BY_DIFFICULTY: Record = { 어려움: "HARD", }; -const MAJOR_BY_OPTION: Record = { - 프론트엔드: "FRONTEND", - 백엔드: "BACKEND", -}; - -// 면접관 카드 선택이 사라지면서, 이미지와 소개 문구는 전문 분야에서 파생한다. -const INTERVIEWER_IMAGE_BY_MAJOR: Record = { - 프론트엔드: InterviewerFrontendImage, - 백엔드: InterviewerOneImage, +const INTERVIEWER_IMAGES: Record = { + 1: InterviewerImage1, + 2: InterviewerImage2, + 3: InterviewerImage3, + 4: InterviewerImage4, }; -const INTERVIEWER_DESCRIPTION_BY_MAJOR: Record = { - 프론트엔드: - "사용자 중심의 사고방식과 시각적 커뮤니케이션 능력을 심층 질문합니다.", - 백엔드: "기술적 역량과 아키텍처 설계 능력을 중점적으로 파악합니다.", +const INTERVIEWER_PROFILE_BY_ID: Record< + InterviewerId, + { gender: InterviewPersonaGender; major: InterviewPersonaMajor } +> = { + 1: { gender: "MALE", major: "BACKEND" }, + 2: { gender: "MALE", major: "FRONTEND" }, + 3: { gender: "FEMALE", major: "FRONTEND" }, + 4: { gender: "FEMALE", major: "BACKEND" }, }; const DEFAULT_INTERVIEW_ROLE: InterviewPersonaRole = "TECH"; -const buildUniquePersonaName = () => - `맞춤 면접관-${Date.now().toString(36)}`; +const buildUniquePersonaName = (personaName: string) => + `${personaName}-${Date.now().toString(36)}`; export const useInterviewSetup = () => { const navigate = useNavigate(); @@ -84,23 +80,12 @@ export const useInterviewSetup = () => { const [selectedStyle, setSelectedStyle] = useState( INTERVIEW_SETTING_DEFAULT_SELECTION.style, ); - const [selectedDifficulty, setSelectedDifficulty] = useState( INTERVIEW_SETTING_DEFAULT_SELECTION.difficulty, ); - - const [selectedPersonality, setSelectedPersonality] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.personality, - ); - - const [selectedTone, setSelectedTone] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.tone, + const [selectedInterviewerId, setSelectedInterviewerId] = useState( + INTERVIEW_SETTING_DEFAULT_SELECTION.interviewerId, ); - - const [selectedMajor, setSelectedMajor] = useState( - INTERVIEW_SETTING_DEFAULT_SELECTION.major, - ); - const [errorMessage, setErrorMessage] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); @@ -114,25 +99,28 @@ export const useInterviewSetup = () => { setErrorMessage(""); setIsSubmitting(true); - const isPressureInterview = selectedStyle === "압박"; - const interviewerGender: InterviewPersonaGender = isPressureInterview - ? "MALE" - : "FEMALE"; - const interviewerImage = isPressureInterview - ? InterviewerOneImage - : INTERVIEWER_IMAGE_BY_MAJOR[selectedMajor]; + const selectedInterviewer = INTERVIEW_SETTING_INTERVIEWERS.find( + (interviewer) => interviewer.id === selectedInterviewerId, + ); + if (!selectedInterviewer) { + setIsSubmitting(false); + setErrorMessage("선택한 면접관 정보를 찾을 수 없습니다."); + return; + } + + const interviewerProfile = INTERVIEWER_PROFILE_BY_ID[selectedInterviewerId]; const personaPayload: SavePersonaParams = { - personaName: buildUniquePersonaName(), + personaName: buildUniquePersonaName(selectedInterviewer.personaName), role: DEFAULT_INTERVIEW_ROLE, level: LEVEL_BY_DIFFICULTY[selectedDifficulty], - major: MAJOR_BY_OPTION[selectedMajor], - type: TYPE_BY_PERSONALITY[selectedPersonality], + major: interviewerProfile.major, + type: TYPE_BY_STYLE[selectedStyle], career: CAREER_BY_DIFFICULTY[selectedDifficulty], - gender: interviewerGender, - tone: TONE_BY_OPTION[selectedTone], - imageUrl: interviewerImage, - description: INTERVIEWER_DESCRIPTION_BY_MAJOR[selectedMajor], + gender: interviewerProfile.gender, + tone: TONE_BY_STYLE[selectedStyle], + imageUrl: INTERVIEWER_IMAGES[selectedInterviewerId], + description: selectedInterviewer.description, }; const { data: savedPersona, errorMessage: savePersonaErrorMessage } = @@ -146,17 +134,13 @@ export const useInterviewSetup = () => { return; } - const interviewPayload = { + const { data, errorMessage: createErrorMessage } = await createInterview({ personaName: personaPayload.personaName, - major: MAJOR_BY_OPTION[selectedMajor], + major: personaPayload.major, type: personaPayload.type, career: personaPayload.career, gender: personaPayload.gender, - }; - //repit-dp - - const { data, errorMessage: createErrorMessage } = - await createInterview(interviewPayload); + }); if (createErrorMessage || !data) { setIsSubmitting(false); @@ -184,7 +168,6 @@ export const useInterviewSetup = () => { ? data.personaId : savedPersona.personaId; setActiveInterviewSessionId(interviewSessionId); - setIsSubmitting(false); navigate(`/main/interview/${data.interviewId}`, { @@ -199,7 +182,7 @@ export const useInterviewSetup = () => { major: personaPayload.major, type: personaPayload.type, personaType: personaPayload.type, - level: LEVEL_BY_DIFFICULTY[selectedDifficulty], + level: personaPayload.level, career: personaPayload.career, gender: personaPayload.gender, tone: personaPayload.tone, @@ -212,19 +195,21 @@ export const useInterviewSetup = () => { interviewers: [ { personaId, - name: personaPayload.personaName, + name: selectedInterviewer.name, roleLabel: "기술 면접관", - image: personaPayload.imageUrl, + image: INTERVIEWER_IMAGES[selectedInterviewerId], gender: personaPayload.gender, + voiceIndex: selectedInterviewerId, + personaType: personaPayload.type, + tone: personaPayload.tone, + level: personaPayload.level, }, ], }, interviewSetting: { style: selectedStyle, difficulty: selectedDifficulty, - major: selectedMajor, - personality: selectedPersonality, - tone: selectedTone, + interviewerId: selectedInterviewerId, }, }, }); @@ -232,10 +217,8 @@ export const useInterviewSetup = () => { const select: InterviewSettingSelectHandlers = { difficulty: setSelectedDifficulty, - major: setSelectedMajor, - personality: setSelectedPersonality, + interviewer: setSelectedInterviewerId, style: setSelectedStyle, - tone: setSelectedTone, }; return { @@ -250,10 +233,8 @@ export const useInterviewSetup = () => { select, selection: { difficulty: selectedDifficulty, - major: selectedMajor, - personality: selectedPersonality, + interviewerId: selectedInterviewerId, style: selectedStyle, - tone: selectedTone, }, }; }; diff --git a/src/pages/interview-page/setting-interview/index.tsx b/src/pages/interview-page/setting-interview/index.tsx index bb5c2364..55f0aa10 100644 --- a/src/pages/interview-page/setting-interview/index.tsx +++ b/src/pages/interview-page/setting-interview/index.tsx @@ -18,8 +18,8 @@ const SettingInterviewPage = () => { selection={setup.selection} /> {setup.errorMessage ? ( diff --git a/src/shared/constants/interview-page/setting-interview/index.ts b/src/shared/constants/interview-page/setting-interview/index.ts index fa5b601c..db0780c0 100644 --- a/src/shared/constants/interview-page/setting-interview/index.ts +++ b/src/shared/constants/interview-page/setting-interview/index.ts @@ -1,4 +1,5 @@ import type { + InterviewSettingInterviewer, InterviewSettingOptionSection, InterviewSettingSelection, InterviewSettingStyleMeta, @@ -33,21 +34,36 @@ export const INTERVIEW_SETTING_STYLE_META = { }, } as const satisfies Record; -export const INTERVIEW_SETTING_INTERVIEWER_SECTION_TITLE = '면접관 설정'; - -export const INTERVIEW_SETTING_PERSONALITY_OPTIONS = [ - '친근한', - '현실적인', - '꼼꼼한', -] as const; - -export const INTERVIEW_SETTING_TONE_OPTIONS = [ - '부드러운', - '직설적인', - '압박하는', -] as const; - -export const INTERVIEW_SETTING_MAJOR_OPTIONS = ['프론트엔드', '백엔드'] as const; +export const INTERVIEW_SETTING_INTERVIEWERS = [ + { + id: 1, + name: '면접관1', + personaName: '면접관1', + tags: ['성격', '말투', '전문 분야'], + description: '기술적 역량과 아키텍처 설계 능력을 중점적으로 파악합니다.', + }, + { + id: 2, + name: '면접관2', + personaName: '면접관2', + tags: ['성격', '말투', '전문 분야'], + description: '문제 해결 프로세스와 데이터 기반의 의사결정 능력을 평가합니다.', + }, + { + id: 3, + name: '면접관3', + personaName: '면접관3', + tags: ['성격', '말투', '전문 분야'], + description: '사용자 중심의 사고방식과 시각적 커뮤니케이션 능력을 심층 질문합니다.', + }, + { + id: 4, + name: '면접관4', + personaName: '면접관4', + tags: ['성격', '말투', '전문 분야'], + description: '협업 능력과 가치관이 팀의 문화와 얼마나 잘 부합하는지 확인합니다.', + }, +] as const satisfies readonly InterviewSettingInterviewer[]; export const INTERVIEW_SETTING_ACTION_LABELS = { back: '돌아가기', @@ -56,17 +72,13 @@ export const INTERVIEW_SETTING_ACTION_LABELS = { export const INTERVIEW_SETTING_DEFAULT_SELECTION: InterviewSettingSelection = { difficulty: '쉬움', - major: '프론트엔드', - personality: '친근한', + interviewerId: 1, style: '편함', - tone: '부드러운', }; export type { - InterviewMajorOption, InterviewDifficultyOption, - InterviewerPersonalityOption, - InterviewerToneOption, + InterviewerId, InterviewSettingSelectHandlers, InterviewSettingSelection, InterviewSettingStyleIcon, diff --git a/src/shared/constants/interview-page/setting-interview/type.ts b/src/shared/constants/interview-page/setting-interview/type.ts index a645032c..0fbe6bde 100644 --- a/src/shared/constants/interview-page/setting-interview/type.ts +++ b/src/shared/constants/interview-page/setting-interview/type.ts @@ -2,11 +2,7 @@ export type InterviewStyleOption = '편함' | '일반' | '압박'; export type InterviewDifficultyOption = '쉬움' | '보통' | '어려움'; -export type InterviewerPersonalityOption = '친근한' | '현실적인' | '꼼꼼한'; - -export type InterviewerToneOption = '부드러운' | '직설적인' | '압박하는'; - -export type InterviewMajorOption = '프론트엔드' | '백엔드'; +export type InterviewerId = 1 | 2 | 3 | 4; export type InterviewSettingStyleIcon = 'smile' | 'neutral' | 'pressure'; @@ -27,18 +23,22 @@ export interface InterviewSettingStyleMeta { icon: InterviewSettingStyleIcon; } +export interface InterviewSettingInterviewer { + description: string; + id: InterviewerId; + name: string; + personaName: string; + tags: readonly string[]; +} + export interface InterviewSettingSelection { difficulty: InterviewDifficultyOption; - major: InterviewMajorOption; - personality: InterviewerPersonalityOption; + interviewerId: InterviewerId; style: InterviewStyleOption; - tone: InterviewerToneOption; } export interface InterviewSettingSelectHandlers { difficulty: (value: InterviewDifficultyOption) => void; - major: (value: InterviewMajorOption) => void; - personality: (value: InterviewerPersonalityOption) => void; + interviewer: (value: InterviewerId) => void; style: (value: InterviewStyleOption) => void; - tone: (value: InterviewerToneOption) => void; } diff --git a/src/widgets/interview-page/setting-interview/data.ts b/src/widgets/interview-page/setting-interview/data.ts index c3d8e5be..5fe2c511 100644 --- a/src/widgets/interview-page/setting-interview/data.ts +++ b/src/widgets/interview-page/setting-interview/data.ts @@ -1,17 +1,26 @@ import { - INTERVIEW_SETTING_INTERVIEWER_SECTION_TITLE, - INTERVIEW_SETTING_MAJOR_OPTIONS, + INTERVIEW_SETTING_INTERVIEWERS, INTERVIEW_SETTING_OPTION_SECTIONS, - INTERVIEW_SETTING_PERSONALITY_OPTIONS, INTERVIEW_SETTING_STYLE_META, - INTERVIEW_SETTING_TONE_OPTIONS, + type InterviewerId, } from '@/shared/constants/interview-page/setting-interview'; +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 DifficultyEasyImage from '@/shared/img/interview-page/easy.svg?url'; import DifficultyNormalImage from '@/shared/img/interview-page/normal.svg?url'; import DifficultyHardImage from '@/shared/img/interview-page/hard.svg?url'; const [styleSection, difficultySection] = INTERVIEW_SETTING_OPTION_SECTIONS; +const INTERVIEWER_IMAGES: Record = { + 1: InterviewerImage1, + 2: InterviewerImage2, + 3: InterviewerImage3, + 4: InterviewerImage4, +}; + const DIFFICULTY_IMAGES: Record< (typeof difficultySection.options)[number], string @@ -37,23 +46,11 @@ export const SETTING_DIFFICULTY_SECTION = { })), }; -export const SETTING_INTERVIEWER_SECTION = { - title: INTERVIEW_SETTING_INTERVIEWER_SECTION_TITLE, - groups: [ - { - key: 'personality', - title: '성격', - options: INTERVIEW_SETTING_PERSONALITY_OPTIONS, - }, - { - key: 'tone', - title: '말투', - options: INTERVIEW_SETTING_TONE_OPTIONS, - }, - { - key: 'major', - title: '전문 분야', - options: INTERVIEW_SETTING_MAJOR_OPTIONS, - }, - ], -} as const; +export const SETTING_INTERVIEWER_CARDS = INTERVIEW_SETTING_INTERVIEWERS.map( + (interviewer) => ({ + ...interviewer, + image: INTERVIEWER_IMAGES[interviewer.id], + }), +); + +export type SettingInterviewerCard = (typeof SETTING_INTERVIEWER_CARDS)[number]; diff --git a/src/widgets/interview-page/setting-interview/interviewer-options/index.tsx b/src/widgets/interview-page/setting-interview/interviewer-options/index.tsx index 68d3f3ca..de4a55ef 100644 --- a/src/widgets/interview-page/setting-interview/interviewer-options/index.tsx +++ b/src/widgets/interview-page/setting-interview/interviewer-options/index.tsx @@ -1,81 +1,52 @@ -import { SETTING_INTERVIEWER_SECTION } from '../data'; +import { SETTING_INTERVIEWER_CARDS } from '../data'; import * as S from '../style'; import type { + InterviewerCardProps, InterviewerOptionsProps, - InterviewerSettingGroupProps, } from './type'; const InterviewerOptions = ({ onSelect, - selection, -}: InterviewerOptionsProps) => { - const [personalityGroup, toneGroup, majorGroup] = - SETTING_INTERVIEWER_SECTION.groups; - - return ( - - - {SETTING_INTERVIEWER_SECTION.title} - - - - - - - - ); -}; - -const InterviewerSettingGroup =
diff --git a/src/pages/interview-page/setting-multi-interview/index.tsx b/src/pages/interview-page/setting-multi-interview/index.tsx index 9005488b..d75c5434 100644 --- a/src/pages/interview-page/setting-multi-interview/index.tsx +++ b/src/pages/interview-page/setting-multi-interview/index.tsx @@ -1,3 +1,4 @@ +import ProfileGuidance from "@/widgets/interview-page/profile-guidance"; import { useMultiInterviewSetup, } from "@/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup"; @@ -57,6 +58,11 @@ const SettingMultiInterviewPage = () => { + {setup.errorMessage ? ( {setup.errorMessage} ) : null} @@ -87,7 +93,7 @@ const SettingMultiInterviewPage = () => { {setup.isSubmitting ? ( ) : null} diff --git a/src/widgets/interview-page/profile-guidance/index.tsx b/src/widgets/interview-page/profile-guidance/index.tsx new file mode 100644 index 00000000..2db1d617 --- /dev/null +++ b/src/widgets/interview-page/profile-guidance/index.tsx @@ -0,0 +1,18 @@ +import { Link } from "react-router-dom"; +import * as S from "./style"; + +interface ProfileGuidanceProps { + message: string; + showMyPageLink: boolean; + onRetry?: () => void; +} + +const ProfileGuidance = ({ message, showMyPageLink, onRetry }: ProfileGuidanceProps) => ( + + {message ? {message} : null} + {showMyPageLink ? 마이페이지로 이동 : null} + {onRetry ? : null} + +); + +export default ProfileGuidance; diff --git a/src/widgets/interview-page/profile-guidance/style.ts b/src/widgets/interview-page/profile-guidance/style.ts new file mode 100644 index 00000000..82049e00 --- /dev/null +++ b/src/widgets/interview-page/profile-guidance/style.ts @@ -0,0 +1,15 @@ +import styled from "styled-components"; + +export const Container = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: ${({ theme }) => theme.spacing.sm}; + margin-top: ${({ theme }) => theme.spacing.md}; + color: ${({ theme }) => theme.colors.text.default}; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.6; + + a, button { color: ${({ theme }) => theme.colors.brand.blue}; } + button { border: 0; background: none; cursor: pointer; text-decoration: underline; } +`; From bf3b3b7d5857a664f25da3eec315ac4ff3d13f66 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 12 Oct 2026 19:19:51 +0900 Subject: [PATCH 23/88] =?UTF-8?q?test:=20=EB=85=B9=ED=99=94=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=A4=80=EB=B9=84=20=EC=9D=91=EB=8B=B5=EC=97=90=20?= =?UTF-8?q?preparationStatus=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- tests/interview-recording.browser.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/interview-recording.browser.mjs b/tests/interview-recording.browser.mjs index 99274c10..a8ab3827 100644 --- a/tests/interview-recording.browser.mjs +++ b/tests/interview-recording.browser.mjs @@ -101,7 +101,7 @@ async function fixture({ multi = false, answerMode = "voice", timeout = false, d } if (pathname.endsWith("/quit")) { quits.push(pathname); return json({ status: "ABANDONED" }); } if (pathname.endsWith("/question")) return json(current); - if (pathname === "/api/questions/tailor") return json({ data: { status: "SUCCEEDED", chatDelivered: true, questions: [current, question(102)], interviewId: 42 } }); + if (pathname === "/api/questions/tailor") return json({ data: { status: "SUCCEEDED", preparationStatus: "READY", chatDelivered: true, questions: [current, question(102)], interviewId: 42 } }); if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } }); if (pathname.includes("/elevenlabs/") || pathname.includes("/supertone/")) return json({ message: "TTS mock" }, 503); if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); From ecffaedf123f2a394e6d097a1cf48131897181d4 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 12 Oct 2026 19:49:36 +0900 Subject: [PATCH 24/88] fix: warn before unload only for pending recordings --- docs/interview-recording.md | 3 +- .../interview/model/useInterviewSession.ts | 15 ++--- .../interview/model/useMediaRecorder.ts | 3 +- tests/interview-recording.browser.mjs | 64 +++++++++++++++++-- 4 files changed, 68 insertions(+), 17 deletions(-) diff --git a/docs/interview-recording.md b/docs/interview-recording.md index b9b362cc..80a81933 100644 --- a/docs/interview-recording.md +++ b/docs/interview-recording.md @@ -66,5 +66,6 @@ node tests/interview-recording.browser.mjs `INTERVIEW_TEST_ORIGIN`으로 개발 서버 주소를 변경할 수 있다(기본 `http://127.0.0.1:5173`). 서버·소켓·전사는 모의하며 Chromium의 실제 MediaRecorder와 가상 카메라·마이크로 파일을 생성한다. 1:1·다중 답변 완료, 자동 완료, 진행 중 종료, 헤더 이동, 뒤로가기, 로그아웃, HTTP·소켓 동시 완료, -업로드 실패·재시도, 장치 권한 거부, 텍스트의 장치 미요청을 검증한다. +업로드 실패·재시도·파일 폐기, 장치 권한 거부, 텍스트의 장치 미요청을 검증한다. +준비 실패·권한 거부·녹화 시작 실패에는 새로고침 경고가 없고, 녹화·파일 확정·미저장 업로드 중에는 경고가 유지되며 업로드 완료·폐기 후 해제되는 것도 확인한다. 실제 API/S3 및 실장치 호환성 검증은 별도로 필요하다. diff --git a/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index 0c01fdf8..e750ecbe 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -267,8 +267,9 @@ export const useInterviewSession = ( useInterviewMedia(isVoiceMode && !isClosing); const fullRecorder = useInterviewRecorder(cloneVideoTrack, cloneAudioTrack); const recorder = useAnswerRecorder(cloneAudioTrack); - const { stopRecording: stopAnswerRecording } = recorder; - const { startRecording: startFullRecording, stopRecording: stopFullRecording } = fullRecorder; + const { stopRecording: stopAnswerRecording, hasPendingRecording: hasPendingAnswerRecording } = recorder; + const { startRecording: startFullRecording, stopRecording: stopFullRecording, + hasPendingRecording: hasPendingFullRecording } = fullRecorder; const voiceLevel = useVoiceLevel(micStream); const voiceAnswer = useVoiceAnswer(); const multiTtsSpeaker = useMemo(() => { @@ -347,7 +348,6 @@ export const useInterviewSession = ( useEffect(() => { controlsRef.current = { questionTts, voiceAnswer }; }); const sessionId = preparedInterview?.sessionId ?? getActiveInterviewSessionId(); const isSessionClosedRef = useRef(false); - const isFinalizingRef = useRef(false); const closePromiseRef = useRef | null>(null); const closeReasonRef = useRef(null); const hasNavigatedRef = useRef(false); @@ -482,7 +482,6 @@ export const useInterviewSession = ( isSessionClosedRef.current = true; closeReasonRef.current = reason; setIsClosing(true); - isFinalizingRef.current = true; clearAwaitingResponseTimeout(); controlsRef.current.questionTts.onStop(); controlsRef.current.voiceAnswer.onExitVoiceMode(); @@ -505,7 +504,6 @@ export const useInterviewSession = ( ]); if (activeSessionId && reason === "quit") await quitInterview(activeSessionId); } finally { - isFinalizingRef.current = false; releaseMedia(); clearActiveInterviewSessionId(); if (activeSessionId) disconnectInterviewSocket(activeSessionId); @@ -527,8 +525,7 @@ export const useInterviewSession = ( useEffect(() => { if (!isVoiceMode || !canSubmitAnswer || cameraState !== "ready" || micState !== "ready" || fullRecordingStartedRef.current || isSessionClosedRef.current) return; - fullRecordingStartedRef.current = true; - startFullRecording(); + fullRecordingStartedRef.current = startFullRecording(); }, [cameraState, canSubmitAnswer, startFullRecording, isVoiceMode, micState]); const closeLatestRef = useRef(endInterviewSession); @@ -536,7 +533,7 @@ export const useInterviewSession = ( useEffect(() => { const unregister = registerInterviewLifecycle(() => closeLatestRef.current(false, "quit")); const warnBeforeUnload = (event: BeforeUnloadEvent) => { - if (!isVoiceMode || (isSessionClosedRef.current && !isFinalizingRef.current)) return; + if (!hasPendingFullRecording() && !hasPendingAnswerRecording()) return; event.preventDefault(); event.returnValue = ""; }; @@ -547,7 +544,7 @@ export const useInterviewSession = ( clearAnswerTimeout(); if (!isSessionClosedRef.current) void closeLatestRef.current(false, "quit"); }; - }, [clearAnswerTimeout, isVoiceMode]); + }, [clearAnswerTimeout, hasPendingFullRecording, hasPendingAnswerRecording]); const blocker = useBlocker(({ currentLocation, nextLocation }) => !isSessionClosedRef.current && currentLocation.pathname !== nextLocation.pathname); diff --git a/src/features/interview-page/interview/model/useMediaRecorder.ts b/src/features/interview-page/interview/model/useMediaRecorder.ts index 33f782bd..d132931b 100644 --- a/src/features/interview-page/interview/model/useMediaRecorder.ts +++ b/src/features/interview-page/interview/model/useMediaRecorder.ts @@ -19,6 +19,7 @@ export const useMediaRecorder = ( 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) { @@ -91,5 +92,5 @@ export const useMediaRecorder = ( }; }, []); - return { errorMessage, isRecording, isStarting, isStopping, startRecording, stopRecording }; + return { errorMessage, isRecording, isStarting, isStopping, hasPendingRecording, startRecording, stopRecording }; }; diff --git a/tests/interview-recording.browser.mjs b/tests/interview-recording.browser.mjs index 99274c10..3a53eeb0 100644 --- a/tests/interview-recording.browser.mjs +++ b/tests/interview-recording.browser.mjs @@ -12,13 +12,13 @@ const question = (id) => ({ questionId: id, parentId: 0, type: "ORIGINAL", inten const results = []; const report = (result) => { results.push(result); console.log(`PASS ${result}`); }; -async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false } = {}) { +async function fixture({ multi = false, answerMode = "voice", timeout = false, denied = false, fail = false, delayUploads = false, complete = false, mimeMode, socketOnAnswer = false, preparationFailed = false, recorderFails = false, slowStop = false } = {}) { const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); const page = await context.newPage(); page.setDefaultTimeout(10_000); const errors = []; page.on("pageerror", (error) => errors.push(error.message)); - await page.addInitScript(({ timeout, denied, mimeMode }) => { + await page.addInitScript(({ timeout, denied, mimeMode, recorderFails, slowStop }) => { window.__tracks = []; window.__captures = []; window.__sockets = []; @@ -47,7 +47,15 @@ async function fixture({ multi = false, answerMode = "voice", timeout = false, d this.__info = { kinds: stream.getTracks().map((track) => track.kind), mimeType: this.mimeType, options, stops: 0 }; window.__captures.push(this.__info); } - stop() { this.__info.stops++; super.stop(); } + start(...args) { + if (recorderFails) throw new Error("테스트 녹화 시작 실패"); + super.start(...args); + } + stop() { + this.__info.stops++; + if (slowStop) setTimeout(() => super.stop(), 500); + else super.stop(); + } }; window.SpeechRecognition = class { start() { @@ -71,7 +79,7 @@ async function fixture({ multi = false, answerMode = "voice", timeout = false, d const originalTimeout = window.setTimeout.bind(window); window.setTimeout = (callback, delay, ...args) => originalTimeout(callback, delay === 210_000 ? 1_200 : delay, ...args); } - }, { timeout, denied, mimeMode }); + }, { timeout, denied, mimeMode, recorderFails, slowStop }); const uploads = []; const answers = []; const quits = []; @@ -101,7 +109,7 @@ async function fixture({ multi = false, answerMode = "voice", timeout = false, d } if (pathname.endsWith("/quit")) { quits.push(pathname); return json({ status: "ABANDONED" }); } if (pathname.endsWith("/question")) return json(current); - if (pathname === "/api/questions/tailor") return json({ data: { status: "SUCCEEDED", chatDelivered: true, questions: [current, question(102)], interviewId: 42 } }); + if (pathname === "/api/questions/tailor") return json({ data: { status: preparationFailed ? "FAILED" : "SUCCEEDED", preparationStatus: preparationFailed ? "FAILED" : "READY", errorMessage: preparationFailed ? "테스트 면접 준비 실패" : null, retryable: false, chatDelivered: !preparationFailed, questions: [current, question(102)], interviewId: 42 } }); if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트" } }); if (pathname.includes("/elevenlabs/") || pathname.includes("/supertone/")) return json({ message: "TTS mock" }, 503); if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); @@ -124,7 +132,7 @@ async function fixture({ multi = false, answerMode = "voice", timeout = false, d }, { multi, answerMode }); const start = page.getByRole("button", { name: multi ? "음성 답변 시작" : "시작하기", exact: true }); const finish = page.getByRole("button", { name: multi ? "답변 끝내기" : "끝내기", exact: true }); - if (answerMode === "voice" && !denied) { + if (answerMode === "voice" && !denied && !preparationFailed) { try { await start.waitFor({ state: "visible" }); } catch (error) { console.error("Fixture failed:", page.url(), errors, await page.locator("body").innerText()); @@ -146,7 +154,35 @@ async function waitUntil(check) { for (let i = 0; i < 100; i++) { if (check()) return; await new Promise((resolve) => setTimeout(resolve, 50)); } assert.fail("timed out waiting for network assertion"); } +const unloadWarns = (page) => page.evaluate(() => !window.dispatchEvent(new Event("beforeunload", { cancelable: true }))); try { + for (const options of [{ denied: true }, { preparationFailed: true }, { recorderFails: true }]) { + const f = await fixture(options); + await f.page.getByText(options.denied ? "카메라와 마이크 권한을 허용해주세요." : options.preparationFailed ? "테스트 면접 준비 실패" : "테스트 녹화 시작 실패", { exact: false }).first().waitFor(); + assert.equal(await unloadWarns(f.page), false); + assert.equal(f.uploads.length, 0); + await f.page.getByRole("button", { name: options.preparationFailed ? "메인으로 돌아가기" : "그만두기", exact: true }).click(); + await f.page.waitForURL("**/main"); + assert.equal(await unloadWarns(f.page), false); + await f.context.close(); + report(`unload without recording: ${Object.keys(options)[0]}`); + } + const stopping = await fixture({ slowStop: true, delayUploads: true }); + await stopping.start.click(); + await stopping.finish.waitFor(); + await stopping.page.waitForTimeout(250); + await stopping.page.getByRole("button", { name: "그만두기", exact: true }).click(); + assert.equal(await unloadWarns(stopping.page), true, "finalizing files stays protected"); + await stopping.page.waitForURL("**/main"); + await waitUntil(() => stopping.uploads.length === 2); + assert.equal(await unloadWarns(stopping.page), true, "queued files stay protected after navigation"); + stopping.release(); + await stopping.page.getByText("업로드 완료", { exact: true }).first().waitFor(); + await stopping.page.waitForFunction(async () => !(await import("/src/features/interview-page/interview/model/interviewRecordingStore.ts")).hasUnsavedRecordings()); + assert.equal(await unloadWarns(stopping.page), false); + await assertReleased(stopping); + await stopping.context.close(); + report("unload covers file finalization and uploads, clears after success"); for (const multi of [false, true]) { const f = await fixture({ multi }); await f.start.click(); @@ -259,8 +295,24 @@ try { await assertReleased(failed); await failed.context.close(); report("failed upload: visible failure, retry preserved files, logout deferred"); + const discarded = await fixture({ fail: true }); + await discarded.start.click(); + await discarded.finish.waitFor(); + await discarded.page.waitForTimeout(250); + await discarded.page.getByRole("button", { name: "그만두기", exact: true }).click(); + await discarded.page.waitForURL("**/main"); + await discarded.page.getByRole("button", { name: "파일 폐기", exact: true }).first().waitFor(); + assert.equal(await unloadWarns(discarded.page), true); + while (await discarded.page.getByRole("button", { name: "파일 폐기", exact: true }).count()) { + await discarded.page.getByRole("button", { name: "파일 폐기", exact: true }).first().click(); + } + assert.equal(await unloadWarns(discarded.page), false); + await assertReleased(discarded); + await discarded.context.close(); + report("discarding failed files clears unload warning"); for (const multi of [false, true]) { const f = await fixture({ multi, answerMode: "text", complete: true }); + assert.equal(await unloadWarns(f.page), false); await f.page.getByRole("textbox").first().fill("프로젝트 경험에 대한 텍스트 답변입니다."); await f.page.getByRole("button", { name: "제출하기", exact: true }).click(); await f.page.waitForURL("**/main/interview/completed"); From 763e5c16127ce68e49067f271130b334198b9fb6 Mon Sep 17 00:00:00 2001 From: gituser Date: Mon, 12 Oct 2026 20:01:44 +0900 Subject: [PATCH 25/88] fix: allow material saves after profile query failures --- docs/profile-preparation.md | 38 +++ .../interview/model/useInterviewSession.ts | 3 +- src/pages/my-page/index.tsx | 2 +- tests/profile-preparation.browser.mjs | 271 ++++++++++++++++++ 4 files changed, 312 insertions(+), 2 deletions(-) create mode 100644 docs/profile-preparation.md create mode 100644 tests/profile-preparation.browser.mjs 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/src/features/interview-page/interview/model/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index 6cd80c0b..8817f332 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -672,9 +672,10 @@ export const useInterviewSession = ( return; } - const retryAfterMs = data?.status === "WAITING" + 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(); diff --git a/src/pages/my-page/index.tsx b/src/pages/my-page/index.tsx index 006175b7..01a596a6 100644 --- a/src/pages/my-page/index.tsx +++ b/src/pages/my-page/index.tsx @@ -28,7 +28,7 @@ const MyPage = () => { ); const portfolioForm = usePortfolioForm({ initialGitUrls, - isSaveBlocked: profileStatus.isLoading || !profileStatus.profile || isAnalyzing || profileStatus.isRestarting, + isSaveBlocked: profileStatus.isLoading || isAnalyzing || profileStatus.isRestarting, requiresChangeConfirmation: profileStatus.profile?.status === "COMPLETED", onAnalysisStarted: profileStatus.markPending, onUploadSuccess: setMetaData, diff --git a/tests/profile-preparation.browser.mjs b/tests/profile-preparation.browser.mjs new file mode 100644 index 00000000..0017fb94 --- /dev/null +++ b/tests/profile-preparation.browser.mjs @@ -0,0 +1,271 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE_PATH; +const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : "playwright"); +const origin = process.env.INTERVIEW_TEST_ORIGIN ?? "http://127.0.0.1:5173"; +const browser = await chromium.launch({ headless: true }); +let passed = 0; +const report = (label) => { passed++; console.log(`PASS ${label}`); }; +const profile = (status, failureReason = null) => ({ + status, failureReason, jobId: "profile-job", errorMessage: null, errorStatusCode: null, + completedAt: status === "COMPLETED" ? "2026-10-12T10:00:00Z" : null, projectSummary: null, +}); +const question = { questionId: 101, parentId: 0, type: "ORIGINAL", intention: "경험", content: "프로젝트 경험을 설명해주세요.", personaId: 1 }; +const preparation = (preparationStatus, extra = {}) => ({ + status: preparationStatus === "READY" ? "SUCCEEDED" : "WAITING", preparationStatus, + chatDelivered: false, interviewId: 42, questions: preparationStatus === "READY" ? [question] : [], + retryAfterMs: null, retryable: false, ...extra, +}); + +async function fixture(options = {}) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + const state = { + profile: profile("COMPLETED"), profileError: false, materials: true, holdProfile: false, + preparation: preparation("READY"), retryError: false, start404: false, ...options, + profileReads: 0, uploads: 0, retries: 0, analysisRequests: 0, personaCount: 0, preparationReads: [], + }; + const releases = []; + await page.addInitScript(({ shortTimeout }) => { + window.__mediaRequests = 0; + navigator.mediaDevices.getUserMedia = async () => { window.__mediaRequests++; throw new DOMException("test", "NotAllowedError"); }; + window.WebSocket = class extends EventTarget { + static OPEN = 1; + readyState = 1; + send() {} + close() { this.readyState = 3; } + }; + const interval = window.setInterval.bind(window); + window.setInterval = (callback, delay, ...args) => interval(callback, delay === 5_000 ? 100 : delay, ...args); + if (shortTimeout) { + const timeout = window.setTimeout.bind(window); + window.setTimeout = (callback, delay, ...args) => timeout(callback, delay === 20 * 60_000 ? 200 : delay, ...args); + } + }, { shortTimeout: options.shortTimeout }); + await page.route("**/*", async (route) => { + const request = route.request(); + const pathname = new URL(request.url()).pathname; + if (!pathname.startsWith("/api/") && !pathname.startsWith("/chat/")) return route.continue(); + const json = (body, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname.endsWith("/profile/latest")) { + state.profileReads++; + if (state.holdProfile) await new Promise((resolve) => releases.push(resolve)); + return state.profileError ? json({ message: "테스트 상태 조회 실패" }, 503) : json({ success: true, data: state.profile }); + } + if (pathname.endsWith("/getMetaData")) return json({ data: { fileUrl: state.materials ? "/portfolio.pdf" : "", gitUrls: state.materials ? ["https://github.com/example/project"] : [] } }); + if (pathname.endsWith("/dataUpload")) { + assert.match(request.headers()["content-type"], /multipart\/form-data; boundary=/); + assert.match(request.headers().authorization, /^Bearer /); + state.uploads++; + state.profileError = false; + state.profile = profile("PENDING"); + return json({ jobId: "profile-job" }); + } + if (pathname.endsWith("/ai/generate")) { + state.analysisRequests++; + state.profile = profile("PENDING"); + return json({ success: true, data: { jobId: "profile-job" } }); + } + if (pathname === "/api/questions/tailor") { + state.preparationReads.push(Date.now()); + return json({ data: state.preparation }); + } + if (pathname.endsWith("/preparation/retry")) { + state.retries++; + if (state.retryError) return json({ message: "테스트 재시도 요청 실패" }, 503); + state.preparation = preparation("READY"); + return json({ success: true }); + } + if (pathname === "/api/persona/save") return json({ data: { personaId: ++state.personaCount } }); + if (pathname === "/api/interviews/create") return json({ data: { + interviewId: 42, sessionId: "test-session", userId: 7, personaId: 1, personaIds: request.postDataJSON().personaIds ?? [1], + mode: request.postDataJSON().personaIds ? "MULTI" : "SOLO", status: "IN_PROGRESS", + } }); + if (pathname === "/api/interviews/42") return state.start404 ? json({ message: "포트폴리오 분석을 먼저 진행해주세요" }, 404) : json({ data: { interviewId: 42, sessionId: "test-session" } }); + if (pathname.endsWith("/question")) return json(question); + if (pathname.endsWith("/me")) return json({ data: { id: 7, name: "테스트", nickname: "테스트", email: "test@example.com" } }); + if (pathname.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); + return json({ data: [] }); + }); + await page.goto(origin); + await page.getByRole("button", { name: "면접 시작하기", exact: true }).first().waitFor(); + await page.evaluate(async () => { + (await import("/src/shared/api/accessToken.ts")).setAccessToken("test-only-token"); + const url = performance.getEntriesByType("resource").map((entry) => entry.name).find((url) => new URL(url).pathname === "/src/app/router.tsx"); + window.__router = (await import(url)).default; + }); + const navigate = (path) => page.evaluate((path) => window.__router.navigate(path), path); + const interview = () => page.evaluate(() => window.__router.navigate("/main/interview/42", { state: { preparedInterview: { + sessionId: "test-session", interviewId: 42, userId: 7, personaId: 1, personaName: "테스트", role: "TECH", major: "FRONTEND", + type: "FRIENDLY", personaType: "FRIENDLY", level: "EASY", career: 0, gender: "FEMALE", tone: "GENTLE", jobId: "test-job", + status: "IN_PROGRESS", currentQuestionIndex: 0, questions: [], mode: "SOLO", answerMode: "text", questionPreparationStatus: "WAITING", + } } })); + const close = async () => { + state.holdProfile = false; + releases.splice(0).forEach((resolve) => resolve()); + assert.deepEqual(errors, []); + await context.close(); + }; + return { page, state, navigate, interview, close, release: () => { state.holdProfile = false; releases.splice(0).forEach((resolve) => resolve()); } }; +} +const saveButton = (page) => page.getByRole("button", { name: "저장", exact: true }).last(); +async function fillPortfolio(page) { + await page.locator('input[type="file"]').setInputFiles({ name: "portfolio.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF") }); + await page.getByLabel("git", { exact: true }).fill("https://github.com/example/new-project"); + await page.getByRole("button", { name: "Git 주소 추가", exact: true }).click(); +} +async function expectEnabled(locator, enabled) { + await locator.waitFor(); + await locator.page().waitForFunction(({ selector, enabled }) => { + const buttons = [...document.querySelectorAll("button")].filter((button) => button.textContent.trim() === selector); + return buttons.some((button) => !button.disabled === enabled); + }, { selector: await locator.innerText(), enabled }); + assert.equal(await locator.isEnabled(), enabled); +} +try { + const failed = await fixture({ profileError: true, holdProfile: true }); + await failed.navigate("/main/mypage"); + await failed.page.getByText("분석 상태를 확인하고 있어요", { exact: true }).waitFor(); + assert.equal(await saveButton(failed.page).isDisabled(), true); + failed.release(); + await failed.page.getByText("테스트 상태 조회 실패", { exact: true }).waitFor(); + await expectEnabled(saveButton(failed.page), true); + await fillPortfolio(failed.page); + await saveButton(failed.page).click(); + await failed.page.getByText("자료를 분석하고 있어요. 잠시만 기다려 주세요", { exact: true }).waitFor(); + assert.equal(failed.state.uploads, 1); + assert.equal(await failed.page.getByRole("button", { name: "분석 중…", exact: true }).isDisabled(), true); + failed.state.profile = profile("COMPLETED"); + await failed.page.getByText("자료 분석이 완료됐어요. 면접을 시작할 수 있어요", { exact: true }).waitFor(); + await expectEnabled(saveButton(failed.page), true); + assert.equal(await failed.page.getByText("테스트 상태 조회 실패", { exact: true }).count(), 0); + await failed.page.screenshot({ path: "/tmp/repit-profile-recovered.png", fullPage: true }); + await failed.close(); + report("initial loading blocks save; query failure permits authenticated upload and analysis recovery"); + + const completed = await fixture(); + await completed.navigate("/main/mypage"); + await completed.page.getByText("자료 분석이 완료됐어요. 면접을 시작할 수 있어요", { exact: true }).waitFor(); + await fillPortfolio(completed.page); + await saveButton(completed.page).click(); + await completed.page.getByRole("dialog").waitFor(); + assert.equal(completed.state.uploads, 0); + await completed.page.getByRole("button", { name: "취소", exact: true }).click(); + assert.equal(completed.state.uploads, 0); + await saveButton(completed.page).click(); + await completed.page.getByRole("button", { name: "변경하고 분석하기", exact: true }).click(); + await completed.page.getByRole("button", { name: "분석 중…", exact: true }).waitFor(); + assert.equal(completed.state.uploads, 1); + const reads = completed.state.profileReads; + completed.state.profileError = true; + await completed.page.getByText("테스트 상태 조회 실패", { exact: true }).waitFor(); + assert.ok(completed.state.profileReads > reads); + assert.equal(await completed.page.getByRole("button", { name: "분석 중…", exact: true }).isDisabled(), true); + await completed.close(); + report("completed material changes require confirmation; known PENDING stays blocked on polling errors"); + + for (const [reason, action] of [["REPO_NOT_PUBLIC", "다시 저장"], ["INVALID_MATERIAL", "파일 교체"], ["INSUFFICIENT_EVIDENCE", "자료 보강"], ["INTERNAL", "다시 분석"]]) { + const f = await fixture({ profile: profile("FAILED", reason) }); + await f.navigate("/main/mypage"); + await f.page.getByRole("button", { name: action, exact: true }).waitFor(); + await expectEnabled(saveButton(f.page), true); + if (reason === "INTERNAL") { + await f.page.getByRole("button", { name: action, exact: true }).click(); + await f.page.getByText("자료를 분석하고 있어요. 잠시만 기다려 주세요", { exact: true }).waitFor(); + assert.equal(f.state.analysisRequests, 1); + } + await f.close(); + report(`analysis failure recovery action: ${reason}`); + } + + for (const path of ["/main/setting/interview", "/main/setting/interview/multi"]) { + for (const [status, materials, allowed] of [["COMPLETED", false, true], ["NONE", true, true], ["NONE", false, false], ["PENDING", true, false], ["FAILED", true, false], ["ERROR", true, false]]) { + const f = await fixture({ profile: profile(status === "ERROR" ? "COMPLETED" : status, status === "FAILED" ? "INTERNAL" : null), profileError: status === "ERROR", materials }); + await f.navigate(path); + const next = f.page.getByRole("button", { name: status === "PENDING" ? /^분석 중/ : "다음", exact: status !== "PENDING" }); + const guidance = status === "ERROR" ? "테스트 상태 조회 실패" : status === "NONE" ? materials ? "첫 면접은 준비에 몇 분 걸릴 수 있어요" : "마이페이지에서 자료를 등록해 주세요" : status === "PENDING" ? "자료 분석이 끝나면 시작할 수 있어요" : status === "FAILED" ? "분석 중 문제가 생겼어요" : null; + if (guidance) await f.page.getByText(guidance, { exact: true }).waitFor(); + await expectEnabled(next, allowed); + if (status === "FAILED" || (status === "NONE" && !materials)) assert.equal(await f.page.getByRole("link", { name: "마이페이지로 이동", exact: true }).count(), 1); + if (status === "ERROR") { + f.state.profileError = false; + await f.page.getByRole("button", { name: "상태 다시 확인", exact: true }).click(); + await expectEnabled(next, true); + } + await f.close(); + report(`${path}: ${status}, materials=${materials}, start=${allowed}`); + } + } + + for (const path of ["/main/setting/interview", "/main/setting/interview/multi"]) { + const f = await fixture({ start404: true }); + await f.navigate(path); + const next = f.page.getByRole("button", { name: "다음", exact: true }); + await expectEnabled(next, true); + if (path.endsWith("multi")) { + for (const [index, name] of ["김도윤", "이서준", "박지민"].entries()) { + await f.page.getByRole("button", { name: `면접관 ${index + 1} 설정`, exact: true }).click(); + await f.page.getByRole("group", { name: "면접관 선택", exact: true }).getByRole("button", { name, exact: true }).click(); + if (index > 0) await f.page.getByRole("group", { name: "역할", exact: true }).getByRole("button", { name: index === 1 ? "HR" : "Design", exact: true }).click(); + } + } + await next.click(); + await f.page.getByText("포트폴리오 분석을 먼저 진행해주세요", { exact: true }).waitFor(); + assert.equal(await f.page.getByRole("link", { name: "마이페이지로 이동", exact: true }).count(), 1); + await f.close(); + report(`${path}: start 404 links to materials`); + } + + const waiting = await fixture({ preparation: preparation("PREPARING", { chatDelivered: true, retryAfterMs: 4_500 }) }); + await waiting.interview(); + await waiting.page.getByText("새 질문을 준비하고 있어요. 길게는 몇 분 걸릴 수 있어요", { exact: true }).waitFor(); + assert.equal(await waiting.page.getByRole("textbox").count(), 0); + waiting.state.preparation = preparation("READY"); + await waiting.page.getByRole("textbox").first().waitFor(); + assert.ok(waiting.state.preparationReads[1] - waiting.state.preparationReads[0] >= 4_300, "server polling delay is respected"); + assert.equal(await waiting.page.evaluate(() => window.__mediaRequests), 0); + await waiting.close(); + report("WAITING stays in preparation even if chatDelivered; retryAfterMs delays poll; READY starts text interview"); + + const retry = await fixture({ preparation: preparation("FAILED", { status: "FAILED", retryable: true, failureStage: "CHAT_DELIVERY" }), retryError: true }); + await retry.interview(); + await retry.page.getByText("채팅 면접을 준비하지 못했습니다.", { exact: true }).waitFor(); + await retry.page.getByRole("button", { name: "다시 시도", exact: true }).click(); + await retry.page.getByText("테스트 재시도 요청 실패", { exact: true }).waitFor(); + retry.state.retryError = false; + await retry.page.getByRole("button", { name: "다시 시도", exact: true }).click(); + await retry.page.getByRole("textbox").first().waitFor(); + assert.equal(retry.state.retries, 2); + await retry.close(); + report("retryable failure preserves retry on request error then resumes preparation successfully"); + + const noRetry = await fixture({ preparation: preparation("FAILED", { status: "FAILED", failureStage: "QUESTION_GENERATION" }) }); + await noRetry.interview(); + await noRetry.page.getByText("면접 질문 준비에 실패했습니다.", { exact: true }).waitFor(); + assert.equal(await noRetry.page.getByRole("button", { name: "다시 시도", exact: true }).count(), 0); + await noRetry.close(); + report("non-retryable preparation failure has no retry button"); + + const timeout = await fixture({ preparation: preparation("PREPARING"), shortTimeout: true }); + await timeout.interview(); + await timeout.page.getByText("면접 준비 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.", { exact: true }).waitFor(); + assert.equal(await timeout.page.getByRole("button", { name: "다시 시도", exact: true }).count(), 0); + await timeout.close(); + report("20-minute safety timeout terminates preparation"); + + const cleanup = await fixture({ preparation: preparation("PREPARING") }); + await cleanup.interview(); + await cleanup.page.getByText("새 질문을 준비하고 있어요. 길게는 몇 분 걸릴 수 있어요", { exact: true }).waitFor(); + await cleanup.navigate("/main"); + await cleanup.page.waitForURL("**/main"); + const preparationReads = cleanup.state.preparationReads.length; + await cleanup.page.waitForTimeout(3_200); + assert.equal(cleanup.state.preparationReads.length, preparationReads); + await cleanup.close(); + report("leaving preparation cancels its polling timer"); + console.log(`${passed} profile/preparation scenarios passed`); +} finally { await browser.close(); } From f0dfa7bd56ca68cad8ab8ac3e1d2312f6d024ba5 Mon Sep 17 00:00:00 2001 From: han0911 Date: Tue, 13 Oct 2026 22:44:30 +0900 Subject: [PATCH 26/88] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=C2=B7?= =?UTF-8?q?=EB=A9=B4=EC=A0=91=20=EC=9E=90=EB=A3=8C=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/my-page/index.tsx | 32 +- src/pages/my-page/style.ts | 53 +- src/widgets/my-page/personal-info/index.tsx | 17 +- src/widgets/my-page/personal-info/style.ts | 191 ++++--- src/widgets/my-page/portfolio/index.tsx | 162 +++--- src/widgets/my-page/portfolio/style.ts | 562 ++++++++----------- src/widgets/my-page/profile-status/index.tsx | 55 +- src/widgets/my-page/profile-status/style.ts | 62 +- tests/profile-preparation.browser.mjs | 2 +- 9 files changed, 578 insertions(+), 558 deletions(-) diff --git a/src/pages/my-page/index.tsx b/src/pages/my-page/index.tsx index 01a596a6..f11a0cd0 100644 --- a/src/pages/my-page/index.tsx +++ b/src/pages/my-page/index.tsx @@ -69,18 +69,26 @@ const MyPage = () => { return ( - - - void profileStatus.refetch()} - onReanalyze={() => void profileStatus.reanalyze()} - onEdit={() => portfolioForm.gitInputRef.current?.focus()} - onReplaceFile={portfolioForm.onPortfolioUploadClick} - onSave={() => void portfolioForm.onPortfolioSave()} - isSaving={portfolioForm.isSaving} - /> - + + 마이페이지 + + 나의 정보와 경험을 채우고, 나에게 맞는 면접을 준비하세요. + + + + + + void profileStatus.refetch()} + onReanalyze={() => void profileStatus.reanalyze()} + onEdit={() => portfolioForm.gitInputRef.current?.focus()} + onReplaceFile={portfolioForm.onPortfolioUploadClick} + onSave={() => void portfolioForm.onPortfolioSave()} + isSaving={portfolioForm.isSaving} + /> + + {portfolioForm.isChangeConfirmationOpen ? ( theme.colors.text.default}; + + button, input { + font-family: inherit; + } - @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; + @media (max-width: 56rem) { + width: min(calc(100% - 2rem), 42rem); + padding-top: 1rem; } +`; + +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: 18rem minmax(0, 1fr); + align-items: start; + 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/widgets/my-page/personal-info/index.tsx b/src/widgets/my-page/personal-info/index.tsx index 3e98661c..b935fb40 100644 --- a/src/widgets/my-page/personal-info/index.tsx +++ b/src/widgets/my-page/personal-info/index.tsx @@ -31,9 +31,16 @@ const PersonalInfo = ({ }; return ( - + + + + {draft.nickname || draft.name || "회원"}님 + 나만의 면접을 준비하는 공간 + - 개인정보 + 개인정보 {isLoading ? ( 유저 정보를 불러오는 중입니다. @@ -73,7 +80,11 @@ const PersonalInfo = ({ autoComplete="email" value={email} readOnly + aria-describedby="my-page-email-hint" /> + + 이메일은 변경할 수 없어요. + )} @@ -88,7 +99,7 @@ const PersonalInfo = ({ )} - {isSaving ? "저장 중" : "저장"} + {isSaving ? "저장 중…" : "개인정보 저장"} diff --git a/src/widgets/my-page/personal-info/style.ts b/src/widgets/my-page/personal-info/style.ts index 05c41ca0..f9663756 100644 --- a/src/widgets/my-page/personal-info/style.ts +++ b/src/widgets/my-page/personal-info/style.ts @@ -1,98 +1,103 @@ import styled from "styled-components"; export const PersonalInfoWrapper = styled.section` - width: 100%; min-width: 0; - min-height: 18.25rem; - padding: 1.55rem 1.5rem 1.25rem; - border: 0.0625rem solid #dce2ec; - border-radius: 0.75rem; - background-color: rgba(255, 255, 255, 0.94); - - @media (min-width: 90rem) { - min-height: 19.125rem; - } + border: 1px solid #e1e7ef; + border-radius: ${({ theme }) => theme.radius.lg}; + background: ${({ theme }) => theme.colors.white}; + box-shadow: 0 4px 24px rgba(34, 55, 88, 0.025); + overflow: hidden; +`; - @media (max-width: 56rem) { - min-height: auto; - } +export const ProfileHeader = styled.div` + display: flex; + flex-direction: column; + align-items: center; + padding: 2rem 1.5rem 1.75rem; + border-bottom: 1px solid #edf0f5; + background: linear-gradient(180deg, #f5f9ff 0%, #ffffff 100%); + text-align: center; `; -export const Form = styled.form` - position: relative; - min-height: 15.4rem; +export const Avatar = styled.div` + display: grid; + place-items: center; + width: 3.5rem; + height: 3.5rem; + margin-bottom: ${({ theme }) => theme.spacing.md}; + border: 1px solid #dce9fc; + border-radius: ${({ theme }) => theme.radius.circle}; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.lg}; + font-weight: 700; +`; - @media (min-width: 90rem) { - min-height: 16.15rem; - } +export const ProfileName = styled.p` + max-width: 100%; + color: #25324a; + font-size: 1.125rem; + font-weight: 700; + line-height: 1.5; + overflow-wrap: anywhere; +`; + +export const ProfileCaption = styled.p` + margin-top: 0.375rem; + color: #778398; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.5; +`; + +export const Form = styled.form` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.lg}; + padding: ${({ theme }) => theme.spacing.lg}; `; export const Title = styled.h2` - margin: 0 0 1.7rem; - color: #46506a; - font-size: 1.25rem; + color: #344159; + font-size: ${({ theme }) => theme.fontSize.md}; font-weight: 700; - line-height: 1.2; - - @media (min-width: 90rem) { - font-size: 1.125rem; - } + line-height: 1.5; `; export const InfoList = styled.div` display: flex; flex-direction: column; - gap: 1.25rem; - margin-left: 0.5rem; - - @media (min-width: 90rem) { - gap: 1.5rem; - } + gap: 1.125rem; `; export const InfoRow = styled.div` - display: grid; - grid-template-columns: 3.4rem minmax(0, 1fr); - align-items: center; - column-gap: 0.35rem; - - @media (min-width: 90rem) { - grid-template-columns: 5.1rem minmax(0, 1fr); - } + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing.sm}; `; export const Label = styled.label` - color: #0875ef; - font-size: 0.95rem; + color: #566176; + font-size: ${({ theme }) => theme.fontSize.sm}; font-weight: 600; - line-height: 1.2; - - @media (min-width: 90rem) { - font-size: 0.875rem; - } + line-height: 1.4; `; export const EditInput = styled.input` width: 100%; min-width: 0; - height: 1.85rem; - padding: 0 0.8rem; - border: 0.0625rem solid #e0e4ec; - border-radius: 0.38rem; + height: 2.75rem; + padding: 0 0.875rem; + border: 1px solid #dfe5ed; + border-radius: ${({ theme }) => theme.radius.sm}; outline: none; - background-color: #ffffff; - color: #525c72; - font-size: 0.88rem; - font-weight: 500; + background: ${({ theme }) => theme.colors.white}; + color: #344159; + font-size: ${({ theme }) => theme.fontSize.sm}; transition: border-color 0.2s ease, box-shadow 0.2s ease; - @media (min-width: 90rem) { - font-size: 0.75rem; - } - &:focus-visible { - border-color: #247bed; - box-shadow: 0 0 0 0.15rem rgba(36, 123, 237, 0.13); + border-color: ${({ theme }) => theme.colors.brand.blue}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.colors.surface.blueLight}; } &:disabled { @@ -101,54 +106,54 @@ export const EditInput = styled.input` } &:read-only { - background-color: #f5f6f9; - color: #8a93a6; - cursor: default; + background: ${({ theme }) => theme.colors.surface.muted}; + color: #788397; + } + + @media (max-width: 36rem) { + font-size: ${({ theme }) => theme.fontSize.md}; } `; -export const StatusText = styled.p` - color: #68738a; - font-size: 0.82rem; - font-weight: 600; +export const FieldHint = styled.p` + color: #7b8799; + font-size: ${({ theme }) => theme.fontSize.xs}; line-height: 1.5; `; +export const StatusText = styled.p` + color: #68758a; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.6; +`; + export const ResultMessage = styled.p<{ $isError: boolean }>` - position: absolute; - right: 0; - bottom: 1.7rem; - left: 0; - overflow: hidden; - color: ${({ $isError }) => ($isError ? "#d64f4f" : "#1976e8")}; - font-size: 0.7rem; - font-weight: 600; - line-height: 1.3; - text-overflow: ellipsis; - white-space: nowrap; + color: ${({ $isError, theme }) => + $isError ? "#c24141" : theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.6; + overflow-wrap: anywhere; `; export const SaveButton = styled.button` - position: absolute; - right: 0; - bottom: 0; - border: none; - padding: 0; - background-color: transparent; - color: #0875ef; - font-size: 0.78rem; + width: 100%; + min-height: 2.75rem; + border: 1px solid #dce7f8; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.sm}; font-weight: 600; cursor: pointer; - transition: color 0.2s ease; + transition: background-color 0.2s ease; &:hover:not(:disabled) { - color: #005ec9; + background: #e3efff; } &:focus-visible { - border-radius: 0.2rem; - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: 0.2rem; + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 3px; } &:disabled { diff --git a/src/widgets/my-page/portfolio/index.tsx b/src/widgets/my-page/portfolio/index.tsx index a08d96f7..594fac3a 100644 --- a/src/widgets/my-page/portfolio/index.tsx +++ b/src/widgets/my-page/portfolio/index.tsx @@ -1,7 +1,5 @@ import type { ChangeEvent, KeyboardEvent, RefObject, ReactNode } from "react"; -import MyPageFileImage from "@/shared/img/my-page/Repit-mypage.svg?url"; - import * as S from "./style"; interface PortfolioProps { @@ -27,18 +25,22 @@ interface PortfolioProps { selectedPortfolioFile: File | null; } +const DocumentIcon = () => ( + +); + +const GithubIcon = () => ( + +); + const TrashIcon = () => ( - UP - - - - - + {children} - {selectedPortfolioFile && ( - - {selectedPortfolioFile.name} - + + + 포트폴리오 + 필수 + + + 프로젝트와 경험이 담긴 PDF 파일을 첨부해 주세요. + + + + + + + {selectedPortfolioFile?.name || "포트폴리오 파일 선택"} + + + {selectedPortfolioFile ? "PDF · 저장하면 자료 분석이 시작돼요" : "PDF 형식의 파일 1개를 등록할 수 있어요"} + + + - + - - - )} - - {fileError && {fileError}} - - - git + {selectedPortfolioFile ? "파일 변경" : "+ 파일 첨부"} + + + + {fileError && {fileError}} + 경험을 구체적으로 담을수록 더 알맞은 질문을 받을 수 있어요. + + + + + + + GitHub + + 필수 + + + 나의 개발 경험을 보여줄 GitHub 주소를 추가해 주세요. + + @@ -130,15 +151,16 @@ const Portfolio = ({ disabled={isGitAddDisabled} onClick={onGitAdd} > - + + 추가 {gitUrls.length > 0 && ( - + {gitUrls.map((gitUrl) => ( - {gitUrl} + + {gitUrl} )} - + {(saveMessage || saveError) && ( @@ -158,19 +180,15 @@ const Portfolio = ({ )} - - - 돌아가기 - - - - {isSaving ? "저장 중..." : isAnalyzing ? "분석 중…" : "저장"} - - + + 자료를 저장하면 AI가 분석을 시작해요. + + 돌아가기 + + {isSaving ? "저장 중..." : isAnalyzing ? "분석 중…" : "저장"} + + + ); }; diff --git a/src/widgets/my-page/portfolio/style.ts b/src/widgets/my-page/portfolio/style.ts index 70e6b2b3..3b39c1e1 100644 --- a/src/widgets/my-page/portfolio/style.ts +++ b/src/widgets/my-page/portfolio/style.ts @@ -1,450 +1,350 @@ -import styled from "styled-components"; +import styled, { css } from "styled-components"; -export const PortfolioWrapper = styled.section` - width: 100%; - min-width: 0; - min-height: 40.625rem; - padding: 2rem 2rem 0.8rem; - border: 0.0625rem solid #dce2ec; - border-radius: 0.75rem; - background-color: rgba(255, 255, 255, 0.96); - - @media (min-width: 90rem) { - min-height: 37.375rem; +const buttonStyle = css` + min-height: 2.75rem; + padding: 0 1.125rem; + border: 1px solid #dfe5ed; + border-radius: ${({ theme }) => theme.radius.sm}; + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: 600; + white-space: nowrap; + cursor: pointer; + transition: background-color 0.2s ease, border-color 0.2s ease; + + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 3px; } - @media (max-width: 56rem) { - min-height: auto; - padding: 1.6rem 1.5rem 1.4rem; + &:disabled { + cursor: not-allowed; + opacity: 0.5; } +`; + +export const PortfolioWrapper = styled.section` + min-width: 0; + padding: ${({ theme }) => theme.spacing.xl}; + border: 1px solid #e1e7ef; + border-radius: ${({ theme }) => theme.radius.lg}; + background: ${({ theme }) => theme.colors.white}; + box-shadow: 0 4px 24px rgba(34, 55, 88, 0.025); @media (max-width: 36rem) { - padding: 1.3rem 1rem 1.2rem; + padding: 1.5rem 1.25rem; } `; +export const Header = styled.div` + margin-bottom: ${({ theme }) => theme.spacing.lg}; +`; + export const Title = styled.h2` - margin: 0 0 0.85rem; - color: #46506a; + margin-bottom: ${({ theme }) => theme.spacing.sm}; + color: #25324a; font-size: 1.25rem; font-weight: 700; - line-height: 1.2; - - @media (min-width: 90rem) { - font-size: 1.125rem; - } + line-height: 1.4; + letter-spacing: -0.025em; `; -export const UploadBox = styled.div` - position: relative; - width: 100%; - min-height: 11.4rem; - overflow: hidden; - border: 0.0625rem solid #dce1e9; - border-radius: 0.45rem; - background-color: #ffffff; - - @media (min-width: 90rem) { - min-height: 12rem; - } +export const Description = styled.p` + color: #748095; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.6; + word-break: keep-all; `; -export const GuideBadge = styled.div` - position: absolute; - top: 0.7rem; - right: 0.75rem; - z-index: 1; - padding: 0.45rem 0.65rem; - border-radius: 0.5rem; - background-color: #eef4ff; - color: #2f3545; - font-size: 0.65rem; - font-weight: 700; - line-height: 1; +export const Section = styled.section` + margin-top: 1.75rem; - span { - color: #397df1; - } - - @media (max-width: 36rem) { - font-size: 0.58rem; + & + & { + padding-top: 1.75rem; + border-top: 1px solid #edf0f5; } `; -export const UploadContent = styled.div` - min-height: 11.4rem; +export const SectionHeading = styled.div` display: flex; - flex-direction: column; align-items: center; - justify-content: center; - gap: 0.55rem; - padding: 2.3rem 1rem 1rem; - text-align: center; - - @media (min-width: 90rem) { - min-height: 12rem; - } + gap: ${({ theme }) => theme.spacing.sm}; + margin-bottom: ${({ theme }) => theme.spacing.sm}; `; -export const FileIcon = styled.img` - width: auto; - height: 4.1rem; - display: block; - margin-left: 0.4rem; - - @media (min-width: 90rem) { - height: 4.375rem; - } +export const SectionNumber = styled.span` + margin-right: 0.125rem; + color: #95a4ba; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: 600; + font-variant-numeric: tabular-nums; `; -export const UploadText = styled.p` - margin: 0; - color: #50576a; - font-size: 0.83rem; - font-weight: 550; - line-height: 1.4; - - span { - color: #8a90a2; - font-size: 0.7rem; - } +export const SectionTitle = styled.h3` + color: #344159; + font-size: ${({ theme }) => theme.fontSize.md}; + font-weight: 650; + line-height: 1.5; `; -export const HiddenFileInput = styled.input` - display: none; -`; - -export const UploadButton = styled.button` - min-width: 5.3rem; - height: 1.75rem; - margin-top: 0.05rem; - padding: 0 0.85rem; - border: none; - border-radius: 0.45rem; - background-color: #323b5a; - color: #ffffff; - font-size: 0.72rem; - font-weight: 700; +export const Label = styled.label` cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - - @media (min-width: 90rem) { - height: 1.875rem; - } - - &:hover { - background-color: #242d4c; - transform: translateY(-0.0625rem); - } +`; - &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: 0.15rem; - } +export const RequiredBadge = styled.span` + padding: 0.1875rem 0.4375rem; + border-radius: 0.25rem; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; `; -export const SelectedFileRow = styled.div` - width: 100%; - min-height: 2.75rem; +export const UploadBox = styled.div<{ $hasFile: boolean; $hasError: boolean }>` display: flex; align-items: center; - gap: 0.75rem; - margin-top: 0.75rem; - padding: 0.25rem 0.45rem 0.25rem 0.75rem; - border: 0.0625rem solid #cfd7e3; - border-radius: 0.45rem; - background-color: #ffffff; - - @media (min-width: 90rem) { - min-height: 2.5rem; + gap: ${({ theme }) => theme.spacing.md}; + margin-top: ${({ theme }) => theme.spacing.md}; + padding: 1.5rem 1.25rem; + border: 1px ${({ $hasFile }) => ($hasFile ? "solid" : "dashed")} + ${({ $hasError, $hasFile }) => $hasError ? "#d77a7a" : $hasFile ? "#b8d3f6" : "#ccd8e8"}; + border-radius: ${({ theme }) => theme.radius.md}; + background: #f8faff; + + @media (max-width: 64rem) and (min-width: 56.01rem), (max-width: 36rem) { + flex-wrap: wrap; + padding: 1.125rem; } `; -export const SelectedFileName = styled.span` +export const FileIcon = styled.div` + display: grid; + place-items: center; + flex: 0 0 2.75rem; + height: 2.75rem; + border: 1px solid #e2eaf6; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.brand.blue}; +`; + +export const UploadContent = styled.div` flex: 1; min-width: 0; +`; + +export const UploadTitle = styled.p` overflow: hidden; - color: #56617a; - font-size: 0.78rem; - font-weight: 500; - line-height: 1.4; + color: #42516b; + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: 600; + line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; `; -export const ReplaceFileButton = styled.button` - width: 1.25rem; - height: 1.25rem; - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; - border: none; - border-radius: 50%; - background-color: #343c59; - color: #ffffff; - font-size: 1rem; - font-weight: 400; - line-height: 1; - cursor: pointer; - transition: background-color 0.2s ease; - - &:hover { - background-color: #1976e8; - } - - &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: 0.15rem; - } +export const UploadText = styled.p` + margin-top: 0.375rem; + color: #748095; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.6; + word-break: keep-all; `; -export const FieldError = styled.p` - margin: 0.4rem 0 0; - color: #d64f4f; - font-size: 0.72rem; - font-weight: 600; - line-height: 1.35; +export const HiddenFileInput = styled.input` + display: none; `; -export const InputSection = styled.div` - margin-top: 1.65rem; +export const UploadButton = styled.button` + ${buttonStyle} + flex-shrink: 0; + min-height: 2.5rem; + padding: 0 0.875rem; + background: ${({ theme }) => theme.colors.white}; + color: #465b79; + font-size: ${({ theme }) => theme.fontSize.xs}; + + &:hover { + border-color: ${({ theme }) => theme.colors.brand.blueLight}; + background: ${({ theme }) => theme.colors.surface.blueLight}; + } - @media (min-width: 90rem) { - margin-top: 1.75rem; + @media (max-width: 64rem) and (min-width: 56.01rem), (max-width: 36rem) { + width: 100%; + min-height: 2.75rem; } `; -export const Label = styled.label` - display: block; - margin: 0 0 0.65rem; - color: #46506a; - font-size: 1.2rem; - font-weight: 700; - line-height: 1.2; +export const FieldHint = styled.p` + margin-top: 0.625rem; + color: #7b8799; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.6; + word-break: keep-all; +`; - @media (min-width: 90rem) { - font-size: 1.125rem; - } +export const FieldError = styled(FieldHint)` + color: #c24141; `; export const GitInputWrapper = styled.div` - width: 100%; - height: 2.25rem; display: flex; align-items: center; - overflow: hidden; - border: 0.0625rem solid #d6dce5; - border-radius: 0.45rem; - background-color: #f9fafc; + gap: 0.625rem; + min-height: 3rem; + margin-top: ${({ theme }) => theme.spacing.md}; + padding: 0.25rem 0.375rem 0.25rem 0.875rem; + border: 1px solid #dfe5ed; + border-radius: ${({ theme }) => theme.radius.sm}; transition: border-color 0.2s ease, box-shadow 0.2s ease; - @media (min-width: 90rem) { - height: 2.5rem; - } - &:focus-within { - border-color: #3b82f6; - box-shadow: 0 0 0 0.14rem rgba(59, 130, 246, 0.12); + border-color: ${({ theme }) => theme.colors.brand.blue}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.colors.surface.blueLight}; } `; +export const InputIcon = styled.span` + display: flex; + flex-shrink: 0; + color: #8a96a8; +`; + export const Input = styled.input` flex: 1; min-width: 0; - height: 100%; - padding: 0 0.8rem; + width: 100%; + height: 2.375rem; + padding: 0; border: none; outline: none; - background-color: transparent; - color: #47516a; - font-size: 0.75rem; + background: transparent; + color: #344159; + font-size: ${({ theme }) => theme.fontSize.sm}; - &::placeholder { - color: #8d98ad; + &::placeholder { color: #8b96a8; } + + @media (max-width: 36rem) { + font-size: ${({ theme }) => theme.fontSize.md}; } `; export const GitAddButton = styled.button` - width: 2.25rem; - height: 100%; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - background-color: transparent; - color: #46506a; - font-size: 1.45rem; - font-weight: 500; - line-height: 1; - cursor: pointer; - transition: color 0.2s ease, opacity 0.2s ease; - - &:hover:not(:disabled) { - color: #1976e8; - } - - &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: -0.2rem; - } + ${buttonStyle} + min-height: 2.25rem; + padding: 0 0.875rem; + border-color: transparent; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.xs}; - &:disabled { - cursor: default; - opacity: 0.45; - } + &:hover:not(:disabled) { background: #e3efff; } `; -export const GitUrlList = styled.div` +export const GitUrlList = styled.ul` display: flex; flex-direction: column; - gap: 0.55rem; - margin-top: 0.65rem; + gap: ${({ theme }) => theme.spacing.sm}; + margin-top: 0.75rem; `; -export const GitUrlRow = styled.div` - width: 100%; - min-height: 2.25rem; +export const GitUrlRow = styled.li` display: flex; align-items: center; - gap: 0.65rem; - padding: 0 0.45rem 0 0.8rem; - border: 0.0625rem solid #cfd7e3; - border-radius: 0.45rem; - background-color: #ffffff; - - @media (min-width: 90rem) { - min-height: 2.5rem; - } + gap: 0.75rem; + min-height: 2.75rem; + padding: 0.25rem 0.375rem 0.25rem 0.875rem; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.surface.muted}; +`; + +export const LinkIndicator = styled.span` + color: #8a96a8; + font-size: ${({ theme }) => theme.fontSize.md}; `; export const GitUrlText = styled.span` flex: 1; min-width: 0; overflow: hidden; - color: #7b869c; - font-size: 0.75rem; - font-weight: 500; - line-height: 1.4; + color: #596a82; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; `; export const GitRemoveButton = styled.button` - width: 1.7rem; - height: 1.7rem; - display: inline-flex; - flex: 0 0 auto; - align-items: center; - justify-content: center; + display: grid; + place-items: center; + flex: 0 0 2.25rem; + height: 2.25rem; border: none; - border-radius: 0.3rem; - background-color: transparent; - color: #596076; + border-radius: ${({ theme }) => theme.radius.sm}; + background: transparent; + color: #8290a4; cursor: pointer; - transition: background-color 0.2s ease, color 0.2s ease; - - &:hover { - background-color: #eef4ff; - color: #1976e8; - } + &:hover { background: #fceeee; color: #c24141; } &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 2px; } `; export const SaveStatus = styled.p<{ $isError: boolean }>` - margin: 0.9rem 0 0; - color: ${({ $isError }) => ($isError ? "#d64f4f" : "#1976e8")}; - font-size: 0.75rem; - font-weight: 700; - line-height: 1.4; - text-align: right; + margin-top: ${({ theme }) => theme.spacing.md}; + color: ${({ $isError, theme }) => $isError ? "#c24141" : theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.sm}; + line-height: 1.6; + overflow-wrap: anywhere; `; -export const ButtonWrapper = styled.div` +export const Footer = styled.div` display: flex; - justify-content: flex-end; - gap: 1.4rem; - margin-top: 2.75rem; - - @media (min-width: 90rem) { - margin-top: 4rem; - } + align-items: center; + justify-content: space-between; + gap: ${({ theme }) => theme.spacing.md}; + margin-top: ${({ theme }) => theme.spacing.xl}; + padding-top: ${({ theme }) => theme.spacing.lg}; + border-top: 1px solid #edf0f5; - @media (max-width: 36rem) { - gap: 0.75rem; - margin-top: 2rem; + @media (max-width: 64rem) { + flex-direction: column; + align-items: stretch; } `; -export const BackButton = styled.button` - width: 8rem; - height: 2.5rem; - border: none; - border-radius: 0.5rem; - background-color: #f1f2f5; - color: #2175e6; - font-size: 0.9rem; - font-weight: 700; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; +export const FooterHint = styled.p` + color: #7b8799; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.6; +`; - @media (min-width: 90rem) { - width: 8.5rem; - } +export const ButtonWrapper = styled.div` + display: flex; + justify-content: flex-end; + gap: ${({ theme }) => theme.spacing.sm}; +`; - &:hover { - background-color: #e8ebf2; - transform: translateY(-0.0625rem); - } +export const BackButton = styled.button` + ${buttonStyle} + background: ${({ theme }) => theme.colors.white}; + color: #68758a; - &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: 0.15rem; - } + &:hover { background: ${({ theme }) => theme.colors.surface.muted}; } - @media (max-width: 36rem) { - flex: 1; - min-width: 0; - } + @media (max-width: 36rem) { flex: 1; } `; export const SaveButton = styled.button` - width: 8rem; - height: 2.5rem; - border: none; - border-radius: 0.5rem; - background-color: #1976e8; - color: #ffffff; - font-size: 0.9rem; - font-weight: 700; - cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease; - - @media (min-width: 90rem) { - width: 8.5rem; - } + ${buttonStyle} + min-width: 6rem; + border-color: ${({ theme }) => theme.colors.brand.blue}; + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; - &:hover:not(:disabled) { - background-color: #0f67d6; - transform: translateY(-0.0625rem); - } - - &:focus-visible { - outline: 0.125rem solid rgba(36, 123, 237, 0.45); - outline-offset: 0.15rem; - } - - &:disabled { - cursor: not-allowed; - opacity: 0.65; - transform: none; - } + &:hover:not(:disabled) { background: #1268d8; border-color: #1268d8; } - @media (max-width: 36rem) { - flex: 1; - min-width: 0; - } + @media (max-width: 36rem) { flex: 1; } `; diff --git a/src/widgets/my-page/profile-status/index.tsx b/src/widgets/my-page/profile-status/index.tsx index 171fd448..51cfcfcc 100644 --- a/src/widgets/my-page/profile-status/index.tsx +++ b/src/widgets/my-page/profile-status/index.tsx @@ -22,8 +22,20 @@ const STATUS_TEXT = { FAILED: "자료 분석을 완료하지 못했어요", }; -const ProfileStatusCard = ({ profile, isLoading, errorMessage, isRestarting, - onRefresh, onReanalyze, onEdit, onReplaceFile, onSave, isSaving }: ProfileStatusCardProps) => { +const ProfileStatusCard = ({ + profile, + isLoading, + errorMessage, + isRestarting, + onRefresh, + onReanalyze, + onEdit, + onReplaceFile, + onSave, + isSaving, +}: ProfileStatusCardProps) => { + const hasError = !!errorMessage || profile?.status === "FAILED"; + const isComplete = !isLoading && !hasError && profile?.status === "COMPLETED"; const failureReason = profile?.failureReason; const action = failureReason === "REPO_NOT_PUBLIC" ? onSave : failureReason === "INVALID_MATERIAL" ? onReplaceFile @@ -33,22 +45,29 @@ const ProfileStatusCard = ({ profile, isLoading, errorMessage, isRestarting, : failureReason === "INSUFFICIENT_EVIDENCE" ? "자료 보강" : "다시 분석"; return ( - - 자료 분석 상태 - {isLoading ? "분석 상태를 확인하고 있어요" - : profile?.status === "FAILED" ? getProfileFailureMessage(profile) - : profile ? STATUS_TEXT[profile.status] : "분석 상태를 확인하지 못했어요"} - {profile?.status === "FAILED" ? ( - - {isRestarting ? "분석 요청 중…" : actionLabel} - - ) : null} - {errorMessage ? ( - <> - {errorMessage} - 상태 다시 확인 - - ) : null} + + + + 자료 분석 상태 + + {isLoading ? "분석 상태를 확인하고 있어요" + : profile?.status === "FAILED" ? getProfileFailureMessage(profile) + : profile ? STATUS_TEXT[profile.status] : "분석 상태를 확인하지 못했어요"} + + {profile?.status === "FAILED" ? ( + + {isRestarting ? "분석 요청 중…" : actionLabel} + + ) : null} + {errorMessage ? ( + <> + {errorMessage} + 상태 다시 확인 + + ) : null} + ); }; diff --git a/src/widgets/my-page/profile-status/style.ts b/src/widgets/my-page/profile-status/style.ts index 6e0f11fb..4df732e8 100644 --- a/src/widgets/my-page/profile-status/style.ts +++ b/src/widgets/my-page/profile-status/style.ts @@ -1,28 +1,64 @@ import styled from "styled-components"; -export const Card = styled.section` - margin-bottom: ${({ theme }) => theme.spacing.lg}; - padding: ${({ theme }) => theme.spacing.md}; - border-radius: ${({ theme }) => theme.radius.sm}; - background: ${({ theme }) => theme.colors.surface.blueLight}; - color: ${({ theme }) => theme.colors.text.default}; +export const Card = styled.section<{ $hasError: boolean }>` + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 1rem 1.125rem; + border: 1px solid ${({ $hasError }) => $hasError ? "#f0d9c5" : "#e0eafa"}; + border-radius: ${({ theme }) => theme.radius.md}; + background: ${({ $hasError }) => $hasError ? "#fff9f2" : "#f4f8ff"}; +`; + +export const StatusIcon = styled.span<{ $isComplete: boolean }>` + display: grid; + place-items: center; + flex: 0 0 1.25rem; + height: 1.25rem; + margin-top: 0.125rem; + border-radius: ${({ theme }) => theme.radius.circle}; + background: ${({ $isComplete }) => $isComplete ? "#e0f2ed" : "#e4edfb"}; + color: ${({ $isComplete, theme }) => $isComplete ? "#2d806d" : theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: 700; `; + +export const Content = styled.div` + flex: 1; + min-width: 0; +`; + export const Title = styled.h3` - margin: 0 0 ${({ theme }) => theme.spacing.sm}; - font-size: ${({ theme }) => theme.fontSize.sm}; + margin-bottom: 0.25rem; + color: #486080; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: 650; + line-height: 1.6; `; + export const Message = styled.p` - margin: 0; + color: #65758e; font-size: ${({ theme }) => theme.fontSize.sm}; line-height: 1.6; + overflow-wrap: anywhere; `; + export const Action = styled.button` + min-height: 2.25rem; margin-top: ${({ theme }) => theme.spacing.sm}; - padding: ${({ theme }) => theme.spacing.sm} ${({ theme }) => theme.spacing.md}; - border: 0; + padding: 0.5rem 0.75rem; + border: 1px solid #dce5f2; border-radius: ${({ theme }) => theme.radius.sm}; - background: ${({ theme }) => theme.colors.brand.blue}; - color: ${({ theme }) => theme.colors.white}; + background: ${({ theme }) => theme.colors.white}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: 600; cursor: pointer; + + &:hover:not(:disabled) { background: ${({ theme }) => theme.colors.surface.blueLight}; } + &:focus-visible { + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 2px; + } &:disabled { opacity: 0.6; cursor: not-allowed; } `; diff --git a/tests/profile-preparation.browser.mjs b/tests/profile-preparation.browser.mjs index 0017fb94..f6c3e2b0 100644 --- a/tests/profile-preparation.browser.mjs +++ b/tests/profile-preparation.browser.mjs @@ -115,7 +115,7 @@ async function fixture(options = {}) { const saveButton = (page) => page.getByRole("button", { name: "저장", exact: true }).last(); async function fillPortfolio(page) { await page.locator('input[type="file"]').setInputFiles({ name: "portfolio.pdf", mimeType: "application/pdf", buffer: Buffer.from("%PDF-1.4\n%%EOF") }); - await page.getByLabel("git", { exact: true }).fill("https://github.com/example/new-project"); + await page.getByRole("textbox", { name: "GitHub", exact: true }).fill("https://github.com/example/new-project"); await page.getByRole("button", { name: "Git 주소 추가", exact: true }).click(); } async function expectEnabled(locator, enabled) { From 4d04cfaaee66007d935ae910e98afe3fce65f1d7 Mon Sep 17 00:00:00 2001 From: han0911 Date: Tue, 13 Oct 2026 22:50:39 +0900 Subject: [PATCH 27/88] =?UTF-8?q?fix:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EA=B0=9C=EB=B0=9C=C2=B7=EB=B9=84=EA=B0=9C?= =?UTF-8?q?=EB=B0=9C=20=EC=A7=81=EB=AC=B4=20=EC=84=A0=ED=83=9D=20UI=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../job-selection/model/useJobSelection.ts | 30 ++++ src/pages/my-page/index.tsx | 10 +- src/shared/constants/my-page/job.ts | 26 +++ src/widgets/my-page/job-selection/index.tsx | 67 ++++++++ src/widgets/my-page/job-selection/style.ts | 162 ++++++++++++++++++ src/widgets/my-page/portfolio/index.tsx | 10 +- src/widgets/my-page/portfolio/style.ts | 4 + 7 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 src/features/my-page/job-selection/model/useJobSelection.ts create mode 100644 src/shared/constants/my-page/job.ts create mode 100644 src/widgets/my-page/job-selection/index.tsx create mode 100644 src/widgets/my-page/job-selection/style.ts diff --git a/src/features/my-page/job-selection/model/useJobSelection.ts b/src/features/my-page/job-selection/model/useJobSelection.ts new file mode 100644 index 00000000..28e1c127 --- /dev/null +++ b/src/features/my-page/job-selection/model/useJobSelection.ts @@ -0,0 +1,30 @@ +import { useState } from "react"; + +import { + JOB_OPTIONS, + type JobCategory, +} from "@/shared/constants/my-page/job"; + +export const useJobSelection = () => { + const [category, setCategory] = useState("DEVELOPMENT"); + const [job, setJob] = useState(""); + + const handleCategoryChange = (nextCategory: JobCategory) => { + if (category === nextCategory) return; + setCategory(nextCategory); + setJob(""); + }; + + const handleJobChange = (nextJob: string) => { + if (JOB_OPTIONS[category].some((option) => option.value === nextJob)) { + setJob(nextJob); + } + }; + + return { + category, + job, + onCategoryChange: handleCategoryChange, + onJobChange: handleJobChange, + }; +}; diff --git a/src/pages/my-page/index.tsx b/src/pages/my-page/index.tsx index f11a0cd0..40b66817 100644 --- a/src/pages/my-page/index.tsx +++ b/src/pages/my-page/index.tsx @@ -6,12 +6,14 @@ import { type MyPageMetaData, } from "@/features/my-page/api/getMyPageData"; import { useMyInfo } from "@/features/my-page/personal-info/model/useMyInfo"; +import { useJobSelection } from "@/features/my-page/job-selection/model/useJobSelection"; import { usePortfolioForm } from "@/features/my-page/portfolio/model/usePortfolioForm"; import { useProfileStatus } from "@/features/profile/model/useProfileStatus"; import ChangeConfirmation from "@/widgets/my-page/portfolio/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"; @@ -19,6 +21,7 @@ const MyPage = () => { const navigate = useNavigate(); const [metaData, setMetaData] = useState(null); const personalInfo = useMyInfo(); + const jobSelection = useJobSelection(); const profileStatus = useProfileStatus(); const isAnalyzing = profileStatus.profile?.status === "PENDING"; @@ -77,7 +80,12 @@ const MyPage = () => { - + } + > void profileStatus.refetch()} diff --git a/src/shared/constants/my-page/job.ts b/src/shared/constants/my-page/job.ts new file mode 100644 index 00000000..bce8be2c --- /dev/null +++ b/src/shared/constants/my-page/job.ts @@ -0,0 +1,26 @@ +export const JOB_CATEGORIES = [ + { value: "DEVELOPMENT", label: "개발" }, + { value: "NON_DEVELOPMENT", label: "비개발" }, +] as const; + +export type JobCategory = (typeof JOB_CATEGORIES)[number]["value"]; + +// 직무 저장 API 연결 전 화면에서 사용하는 선택 항목이다. +export const JOB_OPTIONS = { + DEVELOPMENT: [ + { value: "FRONTEND", label: "프론트엔드" }, + { value: "BACKEND", label: "백엔드" }, + { value: "FULLSTACK", label: "풀스택" }, + { value: "MOBILE", label: "모바일" }, + { value: "DATA_AI", label: "데이터·AI" }, + { value: "DEVOPS", label: "인프라·DevOps" }, + ], + NON_DEVELOPMENT: [ + { value: "DESIGN", label: "디자인" }, + { value: "PRODUCT", label: "기획·PM" }, + { value: "MARKETING", label: "마케팅" }, + { value: "SALES", label: "영업·사업개발" }, + { value: "HR", label: "인사" }, + { value: "FINANCE", label: "재무·회계" }, + ], +} as const; diff --git a/src/widgets/my-page/job-selection/index.tsx b/src/widgets/my-page/job-selection/index.tsx new file mode 100644 index 00000000..b9e26e7a --- /dev/null +++ b/src/widgets/my-page/job-selection/index.tsx @@ -0,0 +1,67 @@ +import { + JOB_CATEGORIES, + JOB_OPTIONS, + type JobCategory, +} from "@/shared/constants/my-page/job"; + +import * as S from "./style"; + +interface JobSelectionProps { + category: JobCategory; + job: string; + onCategoryChange: (category: JobCategory) => void; + onJobChange: (job: string) => void; +} + +const JobSelection = ({ + category, + job, + onCategoryChange, + onJobChange, +}: JobSelectionProps) => ( + + + + 직무 + 필수 + + + + {JOB_CATEGORIES.map((option) => ( + + onCategoryChange(option.value)} + /> + {option.label} + + ))} + + + onJobChange(event.target.value)} + > + + {JOB_OPTIONS[category].map((option) => ( + + ))} + + + + + + 개발·비개발을 선택한 뒤, 준비하고 싶은 세부 직무를 선택해 주세요. + + +); + +export default JobSelection; diff --git a/src/widgets/my-page/job-selection/style.ts b/src/widgets/my-page/job-selection/style.ts new file mode 100644 index 00000000..b99bbc16 --- /dev/null +++ b/src/widgets/my-page/job-selection/style.ts @@ -0,0 +1,162 @@ +import styled from "styled-components"; + +export const Section = styled.section` + padding-bottom: ${({ theme }) => theme.spacing.lg}; + border-bottom: 1px solid #edf0f5; +`; + +export const Heading = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.spacing.sm}; + margin-bottom: ${({ theme }) => theme.spacing.md}; +`; + +export const SectionNumber = styled.span` + margin-right: 0.125rem; + color: #95a4ba; + font-size: ${({ theme }) => theme.fontSize.xs}; + font-weight: 600; + font-variant-numeric: tabular-nums; +`; + +export const Title = styled.h3` + color: #344159; + font-size: ${({ theme }) => theme.fontSize.md}; + font-weight: 650; + line-height: 1.5; +`; + +export const RequiredBadge = styled.span` + padding: 0.1875rem 0.4375rem; + border-radius: 0.25rem; + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + font-size: 0.6875rem; + font-weight: 600; + line-height: 1.4; +`; + +export const Controls = styled.div` + display: flex; + align-items: center; + gap: 0.75rem; + + @media (max-width: 64rem) and (min-width: 56.01rem), (max-width: 36rem) { + flex-direction: column; + align-items: stretch; + } +`; + +export const CategoryGroup = styled.fieldset` + display: flex; + flex: 0 0 11rem; + min-width: 0; + height: 2.75rem; + padding: 0.1875rem; + border: 1px solid #dfe5ed; + border-radius: ${({ theme }) => theme.radius.sm}; + background: ${({ theme }) => theme.colors.surface.muted}; + + @media (max-width: 64rem) and (min-width: 56.01rem), (max-width: 36rem) { + flex-basis: auto; + } +`; + +export const CategoryOption = styled.label` + position: relative; + display: flex; + flex: 1; + min-width: 0; + cursor: pointer; +`; + +export const CategoryInput = styled.input` + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +`; + +export const CategoryText = styled.span` + display: flex; + align-items: center; + justify-content: center; + width: 100%; + border-radius: 0.375rem; + color: #748095; + font-size: ${({ theme }) => theme.fontSize.sm}; + font-weight: 600; + line-height: 1.5; + transition: background-color 0.2s ease, color 0.2s ease; + + ${CategoryInput}:checked + & { + background: ${({ theme }) => theme.colors.brand.blue}; + color: ${({ theme }) => theme.colors.white}; + } + + ${CategoryInput}:not(:checked) + &:hover { + background: ${({ theme }) => theme.colors.surface.blueLight}; + color: ${({ theme }) => theme.colors.brand.blue}; + } + + ${CategoryInput}:focus-visible + & { + outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; + outline-offset: 3px; + } +`; + +export const SelectWrapper = styled.div` + position: relative; + flex: 1; + min-width: 0; +`; + +export const Select = styled.select<{ $isPlaceholder: boolean }>` + width: 100%; + height: 2.75rem; + padding: 0 2.5rem 0 0.875rem; + border: 1px solid #dfe5ed; + border-radius: ${({ theme }) => theme.radius.sm}; + appearance: none; + background: ${({ theme }) => theme.colors.white}; + color: ${({ $isPlaceholder }) => $isPlaceholder ? "#8b96a8" : "#344159"}; + font-family: inherit; + font-size: ${({ theme }) => theme.fontSize.sm}; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:hover { border-color: #b9cbe4; } + &:focus-visible { + outline: none; + border-color: ${({ theme }) => theme.colors.brand.blue}; + box-shadow: 0 0 0 3px ${({ theme }) => theme.colors.surface.blueLight}; + } + + @media (max-width: 36rem) { + font-size: ${({ theme }) => theme.fontSize.md}; + } +`; + +export const Chevron = styled.svg` + position: absolute; + top: 50%; + right: 0.875rem; + width: 1.125rem; + height: 1.125rem; + color: #8290a4; + transform: translateY(-50%); + pointer-events: none; +`; + +export const Hint = styled.p` + margin-top: 0.625rem; + color: #7b8799; + font-size: ${({ theme }) => theme.fontSize.xs}; + line-height: 1.6; + word-break: keep-all; +`; diff --git a/src/widgets/my-page/portfolio/index.tsx b/src/widgets/my-page/portfolio/index.tsx index 594fac3a..b0435d26 100644 --- a/src/widgets/my-page/portfolio/index.tsx +++ b/src/widgets/my-page/portfolio/index.tsx @@ -4,6 +4,7 @@ import * as S from "./style"; interface PortfolioProps { children?: ReactNode; + jobSelection: ReactNode; gitInputRef: RefObject; isSaveDisabled: boolean; isAnalyzing: boolean; @@ -46,6 +47,7 @@ const TrashIcon = () => ( const Portfolio = ({ children, + jobSelection, gitInputRef, isSaveDisabled, isAnalyzing, @@ -77,11 +79,11 @@ const Portfolio = ({ - {children} + {jobSelection} - + 포트폴리오 필수 @@ -121,7 +123,7 @@ const Portfolio = ({ - + GitHub @@ -174,6 +176,8 @@ const Portfolio = ({ )} + {children && {children}} + {(saveMessage || saveError) && ( {saveError || saveMessage} diff --git a/src/widgets/my-page/portfolio/style.ts b/src/widgets/my-page/portfolio/style.ts index 3b39c1e1..1e149b63 100644 --- a/src/widgets/my-page/portfolio/style.ts +++ b/src/widgets/my-page/portfolio/style.ts @@ -300,6 +300,10 @@ export const SaveStatus = styled.p<{ $isError: boolean }>` overflow-wrap: anywhere; `; +export const AnalysisStatus = styled.div` + margin-top: 1.75rem; +`; + export const Footer = styled.div` display: flex; align-items: center; From 02955609e437f2d3616538798ceb11223ac878f1 Mon Sep 17 00:00:00 2001 From: han0911 Date: Wed, 14 Oct 2026 20:23:57 +0900 Subject: [PATCH 28/88] =?UTF-8?q?fix:=20=EC=A2=85=ED=95=A9=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20UI=EB=A5=BC=20=EC=B0=B8=EA=B3=A0=20?= =?UTF-8?q?=EB=94=94=EC=9E=90=EC=9D=B8=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EA=B0=9C=ED=8E=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feedback-page/model/feedbackDisplay.ts | 129 ++- .../feedback-page/feedback-overall/style.ts | 10 +- .../feedback-overall/bottom-section/index.tsx | 78 +- .../feedback-overall/middle-section/index.tsx | 116 ++- .../feedback-page/feedback-overall/style.ts | 900 ++++++++---------- .../feedback-overall/top-section/index.tsx | 89 +- .../feedback-page/feedback-overall/type.ts | 38 +- 7 files changed, 631 insertions(+), 729 deletions(-) diff --git a/src/features/feedback-page/model/feedbackDisplay.ts b/src/features/feedback-page/model/feedbackDisplay.ts index ec3dd64d..05cbeb20 100644 --- a/src/features/feedback-page/model/feedbackDisplay.ts +++ b/src/features/feedback-page/model/feedbackDisplay.ts @@ -14,9 +14,6 @@ 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; @@ -70,112 +67,106 @@ 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), + // The current feedback API only supplies the intent score; other criteria + // remain unscored until dedicated analysis results are available. + metrics: [ + { label: "의도 충족", score: getOptionalScore(feedback.intentAlignmentScore) }, + { label: "깊이", score: null }, + { label: "구체성", score: null }, + { label: "정확성", score: null }, + ], + 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, + }, 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: { + speechMetrics: ["발화 속도", "발음 명료도", "침묵 비율", "음량 안정성"].map((label) => ({ + label, + score: null, + description: "음성 분석 결과가 아직 없습니다.", + })), 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로 저장", diff --git a/src/pages/feedback-page/feedback-overall/style.ts b/src/pages/feedback-page/feedback-overall/style.ts index 68121553..a590b139 100644 --- a/src/pages/feedback-page/feedback-overall/style.ts +++ b/src/pages/feedback-page/feedback-overall/style.ts @@ -6,7 +6,8 @@ export const Page = styled.section` align-items: flex-start; justify-content: center; min-height: 100%; - padding: 1.2rem 2rem 2rem; + padding: 0.625rem clamp(1rem, 3.87vw, 3.5rem) 4.375rem; + color: ${({ theme }) => theme.colors.text.default}; box-sizing: border-box; @media (max-width: 48rem) { @@ -15,15 +16,14 @@ export const Page = styled.section` `; export const Panel = styled.section` - width: min(100%, 90rem); + width: min(100%, 75rem); 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/widgets/feedback-page/feedback-overall/bottom-section/index.tsx b/src/widgets/feedback-page/feedback-overall/bottom-section/index.tsx index 1ae0c1e1..242a43b5 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,50 @@ 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, isDownloadingPdf = false, onDownloadPdf, }: FeedbackOverallBottomSectionProps) => { - const { keywords, questionFit, reliability } = content; + const { keywords, reliability, speechMetrics } = content; return ( - - - + + + 음성 분석 결과 + + {speechMetrics.map((metric) => ( + + + {metric.label} + {metric.score === null ? "분석 대기" : `${metric.score}점`} + + + {metric.description} + + ))} + + - + + + {keywords.items.length > 0 ? ( + + {keywords.items.map((item) => ( + + {item.text} + + ))} + + ) : 분석된 키워드가 아직 없습니다.} + + {reliability.title} @@ -45,24 +52,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..9669600f 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.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}점`} + + + )) : ( 면접관별 코멘트가 아직 없습니다. )} - + ); }; diff --git a/src/widgets/feedback-page/feedback-overall/style.ts b/src/widgets/feedback-page/feedback-overall/style.ts index 42d67e93..8c8913a8 100644 --- a/src/widgets/feedback-page/feedback-overall/style.ts +++ b/src/widgets/feedback-page/feedback-overall/style.ts @@ -1,164 +1,77 @@ 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; + gap: 0.75rem; + min-width: 0; +`; - @media (max-width: 48rem) { - gap: 0.8rem; - } +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 PageHeading = styled.h1` - margin: 0; - color: #4a4a4a; - font-size: 1.2rem; + color: #4a4d53; + font-size: 1.125rem; 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; - } -`; - -export const BottomSectionShell = styled(SectionShell)` - padding-bottom: 1.4rem; - - @media (max-width: 48rem) { - padding-bottom: 1rem; - } + letter-spacing: 0.025em; `; -export const TabGroup = styled.div` - width: fit-content; +export const TabGroup = styled.nav` 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; - font-weight: 700; + flex-shrink: 0; + padding: 0.1875rem; + border: 1px solid #e1e3e7; + border-radius: 0.25rem; + background: #ededed; +`; + +export const TabButton = styled.button<{ $active: boolean }>` + min-height: 1.375rem; + padding: 0.25rem 0.5rem; + border: 0; + border-radius: 0.125rem; + background: ${({ $active, theme }) => $active ? "transparent" : theme.colors.white}; + color: ${({ $active, theme }) => $active ? theme.colors.text.default : theme.colors.brand.blue}; + font: inherit; + font-size: 0.6875rem; cursor: pointer; - @media (max-width: 40rem) { - min-width: 9rem; - height: 2rem; - font-size: 0.94rem; - } + &:focus-visible { outline: 2px solid ${({ theme }) => theme.colors.brand.blue}; outline-offset: 2px; } `; export const TopGrid = styled.div` display: grid; - grid-template-columns: minmax(14rem, 0.7fr) minmax(0, 1.35fr); - gap: 1rem; - - @media (max-width: 48rem) { - grid-template-columns: 1fr; - } -`; + grid-template-columns: minmax(0, 0.44fr) minmax(0, 1fr); + gap: 0.5rem; -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 +80,490 @@ 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 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..c5411f13 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,52 @@ +import { useNavigate, useParams } from "react-router-dom"; 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 FeedbackOverallTopSection = ({ content }: FeedbackOverallTopSectionProps) => { + const { evaluation, metrics, summary, tabs } = content; const navigate = useNavigate(); const { id } = useParams(); - const feedbackId = id ?? "1"; return ( - 종합 피드백 - - navigate(`/main/feedback/overall/${feedbackId}`)} - > - {overallLabel} - - navigate(`/main/feedback/detail/${feedbackId}`)} - > - {detailLabel} - - + + 종합 피드백 + + navigate(`/main/feedback/overall/${id}`)} + > + {tabs.overallLabel} + + navigate(`/main/feedback/detail/${id}`)} + > + {tabs.detailLabel} + + + - + + {summary.title} - {summary.score}점 + {summary.score === null ? "—" : `${summary.score}점`} + 100점 @@ -47,17 +57,22 @@ const FeedbackOverallTopSection = ({ - {averageComparison.bars.map((bar) => ( - - - {bar.label} - {bar.score}점 - - - - - - ))} +
+ 종합 점수 + 아래 4가지 평가 기준을 종합해 산출한 최종 점수입니다. +
+ + {metrics.map((metric) => ( + + {metric.label} + {metric.score === null ? "분석 대기" : `${metric.score}점`} + + + ))} + + {evaluation}
diff --git a/src/widgets/feedback-page/feedback-overall/type.ts b/src/widgets/feedback-page/feedback-overall/type.ts index c171b9b8..156dcb86 100644 --- a/src/widgets/feedback-page/feedback-overall/type.ts +++ b/src/widgets/feedback-page/feedback-overall/type.ts @@ -1,24 +1,20 @@ -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; } 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,35 +27,33 @@ 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; + }; personaFeedbacks: readonly FeedbackOverallPersonaFeedback[]; } export interface FeedbackOverallBottomSectionData { + speechMetrics: readonly FeedbackOverallMetric[]; reliability: { title: string; scoreLabel: string; levelLabel: string; }; - questionFit: { - title: string; - content: string; - }; keywords: { items: readonly FeedbackOverallKeywordItem[]; }; From 3f5773657630010fe8b7ea5d699e7512ac63df52 Mon Sep 17 00:00:00 2001 From: han0911 Date: Wed, 14 Oct 2026 20:24:04 +0900 Subject: [PATCH 29/88] =?UTF-8?q?fix:=20=EC=A2=85=ED=95=A9=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=EC=97=90=20=EC=9D=B4=EC=A0=84=20=EB=A9=B4?= =?UTF-8?q?=EC=A0=91=20=EC=A0=90=EC=88=98=20=EB=B9=84=EA=B5=90=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../model/useFeedbackInterview.ts | 31 +++++++++++++++++-- .../feedback-page/feedback-overall/index.tsx | 5 +-- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/features/feedback-page/model/useFeedbackInterview.ts b/src/features/feedback-page/model/useFeedbackInterview.ts index 9a072409..a6e495fb 100644 --- a/src/features/feedback-page/model/useFeedbackInterview.ts +++ b/src/features/feedback-page/model/useFeedbackInterview.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { + getAllFeedbacks, getAllInterviews, getFeedback, type FeedbackData, @@ -12,9 +13,10 @@ import { } from "@/features/feedback-page/model/interviewDisplay"; import { extractErrorMessage } from "@/shared/api/errorMessage"; -export const useFeedbackInterview = (id?: string) => { +export const useFeedbackInterview = (id?: string, includePreviousFeedback = false) => { const [interview, setInterview] = useState(null); const [feedback, setFeedback] = useState(null); + const [previousFeedback, setPreviousFeedback] = useState(null); const [feedbackErrorMessage, setFeedbackErrorMessage] = useState( null, ); @@ -23,6 +25,7 @@ export const useFeedbackInterview = (id?: string) => { const loadInterview = useCallback(async () => { const interviewId = Number(id); + setPreviousFeedback(null); if (!interviewId) { setInterview(null); @@ -36,9 +39,10 @@ export const useFeedbackInterview = (id?: string) => { setErrorMessage(null); setFeedbackErrorMessage(null); - const [interviewsResult, feedbackResult] = await Promise.allSettled([ + const [interviewsResult, feedbackResult, historyResult] = await Promise.allSettled([ getAllInterviews(), getFeedback(interviewId), + includePreviousFeedback ? getAllFeedbacks() : Promise.resolve([]), ]); if (interviewsResult.status === "fulfilled") { @@ -49,6 +53,26 @@ export const useFeedbackInterview = (id?: string) => { 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("면접 정보를 찾을 수 없습니다."); } @@ -75,7 +99,7 @@ export const useFeedbackInterview = (id?: string) => { } setIsLoading(false); - }, [id]); + }, [id, includePreviousFeedback]); useEffect(() => { const timeoutId = window.setTimeout(() => { @@ -92,6 +116,7 @@ export const useFeedbackInterview = (id?: string) => { feedback, feedbackErrorMessage, interview, + previousFeedback, isLoading, refetch: loadInterview, }; diff --git a/src/pages/feedback-page/feedback-overall/index.tsx b/src/pages/feedback-page/feedback-overall/index.tsx index fc5b012f..b785937f 100644 --- a/src/pages/feedback-page/feedback-overall/index.tsx +++ b/src/pages/feedback-page/feedback-overall/index.tsx @@ -15,15 +15,16 @@ const FeedbackOverallPage = () => { feedback, feedbackErrorMessage, interview, + previousFeedback, isLoading, refetch, - } = useFeedbackInterview(id); + } = 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 panelRef = useRef(null); const [isDownloadingPdf, setIsDownloadingPdf] = useState(false); From 535e0d9ac51800f02ef86d0b70fb92c9096331d9 Mon Sep 17 00:00:00 2001 From: han0911 Date: Wed, 14 Oct 2026 20:24:12 +0900 Subject: [PATCH 30/88] =?UTF-8?q?fix:=20=EC=A2=85=ED=95=A9=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EC=A0=84=EC=9A=A9=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EC=99=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=84=EA=B2=A9=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout/main-layout/index.tsx | 3 ++- src/app/layout/main-layout/style.ts | 5 ++-- src/shared/components/header/index.tsx | 4 +-- src/shared/components/header/style.ts | 35 ++++++++++++++++++++++++-- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/layout/main-layout/index.tsx b/src/app/layout/main-layout/index.tsx index 51ee517b..1b3ca4bd 100644 --- a/src/app/layout/main-layout/index.tsx +++ b/src/app/layout/main-layout/index.tsx @@ -8,6 +8,7 @@ const MainLayout = () => { const { pathname } = useLocation(); useSessionCheck(); const normalizedPathname = pathname.toLowerCase().replace(/\/+$/, ""); + const isFeedbackOverallRoute = normalizedPathname.startsWith("/main/feedback/overall/"); const isInterviewRoute = normalizedPathname === "/main/interview" || normalizedPathname.startsWith("/main/interview/"); @@ -15,7 +16,7 @@ const MainLayout = () => { return ( - +
diff --git a/src/app/layout/main-layout/style.ts b/src/app/layout/main-layout/style.ts index 336d03e7..f8d22635 100644 --- a/src/app/layout/main-layout/style.ts +++ b/src/app/layout/main-layout/style.ts @@ -2,6 +2,7 @@ import styled from "styled-components"; interface RouteLayoutProps { $isInterviewRoute: boolean; + $isFeedbackOverallRoute?: boolean; } export const Page = styled.main` @@ -25,11 +26,11 @@ export const Content = styled.div` min-height: 100dvh; display: grid; grid-template-rows: auto minmax(0, 1fr); - row-gap: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "1rem")}; + row-gap: ${({ $isInterviewRoute, $isFeedbackOverallRoute }) => ($isInterviewRoute || $isFeedbackOverallRoute ? 0 : "1rem")}; box-sizing: border-box; @media (max-width: 56rem) { - row-gap: ${({ $isInterviewRoute }) => ($isInterviewRoute ? 0 : "0.75rem")}; + row-gap: ${({ $isInterviewRoute, $isFeedbackOverallRoute }) => ($isInterviewRoute || $isFeedbackOverallRoute ? 0 : "0.75rem")}; } `; diff --git a/src/shared/components/header/index.tsx b/src/shared/components/header/index.tsx index e8fd401b..571b038b 100644 --- a/src/shared/components/header/index.tsx +++ b/src/shared/components/header/index.tsx @@ -57,7 +57,7 @@ const Header = () => { const handleLogout = () => { void handleLeave("/login", true); }; return ( - + handleNavigate("/main")} /> @@ -93,4 +93,4 @@ const Header = () => { ); }; -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 987fb968..9a2ccceb 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; @@ -204,3 +204,34 @@ export const ErrorMessage = styled.p` color: ${({ theme }) => theme.colors.text.default}; font-size: ${({ theme }) => theme.fontSize.sm}; `; + +export const Header = styled(BaseHeader)<{ $compact?: boolean }>` + ${({ $compact }) => $compact && css` + min-height: 4.1875rem; + grid-template-columns: auto 1fr auto; + gap: 2rem; + padding: 0 2rem; + border-bottom: 1px solid #dfe5ef; + + ${LogoImage} { height: 3rem; margin-left: -0.25rem; justify-self: start; } + ${TopButtons} { justify-self: start; width: auto; gap: 2rem; margin: 0; } + ${TopButton} { + min-width: 0; + flex: none; + padding: 0.75rem; + font-size: 0.8125rem; + &::after { display: none; } + } + ${StatusButtons} { justify-self: end; gap: 0.75rem; } + ${StatusButton} { width: 4.25rem; height: 1.875rem; font-size: 0.75rem; border-radius: 0.375rem; } + ${LogoutButton} { width: auto; height: 1.875rem; padding: 0 0.375rem; font-size: 0.8125rem; } + + @media (max-width: 42rem) { + grid-template-columns: auto 1fr; + gap: 0.25rem 0.5rem; + padding: 0.5rem 1rem; + ${TopButtons} { grid-row: 2; grid-column: 1 / -1; justify-self: center; gap: 1.5rem; } + ${StatusButtons} { grid-row: 1; grid-column: 2; } + } + `} +`; From 27e5e0099005448bdf120b42284bbe0bf21308cc Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 14 Oct 2026 21:29:53 +0900 Subject: [PATCH 31/88] =?UTF-8?q?feat:=20=EB=A9=B4=EC=A0=91=EA=B4=80=20?= =?UTF-8?q?=EC=9D=8C=EC=84=B1=20=EC=B9=B4=ED=83=88=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=99=80=20voiceIndex=20=EC=A0=80=EC=9E=A5=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/tts-web-implementation.md | 38 +++++++++ .../interview/api/type/save-persona.ts | 2 + .../model/useInterviewSetup.ts | 10 ++- .../model/useMultiInterviewSetup.ts | 1 + .../setting-multi-interview/index.ts | 15 ++-- .../setting-multi-interview/type.ts | 3 +- .../constants/interview-page/voice-catalog.ts | 14 ++++ tests/interview-persona.browser.mjs | 82 +++++++++++++++++++ 8 files changed, 151 insertions(+), 14 deletions(-) create mode 100644 docs/tts-web-implementation.md create mode 100644 src/shared/constants/interview-page/voice-catalog.ts create mode 100644 tests/interview-persona.browser.mjs diff --git a/docs/tts-web-implementation.md b/docs/tts-web-implementation.md new file mode 100644 index 00000000..b8c2d05c --- /dev/null +++ b/docs/tts-web-implementation.md @@ -0,0 +1,38 @@ +# 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과 구분한다. +- 텍스트 첫 입력 시각은 공백만 입력해도 기록한다(D5의 첫 글자 기준). 삭제 후 다시 입력해도 최초 시각을 유지한다. +- 패키지를 추가하지 않는다. 기존 외부 Playwright 런타임과 Chromium을 사용한다. + +## 검증 기준 + +- 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). 운영 값을 문서에 복사하지 않는다. +Chat 비종료 응답 정규화·운영 CORS·최종 음성 튜닝은 Web 밖의 후속 작업이며 이번 코드 변경 범위에 포함하지 않는다. 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..d35180e2 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, @@ -16,6 +17,7 @@ export interface SavePersonaParams { level: InterviewLevel; career: number; gender: InterviewPersonaGender; + voiceIndex?: VoiceIndex; imageUrl: string; description: string; } diff --git a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts index 413dd614..2b6bd984 100644 --- a/src/features/interview-page/setting-interview/model/useInterviewSetup.ts +++ b/src/features/interview-page/setting-interview/model/useInterviewSetup.ts @@ -28,6 +28,7 @@ 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 { getVoiceGender } from "@/shared/constants/interview-page/voice-catalog"; const TYPE_BY_STYLE: Record = { 편함: "FRIENDLY", @@ -64,10 +65,10 @@ const INTERVIEWER_PROFILE_BY_ID: Record< InterviewerId, { gender: InterviewPersonaGender; major: InterviewPersonaMajor } > = { - 1: { gender: "MALE", major: "BACKEND" }, - 2: { gender: "MALE", major: "FRONTEND" }, - 3: { gender: "FEMALE", major: "FRONTEND" }, - 4: { gender: "FEMALE", major: "BACKEND" }, + 1: { gender: getVoiceGender(1), major: "BACKEND" }, + 2: { gender: getVoiceGender(2), major: "FRONTEND" }, + 3: { gender: getVoiceGender(3), major: "FRONTEND" }, + 4: { gender: getVoiceGender(4), major: "BACKEND" }, }; const DEFAULT_INTERVIEW_ROLE: InterviewPersonaRole = "TECH"; @@ -123,6 +124,7 @@ export const useInterviewSetup = () => { type: TYPE_BY_STYLE[selectedStyle], career: CAREER_BY_DIFFICULTY[selectedDifficulty], gender: interviewerProfile.gender, + voiceIndex: selectedInterviewerId, tone: TONE_BY_STYLE[selectedStyle], imageUrl: INTERVIEWER_IMAGES[selectedInterviewerId], description: selectedInterviewer.description, 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 4be7c960..b25e43eb 100644 --- a/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts +++ b/src/features/interview-page/setting-multi-interview/model/useMultiInterviewSetup.ts @@ -85,6 +85,7 @@ export const useMultiInterviewSetup = () => { level: LEVEL_BY_DIFFICULTY[difficulty], career: template.career, gender: template.gender, + voiceIndex: template.voiceIndex, imageUrl: template.image, description: template.description, }; 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..6a8bb7ce 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,7 @@ 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 { getVoiceGender } from "@/shared/constants/interview-page/voice-catalog"; import type { InterviewerTemplate, @@ -30,7 +31,7 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ specialty: "백엔드 아키텍처", major: "BACKEND", career: 9, - gender: "FEMALE", + gender: getVoiceGender(1), image: InterviewerImage1, tags: ["논리적 사고", "문제 해결"], description: "핵심 내용을 구조적으로 정리하고 깊이 있게 확인합니다.", @@ -44,7 +45,7 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ specialty: "프론트엔드 리드", major: "FRONTEND", career: 8, - gender: "FEMALE", + gender: getVoiceGender(2), image: InterviewerImage2, tags: ["협업", "실행력"], description: "경험을 바탕으로 구체적인 판단과 실행 과정을 확인합니다.", @@ -58,7 +59,7 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ specialty: "플랫폼 엔지니어링", major: "BACKEND", career: 6, - gender: "MALE", + gender: getVoiceGender(3), image: InterviewerImage3, tags: ["분석력", "성장"], description: "현실적인 상황에서의 문제 해결 방식과 성장 가능성을 살펴봅니다.", @@ -72,7 +73,7 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ specialty: "프론트엔드 개발", major: "FRONTEND", career: 8, - gender: "MALE", + gender: getVoiceGender(4), image: InterviewerImage4, tags: ["소통", "조직 적합성"], description: "지원자의 강점과 협업 방식을 다양한 관점에서 확인합니다.", @@ -86,17 +87,13 @@ export const MULTI_INTERVIEWER_TEMPLATES = [ specialty: "데이터 엔지니어링", major: "BACKEND", career: 5, - gender: "MALE", + gender: getVoiceGender(5), image: InterviewerImage2, 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..fefe7316 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, @@ -21,7 +22,7 @@ export interface InterviewerSlot { export interface InterviewerTemplate { key: string; name: string; - voiceIndex: 1 | 2 | 3 | 4 | 5; + voiceIndex: VoiceIndex; role: InterviewerTemplateRole; roleLabel: string; specialty: 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/tests/interview-persona.browser.mjs b/tests/interview-persona.browser.mjs new file mode 100644 index 00000000..4a5f7c99 --- /dev/null +++ b/tests/interview-persona.browser.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE_PATH; +const { chromium } = await import(modulePath ? pathToFileURL(modulePath).href : "playwright"); +const origin = process.env.INTERVIEW_TEST_ORIGIN ?? "http://127.0.0.1:5173"; +const browser = await chromium.launch({ headless: true }); +let passed = 0; + +async function fixture(multi, fail = false) { + const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const saves = []; + let creates = 0; + const errors = []; + page.on("pageerror", (error) => errors.push(error.message)); + await page.route("**/*", (route) => { + const request = route.request(); + const path = new URL(request.url()).pathname; + if (!path.startsWith("/api/") && !path.startsWith("/chat/")) return route.continue(); + const json = (body, status = 200) => route.fulfill({ status, contentType: "application/json", body: JSON.stringify(body) }); + if (path.endsWith("/profile/latest")) return json({ success: true, data: { status: "COMPLETED" } }); + if (path.endsWith("/me")) return json({ data: { id: 7, name: "테스트" } }); + if (path.endsWith("/refresh")) return json({ accessToken: "test-only-token" }); + if (path.endsWith("/persona/save")) { + saves.push(request.postDataJSON()); + return fail ? json({ message: "테스트 저장 실패" }, 422) : json({ data: { personaId: saves.length } }); + } + if (path.endsWith("/interviews/create")) { + creates++; + return json({ data: { interviewId: 42, sessionId: "test-session", userId: 7, personaId: 1, personaIds: request.postDataJSON().personaIds ?? [1], status: "IN_PROGRESS" } }); + } + // 준비 화면에서 멈춰 장치/소켓과 독립적으로 설정 요청을 검증한다. + if (path === "/api/interviews/42") return json({ message: "테스트 준비 중지" }, 503); + return json({ data: [] }); + }); + await page.goto(origin); + await page.getByRole("button", { name: "면접 시작하기", exact: true }).first().waitFor(); + await page.evaluate(async (multi) => { + (await import("/src/shared/api/accessToken.ts")).setAccessToken("test-only-token"); + const url = performance.getEntriesByType("resource").map((entry) => entry.name).find((url) => new URL(url).pathname === "/src/app/router.tsx"); + await (await import(url)).default.navigate(multi ? "/main/setting/interview/multi" : "/main/setting/interview"); + }, multi); + const close = async () => { assert.deepEqual(errors, []); await context.close(); passed++; }; + return { page, saves, close, creates: () => creates }; +} + +try { + for (let id = 1; id <= 4; id++) { + const f = await fixture(false); + await f.page.getByRole("radio", { name: new RegExp(`면접관${id}`) }).click(); + await f.page.getByRole("button", { name: "다음", exact: true }).click(); + await f.page.getByText("테스트 준비 중지", { exact: true }).waitFor(); + assert.equal(f.saves[0].voiceIndex, id); + assert.equal(f.saves[0].gender, id <= 2 ? "MALE" : "FEMALE"); + await f.close(); + console.log(`PASS SOLO ${id}: voiceIndex + gender request`); + } + for (const names of [["김도윤", "이서준", "박지민"], ["한민석", "최유진", "김도윤"]]) { + const f = await fixture(true); + for (const [index, name] of names.entries()) { + await f.page.getByRole("button", { name: `면접관 ${index + 1} 설정`, exact: true }).click(); + await f.page.getByRole("group", { name: "면접관 선택", exact: true }).getByRole("button", { name, exact: true }).click(); + if (index > 0) await f.page.getByRole("group", { name: "역할", exact: true }).getByRole("button", { name: index === 1 ? "HR" : "Design", exact: true }).click(); + } + await f.page.getByRole("button", { name: "다음", exact: true }).click(); + await f.page.getByText("테스트 준비 중지", { exact: false }).waitFor(); + const expected = names.map((name) => ({ 김도윤: [1, "MALE"], 이서준: [2, "MALE"], 박지민: [3, "FEMALE"], 최유진: [4, "FEMALE"], 한민석: [5, "MALE"] })[name]); + assert.deepEqual(f.saves.map((save) => [save.voiceIndex, save.gender]), expected); + await f.close(); + console.log(`PASS MULTI ${names.join(", ")}: selected identity independent of slot`); + } + const failed = await fixture(false, true); + await failed.page.getByRole("button", { name: "다음", exact: true }).click(); + await failed.page.getByText("테스트 저장 실패", { exact: true }).waitFor(); + assert.equal(failed.creates(), 0); + await failed.close(); + console.log(`PASS save failure blocks interview creation; ${passed} scenarios`); +} finally { + await browser.close(); +} From 6dcf1217924a874d7c325cbbc888e7f1cf1b59c3 Mon Sep 17 00:00:00 2001 From: gituser Date: Wed, 14 Oct 2026 21:41:51 +0900 Subject: [PATCH 32/88] =?UTF-8?q?feat:=20Chat=20=EC=A7=88=EB=AC=B8=20?= =?UTF-8?q?=EC=98=A4=EB=94=94=EC=98=A4=20=EC=9E=AC=EC=83=9D=EA=B3=BC=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EC=8B=9C=EC=9E=91=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=A0=81=EC=9A=A9=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../interview-page/interview/api/index.ts | 1 + .../interview/api/question-audio/index.ts | 34 ++ .../interview-page/interview/api/shared.ts | 19 + .../interview/api/type/index.ts | 1 + .../interview/api/type/question-audio.ts | 8 + .../model/interviewSessionContext.tsx | 16 +- .../interview/model/useElevenLabsTts.ts | 498 ------------------ .../interview/model/useInterviewSession.ts | 113 +--- .../interview/model/useQuestionAudio.ts | 181 +++++++ .../interview/model/useSupertoneTts.ts | 132 ----- src/pages/interview-page/interview/index.tsx | 28 - .../interview/interview-content/index.tsx | 3 + .../interview/interview-dashboard/index.tsx | 20 +- .../interview/interview-dashboard/style.ts | 22 - .../question-audio-control/index.tsx | 24 + .../interview/question-audio-control/style.ts | 31 ++ src/widgets/interview-page/interview/type.ts | 2 +- 17 files changed, 337 insertions(+), 796 deletions(-) create mode 100644 src/features/interview-page/interview/api/question-audio/index.ts create mode 100644 src/features/interview-page/interview/api/type/question-audio.ts delete mode 100644 src/features/interview-page/interview/model/useElevenLabsTts.ts create mode 100644 src/features/interview-page/interview/model/useQuestionAudio.ts delete mode 100644 src/features/interview-page/interview/model/useSupertoneTts.ts create mode 100644 src/widgets/interview-page/interview/question-audio-control/index.tsx create mode 100644 src/widgets/interview-page/interview/question-audio-control/style.ts diff --git a/src/features/interview-page/interview/api/index.ts b/src/features/interview-page/interview/api/index.ts index 26bfe1bd..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, 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/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/index.ts b/src/features/interview-page/interview/api/type/index.ts index b1c2617c..a39e52a2 100644 --- a/src/features/interview-page/interview/api/type/index.ts +++ b/src/features/interview-page/interview/api/type/index.ts @@ -14,6 +14,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/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/model/interviewSessionContext.tsx b/src/features/interview-page/interview/model/interviewSessionContext.tsx index 7f5115b0..6c472b7c 100644 --- a/src/features/interview-page/interview/model/interviewSessionContext.tsx +++ b/src/features/interview-page/interview/model/interviewSessionContext.tsx @@ -1,31 +1,19 @@ import { type ReactNode } from "react"; import type { PreparedInterviewData } from "@/features/interview-page/interview/api"; -import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import { InterviewSessionContext } from "./interviewSessionContextValue"; -import { - useInterviewSession, - type InterviewTtsProvider, -} from "./useInterviewSession"; +import { useInterviewSession } from "./useInterviewSession"; interface InterviewSessionProviderProps { children: ReactNode; - interviewStyle?: InterviewStyleOption; preparedInterview?: PreparedInterviewData | null; - ttsProvider?: InterviewTtsProvider; } export const InterviewSessionProvider = ({ children, - interviewStyle, preparedInterview, - ttsProvider = "elevenlabs", }: InterviewSessionProviderProps) => { - const session = useInterviewSession( - preparedInterview, - ttsProvider, - interviewStyle, - ); + const session = useInterviewSession(preparedInterview); return ( 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 ad18451d..00000000 --- a/src/features/interview-page/interview/model/useElevenLabsTts.ts +++ /dev/null @@ -1,498 +0,0 @@ -import type { - InterviewPersonality, - InterviewTone, -} from "../api/type/create-interview"; -import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; -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; - -export interface InterviewerSpeechStyle { - interviewStyle?: InterviewStyleOption; - personality?: InterviewPersonality; - tone?: InterviewTone; -} - -interface VoiceSettings { - stability: number; - similarity_boost: number; - style: number; - speed: number; - use_speaker_boost: boolean; -} - -const DEFAULT_VOICE_SETTINGS: VoiceSettings = { - stability: 0.55, - similarity_boost: 0.8, - style: 0.1, - speed: ELEVENLABS_SPEECH_SPEED, - use_speaker_boost: true, -}; - -const TONE_PRESETS: Record< - InterviewTone, - Pick -> = { - GENTLE: { stability: 0.72, style: 0.04, speed: 1.02 }, - DIRECT: { stability: 0.42, style: 0.38, speed: 1.13 }, - // 낮은 안정성 + 강한 style + 빠른 속도로 긴장감과 압박감을 크게 높인다. - PRESSURING: { stability: 0.2, style: 0.82, speed: 1.17 }, -}; - -const INTERVIEW_STYLE_PRESETS: Record< - InterviewStyleOption, - Pick -> = { - 편함: { stability: 0.74, style: 0.04, speed: 1.02 }, - 일반: { stability: 0.55, style: 0.18, speed: 1.1 }, - 압박: { stability: 0.05, style: 1, speed: 1.17 }, -}; - -const PERSONALITY_ADJUSTMENTS: Record< - InterviewPersonality, - Partial> -> = { - FRIENDLY: { style: -0.04, speed: -0.02 }, - REALISTIC: { style: 0.04 }, - METICULOUS: { stability: 0.04, style: 0.02, speed: -0.01 }, -}; - -const clamp = (value: number) => Math.min(1, Math.max(0, value)); - -const TONE_ADJUSTMENTS: Record< - InterviewTone, - Partial> -> = { - GENTLE: { stability: 0.04, style: -0.03, speed: -0.02 }, - DIRECT: { stability: -0.04, style: 0.08, speed: 0.02 }, - PRESSURING: { stability: -0.08, style: 0.12, speed: 0.03 }, -}; - -const getVoiceSettings = ({ - interviewStyle, - personality, - tone, -}: InterviewerSpeechStyle = {}): VoiceSettings => { - const basePreset = interviewStyle - ? INTERVIEW_STYLE_PRESETS[interviewStyle] - : tone - ? TONE_PRESETS[tone] - : DEFAULT_VOICE_SETTINGS; - const personalityAdjustment = personality - ? PERSONALITY_ADJUSTMENTS[personality] - : {}; - const toneAdjustment = tone ? TONE_ADJUSTMENTS[tone] : {}; - - return { - ...DEFAULT_VOICE_SETTINGS, - stability: clamp( - basePreset.stability + - (personalityAdjustment.stability ?? 0) + - (toneAdjustment.stability ?? 0), - ), - style: clamp( - basePreset.style + - (personalityAdjustment.style ?? 0) + - (toneAdjustment.style ?? 0), - ), - speed: Math.min( - 1.2, - Math.max( - 0.8, - basePreset.speed + - (personalityAdjustment.speed ?? 0) + - (toneAdjustment.speed ?? 0), - ), - ), - }; -}; - -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, voiceSettings: VoiceSettings) => - JSON.stringify({ - text, - model_id: ELEVENLABS_MODEL_ID, - voice_settings: voiceSettings, - }); - -const requestSpeech = ( - voiceId: string, - text: string, - voiceSettings: VoiceSettings, - signal: AbortSignal, -) => - fetch( - `${ELEVENLABS_TTS_API_URL}/${encodeURIComponent(voiceId)}?output_format=${ELEVENLABS_OUTPUT_FORMAT}`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: createSpeechRequest(text, voiceSettings), - 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, - speechStyle?: InterviewerSpeechStyle, -) => { - 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 voiceSettings = getVoiceSettings(speechStyle); - - 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, - speechRate = voiceSettings.speed, - ) => { - 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 = speechRate; - 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, - voiceSettings, - 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, - voiceSettings, - controller.signal, - ); - } - } - - if (!response.ok) { - const responseMessage = await response.clone().text(); - console.error("ElevenLabs TTS response error:", { - status: response.status, - body: responseMessage, - }); - - if (response.status === 401) { - setErrorMessage( - "ElevenLabs 인증에 실패했습니다. API 키와 권한을 확인해주세요.", - ); - await playWithBrowserSpeech(text, controller.signal); - return; - } - - setErrorMessage( - `ElevenLabs TTS 요청에 실패했습니다. (${response.status})`, - ); - await playWithBrowserSpeech(text, controller.signal); - return; - } - - 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/useInterviewSession.ts b/src/features/interview-page/interview/model/useInterviewSession.ts index 8817f332..ac2b8683 100644 --- a/src/features/interview-page/interview/model/useInterviewSession.ts +++ b/src/features/interview-page/interview/model/useInterviewSession.ts @@ -18,10 +18,8 @@ import { import { INTERVIEW_STATUS_MESSAGES, } from "@/shared/constants/interview-page/interview"; -import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; -import { useElevenLabsTts } from "./useElevenLabsTts"; -import { useSupertoneTts } from "./useSupertoneTts"; +import { useQuestionAudio } from "./useQuestionAudio"; import { useInterviewElapsedTime } from "./useInterviewElapsedTime"; import { useInterviewMedia } from "./useInterviewMedia"; import { useInterviewRecorder } from "./useInterviewRecorder"; @@ -37,12 +35,9 @@ 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 DEFAULT_SOLO_VOICE_INDEX = 1; const INTERVIEW_PREPARATION_TIMEOUT_MS = 20 * 60_000; const INTERVIEW_QUESTIONS_WAITING_POLL_INTERVAL_MS = 3_000; -export type InterviewTtsProvider = "elevenlabs" | "supertone"; - const buildQuestionKey = (question: CurrentInterviewQuestion | null) => { if (!question) { return null; @@ -242,8 +237,6 @@ const interviewSessionReducer = ( export const useInterviewSession = ( preparedInterview?: PreparedInterviewData | null, - ttsProvider: InterviewTtsProvider = "elevenlabs", - interviewStyle?: InterviewStyleOption, ) => { const navigate = useNavigate(); const [session, dispatch] = useReducer( @@ -281,41 +274,6 @@ export const useInterviewSession = ( 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; @@ -333,29 +291,15 @@ export const useInterviewSession = ( ? personaId : undefined; }, [currentQuestion?.personaId, preparedInterview]); - const soloTtsVoiceIndex = - preparedInterview?.mode === "SOLO" - ? preparedInterview.interviewers?.[0]?.voiceIndex - : undefined; - const elevenLabsTts = useElevenLabsTts( - currentQuestion?.content ?? "", - soloTtsVoiceIndex ?? - (preparedInterview?.mode === "SOLO" && interviewStyle === "압박" - ? DEFAULT_SOLO_VOICE_INDEX - : multiTtsSpeaker?.voiceIndex), - preparedInterview?.mode === "SOLO" - ? { - interviewStyle, - personality: preparedInterview.personaType, - tone: preparedInterview.tone, - } - : undefined, - ); - const supertoneTts = useSupertoneTts(currentQuestion?.content ?? ""); - const questionTts = ttsProvider === "elevenlabs" ? elevenLabsTts : supertoneTts; - const controlsRef = useRef({ questionTts, voiceAnswer }); - useEffect(() => { controlsRef.current = { questionTts, voiceAnswer }; }); const sessionId = preparedInterview?.sessionId ?? getActiveInterviewSessionId(); + const questionAudio = useQuestionAudio({ + sessionId, + questionId: currentQuestion?.questionId ?? null, + enabled: isChatSessionReady && currentQuestion !== null && !isClosing, + autoplay: isVoiceMode, + }); + const controlsRef = useRef({ questionAudio, voiceAnswer }); + useEffect(() => { controlsRef.current = { questionAudio, voiceAnswer }; }); const isSessionClosedRef = useRef(false); const closePromiseRef = useRef | null>(null); const closeReasonRef = useRef(null); @@ -372,10 +316,10 @@ export const useInterviewSession = ( 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); @@ -424,24 +368,27 @@ 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); - if (isVoiceMode && !isSessionClosedRef.current) void questionTts.onPlay(); }, [ clearAwaitingResponseTimeout, currentQuestion, isChatSessionReady, - isVoiceMode, - 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) => { if (!isSessionClosedRef.current) dispatch({ type: "SYNC_QUESTION", question: nextQuestion }); @@ -492,7 +439,7 @@ export const useInterviewSession = ( closeReasonRef.current = reason; setIsClosing(true); clearAwaitingResponseTimeout(); - controlsRef.current.questionTts.onStop(); + controlsRef.current.questionAudio.onStop(); controlsRef.current.voiceAnswer.onExitVoiceMode(); const activeSessionId = sessionIdRef.current ?? getActiveInterviewSessionId(); // 같은 종료 Promise를 HTTP, 소켓, 메뉴와 뒤로가기가 공유한다. @@ -726,9 +673,9 @@ export const useInterviewSession = ( if (!isVoiceMode || isSessionClosedRef.current || isCompletingVoiceRef.current || submittingRef.current || isAwaitingNextQuestion || !canSubmitAnswer || cameraState !== "ready" || micState !== "ready" || !fullRecorder.isRecording) return false; - questionTts.onStop(); + questionAudio.onStop(); if (!recorder.startRecording()) return false; - answerRecordingRef.current = { question: currentQuestion, startedAt: questionStartedAtRef.current }; + answerRecordingRef.current = { question: currentQuestion, startedAt: Date.now() }; setAnswerError(null); void voiceAnswer.onStartVoice(); clearAnswerTimeout(); @@ -741,7 +688,7 @@ export const useInterviewSession = ( const submitAnswer = async ( content: string, submittedQuestion = currentQuestion, - startedAt = questionStartedAtRef.current, + startedAt = 0, ) => { if (isSessionClosedRef.current || submittingRef.current || isAwaitingNextQuestion || !canSubmitAnswer || !submittedQuestion) { return; @@ -792,7 +739,7 @@ export const useInterviewSession = ( return; } - questionTts.onStop(); + questionAudio.onStop(); voiceAnswer.onClearAnswer(); if (data?.status) { @@ -854,7 +801,7 @@ export const useInterviewSession = ( useEffect(() => { completionRef.current = handleCompleteVoice; }); const handleSubmitText = async () => { - if (!isVoiceMode) await submitAnswer(voiceAnswer.answerText); + if (!isVoiceMode) await submitAnswer(voiceAnswer.answerText, currentQuestion, textAnswerStartedAtRef.current); }; const handleQuitInterview = () => endInterviewSession(true, "quit"); @@ -883,18 +830,16 @@ export const useInterviewSession = ( isRetryingPreparation, isWaitingForQuestions, onRetryPreparation: handleRetryPreparation, - questionAudioStatus: questionTts.status, - questionAudioErrorMessage: - ttsProvider === "elevenlabs" ? elevenLabsTts.errorMessage : null, + questionAudioStatus: questionAudio.status, + questionAudioErrorMessage: questionAudio.errorMessage, speakingPersonaId, - ttsSpeakerPersonaId: multiTtsSpeaker?.personaId, onAnswerTextChange: voiceAnswer.onAnswerTextChange, onClearAnswer: voiceAnswer.onClearAnswer, onCompleteVoice: () => handleCompleteVoice("user"), onQuitInterview: handleQuitInterview, onStartVoice: handleStartVoice, onSubmitText: handleSubmitText, - onToggleQuestionAudio: questionTts.onToggle, + onToggleQuestionAudio: questionAudio.onToggle, micState, videoRef, voiceLevel, 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..a800dab9 --- /dev/null +++ b/src/features/interview-page/interview/model/useQuestionAudio.ts @@ -0,0 +1,181 @@ +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]; +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; + while (isCurrent(operation)) { + if (Date.now() - startedAt >= AUDIO_WAIT_MS) { fail(); return; } + 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 new Promise((resolve) => { + releaseRetry = resolve; + retryTimer = window.setTimeout(resolve, delay); + }); + continue; + } + const data = result.data; + if (data.status === "PENDING") { errorCount = 0; 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/pages/interview-page/interview/index.tsx b/src/pages/interview-page/interview/index.tsx index 36282200..57f6c716 100644 --- a/src/pages/interview-page/interview/index.tsx +++ b/src/pages/interview-page/interview/index.tsx @@ -7,7 +7,6 @@ import { } from "@/features/interview-page/interview/model/interviewSessionContext"; import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; import { INTERVIEW_DEVICE_STATUS_LABELS } from "@/shared/constants/interview-page/interview"; -import type { InterviewStyleOption } from "@/shared/constants/interview-page/setting-interview"; import CameraIcon from "@/shared/img/interview-page/camara.svg?url"; import MicIcon from "@/shared/img/interview-page/mike.svg?url"; import Loading from "@/shared/components/loading"; @@ -30,26 +29,6 @@ const getPreparedInterviewFromState = (state: unknown) => { return preparedInterview as PreparedInterviewData; }; -const getInterviewStyleFromState = ( - state: unknown, -): InterviewStyleOption | undefined => { - if (!state || typeof state !== "object" || !("interviewSetting" in state)) { - return undefined; - } - - const interviewSetting = (state as { interviewSetting?: unknown }).interviewSetting; - - if (!interviewSetting || typeof interviewSetting !== "object") { - return undefined; - } - - const style = (interviewSetting as { style?: unknown }).style; - - return style === "편함" || style === "일반" || style === "압박" - ? style - : undefined; -}; - const formatElapsedTime = (elapsedSeconds: number) => { const minutes = Math.floor(elapsedSeconds / 60); const seconds = elapsedSeconds % 60; @@ -63,18 +42,11 @@ const InterviewPage = () => { () => getPreparedInterviewFromState(location.state), [location.state], ); - const interviewStyle = useMemo( - () => getInterviewStyleFromState(location.state), - [location.state], - ); - const ttsProvider = "elevenlabs" as const; return ( diff --git a/src/widgets/interview-page/interview/interview-content/index.tsx b/src/widgets/interview-page/interview/interview-content/index.tsx index 6de4309f..480d4d92 100644 --- a/src/widgets/interview-page/interview/interview-content/index.tsx +++ b/src/widgets/interview-page/interview/interview-content/index.tsx @@ -1,6 +1,7 @@ import { useInterviewSessionContext } from "@/features/interview-page/interview/model/useInterviewSessionContext"; import InterviewVisualizerIcon from "@/shared/img/interview-page/Repit-Interview.svg?url"; import * as S from "../style"; +import QuestionAudioControl, { QuestionAudioError } from "../question-audio-control"; const TEXT_ANSWER_MAX_LENGTH = 10000; const TEXT_ANSWER_PLACEHOLDER = @@ -78,12 +79,14 @@ const InterviewContentView = () => { {question.id} + {activeInterviewer ? ( 현재 면접관 · {activeInterviewer.name} ({activeInterviewer.roleLabel}) ) : null} {question.text} + {isVoiceMode ? (