diff --git a/.github/workflows/ai-code-review.yml b/.github/workflows/ai-code-review.yml index bcaba2b4..8fb76585 100644 --- a/.github/workflows/ai-code-review.yml +++ b/.github/workflows/ai-code-review.yml @@ -3,6 +3,8 @@ name: AI Code Review on: pull_request: types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - refactor/dbjoung issue_comment: types: [created] diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 854cdff7..89873c3d 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -10,6 +10,8 @@ env: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VITE_API_URL: ${{ secrets.VITE_API_URL }} + VITE_DEMO_EMAIL: ${{ secrets.VITE_DEMO_EMAIL }} + VITE_DEMO_PASSWORD: ${{ secrets.VITE_DEMO_PASSWORD }} jobs: Vercel-Deploy : @@ -21,6 +23,8 @@ jobs: - name: Create .env run: | echo "VITE_API_URL=$VITE_API_URL" > .env + echo "VITE_DEMO_EMAIL=$VITE_DEMO_EMAIL" >> .env + echo "VITE_DEMO_PASSWORD=$VITE_DEMO_PASSWORD" >> .env - name: Vercel pull (prod) run: npx vercel pull --yes --environment=production --token=$VERCEL_TOKEN diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5581e79..58584b6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ env: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VITE_API_URL: ${{ secrets.VITE_API_URL }} + VITE_DEMO_EMAIL : ${{ secrets.VITE_DEMO_EMAIL }} + VITE_DEMO_PASSWORD: ${{ secrets.VITE_DEMO_PASSWORD }} jobs: Test-And-Build: @@ -88,6 +90,8 @@ jobs: - name: Create .env run: | echo "VITE_API_URL=$VITE_API_URL" > .env + echo "VITE_DEMO_EMAIL=$VITE_DEMO_EMAIL" >> .env + echo "VITE_DEMO_PASSWORD=$VITE_DEMO_PASSWORD" >> .env - name: Run build run: npm run build @@ -104,6 +108,8 @@ jobs: - name: Create .env run: | echo "VITE_API_URL=$VITE_API_URL" > .env + echo "VITE_DEMO_EMAIL=$VITE_DEMO_EMAIL" >> .env + echo "VITE_DEMO_PASSWORD=$VITE_DEMO_PASSWORD" >> .env - name: Vercel pull (preview) run: npx vercel pull --yes --environment=preview --token=$VERCEL_TOKEN diff --git a/index.html b/index.html index b52fa8ec..12c3e1cd 100644 --- a/index.html +++ b/index.html @@ -2,10 +2,6 @@ - jobda-fe diff --git a/package-lock.json b/package-lock.json index 5361dfa3..e165581e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.5", - "sockjs-client": "^1.6.1" + "sockjs-client": "^1.6.1", + "zustand": "^5.0.9" }, "devDependencies": { "@eslint/js": "^9.39.0", @@ -1832,7 +1833,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.3.tgz", "integrity": "sha512-k5dJVszUiNr1DSe8Cs+knKR6IrqhqdhpUwzqhkS8ecQTSf3THNtbfIp/umqHMpX2bv+9dkx3fwDv/86LcSfvSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2735,7 +2736,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-view-buffer": { @@ -7085,6 +7086,35 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz", + "integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 73c2a0e7..295ad4e6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.9.5", - "sockjs-client": "^1.6.1" + "sockjs-client": "^1.6.1", + "zustand": "^5.0.9" }, "devDependencies": { "@eslint/js": "^9.39.0", diff --git a/src/App.css b/src/App.css deleted file mode 100644 index b9d355df..00000000 --- a/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git "a/src/assets/default-profile \353\263\265\354\202\254\353\263\270.jpg" "b/src/assets/default-profile \353\263\265\354\202\254\353\263\270.jpg" deleted file mode 100644 index 48b861f8..00000000 Binary files "a/src/assets/default-profile \353\263\265\354\202\254\353\263\270.jpg" and /dev/null differ diff --git a/src/components/dashboard/BlankCard.tsx b/src/components/dashboard/BlankCard.tsx deleted file mode 100644 index 21a9199e..00000000 --- a/src/components/dashboard/BlankCard.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import SelectIcon from '@components/SelectIcon.tsx'; - -export default function BlankCard({ text }: { text: string }) { - return ( -
- - {text} -
- ); -} diff --git a/src/components/layouts/Footer.tsx b/src/components/layouts/Footer.tsx deleted file mode 100644 index 437438ed..00000000 --- a/src/components/layouts/Footer.tsx +++ /dev/null @@ -1,53 +0,0 @@ -const Footer = () => { - return ( - <> - - - ); -}; - -export default Footer; diff --git a/src/components/layouts/MainLayout.tsx b/src/components/layouts/MainLayout.tsx deleted file mode 100644 index 9bb3aece..00000000 --- a/src/components/layouts/MainLayout.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import Header from './Header'; -import Footer from './Footer'; -import { Outlet } from 'react-router-dom'; -import { useEffect, useState } from 'react'; - -const MainLayout = () => { - const [drawerOpen, setDrawerOpen] = useState(false); - const [isDesktop, setIsDesktop] = useState(false); - - useEffect(() => { - const mq = window.matchMedia('(min-width: 1024px)'); - setIsDesktop(mq.matches); - const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); - mq.addEventListener('change', handler); - return () => mq.removeEventListener('change', handler); - }, []); - - useEffect(() => { - const handler = (e: Event) => { - const custom = e as CustomEvent; - setDrawerOpen(custom.detail); - }; - const closeHandler = () => setDrawerOpen(false); - - window.addEventListener('drawer-state-change', handler); - window.addEventListener('close-drawer', closeHandler); - - return () => { - window.removeEventListener('drawer-state-change', handler); - window.removeEventListener('close-drawer', closeHandler); - }; - }, []); - - useEffect(() => { - if (!isDesktop && drawerOpen) document.body.style.overflow = 'hidden'; - else document.body.style.overflow = ''; - return () => { - document.body.style.overflow = ''; - }; - }, [drawerOpen, isDesktop]); - - return ( -
- {/* 모바일 전용 오버레이 */} -
window.dispatchEvent(new CustomEvent('close-drawer'))} - /> - -
-
{ - if (drawerOpen) { - window.dispatchEvent(new CustomEvent('close-drawer')); - } - }} - > - -
-
-
- ); -}; - -export default MainLayout; diff --git a/src/features/auth/api/auth.api.ts b/src/features/auth/api/auth.api.ts index e8bcfba9..743a199c 100644 --- a/src/features/auth/api/auth.api.ts +++ b/src/features/auth/api/auth.api.ts @@ -1,6 +1,6 @@ -import { request, unwrap } from '../../../lib/utils/base'; +import { unwrap } from '@lib/utils/base.ts'; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; -// 타입 정의 export interface LoginRequest { email: string; password: string; @@ -8,100 +8,93 @@ export interface LoginRequest { export interface LoginResponse { accessToken: string; - refreshToken?: string; + refreshToken: string; } -// 로그인 API -export async function login(dto: LoginRequest): Promise { - const raw = await request('/api/v1/auth/token', { +export async function login(dto: LoginRequest, client: ClientRequestType): Promise { + const raw = await client.request('/api/v1/auth/token', { method: 'POST', - body: JSON.stringify(dto), + body: dto, }); - // 1) 공통 응답에서 data 꺼내기 (또는 raw 자체) const result = unwrap(raw); - // 2) 기본적인 타입/필수값 검증 if ( !result || // null / undefined typeof result !== 'object' || - typeof result.accessToken !== 'string' || !result.accessToken.trim() ) { console.error('서버에서 로그인 토큰이 전달되지 않았습니다:', raw); throw new Error('로그인에 실패했습니다. 다시 시도해주세요.'); } - localStorage.setItem('accessToken', result.accessToken); - - console.log('로그인 성공:', result); return result; } -// 로그아웃 API -export async function logout() { - await request('/api/v1/auth/logout', { +export async function logout(client: ClientRequestType) { + await client.request('/api/v1/auth/logout', { method: 'POST', }); } -// 로그인 전 비번 찾기 -export async function requestResetEmail(email: string) { - await request('/api/v1/auth/password/reset-requests', { +export async function requestResetEmail(client: ClientRequestType, email: string) { + await client.request('/api/v1/auth/password/reset-requests', { method: 'POST', - body: JSON.stringify({ email }), + body: { email }, }); } -// 토큰으로 비번 찾기 -export async function resetPasswordByToken(token: string, newPassword: string) { - await request('/api/v1/auth/password/reset', { + +export async function resetPasswordByToken( + client: ClientRequestType, + token: string, + newPassword: string +) { + await client.request('/api/v1/auth/password/reset', { method: 'POST', - body: JSON.stringify({ token, newPassword }), + body: { token, newPassword }, }); } -// 회원가입 전 회사 이메일 인증 - -// 이메일 인증 링크 요청 -export async function sendCompanyVerifyLink(email: string) { - await request('/api/v1/auth/email-verifications', { +export async function sendCompanyVerifyLink(client: ClientRequestType, email: string) { + await client.request('/api/v1/auth/email-verifications', { method: 'POST', - body: JSON.stringify({ email }), + body: { email }, }); } -// 이메일 토큰 검증 +/* TODO : 미사용로직 삭제 export async function verifyCompanyEmailToken(token: string) { const path = `/api/v1/auth/email-verifications/complete?token=${encodeURIComponent(token)}`; - // GET 요청 보내기 + const raw = await request(path, { method: 'GET', }); - // CommonResponse 래핑해제 → data 꺼내기 + const data = unwrap<{ email: string; verifiedAt: string | null }>(raw); - // 응답에서 verified + email을 보고, 인증된 이메일 저장 if (data?.email) { - // 이메일 인증이 완료된 회사 이메일을 세션 스토리지에 저장 sessionStorage.setItem('verifiedCompanyEmail', data.email); } return data; } +*/ -// 회원가입 요청 -export async function signup(payload: { - token: string; - email: string; - name: string; - nickname: string; - position: string; - companyName: string; - password: string; -}) { - const raw = await request('/api/v1/users', { +export async function signup( + payload: { + token: string; + email: string; + name: string; + nickname: string; + position: string; + companyName: string; + password: string; + }, + client: ClientRequestType +) { + const raw = await client.request('/api/v1/users', { method: 'POST', - body: JSON.stringify(payload), + body: payload, }); - return raw?.data ?? raw; + return unwrap(raw); } diff --git a/src/features/auth/components/AuthShell.tsx b/src/features/auth/components/AuthShell.tsx index ed9ccb49..7ddc79b5 100644 --- a/src/features/auth/components/AuthShell.tsx +++ b/src/features/auth/components/AuthShell.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import type { PropsWithChildren, ReactNode } from 'react'; +import { type PropsWithChildren, type ReactNode } from 'react'; type AuthShellProps = PropsWithChildren<{ withVideo?: boolean; // 로그인에서만 true @@ -21,59 +21,41 @@ export default function AuthShell({ : 'mx-auto w-full max-w-[520px]'; return ( -
-
-
-
\")", - }} - /> -
- - {/* 글래스 카드 */} -
-
-
- {/* 좌측 로고/카피 */} - {showLeftPane && ( -
- jobda navigate('/login')} - className="h-[120px] w-auto cursor-pointer drop-shadow-[0_3px_10px_rgba(0,0,0,.25)] transition-transform sm:h-[160px] md:h-[200px]" - style={{ height: '200px' }} - /> - {caption ? ( -

- {caption} -

- ) : null} -
- )} - - {/* 우측(또는 전체) 컨텐츠 */} -
{children}
+
+
+ {/* 좌측 로고/카피 */} + {showLeftPane && ( +
+ jobda navigate('/login')} + className="h-[120px] w-auto cursor-pointer drop-shadow-[0_3px_10px_rgba(0,0,0,.25)] transition-transform sm:h-[160px] md:h-[200px]" + style={{ height: '200px' }} + /> + {caption ? ( +

+ {caption} +

+ ) : null}
-
-
-
+ )} + + {/* 우측(또는 전체) 컨텐츠 */} +
{children}
+
+ ); } diff --git a/src/features/auth/pages/LoginPage.tsx b/src/features/auth/pages/LoginPage.tsx index 3cc405e7..e0fbfeb5 100644 --- a/src/features/auth/pages/LoginPage.tsx +++ b/src/features/auth/pages/LoginPage.tsx @@ -1,217 +1,179 @@ -import React, { useEffect, useRef, useState, useContext } from 'react'; +import { useState, useContext, type SyntheticEvent } from 'react'; import { AuthContext } from '@/AuthContext.ts'; import { useNavigate } from 'react-router-dom'; -import { login } from '../api/auth.api.ts'; -import AlertModal from '@components/Alertmodal.tsx'; +import { useAuthedClient } from '@shared/hooks/useAuthClient.ts'; +import AuthShell from '@features/auth/components/AuthShell.tsx'; +import { useAlertStore } from '@shared/store/useAlertStore.ts'; +import { login } from '@features/auth/api/auth.api.ts'; export default function LoginPage() { - const videoRef = useRef(null); const { setToken } = useContext(AuthContext); const [submitting, setSubmitting] = useState(false); const navigate = useNavigate(); + const client = useAuthedClient(); - const [alertModal, setAlertModal] = useState({ - open: false, - type: 'error' as 'success' | 'error' | 'info' | 'warning', - message: '', - }); + const openAlertModal = useAlertStore((s) => s.action.openAlertModal); - const showAlert = (message: string, type: 'success' | 'error' | 'info' | 'warning' = 'error') => { - setAlertModal({ open: true, type, message }); + const fetchLogin = async (email: string, password: string) => { + try { + const result = await login({ email, password }, client); // 서버에서 accessToken을 받기 + setToken(result.accessToken, result.refreshToken); + } catch { + openAlertModal({ + type: 'error', + title: '로그인 실패', + message: '이메일 또는 비밀번호를 확인해 주세요.', + }); + } finally { + setSubmitting(false); + navigate('/dashboard', { replace: true }); + } }; - const closeAlert = () => { - setAlertModal((prev) => ({ ...prev, open: false })); - }; + const demoLogin = async () => { + setSubmitting(true); - useEffect(() => { - const onVisibility = () => { - const v = videoRef.current; - if (!v) return; - if (document.hidden) v.pause(); - else v.play().catch(() => {}); - }; - document.addEventListener('visibilitychange', onVisibility); - return () => document.removeEventListener('visibilitychange', onVisibility); - }, []); + const email = import.meta.env.VITE_DEMO_EMAIL as string; + const password = import.meta.env.VITE_DEMO_PASSWORD as string; + await fetchLogin(email, password); + }; - const onSubmit = async (e: React.FormEvent) => { + const onSubmit = async (e: SyntheticEvent) => { e.preventDefault(); - setSubmitting(true); // 중복 클릭을 방지 + + setSubmitting(true); const form = new FormData(e.target as HTMLFormElement); + const email = String(form.get('email') || ''); // 비어 있으면 공백 문자열 const password = String(form.get('password') || ''); - try { - const result = await login({ email, password }); // 서버에서 accessToken을 받기 - setToken(result.accessToken, result.refreshToken); - } catch { - showAlert('이메일 또는 비밀번호를 확인해 주세요.', 'error'); - } finally { - setSubmitting(false); // 버튼 비활성화를 해제 - window.location.href = '/dashboard'; - } + + await fetchLogin(email, password); }; return ( -
- {/* 배경 비디오 */} - - - {/* 글래스 카드 */} -
-
- {/* 좌측 520px / 우측 480px, 사이 간격 64px */} -
- {/* 좌측: 로고 + 카피 (정중앙 정렬) */} -
- jobda -

- 잡다와 함께 효율적인 채용을 -
- 경험해 보세요 -

+ + 잡다와 함께 효율적인 +
+ 채용을 경험해보세요. + + } + > +
+
+
+
+ +
+
+ + + +
+ +
- {/* 우측: 폼 */} -
- - {/* 입력 필드 그룹 */} -
- {/* 이메일 */} -
- -
-
- - - -
- -
-
- - {/* 비밀번호 */} -
- -
-
-
+
+ -
- -
-
+ /> +
+ +
+
+
- {/* 로그인 버튼 */} - +
+ + +
- {/* 하단 링크 */} -
- +
+ - | + | - -
- -
+
-
-
- - -
+ +
+ ); } diff --git a/src/features/auth/pages/ResetPasswordEmailPage.tsx b/src/features/auth/pages/ResetPasswordEmailPage.tsx index f1bd5156..01cb9a1f 100644 --- a/src/features/auth/pages/ResetPasswordEmailPage.tsx +++ b/src/features/auth/pages/ResetPasswordEmailPage.tsx @@ -1,22 +1,17 @@ -import { type FormEvent, useState } from 'react'; +import { type FormEvent } from 'react'; import { requestResetEmail } from '../api/auth.api.ts'; import AuthShell from '../components/AuthShell.tsx'; -import AlertModal from '@components/Alertmodal.tsx'; +import { useAuthedClient } from '@shared/hooks/useAuthClient.ts'; +import { useAlertStore } from '@shared/store/useAlertStore.ts'; export default function ResetPasswordEmailPage() { - const [alertModal, setAlertModal] = useState({ - open: false, - type: 'info' as 'success' | 'error' | 'info' | 'warning', - message: '', - }); + const client = useAuthedClient(); - const showAlert = (message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => { - setAlertModal({ open: true, type, message }); - }; + const openAlertModal = useAlertStore((s) => s.action.openAlertModal); - const closeAlert = () => { - setAlertModal((prev) => ({ ...prev, open: false })); + const showAlert = (message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') => { + openAlertModal({ type, message }); }; const onSubmit = async (e: FormEvent) => { @@ -24,7 +19,7 @@ export default function ResetPasswordEmailPage() { const form = new FormData(e.target as HTMLFormElement); const email = String(form.get('email') || ''); try { - await requestResetEmail(email); // 서버에 메일 발송 요청 + await requestResetEmail(client, email); // 서버에 메일 발송 요청 showAlert('이메일을 확인해 주세요.\n비밀번호 재설정 링크가 발송되었습니다.', 'success'); } catch { showAlert('이메일 전송에 실패했습니다.\n다시 시도해주세요.', 'error'); @@ -70,12 +65,6 @@ export default function ResetPasswordEmailPage() { 이메일 인증 - ); } diff --git a/src/features/auth/pages/ResetPasswordPage.tsx b/src/features/auth/pages/ResetPasswordPage.tsx index f3a8caa1..af2ec789 100644 --- a/src/features/auth/pages/ResetPasswordPage.tsx +++ b/src/features/auth/pages/ResetPasswordPage.tsx @@ -4,6 +4,7 @@ import AuthShell from '../components/AuthShell'; import ReqBadge from '../components/ReqBadge'; import { buildPasswordChecks } from '../utils/passwordChecks'; import { resetPasswordByToken } from '../api/auth.api.ts'; +import { useAuthedClient } from '@shared/hooks/useAuthClient.ts'; export default function ResetPasswordPage() { const [searchParams] = useSearchParams(); @@ -22,6 +23,8 @@ export default function ResetPasswordPage() { }); const [didSubmit, setDidSubmit] = useState(false); + const client = useAuthedClient(); + useEffect(() => { if (!token) { setServerError('유효하지 않은 링크입니다. 이메일의 버튼을 다시 눌러 주세요.'); @@ -50,7 +53,7 @@ export default function ResetPasswordPage() { setSubmitting(true); setServerError(null); try { - await resetPasswordByToken(token, password); + await resetPasswordByToken(client, token, password); setSuccess(true); setTimeout(() => navigate('/login'), 1800); diff --git a/src/features/auth/pages/SignupCompanyEmailPage.tsx b/src/features/auth/pages/SignupCompanyEmailPage.tsx index 76c3eabf..e2df4be0 100644 --- a/src/features/auth/pages/SignupCompanyEmailPage.tsx +++ b/src/features/auth/pages/SignupCompanyEmailPage.tsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import AuthShell from '../components/AuthShell'; import { sendCompanyVerifyLink } from '../api/auth.api.ts'; import { ERROR_CODES } from '@features/constants/errorCodes.ts'; +import { useAuthedClient } from '@shared/hooks/useAuthClient.ts'; const PERSONAL_DOMAINS = new Set([ 'gmail.com', @@ -21,6 +22,7 @@ export default function SignupCompanyEmailPage() { const [loading, setLoading] = useState(false); // 전송 중 중복 클릭 방지 const [sent, setSent] = useState(false); // 전송 완료 안내 표시 const [error, setError] = useState(''); + const client = useAuthedClient(); const handleChange = (e: React.ChangeEvent) => { const value = e.target.value.trim(); @@ -38,7 +40,7 @@ export default function SignupCompanyEmailPage() { if (invalid || !email || loading) return; // 회사메일 아닐 때/중복 클릭 방지 try { setLoading(true); // 버튼 잠금 - await sendCompanyVerifyLink(email); // 서버에 “인증 링크 보내기” 요청 + await sendCompanyVerifyLink(client, email); // 서버에 “인증 링크 보내기” 요청 setSent(true); // 안내 메시지 노출 setError(''); } catch (err: any) { diff --git a/src/features/auth/pages/SignupFormPage.tsx b/src/features/auth/pages/SignupFormPage.tsx index 07df53fc..05dcbc20 100644 --- a/src/features/auth/pages/SignupFormPage.tsx +++ b/src/features/auth/pages/SignupFormPage.tsx @@ -6,8 +6,9 @@ import PrivacyModal from '../components/PrivacyModal'; import ReqBadge from '../components/ReqBadge'; import { buildPasswordChecks } from '../utils/passwordChecks'; import { signup } from '../api/auth.api.ts'; -import AlertModal from '@components/Alertmodal.tsx'; +import AlertModal from '@shared/components/Alertmodal.tsx'; import type { PositionValue } from '@features/user/components/PositionSelect.tsx'; +import { useAuthedClient } from '@shared/hooks/useAuthClient.ts'; const POSITION_OPTIONS: { value: PositionValue; label: string }[] = [ { value: 'OWNER', label: '대표 / Founder' }, @@ -21,14 +22,12 @@ export default function SignupFormPage() { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - // URL에서 email + token 둘 다 읽기 const token = searchParams.get('token'); // URL에서 토큰만 읽음 const emailFromUrl = searchParams.get('email') ?? ''; // 인증된 회사 이메일 const [companyEmail, setCompanyEmail] = useState(emailFromUrl); const [loading, setLoading] = useState(true); - // 필수 입력값 상태들 — 컴포넌트 "안"에서 선언 const [name, setName] = useState(''); const [companyName, setCompanyName] = useState(''); const [position, setPosition] = useState(''); @@ -37,12 +36,11 @@ export default function SignupFormPage() { const [errors, setErrors] = useState<{ [key: string]: string }>({}); const [nickname, setNickname] = useState(''); - // 비밀번호 입력 상태 - const [password, setPassword] = useState(''); const [passwordConfirm, setPasswordConfirm] = useState(''); - // 모달 트리거 버튼 ref + const client = useAuthedClient(); + const openPrivacyBtnRef = useRef(null); const [alertModal, setAlertModal] = useState({ @@ -175,15 +173,18 @@ export default function SignupFormPage() { const finalPosition = position === 'OTHER' ? customPosition.trim() : (position as string); try { - await signup({ - token, - email: companyEmail, - name, - nickname, - position: finalPosition, - companyName, - password, - }); + await signup( + { + token, + email: companyEmail, + name, + nickname, + position: finalPosition, + companyName, + password, + }, + client + ); showAlert('가입이 완료되었습니다.\n로그인 해주세요.', 'success'); navigate('/login', { replace: true }); diff --git a/src/features/dashboard/DetailSection.tsx b/src/features/dashboard/DetailSection.tsx index 1347f957..838f0681 100644 --- a/src/features/dashboard/DetailSection.tsx +++ b/src/features/dashboard/DetailSection.tsx @@ -21,7 +21,6 @@ export default function DetailSection() { title={'이번 주 캘린더'} description={'주간 일정 개요'} className="jd-LongViewContainerCard-RWD-full" - detailUrl={'#'} > diff --git a/src/features/dashboard/card/ApplicantsStatCard.tsx b/src/features/dashboard/card/ApplicantsStatCard.tsx index 9430d6d6..5f933446 100644 --- a/src/features/dashboard/card/ApplicantsStatCard.tsx +++ b/src/features/dashboard/card/ApplicantsStatCard.tsx @@ -2,9 +2,9 @@ import LongViewContainer from '@features/dashboard/container/LongViewContainer.t import NumberSlotsCard, { type NumberSlotsCardProps, } from '@features/dashboard/card/NumberSlotCardUI.tsx'; -import useFetch from '@/hooks/useFetch.ts'; -import { Skeleton } from '@components/Skeleton.tsx'; -import BlankCard from '@components/dashboard/BlankCard.tsx'; +import useFetch from '@shared/hooks/useFetch.ts'; +import { Skeleton } from '@shared/components/Skeleton.tsx'; +import BlankCard from '@shared/components/BlankCard.tsx'; export default function ApplicantsStatCard() { const { resData } = useFetch( diff --git a/src/features/dashboard/card/DateInfoCardUI.tsx b/src/features/dashboard/card/DateInfoCardUI.tsx index 691613e4..77394eea 100644 --- a/src/features/dashboard/card/DateInfoCardUI.tsx +++ b/src/features/dashboard/card/DateInfoCardUI.tsx @@ -1,4 +1,4 @@ -import SelectIcon from '@components/SelectIcon.tsx'; +import SelectIcon from '@shared/components/SelectIcon.tsx'; export type DataInfoCardProps = { isOrganizer: boolean; diff --git a/src/features/dashboard/card/InterviewStatusCard.tsx b/src/features/dashboard/card/InterviewStatusCard.tsx index b80abd4a..08bb19b1 100644 --- a/src/features/dashboard/card/InterviewStatusCard.tsx +++ b/src/features/dashboard/card/InterviewStatusCard.tsx @@ -1,5 +1,5 @@ import LongViewContainer from '@features/dashboard/container/LongViewContainer.tsx'; -import useFetch from '@/hooks/useFetch.ts'; +import useFetch from '@shared/hooks/useFetch.ts'; import StatusCountCardUI from '@features/dashboard/card/StatusCountCardUI.tsx'; type NumByProgressStatus = { diff --git a/src/features/dashboard/card/JobStatusCard.tsx b/src/features/dashboard/card/JobStatusCard.tsx index 5904f28f..7be3cb36 100644 --- a/src/features/dashboard/card/JobStatusCard.tsx +++ b/src/features/dashboard/card/JobStatusCard.tsx @@ -1,5 +1,5 @@ import LongViewContainer from '@features/dashboard/container/LongViewContainer.tsx'; -import useFetch from '@/hooks/useFetch.ts'; +import useFetch from '@shared/hooks/useFetch.ts'; import StatusCountCardUI from '@features/dashboard/card/StatusCountCardUI.tsx'; type NumByProgressStatus = { diff --git a/src/features/dashboard/card/NumberSlotCardUI.tsx b/src/features/dashboard/card/NumberSlotCardUI.tsx index b6e4d726..3cb618f7 100644 --- a/src/features/dashboard/card/NumberSlotCardUI.tsx +++ b/src/features/dashboard/card/NumberSlotCardUI.tsx @@ -1,4 +1,4 @@ -import Badge, { type BadgeType } from '@components/dashboard/Badge.tsx'; +import Badge, { type BadgeType } from '@features/dashboard/components/Badge.tsx'; const STATUS_INFO = { DOCUMENT: { @@ -45,7 +45,7 @@ export default function NumberSlotsCard({ title, slotData, status }: NumberSlots
{['지원자', '북마크', '면접', '합격'].map((item, index) => { - if (index == 1) return <>; + if (index == 1) return null; return ; })}
diff --git a/src/features/dashboard/card/StatusCountCardUI.tsx b/src/features/dashboard/card/StatusCountCardUI.tsx index 96cefbac..b918377a 100644 --- a/src/features/dashboard/card/StatusCountCardUI.tsx +++ b/src/features/dashboard/card/StatusCountCardUI.tsx @@ -1,5 +1,5 @@ -import Badge, { type BadgeType } from '@components/dashboard/Badge.tsx'; -import { Skeleton } from '@components/Skeleton.tsx'; +import Badge, { type BadgeType } from '@features/dashboard/components/Badge.tsx'; +import { Skeleton } from '@shared/components/Skeleton.tsx'; const STATUS_INFO = { byJob: { diff --git a/src/features/dashboard/card/SummationCardUI.tsx b/src/features/dashboard/card/SummationCardUI.tsx index 1ee1a5e3..4f34ce34 100644 --- a/src/features/dashboard/card/SummationCardUI.tsx +++ b/src/features/dashboard/card/SummationCardUI.tsx @@ -1,8 +1,8 @@ -import DetailButton from '@components/dashboard/DetailButton.tsx'; +import DetailButton from '@features/dashboard/components/DetailButton.tsx'; -import useFetch from '@/hooks/useFetch.ts'; -import SelectIcon from '@components/SelectIcon.tsx'; -import { Skeleton } from '@components/Skeleton.tsx'; +import useFetch from '@shared/hooks/useFetch.ts'; +import SelectIcon from '@shared/components/SelectIcon.tsx'; +import { Skeleton } from '@shared/components/Skeleton.tsx'; type SummationCardProps = { title: string; diff --git a/src/features/dashboard/card/UpComingInterviewsCard.tsx b/src/features/dashboard/card/UpComingInterviewsCard.tsx index dd37cb96..4f9e94d0 100644 --- a/src/features/dashboard/card/UpComingInterviewsCard.tsx +++ b/src/features/dashboard/card/UpComingInterviewsCard.tsx @@ -1,10 +1,10 @@ import LongViewContainer from '@features/dashboard/container/LongViewContainer.tsx'; -import useFetch from '@/hooks/useFetch.ts'; +import useFetch from '@shared/hooks/useFetch.ts'; import DateInfoCardUI, { type DataInfoCardProps, } from '@features/dashboard/card/DateInfoCardUI.tsx'; -import { Skeleton } from '@components/Skeleton.tsx'; -import BlankCard from '@components/dashboard/BlankCard.tsx'; +import { Skeleton } from '@shared/components/Skeleton.tsx'; +import BlankCard from '@shared/components/BlankCard.tsx'; export default function UpComingInterviewsCard() { const { resData } = useFetch('/api/v1/dashboard/detail/upcoming-interview'); diff --git a/src/components/dashboard/Badge.tsx b/src/features/dashboard/components/Badge.tsx similarity index 100% rename from src/components/dashboard/Badge.tsx rename to src/features/dashboard/components/Badge.tsx diff --git a/src/components/dashboard/DetailButton.tsx b/src/features/dashboard/components/DetailButton.tsx similarity index 89% rename from src/components/dashboard/DetailButton.tsx rename to src/features/dashboard/components/DetailButton.tsx index d17849d3..66ecf92b 100644 --- a/src/components/dashboard/DetailButton.tsx +++ b/src/features/dashboard/components/DetailButton.tsx @@ -1,5 +1,5 @@ import cn from '@lib/utils/cn.ts'; -import SelectIcon from '@components/SelectIcon.tsx'; +import SelectIcon from '@shared/components/SelectIcon.tsx'; export default function DetailButton({ className, url }: { className?: string; url: string }) { return ( diff --git a/src/components/dashboard/TimeSlot.tsx b/src/features/dashboard/components/TimeSlot.tsx similarity index 88% rename from src/components/dashboard/TimeSlot.tsx rename to src/features/dashboard/components/TimeSlot.tsx index c5b406ce..0db0b567 100644 --- a/src/components/dashboard/TimeSlot.tsx +++ b/src/features/dashboard/components/TimeSlot.tsx @@ -31,7 +31,11 @@ export default function TimeSlot({ time, title, type = 'INTERVIEW' }: TimeSlotPr return ( <>
-

{time}

+ {type == 'INTERVIEW' ? ( +

{time}

+ ) : ( +

{title}

+ )}

{SLOT_STATUS[type]}

[{title}] - 접수 마감 + 공고 마감

)}
diff --git a/src/features/dashboard/container/LongViewContainer.tsx b/src/features/dashboard/container/LongViewContainer.tsx index 8a2de75c..5ac143c7 100644 --- a/src/features/dashboard/container/LongViewContainer.tsx +++ b/src/features/dashboard/container/LongViewContainer.tsx @@ -1,4 +1,4 @@ -import DetailButton from '@components/dashboard/DetailButton.tsx'; +import DetailButton from '@features/dashboard/components/DetailButton.tsx'; import type { ReactNode } from 'react'; import cn from '@lib/utils/cn.ts'; @@ -11,7 +11,7 @@ export default function LongViewContainer({ }: { title: string; description: string; - detailUrl: string; + detailUrl?: string; className?: string; children: ReactNode; }) { @@ -22,7 +22,7 @@ export default function LongViewContainer({ className )} > - + {detailUrl && }

{title}

{description}

diff --git a/src/features/dashboard/container/WeekendContainer.tsx b/src/features/dashboard/container/WeekendContainer.tsx index 5e7fe4b9..e73c166f 100644 --- a/src/features/dashboard/container/WeekendContainer.tsx +++ b/src/features/dashboard/container/WeekendContainer.tsx @@ -1,8 +1,8 @@ -import TimeSlot, { type TimeSlotType } from '@components/dashboard/TimeSlot.tsx'; -import useFetch from '@/hooks/useFetch.ts'; -import { Skeleton } from '@components/Skeleton.tsx'; +import TimeSlot, { type TimeSlotType } from '@features/dashboard/components/TimeSlot.tsx'; +import useFetch from '@shared/hooks/useFetch.ts'; +import { Skeleton } from '@shared/components/Skeleton.tsx'; import { useEffect, useState } from 'react'; -import BlankCard from '@components/dashboard/BlankCard.tsx'; +import BlankCard from '@shared/components/BlankCard.tsx'; const WeekName = { mon: '월', diff --git a/src/features/interview/api/evaluation.api.ts b/src/features/interview/api/evaluation.api.ts index e0aa7b84..12025705 100644 --- a/src/features/interview/api/evaluation.api.ts +++ b/src/features/interview/api/evaluation.api.ts @@ -1,5 +1,5 @@ // src/features/interview/services/evaluation.api.ts -import type { ApiResponse } from '@features/jd/services/jobApi'; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; export type InterviewEvaluationDto = { evaluationId: number; @@ -9,72 +9,58 @@ export type InterviewEvaluationDto = { comment: string; }; -const API_BASE = import.meta.env.VITE_API_URL; - // 1) 면접 평가 조회 (GET) export async function getInterviewEvaluation( + client: ClientRequestType, interviewId: number ): Promise { - const res = await fetch(`${API_BASE}/api/v1/interviews/${interviewId}/evaluation`, { - credentials: 'include', - }); - - if (res.status === 404) return null; - if (!res.ok) throw new Error('평가 조회 실패'); - - const body: ApiResponse = await res.json(); + const res = await client.request( + `/api/v1/interviews/${interviewId}/evaluation`, + {}, + '평가 조회 실패' + ); - // data 없으면 null 넘기기 - return body.data ?? null; + return res ?? null; } // 2) 면접 평가 생성 (POST) export async function createInterviewEvaluation( + client: ClientRequestType, interviewId: number, payload: { scoreTech: number; scoreComm: number; scoreOverall: number; comment: string } ): Promise { - const res = await fetch(`${API_BASE}/api/v1/interviews/${interviewId}/evaluation`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload), - }); - - if (!res.ok) throw new Error('평가 생성 실패'); - - const body: ApiResponse = await res.json(); + const res = await client.request( + `/api/v1/interviews/${interviewId}/evaluation`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: payload, + }, + '평가 생성 실패' + ); - // 여기서 한 번 체크해서 TS 한테 "undefined 아님"이라고 확신 주기 - if (!body.data) { - throw new Error('평가 생성 응답에 data가 없습니다.'); - } + if (!res) throw new Error('평가 생성 응답에 data가 없습니다.'); - return body.data; + return res; } // 3) 면접 평가 수정 (PATCH) export async function updateInterviewEvaluation( + client: ClientRequestType, interviewId: number, evaluationId: number, payload: { scoreTech: number; scoreComm: number; scoreOverall: number; comment: string } ): Promise { - const res = await fetch( - `${API_BASE}/api/v1/interviews/${interviewId}/evaluation/${evaluationId}`, + const res = await client.request( + `/api/v1/interviews/${interviewId}/evaluation/${evaluationId}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - credentials: 'include', - body: JSON.stringify(payload), - } + body: payload, + }, + '평가 수정 실패' ); - if (!res.ok) throw new Error('평가 수정 실패'); - - const body: ApiResponse = await res.json(); - - if (!body.data) { - throw new Error('평가 수정 응답에 data가 없습니다.'); - } + if (!res) throw new Error('평가 수정 응답에 data가 없습니다.'); - return body.data; + return res; } diff --git a/src/features/interview/api/file.api.ts b/src/features/interview/api/file.api.ts index 82209cb4..99698a73 100644 --- a/src/features/interview/api/file.api.ts +++ b/src/features/interview/api/file.api.ts @@ -1,5 +1,6 @@ // src/features/interview/api/file.api.ts -import { authedRequest } from '@/lib/utils/authedRequest'; + +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; type CommonResponse = { errorCode?: number; @@ -8,12 +9,12 @@ type CommonResponse = { }; /** 로그인 사용자(Authorization)로 presigned download url 받기 */ -export async function getPresignedDownloadUrlAuthed(fileKey: string): Promise { - const raw = await authedRequest>( +export async function getPresignedDownloadUrlAuthed(client: ClientRequestType, fileKey: string) { + const raw = await client.request>( `/api/v1/files/download?fileKey=${encodeURIComponent(fileKey)}`, { method: 'GET' } ); - return raw.data; + return raw?.data; // 너 코드 스타일에 맞춰 조정 } /** 게스트(Interview-Token 헤더)로 presigned download url 받기 */ diff --git a/src/features/interview/api/interview-detail.api.ts b/src/features/interview/api/interview-detail.api.ts index b79a52c3..b3add747 100644 --- a/src/features/interview/api/interview-detail.api.ts +++ b/src/features/interview/api/interview-detail.api.ts @@ -1,11 +1,5 @@ // src/features/interview/api/interview-detail.api.ts -import { authedRequest } from '@/lib/utils/authedRequest'; - -type CommonResponse = { - errorCode?: number; - message: string; - data: T; -}; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; export type InterviewDetail = { interviewId: number; @@ -25,42 +19,28 @@ export type InterviewDetail = { }; /** 로그인 사용자(Authorization)로 호출 */ -export async function getInterviewDetailAuthed(interviewId: number): Promise { - const raw = await authedRequest>( - `/api/v1/interviews/${interviewId}`, - { - method: 'GET', - } - ); - return raw.data; +export async function getInterviewDetailAuthed( + client: ClientRequestType, + interviewId: number +): Promise { + const raw = await client.request(`/api/v1/interviews/${interviewId}`, { + method: 'GET', + }); + return raw!; } /** 게스트(Interview-Token 헤더)로 호출 */ export async function getInterviewDetailWithGuestToken( + client: ClientRequestType, interviewId: number, guestToken: string ): Promise { - const BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, ''); - const url = `${BASE_URL}/api/v1/interviews/${interviewId}`; - - const res = await fetch(url, { + const res = await client.request(`/api/v1/interviews/${interviewId}`, { method: 'GET', headers: { 'Interview-Token': guestToken, }, - credentials: 'include', }); - let body: any = null; - try { - body = await res.json(); - } catch { - body = null; - } - - if (!res.ok) { - throw new Error(body?.message ?? `요청 실패 (status=${res.status})`); - } - - return (body?.data ?? body) as InterviewDetail; + return res!; } diff --git a/src/features/interview/api/interview.api.ts b/src/features/interview/api/interview.api.ts index 68fec40c..52043808 100644 --- a/src/features/interview/api/interview.api.ts +++ b/src/features/interview/api/interview.api.ts @@ -1,13 +1,7 @@ -import { authedRequest } from '@/lib/utils/authedRequest'; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; -type CommonResponse = { - errorCode: number; - message: string; - data: T; -}; - -export async function endInterview(interviewId: number): Promise { - await authedRequest>(`/api/v1/interviews/${interviewId}/end`, { +export async function endInterview(client: ClientRequestType, interviewId: number): Promise { + await client.request(`/api/v1/interviews/${interviewId}/end`, { method: 'PATCH', }); } diff --git a/src/features/interview/api/me.authed.api.ts b/src/features/interview/api/me.authed.api.ts index 5693962e..15c69d49 100644 --- a/src/features/interview/api/me.authed.api.ts +++ b/src/features/interview/api/me.authed.api.ts @@ -1,10 +1,4 @@ -import { authedRequest } from '@/lib/utils/authedRequest'; - -type CommonResponse = { - errorCode: number; - message: string; - data: T; -}; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; export type Me = { id: number; @@ -12,7 +6,7 @@ export type Me = { nickname?: string; }; -export async function getMeAuthed(): Promise { - const raw = await authedRequest>('/api/v1/users/me', { method: 'GET' }); - return raw.data; +export async function getMeAuthed(client: ClientRequestType): Promise { + const raw = await client.request('/api/v1/users/me', { method: 'GET' }); + return raw!; } diff --git a/src/features/interview/api/profile.api.ts b/src/features/interview/api/profile.api.ts index 20f8e9b5..c52e12af 100644 --- a/src/features/interview/api/profile.api.ts +++ b/src/features/interview/api/profile.api.ts @@ -1,10 +1,5 @@ -// src/features/interview/api/profile.api.ts -import { authedRequest } from '@/lib/utils/authedRequest'; - -type CommonResponse = { - message: string; - data: T; -}; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; +import { unwrap } from '@lib/utils/base.ts'; export type UserProfile = { id: number; @@ -18,21 +13,30 @@ export type ResumeProfile = { resumeFileUrl?: string; }; -export async function getUserProfileAuthed(userId: number): Promise { - const raw = await authedRequest>(`/api/v1/users/${userId}`, { +export async function getUserProfileAuthed( + client: ClientRequestType, + userId: number +): Promise { + const raw = await client.request(`/api/v1/users/${userId}`, { method: 'GET', }); - return raw.data; + + return unwrap(raw); } -export async function getResumeAuthed(resumeId: number): Promise { - const raw = await authedRequest>(`/api/v1/resumes/${resumeId}`, { +export async function getResumeAuthed( + client: ClientRequestType, + resumeId: number +): Promise { + const raw = await client.request(`/api/v1/resumes/${resumeId}`, { method: 'GET', }); - return raw.data; + + return unwrap(raw); } /** ✅ 게스트(Interview-Token)로 resume 조회 */ +/* TODO : 미사용 로직 삭제 export async function getResumeWithGuestToken( resumeId: number, guestToken: string @@ -59,3 +63,4 @@ export async function getResumeWithGuestToken( return (body?.data ?? body) as ResumeProfile; } +*/ diff --git a/src/features/interview/api/question.api.ts b/src/features/interview/api/question.api.ts index 48b76a16..2ec49baa 100644 --- a/src/features/interview/api/question.api.ts +++ b/src/features/interview/api/question.api.ts @@ -1,4 +1,4 @@ -import { authedRequest } from '@/lib/utils/authedRequest'; +import type { ClientRequestType } from '@shared/hooks/useAuthClient.ts'; export interface InterviewQuestion { questionId: number; @@ -26,90 +26,81 @@ export interface InterviewMemo { updatedAt: string; } -type CommonResponse = { - errorCode: number; - message: string; - data: T; -}; - -export async function getInterviewQuestions(interviewId: number): Promise { - const res = await authedRequest>( +export async function getInterviewQuestions( + client: ClientRequestType, + interviewId: number +): Promise { + const res = await client.request( `/api/v1/interviews/${interviewId}/questions`, { method: 'GET' } ); - return res.data ?? []; + + return res ?? []; } export async function toggleQuestionCheck( + client: ClientRequestType, interviewId: number, questionId: number ): Promise { - const res = await authedRequest>( + const res = await client.request( `/api/v1/interviews/${interviewId}/questions/${questionId}/toggle-check`, { method: 'PATCH' } ); - return res.data; + + return res!; } -export async function getChatHistory(interviewId: number): Promise { - const res = await authedRequest>( - `/api/v1/interviews/${interviewId}/chat`, - { method: 'GET' } - ); - return res.data ?? []; +export async function getChatHistory( + client: ClientRequestType, + interviewId: number +): Promise { + const res = await client.request(`/api/v1/interviews/${interviewId}/chat`, { + method: 'GET', + }); + + return res ?? []; } export async function getChatHistoryWithGuestToken( + client: ClientRequestType, interviewId: number, guestToken: string ): Promise { - const BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/$/, ''); - const url = `${BASE_URL}/api/v1/interviews/${interviewId}/chat`; - - const res = await fetch(url, { + const res = await client.request(`/api/v1/interviews/${interviewId}/chat`, { method: 'GET', headers: { 'Interview-Token': guestToken, }, - credentials: 'include', }); - let body: unknown = null; - try { - body = await res.json(); - } catch { - body = null; - } - - if (!res.ok) { - const message = (body as any)?.message ?? `요청 실패 (status=${res.status})`; - throw new Error(message); - } - - const response = body as CommonResponse; - return response.data ?? []; + return res ?? []; } -export async function getInterviewMemos(interviewId: number): Promise { - const res = await authedRequest>( - `/api/v1/interviews/${interviewId}/memos`, - { method: 'GET' } - ); - return res.data ?? []; +export async function getInterviewMemos( + client: ClientRequestType, + interviewId: number +): Promise { + const res = await client.request(`/api/v1/interviews/${interviewId}/memos`, { + method: 'GET', + }); + + return res ?? []; } export async function updateInterviewMemo( + client: ClientRequestType, interviewId: number, memoId: number, content: string ): Promise<{ memoId: number; content: string; updatedAt: string }> { - const res = await authedRequest< - CommonResponse<{ memoId: number; content: string; updatedAt: string }> - >(`/api/v1/interviews/${interviewId}/memos/${memoId}`, { - method: 'PATCH', // 서버가 PUT이면 PUT으로 변경 - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content }), - }); + const res = await client.request<{ memoId: number; content: string; updatedAt: string }>( + `/api/v1/interviews/${interviewId}/memos/${memoId}`, + { + method: 'PATCH', // 서버가 PUT이면 PUT으로 변경 + body: { content }, + } + ); - return res.data; + return res!; } diff --git a/src/features/interview/components/chat/ChatSection.tsx b/src/features/interview/components/chat/ChatSection.tsx index 673f2f21..b60b9f21 100644 --- a/src/features/interview/components/chat/ChatSection.tsx +++ b/src/features/interview/components/chat/ChatSection.tsx @@ -1,6 +1,6 @@ -// src/components/chat/ChatSection.tsx import { useEffect, useRef, useState } from 'react'; import { DEFAULT_AVATAR } from '../../util/avatar'; +import { debugLog } from '@lib/utils/debugLog.ts'; type UiMsg = { id: number; text: string; senderId: number; isMine: boolean }; @@ -20,7 +20,8 @@ export default function ChatSection({ const messagesEndRef = useRef(null); useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end', inline: 'nearest' }); + debugLog('initialMessages : ', initialMessages); }, [initialMessages]); const handleSend = () => { @@ -31,7 +32,7 @@ export default function ChatSection({ }; return ( -
+
{initialMessages.length === 0 ? (
diff --git a/src/features/interview/components/chat/InterviewSummarySection.tsx b/src/features/interview/components/chat/InterviewSummarySection.tsx index a1c00de6..519f845b 100644 --- a/src/features/interview/components/chat/InterviewSummarySection.tsx +++ b/src/features/interview/components/chat/InterviewSummarySection.tsx @@ -68,7 +68,7 @@ export default function InterviewSummarySection({ ); return ( -
+
{/* 새 메모 작성 영역 */} {!isWriting && !mySummary && ( diff --git a/src/features/interview/components/chat/QuestionNoteSection.tsx b/src/features/interview/components/chat/QuestionNoteSection.tsx index 7142451e..8e9ff03a 100644 --- a/src/features/interview/components/chat/QuestionNoteSection.tsx +++ b/src/features/interview/components/chat/QuestionNoteSection.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { Check } from 'lucide-react'; import type { QuestionSection } from '../../types/chatroom'; +import BlankCard from '@shared/components/BlankCard.tsx'; interface QuestionNoteSectionProps { questionNotes: QuestionSection[]; @@ -34,35 +35,39 @@ export default function QuestionNoteSection({ }; return ( -
+

질문 노트

- {questionNotes.map((section) => ( -
-

{section.topic}

-
    - {section.questions.map((q) => ( -
  • - {q.content} - -
  • - ))} -
-
- ))} + {q.content} + + + ))} + +
+ )) + )}
); diff --git a/src/features/interview/components/create/ApplicantProfileCard.tsx b/src/features/interview/components/create/ApplicantProfileCard.tsx index 6b3e8b76..53250497 100644 --- a/src/features/interview/components/create/ApplicantProfileCard.tsx +++ b/src/features/interview/components/create/ApplicantProfileCard.tsx @@ -15,7 +15,7 @@ export default function ApplicantProfileCard({ applicant }: ApplicantProfile) { const { name, email, phoneNumber, birthDate, avatar, jdTitle } = applicant; return ( -
+
{name} (
handleInvite(i)} >
{i.email}

-
)) ) : ( diff --git a/src/features/interview/components/create/InvitedList.tsx b/src/features/interview/components/create/InvitedList.tsx index d73fc634..69a4093e 100644 --- a/src/features/interview/components/create/InvitedList.tsx +++ b/src/features/interview/components/create/InvitedList.tsx @@ -22,7 +22,10 @@ export default function InvitedList({ invited, handleRemove }: Props) { alt={i.name} className="h-10 w-10 rounded-full object-cover" /> - {i.name} +
+ {i.name} + {i.email} +
{/* 오른쪽 상단 X 버튼 */} diff --git a/src/features/interview/components/manage/InterviewActions.tsx b/src/features/interview/components/manage/InterviewActions.tsx index 0b30356b..0754b42f 100644 --- a/src/features/interview/components/manage/InterviewActions.tsx +++ b/src/features/interview/components/manage/InterviewActions.tsx @@ -42,7 +42,7 @@ export default function InterviewActions({ const handleOpenInterviewNote = () => { if (!interviewId) return; navigate(`/interview/${interviewId}/note`, { - state: { name, avatar, interviewId }, + state: { name, avatar, date, time, interviewers, position, resumeId, interviewId }, }); }; diff --git a/src/features/interview/components/manage/InterviewCard.tsx b/src/features/interview/components/manage/InterviewCard.tsx index aabea728..1bdbc591 100644 --- a/src/features/interview/components/manage/InterviewCard.tsx +++ b/src/features/interview/components/manage/InterviewCard.tsx @@ -32,25 +32,21 @@ export default function InterviewCard({ const [currResult, setResult] = useState(result); return ( -
- {/* */} +
+ - {/* 헤더 영역 */} - - - {/* 인터뷰 정보 */} - - - {/* 버튼 영역 */} + +
-
+
); } diff --git a/src/features/interview/components/manage/InterviewFilterTabs.tsx b/src/features/interview/components/manage/InterviewFilterTabs.tsx index 37a08368..a454378b 100644 --- a/src/features/interview/components/manage/InterviewFilterTabs.tsx +++ b/src/features/interview/components/manage/InterviewFilterTabs.tsx @@ -1,4 +1,5 @@ import type { TabStatus } from '../../types/interviewer'; +import cn from '@lib/utils/cn.ts'; interface InterviewFilterTabsProps { activeTab: TabStatus; @@ -17,16 +18,17 @@ export default function InterviewFilterTabs({ activeTab, onChange }: InterviewFi }; return ( -
+
{tabs.map((tab) => { const isActive = activeTab === tab; return ( diff --git a/src/features/interview/components/manage/InterviewHeader.tsx b/src/features/interview/components/manage/InterviewHeader.tsx index d0351f51..507323f9 100644 --- a/src/features/interview/components/manage/InterviewHeader.tsx +++ b/src/features/interview/components/manage/InterviewHeader.tsx @@ -4,6 +4,7 @@ import { statusLabelMap, resultStatusLabelMap, } from '../../types/interviewer'; +import cn from '@lib/utils/cn.ts'; interface InterviewHeaderProps { avatar: string; @@ -46,9 +47,15 @@ export default function InterviewHeader({ {name}

{name}

-

{position}

+

{position}

- + {label}
diff --git a/src/features/interview/components/manage/InterviewSortDropdown.tsx b/src/features/interview/components/manage/InterviewSortDropdown.tsx index cb333564..0d6ac485 100644 --- a/src/features/interview/components/manage/InterviewSortDropdown.tsx +++ b/src/features/interview/components/manage/InterviewSortDropdown.tsx @@ -39,16 +39,16 @@ export default function InterviewSortDropdown({ jobPosts, onSelect }: InterviewS }; return ( -
+
{isOpen && ( -
+
{/* 전체 보기 */}