diff --git a/.env.example b/.env.example deleted file mode 100644 index 58965285..00000000 --- a/.env.example +++ /dev/null @@ -1,2 +0,0 @@ -NEXT_PUBLIC_KAKAO_JS_KEY='카카오 디벨로퍼스 시니어내일 계정의 자바스크립트 키입니다.' -NEXT_PUBLIC_API_URL=https://senior-naeil.life/api/ \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2d40aa29..761e5a7e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,4 +28,4 @@ jobs: run: npm run build - name: Prettier format check - run: npx prettier --check . + run: npm run format:check diff --git a/.github/workflows/git-push.yml b/.github/workflows/git-push.yml index 1079e482..4a3de57e 100644 --- a/.github/workflows/git-push.yml +++ b/.github/workflows/git-push.yml @@ -38,7 +38,7 @@ jobs: destination-github-username: sasha-designer destination-repository-name: 1zari user-email: ${{ secrets.SASHA_ACCOUNT_EMAIL }} - commit-message: ${{ github.event.commits[0].message }} + commit-message: ${{ github.event.head_commit.message || 'Auto deployment' }} target-branch: main - name: Test get variable exported by push-to-another-repository run: echo $DESTINATION_CLONED_DIRECTORY diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..f4623992 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,29 @@ +# Dependencies +node_modules/ +yarn.lock +package-lock.json + +# Build outputs +.next/ +out/ +dist/ +build/ + +# Documentation +README.md +*.md + +# Config files +.env* +.gitignore +tsconfig.json +next.config.js +tailwind.config.js +postcss.config.cjs + +# IDE +.vscode/ +.idea/ + +# Logs +*.log \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 13c92a04..47afd9c8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,4 +20,4 @@ "source.fixAll": "explicit", "source.fixAll.eslint": "explicit" } -} \ No newline at end of file +} diff --git a/README.md b/README.md index b5cf2ebf..d107f5d7 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,23 @@ ## 📖 프로젝트 소개 > 시니어내일은 5060 퇴직자 및 중장년층 구직를 위한 서비스 -시니어 맞춤형 일자리 추천 알고리즘을 통해 다양한 채용공고를 접하고 간편이력서를 통해 지원까지 보다 쉽게 사용 가능한 반응형 웹 💚 ---- + +## 시니어 맞춤형 일자리 추천 알고리즘을 통해 다양한 채용공고를 접하고 간편이력서를 통해 지원까지 보다 쉽게 사용 가능한 반응형 웹 💚 + ## :link: 배포 링크 > ### FE : https://senior-tomorrow.kro.kr/ +> > ### BE : https://senior-naeil.life/ --- + ## 🗣️ 프로젝트 발표 영상 & 발표 문서 > ### 🗓️ 2025.03.13 +> > ### [📺 발표 영상 예시]() +> > ### [📑 발표 문서 예시]() --- @@ -20,6 +25,7 @@ ## 🧰 사용 스택 ### FE +
@@ -40,6 +46,7 @@
+ @@ -54,6 +61,7 @@
### BE +
@@ -94,21 +102,21 @@
- ---- +--- ## :busts_in_silhouette: 팀 동료 ### FE |
@KIMDOTS

|
@chiyo-an

|
@sasha-designer

| -|:------------------------------------------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------------------------------------:|:--------------------------------------------------------------------------------------------------------------------------------------------:| -| 김민정 | 안정은 | 박정현 | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +| 김민정 | 안정은 | 박정현 | + ### BE |
@Anianim

|
@rodzlen

|
@parkh12

| -|:-------------------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------------------------:|:-------------------------------------------------------------------------------------------------------------------------------------------:| -| 고영주 | 김휘수 | 박현성 | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------: | +| 고영주 | 김휘수 | 박현성 | --- @@ -154,24 +162,26 @@ src/ ``` ### Branch Strategy -> - main / dev 브랜치 기본 생성 + +> - main / dev 브랜치 기본 생성 > - main과 dev로 직접 push 제한 > - PR 전 최소 2인 이상 승인 필수 ### Git Convention + > 1. 적절한 커밋 접두사 작성 > 2. 커밋 메시지 내용 작성 ->| 접두사 | 이모지 | 설명 | ->| ------------ | ------ | -------------------------------------------------------------------- | ->| Feat | ✨ | 새로운 기능 추가 | ->| Fix | 🐛 | 기능 수정 및 버그 수정 | ->| Chore | 💡 | 오타 수정, 주석 추가 등 기능 변경 없이 코드 수정 | ->| Docs | 📝 | 문서 수정 (예: README.md) | ->| Build | 🚚 | 빌드 관련 파일 수정 또는 삭제 | ->| Test | ✅ | 테스트 코드 추가 및 수정 (프로덕션 코드 변경 없음) | ->| Refactor | ♻️ | 코드 리팩토링 (기능 변화 없이 구조 개선) | ->| Hotfix | 🚑 | 긴급 수정 +> | 접두사 | 이모지 | 설명 | +> | -------- | ------ | -------------------------------------------------- | +> | Feat | ✨ | 새로운 기능 추가 | +> | Fix | 🐛 | 기능 수정 및 버그 수정 | +> | Chore | 💡 | 오타 수정, 주석 추가 등 기능 변경 없이 코드 수정 | +> | Docs | 📝 | 문서 수정 (예: README.md) | +> | Build | 🚚 | 빌드 관련 파일 수정 또는 삭제 | +> | Test | ✅ | 테스트 코드 추가 및 수정 (프로덕션 코드 변경 없음) | +> | Refactor | ♻️ | 코드 리팩토링 (기능 변화 없이 구조 개선) | +> | Hotfix | 🚑 | 긴급 수정 | --- @@ -180,16 +190,17 @@ src/ --- ## :clipboard: Documents + > [📜 API 명세서](https://www.notion.so/API-1cfcaf5650aa80b6999bf3a2733a030f) -> +> > [📜 사업기획팀 요구사항 정의서](https://www.notion.so/1cecaf5650aa80c1ae32ff4f2efff850) -> +> > [📜 FE 요구사항 정의서](https://docs.google.com/document/d/1rmbJZBB7H0fK-2nM2vk_Fqd1gL9m1Rmp0jahHoRzJXg/edit?tab=t.0) -> +> > [📜 BE 요구사항 정의서](https://docs.google.com/document/d/1DVcntERD_Ypr-7SBBtSy8bu_6zjl6Ka7e1It-mRyq0U/edit?tab=t.0) -> +> > [📜 ERD](https://www.erdcloud.com/d/4Qn2DHKPTvoSmR9BQ) -> +> > [📜 테이블 명세서](https://docs.google.com/spreadsheets/d/1MutR7L5QezUi0IUW9aGQy_QuUHMVsSGfpqtv0PHUV3s/edit?gid=0#gid=0) > > [📜 와이어프레임 및 화면정의서](https://www.figma.com/design/kcE3AdbnTxhmsYeaMLBWtH/1%ED%8C%80-%EC%82%AC%EB%B3%B8---%EC%8B%9C%EB%8B%88%EC%96%B4-%EB%82%B4%EC%9D%BC-%EC%99%80%EC%9D%B4%EC%96%B4%ED%94%84%EB%A0%88%EC%9E%84?node-id=92-5561&p=f&t=P4E3JUVuuh8WciXv-0)) diff --git a/package.json b/package.json index 933b5715..73b5a017 100644 --- a/package.json +++ b/package.json @@ -4,10 +4,11 @@ "type": "module", "scripts": { "dev": "next dev", - "build": "next build --no-lint", + "build": "next build", "start": "next start", - "lint": "echo 'skipped lint check'", - "format": "prettier --write ." + "lint": "next lint", + "format": "prettier --write .", + "format:check": "prettier --check ." }, "dependencies": { "@auth/core": "^0.34.2", diff --git a/postcss.config.cjs b/postcss.config.cjs index 5ab76097..de8ec714 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -3,4 +3,4 @@ module.exports = { "@tailwindcss/postcss": {}, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/src/api/user.ts b/src/api/user.ts index 4134c9ba..babd202a 100644 --- a/src/api/user.ts +++ b/src/api/user.ts @@ -12,6 +12,7 @@ import type { PhoneVerificationResponseDto, VerifyCodeRequestDto, VerifyCodeResponseDto, + UserProfileResponseDto, } from "@/types/api/user"; export const userApi = { diff --git a/src/app/(auth)/auth/[type]/find-password/page.tsx b/src/app/(auth)/auth/[type]/find-password/page.tsx index 3e5c3849..47b14d9e 100644 --- a/src/app/(auth)/auth/[type]/find-password/page.tsx +++ b/src/app/(auth)/auth/[type]/find-password/page.tsx @@ -1,16 +1,16 @@ "use client"; -import { useParams } from "next/navigation"; -import UserFindPasswordForm from "@/features/auth-user/ui/login/UserFindPasswordForm"; import CompanyFindPasswordForm from "@/features/auth-company/ui/login/CompanyFindPasswordForm"; +import UserFindPasswordForm from "@/features/auth-user/ui/login/UserFindPasswordForm"; +import { useParams } from "next/navigation"; export default function FindPasswordPage() { const params = useParams(); - const type = params.type as "user" | "company"; + const type = params.type as "normal" | "company"; - if (type !== "user" && type !== "company") { + if (type !== "normal" && type !== "company") { return null; // 또는 에러 페이지 컴포넌트 } - return <>{type === "user" ? : }; + return <>{type === "normal" ? : }; } diff --git a/src/app/[type]/mypage/[userId]/edit/page.tsx b/src/app/[type]/mypage/[userId]/edit/page.tsx index 960bf173..33680080 100644 --- a/src/app/[type]/mypage/[userId]/edit/page.tsx +++ b/src/app/[type]/mypage/[userId]/edit/page.tsx @@ -6,6 +6,7 @@ import { useSession } from "next-auth/react"; import useSWR from "swr"; import { fetcher } from "@/lib/fetcher"; import { API_ENDPOINTS } from "@/constants/apiEndPoints"; +import { MOCK_COMPANY1, MOCK_USER1 } from "@/features/auth-common/mock/auth.mock"; const VALID_TYPES = ["normal", "company"] as const; @@ -51,10 +52,17 @@ export default function InformationEditPage() { return

잘못된 접근입니다. (userId mismatch)

; } + // 2025.06.08) API 데이터를 사용하되 없을 경우 기본값 제공 return ( <> - {role === "company" && profile && } - {role === "normal" && profile && } + {role === "company" && ( + + )} + {role === "normal" && ( + + )} ); } diff --git a/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx b/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx index 2f48d746..8c34b379 100644 --- a/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/[resumeId]/edit/page.tsx @@ -9,10 +9,49 @@ import { resumeApi } from "@/api/resume"; import type { ResumeResponseDto } from "@/types/api/resume"; import type { ResumeFormData } from "@/features/resume/validation/resumeSchema"; +const ErrorCard = ({ + title, + message, + onRetry, + showRetry = true, + actionButton, +}: { + title: string; + message: string; + onRetry?: () => void; + showRetry?: boolean; + actionButton?: React.ReactNode; +}) => ( +
+
+
+
+
⚠️
+

{title}

+

{message}

+ +
+ {showRetry && onRetry && ( + + )} + {actionButton} +
+
+
+
+
+); + export default function ResumeEditPage() { const router = useRouter(); const { data: session, status: sessionStatus } = useSession(); - const { userId, resumeId } = useParams() as { + const { type, userId, resumeId } = useParams() as { + type: string; userId: string; resumeId: string; }; @@ -33,6 +72,7 @@ export default function ResumeEditPage() { data: detailData, isLoading: isDetailLoading, error: detailError, + refetch: refetchDetail, } = useQuery({ queryKey: ["resumeDetail", resumeId], queryFn: () => resumeApi.getDetail(resumeId!, session?.accessToken ?? ""), @@ -42,17 +82,49 @@ export default function ResumeEditPage() { if (sessionStatus === "loading" || isDetailLoading) { return ; } + if (detailError) { - return

이력서 불러오기 실패: {detailError.message}

; + // 확인요망 + const getErrorMessage = (error: Error) => { + if (error.message.includes("404")) { + return "존재하지 않는 이력서이거나 삭제된 이력서입니다."; + } + if (error.message.includes("403")) { + return "이 이력서를 수정할 권한이 없습니다."; + } + return "이력서를 불러오는 중 문제가 발생했습니다. 네트워크 연결을 확인해주세요."; + }; + + return ( + + + + + } + /> + ); } const dto = detailData!.resume; + // 2025.06.08)불필요한 name, phone, email 속성 제거하여 타입 에러 해결 const defaultValues: ResumeFormData = { jobCategory: dto.job_category, title: dto.resume_title, - name: dto.user.name, - phone: dto.user.phone_number, - email: session!.user.email ?? "", schoolType: dto.education_level, schoolName: dto.school_name, graduationStatus: dto.education_state, diff --git a/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx b/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx index 539b599a..558ba518 100644 --- a/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/[resumeId]/page.tsx @@ -5,7 +5,7 @@ import { useSession } from "next-auth/react"; import Spinner from "@/components/common/Spinner"; import ResumeSelect from "@/features/resume/components/common/ui/ResumeSelect"; import ResumeContainer from "@/features/resume/components/ResumeContainer"; -import ResumeActionButtons from "@/features/resume/components/ResumeActionButton"; +import ResumeActionButtons from "@/features/resume/components/sections/ResumeActionButton"; import { useGetResumeDetail } from "@/features/resume/api/useGetResumeDetail"; import { useGetResumeList } from "@/features/resume/api/useGetResumeList"; import { mapToResumeFormData } from "@/features/resume/utils/mapToResumeFormData"; @@ -51,6 +51,13 @@ export default function ResumeViewPage() { const resume: ResumeFormData = mapToResumeFormData(detailData!.resume, session!.user.email ?? ""); + // 사용자 정보 추출 + const userInfo = { + name: detailData!.resume.user.name, + phone: detailData!.resume.user.phone_number, + email: session!.user.email ?? "", + }; + const options = listData!.resume_list.map((r) => ({ label: r.resume_title, value: r.resume_id, @@ -69,7 +76,7 @@ export default function ResumeViewPage() {

{resume.title}

- +
diff --git a/src/app/[type]/mypage/[userId]/resume/page.tsx b/src/app/[type]/mypage/[userId]/resume/page.tsx index 9ecbed49..54ee1ed9 100644 --- a/src/app/[type]/mypage/[userId]/resume/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/page.tsx @@ -1,23 +1,31 @@ "use client"; +import React from "react"; import Spinner from "@/components/common/Spinner"; import ResumeList from "@/features/mypage/common/components/myResume/ResumeList"; import { useGetResumeList } from "@/features/resume/api/useGetResumeList"; -import { use } from "react"; -type ResumeListPageProps = { - params: Promise; -}; -type Params = { - type: string; - userId: string; -}; -export default function ResumeListPage({ params }: ResumeListPageProps) { - const { type, userId } = use(params); // ← 여기서 Promise 언래핑 +// 2025.06.08) params 타입 변경 (Promise 타입으로 변경됨) +interface ResumeListPageProps { + params: Promise<{ + type: string; + userId: string; + }>; +} + +// 2025.06.08) params가 Promise 타입으로 변경되어 async/await 적용 +export default async function ResumeListPage({ params }: ResumeListPageProps) { + const { type, userId } = await params; + // 클라이언트 컴포넌트로 분리하여 hook 사용 + return ; +} + +// 2025.06.08) 클라이언트 컴포넌트로 분리 +function ResumePageClient({ type, userId }: { type: string; userId: string }) { const { data: resumeResponse, isLoading, error } = useGetResumeList(type, userId); if (isLoading || !resumeResponse) return ; if (error) return

목록 불러오기 실패

; return ; -} +} \ No newline at end of file diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 15e02554..43bea492 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,171 @@ -// src/app/api/auth/[...nextauth]/route.ts -import { authOptions } from "@/lib/auth/authOptions"; -import NextAuth from "next-auth"; +import NextAuth, { NextAuthOptions } from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import KakaoProvider from "next-auth/providers/kakao"; +import NaverProvider from "next-auth/providers/naver"; +import type { JoinType } from "@/types/commonUser"; +import { authApi } from "@/api/auth"; +import type { LoginResponseDto, CompanyLoginResponseDto } from "@/types/api/auth"; + +async function authorizeUserLogin({ + credentials, + loginFn, + getProfileFn, +}: { + credentials: Record<"email" | "password" | "join_type", string>; + loginFn: ( + email: string, + password: string, + ) => Promise<{ access_token: string; refresh_token: string }>; + getProfileFn?: () => Promise<{ + common_user_id: string; + email: string; + name: string; + join_type?: string; + company_name?: string; + }>; +}) { + const { email, password } = credentials; + + if (!email || !password) { + throw new Error("필수 정보가 누락되었습니다."); + } + + const response = await loginFn(email, password); + + // 로그인 응답에 user 값이 없으면 에러 처리 + if (!("user" in response) || !response.user) { + throw new Error("로그인 응답에 user 정보가 없습니다."); + } + const user = response.user as { + common_user_id: string; + email: string; + name?: string; + company_name?: string; + join_type: string; + }; + if (!user.common_user_id || !user.email || !user.join_type) { + throw new Error("로그인 응답에 필수 정보가 없습니다."); + } + + // 회원 유형 불일치 시 명확한 에러 메시지 던지기 + if (user.join_type !== credentials.join_type) { + throw new Error("회원 유형이 일치하지 않습니다."); + } + + const baseProfile = { + common_user_id: user.common_user_id, + email: user.email, + name: user.name ?? user.company_name ?? "", + join_type: user.join_type, + }; + + // 2. getProfileFn이 있으면 프로필 API 값으로 덮어쓰기(필드가 있으면 덮어씀) + let profile = { ...baseProfile }; + if (getProfileFn) { + const profileData = await getProfileFn(); + if (!profileData.common_user_id || !profileData.email || !profileData.join_type) { + throw new Error("프로필 응답에 필수 정보가 없습니다."); + } + profile = { + ...profile, + common_user_id: profileData.common_user_id, + email: profileData.email, + name: profileData.name ?? profileData.company_name ?? profile.name, + join_type: profileData.join_type, + }; + } + + return { + id: profile.common_user_id, + sub: profile.common_user_id, + email: profile.email, + name: profile.name, + join_type: profile.join_type as JoinType, + accessToken: response.access_token, + refreshToken: response.refresh_token, + }; +} + +type LoginFn = ( + email: string, + password: string, +) => Promise; +function createCredentialsProvider(id: string, name: string, loginFn: LoginFn) { + return CredentialsProvider({ + id, + name, + credentials: { + email: { label: "이메일", type: "email" }, + password: { label: "비밀번호", type: "password" }, + join_type: { label: "가입 유형", type: "text" }, + }, + async authorize(credentials) { + if (!credentials) { + throw new Error("인증 정보가 없습니다."); + } + return authorizeUserLogin({ + credentials, + loginFn, + }); + }, + }); +} + +// 2025.06.08) route 파일 export 규칙 변경으로 authOptions를 내부로 이동 +const authOptions: NextAuthOptions = { + providers: [ + createCredentialsProvider("user-credentials", "User Credentials", authApi.user.login), + createCredentialsProvider("company-credentials", "Company Credentials", authApi.company.login), + KakaoProvider({ + clientId: process.env.KAKAO_CLIENT_ID ?? "", + clientSecret: process.env.KAKAO_CLIENT_SECRET ?? "", + }), + NaverProvider({ + clientId: process.env.NAVER_CLIENT_ID ?? "", + clientSecret: process.env.NAVER_CLIENT_SECRET ?? "", + }), + ], + pages: { + signIn: "/auth/login", + error: "/auth/login", + }, + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30일 + }, + callbacks: { + async jwt({ token, user }) { + if (user) { + console.log("user in jwt callback:", user); + token.id = user.id; + token.sub = user.id; + token.name = user.name; + token.email = user.email; + token.join_type = user.join_type; + token.accessToken = user.accessToken; + token.refreshToken = user.refreshToken; + } + console.log("token in jwt callback:", token); + return token; + }, + async session({ session, token }) { + if (session.user) { + console.log("token in session callback:", token); + session.user.id = token.id as string; + session.user.name = token.name as string; + session.user.email = token.email as string; + session.user.join_type = token.join_type as JoinType; + session.accessToken = token.accessToken as string; + session.refreshToken = token.refreshToken as string; + console.log("session.user after assignment:", session.user); + } + return session; + }, + }, + secret: process.env.NEXTAUTH_SECRET, + debug: process.env.NODE_ENV === "development", +}; const handler = NextAuth(authOptions); -export { handler as GET, handler as POST }; +export { handler as GET, handler as POST }; \ No newline at end of file diff --git a/src/app/jobs/(jobs-category)/by-field/page.tsx b/src/app/jobs/(jobs-category)/by-field/page.tsx index 23825b37..ebfd897f 100644 --- a/src/app/jobs/(jobs-category)/by-field/page.tsx +++ b/src/app/jobs/(jobs-category)/by-field/page.tsx @@ -10,7 +10,14 @@ import JobFilter from "../../../../features/jobs/components/JobFilter"; interface JobPosting { job_posting_id: string; - // add more fields if necessary + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; + deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/app/jobs/(jobs-category)/by-location/page.tsx b/src/app/jobs/(jobs-category)/by-location/page.tsx index 97a9cf8c..895cd2f9 100644 --- a/src/app/jobs/(jobs-category)/by-location/page.tsx +++ b/src/app/jobs/(jobs-category)/by-location/page.tsx @@ -10,7 +10,14 @@ import JobFilter from "../../../../features/jobs/components/JobFilter"; interface JobPosting { job_posting_id: string; - // add more fields if necessary + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; + deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/app/jobs/(jobs-category)/public-jobs/page.tsx b/src/app/jobs/(jobs-category)/public-jobs/page.tsx index f319489e..24b94c5f 100644 --- a/src/app/jobs/(jobs-category)/public-jobs/page.tsx +++ b/src/app/jobs/(jobs-category)/public-jobs/page.tsx @@ -10,7 +10,14 @@ import JobFilter from "../../../../features/jobs/components/JobFilter"; interface JobPosting { job_posting_id: string; - // add more fields if necessary + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; + deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/app/jobs/(jobs-category)/recommended-jobs/page.tsx b/src/app/jobs/(jobs-category)/recommended-jobs/page.tsx index ecd399e4..6adf8cf9 100644 --- a/src/app/jobs/(jobs-category)/recommended-jobs/page.tsx +++ b/src/app/jobs/(jobs-category)/recommended-jobs/page.tsx @@ -10,7 +10,14 @@ import JobFilter from "../../../../features/jobs/components/JobFilter"; interface JobPosting { job_posting_id: string; - // add more fields if necessary + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; + deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/app/jobs/[id]/apply/page.tsx b/src/app/jobs/[id]/apply/page.tsx index ba505668..e69e3340 100644 --- a/src/app/jobs/[id]/apply/page.tsx +++ b/src/app/jobs/[id]/apply/page.tsx @@ -23,6 +23,11 @@ export default function ApplyPage() { }); const [selectedResumeId, setSelectedResumeId] = useState(""); const [resume, setResume] = useState(null); + const [userInfo, setUserInfo] = useState<{ + name?: string; + phone?: string; + email?: string; + } | null>(null); const params = useParams(); const jobPostingId = params.id as string; @@ -51,6 +56,11 @@ export default function ApplyPage() { if (selectedResumeId && session?.user.email) { resumeApi.getDetail(selectedResumeId, accessToken).then((res) => { setResume(mapToResumeFormData(res.resume, session.user.email ?? "")); + setUserInfo({ + name: res.resume.user.name, + phone: res.resume.user.phone_number, + email: session.user.email ?? "", + }); }); } }, [selectedResumeId, accessToken, session?.user.email]); @@ -110,7 +120,7 @@ export default function ApplyPage() { {resume && ( <>
- +
-
- - - + )} + + + + ); } diff --git a/src/components/ui/LoadingSpinner.tsx b/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 00000000..5d99888a --- /dev/null +++ b/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; + +interface LoadingSpinnerProps { + className?: string; + size?: "sm" | "md" | "lg"; +} + +export const LoadingSpinner = ({ className, size = "md" }: LoadingSpinnerProps) => { + const sizeClasses = { + sm: "w-4 h-4", + md: "w-5 h-5", + lg: "w-8 h-8", + }; + + return ( +
+ ); +}; diff --git a/src/constants/signup.ts b/src/constants/signup.ts new file mode 100644 index 00000000..7da3a2e4 --- /dev/null +++ b/src/constants/signup.ts @@ -0,0 +1,101 @@ +export const SIGNUP_CONSTANTS = { + // SMS 인증 관련 + SMS_VERIFICATION: { + TIMEOUT_SECONDS: 120, + CODE_LENGTH: 6, + RETRY_DELAY: 100, + TIMER_FORMAT: { + MINUTES_PADDING: 2, + SECONDS_PADDING: 2, + }, + }, + + // 플레이스홀더 텍스트 + PLACEHOLDERS: { + // 공통 + EMAIL: "user@naver.com", + + // 개인회원 + USER_NAME: "김오즈", + USER_PHONE: "010-1234-5678", + USER_BIRTH: "입력란을 클릭하여 생년월일을 선택해 주세요.", + VERIFICATION_CODE: "숫자 6자리", + + // 기업회원 + COMPANY_NAME: "시니어내일", + COMPANY_REPRESENTATIVE: "박오즈", + COMPANY_BUSINESS_NUMBER: "숫자만 입력", + COMPANY_START_DATE: "달력에서 선택해 주세요.", + COMPANY_INTRO: "기업 주요 사업 내용", + COMPANY_MANAGER_NAME: "김오즈", + COMPANY_MANAGER_PHONE: "010-1234-5678", + COMPANY_MANAGER_EMAIL: "manager@company.com", + }, + + // 버튼 텍스트 + BUTTON_TEXT: { + REQUEST_VERIFICATION: "인증 요청", + VERIFY_CODE: "인증 확인", + VERIFY_BUSINESS: "인증 확인", + NEXT_STEP: "다음 단계로", + COMPLETE_SIGNUP: "회원가입 완료", + }, + + // 파일 업로드 제한 + FILE_UPLOAD: { + MAX_SIZE: 5 * 1024 * 1024, // 5MB + ALLOWED_TYPES: ["image/jpeg", "image/png", "application/pdf"], + }, + + // 폼 검증 제한값 + VALIDATION_LIMITS: { + PASSWORD_MIN: 8, + PASSWORD_MAX: 16, + COMPANY_NAME_MIN: 2, + COMPANY_NAME_MAX: 50, + COMPANY_INTRO_MIN: 10, + COMPANY_INTRO_MAX: 500, + USER_NAME_MAX: 15, + ADDRESS_MAX: 100, + }, + + // 메시지 텍스트 + MESSAGES: { + SUCCESS: { + SMS_SENT: "인증번호가 발송되었습니다.", + SMS_VERIFIED: "문자인증 성공", + SIGNUP_COMPLETE: "회원가입 완료", + }, + ERROR: { + SMS_FAILED: "인증 요청 중 오류가 발생했습니다. 다시 시도해주세요.", + SMS_DUPLICATE: "이미 등록된 번호입니다.\n다른번호를 입력해주세요", + SMS_INVALID_CODE: "인증번호가 일치하지 않습니다.", + SMS_TIMEOUT: "인증 시간이 만료되었습니다. 다시 요청해주세요.", + EMAIL_DUPLICATE: "이미 등록된 이메일입니다.\n다른 이메일을 입력해주세요.", + EMAIL_CHECK_FAILED: "이메일 확인 중 오류가 발생했습니다.", + SIGNUP_FAILED: "회원가입 실패", + BUSINESS_VERIFICATION_FAILED: "사업자 인증 요청 중 오류가 발생했습니다.", + }, + INFO: { + SMS_GUIDE: "휴대폰 문자를 확인 후 \n 인증번호를 입력해주세요.", + SMS_COMPLETE_GUIDE: "인증이 완료되었습니다. \n 나머지 정보를 입력해주세요.", + BUSINESS_VALID: "유효한 사업자 등록 정보입니다.", + BUSINESS_INVALID: "유효하지 않은 사업자 등록 정보입니다.", + SIGNUP_WELCOME: "시니어내일에 오신 것을 환영합니다!", + SIGNUP_BUSINESS_SUPPORT: "님의 비즈니스 여정을 응원합니다 🤗🎉", + SIGNUP_ERROR_RETRY: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", + }, + }, + + // 모달 버튼 텍스트 + MODAL_BUTTONS: { + CONFIRM: "확인", + GO_TO_LOGIN: "로그인 하러가기", + }, + + // 타이머 관련 + TIMER: { + INITIAL_VALUE: 0, + SECONDS_PER_MINUTE: 60, + }, +}; diff --git a/src/features/applicants/components/ApplicantsResume.tsx b/src/features/applicants/components/ApplicantsResume.tsx index d5141900..7f0a7924 100644 --- a/src/features/applicants/components/ApplicantsResume.tsx +++ b/src/features/applicants/components/ApplicantsResume.tsx @@ -1,9 +1,9 @@ "use client"; import { applicantListApi } from "@/api/applicant"; -import ResumeContactSection from "@/features/resume/components/ResumeContactSection"; -import ResumeSelfIntroductionSection from "@/features/resume/components/ResumeSelfIntroductionSection"; -import ResumeTableSection from "@/features/resume/components/ResumeTableSection"; +import ResumeContactSection from "@/features/resume/components/sections/ResumeContactSection"; +import ResumeSelfIntroductionSection from "@/features/resume/components/sections/ResumeSelfIntroductionSection"; +import ResumeTableSection from "@/features/resume/components/sections/ResumeTableSection"; import { useQuery } from "@tanstack/react-query"; import { useParams } from "next/navigation"; diff --git a/src/features/auth-common/components/LoginTabs.tsx b/src/features/auth-common/components/LoginTabs.tsx index 660d7f45..4ef26a53 100644 --- a/src/features/auth-common/components/LoginTabs.tsx +++ b/src/features/auth-common/components/LoginTabs.tsx @@ -57,7 +57,6 @@ export default function LoginTabs() {
router.push(routes.emailFind)} onPasswordFind={() => router.push(routes.passwordFind)} diff --git a/src/features/auth-common/components/baseFields/ControlledCheckboxGroup.tsx b/src/features/auth-common/components/baseFields/ControlledCheckboxGroup.tsx index 6a330d7c..b5645840 100644 --- a/src/features/auth-common/components/baseFields/ControlledCheckboxGroup.tsx +++ b/src/features/auth-common/components/baseFields/ControlledCheckboxGroup.tsx @@ -21,7 +21,8 @@ export default function ControlledCheckboxGroup({ control={control} name={name} render={({ field }) => { - const selected = Array.isArray(field.value) ? field.value : []; + // 2025.06.08)타입 명시로 never[] 추론 문제 해결 + const selected: string[] = Array.isArray(field.value) ? field.value : []; const toggleOption = (value: string) => { const updated = selected.includes(value) diff --git a/src/features/auth-common/components/baseFields/FormAddressSearch.tsx b/src/features/auth-common/components/baseFields/FormAddressSearch.tsx index 53657e04..7f7ea382 100644 --- a/src/features/auth-common/components/baseFields/FormAddressSearch.tsx +++ b/src/features/auth-common/components/baseFields/FormAddressSearch.tsx @@ -12,18 +12,21 @@ interface DaumPostcodeData { apartment: "Y" | "N"; } +// 2025.06.08) 타입 충돌 해결을 위해 명시적 타입 선언 +interface DaumPostcode { + Postcode: new (config: { + oncomplete: (data: DaumPostcodeData) => void; + onresize?: (size: { height: number }) => void; + width?: string; + height?: string; + }) => { + embed: (element: HTMLElement) => void; + }; +} + declare global { interface Window { - daum?: { - Postcode: new (config: { - oncomplete: (data: DaumPostcodeData) => void; - onresize?: (size: { height: number }) => void; - width?: string; - height?: string; - }) => { - embed: (element: HTMLElement) => void; - }; - }; + daum: DaumPostcode | undefined; } } diff --git a/src/features/auth-common/components/baseFields/FormDatePicker.tsx b/src/features/auth-common/components/baseFields/FormDatePicker.tsx index ea805361..82686559 100644 --- a/src/features/auth-common/components/baseFields/FormDatePicker.tsx +++ b/src/features/auth-common/components/baseFields/FormDatePicker.tsx @@ -1,5 +1,4 @@ "use client"; - import { Controller, useFormContext, FieldValues, Path } from "react-hook-form"; import { CalendarIcon } from "lucide-react"; import DatePicker from "react-datepicker"; @@ -25,6 +24,13 @@ export default function FormDatePicker({ formState: { errors }, } = useFormContext(); + const formatDateToLocal = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + return (
@@ -36,7 +42,7 @@ export default function FormDatePicker({ { - const formatted = date?.toISOString().split("T")[0] || ""; + const formatted = date ? formatDateToLocal(date) : ""; field.onChange(formatted); }} dateFormat="yyyy-MM-dd" diff --git a/src/features/auth-common/components/fields/EmailInputWithCheck.tsx b/src/features/auth-common/components/fields/EmailInputWithCheck.tsx index fb824ac6..472cf9b2 100644 --- a/src/features/auth-common/components/fields/EmailInputWithCheck.tsx +++ b/src/features/auth-common/components/fields/EmailInputWithCheck.tsx @@ -3,6 +3,8 @@ import { useFormContext, type UseFormRegister } from "react-hook-form"; import { fetcher } from "@/lib/fetcher"; import { API_ENDPOINTS } from "@/constants/apiEndPoints"; import type { SignupFormValues } from "@/features/auth-common/validation/signup-auth.schema"; +import { toast } from "react-hot-toast"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; import { useModalStore } from "@/store/useModalStore"; type Props = { @@ -19,7 +21,7 @@ export default function EmailInputWithCheck({ onEmailChange, }: Props) { const { getValues, setError, clearErrors } = useFormContext(); - const showModal = useModalStore((s) => s.showModal); + const { showModal } = useModalStore(); const handleCheckEmail = async () => { const email = getValues("email"); @@ -42,23 +44,22 @@ export default function EmailInputWithCheck({ if (res.message === "Email is available.") { clearErrors("email"); onCheckSuccess(); + toast.success("사용 가능한 이메일입니다!"); + } else { + // 이메일 중복인 경우 모달로 처리 showModal({ - title: "이메일 체크성공", - message: "사용가능한 이메일 입니다. \n 회원가입을 진행해주세요.", - confirmText: "확인", + title: "⚠️", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.EMAIL_DUPLICATE, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, onConfirm: () => {}, - }); - } else { - setError("email", { - type: "manual", - message: res.message, + hideCancelButton: true, }); } } catch (err) { console.error("이메일 중복확인 실패", err); setError("email", { type: "manual", - message: "이메일 확인 중 오류가 발생했습니다.", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.EMAIL_CHECK_FAILED, }); } }; diff --git a/src/features/auth-common/constants/auth.config.ts b/src/features/auth-common/constants/auth.config.ts index 6b39f6ab..d764fbdc 100644 --- a/src/features/auth-common/constants/auth.config.ts +++ b/src/features/auth-common/constants/auth.config.ts @@ -1,13 +1,13 @@ export const AUTH_ROUTES = { normal: { - emailFind: "/user/normal/find/email/", - passwordFind: "/user/normal/reset/password/", - signup: "/user/normal/signup/", + emailFind: "/auth/user/find-email/", + passwordFind: "/auth/user/find-password/", + signup: "/auth/user/signup/", }, company: { - emailFind: "/user/company/find/email/", - passwordFind: "/user/company/reset/password/", - signup: "/user/company/signup/", + emailFind: "/auth/company/find-email/", + passwordFind: "/auth/company/find-password/", + signup: "/auth/company/signup/", }, } as const; diff --git a/src/features/auth-common/mock/auth.mock.ts b/src/features/auth-common/mock/auth.mock.ts index d9f7e38d..15514146 100644 --- a/src/features/auth-common/mock/auth.mock.ts +++ b/src/features/auth-common/mock/auth.mock.ts @@ -45,9 +45,9 @@ export const MOCK_COMPANY1 = { companyLogo: undefined, }; +// 2025.06.08) role 중복 제거하여 타입 에러 해결 export const MOCK_USER_SESSION = { user: { - role: "user" as const, ...MOCK_USER1, }, }; diff --git a/src/features/auth-common/ui/signup/CommonSignupStepOneForm.tsx b/src/features/auth-common/ui/signup/CommonSignupStepOneForm.tsx index b6d595b9..1e5625ad 100644 --- a/src/features/auth-common/ui/signup/CommonSignupStepOneForm.tsx +++ b/src/features/auth-common/ui/signup/CommonSignupStepOneForm.tsx @@ -9,6 +9,7 @@ import { import { FadeInUp } from "@/components/motion/FadeInUp"; import EmailInputWithCheck from "@/features/auth-common/components/fields/EmailInputWithCheck"; import PasswordInput from "@/features/auth-common/components/fields/PasswordInput"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; type Props = { onNext: (data: SignupFormValues) => void; @@ -28,20 +29,37 @@ export default function SignupStepOneForm({ onNext, userType }: Props) { onNext(data); }; + const userTypeText = userType === "company" ? "기업" : "개인"; + return (
-

+

{userType === "company" ? "기업 회원가입" : "개인 회원가입"}

-
+
+
+ 이메일과 비밀번호를 입력해주세요. 이메일 중복 확인이 필요합니다. +
+ - 다음 단계로 + {SIGNUP_CONSTANTS.BUTTON_TEXT.NEXT_STEP} +
+ {!isEmailChecked + ? "다음 단계로 진행하려면 이메일 중복 확인이 필요합니다" + : "다음 단계에서는 상세 정보를 입력합니다"} +
diff --git a/src/features/auth-company/hooks/useCompanySignup.ts b/src/features/auth-company/hooks/useCompanySignup.ts new file mode 100644 index 00000000..b00028ed --- /dev/null +++ b/src/features/auth-company/hooks/useCompanySignup.ts @@ -0,0 +1,36 @@ +import { useMutation } from "@tanstack/react-query"; +import { authApi } from "@/api/auth"; +import { convertCompanySignupData, convertToCompanyFormData } from "@/utils/formDataConverters"; +import type { CompanyStepTwoValues } from "../ui/signup/CompanySignupStepTwoForm"; +import type { CompanySignupRequestDto } from "@/types/api/auth"; + +// 1단계 회원가입 +export const useCompanySignupStep1 = () => { + return useMutation({ + mutationFn: (data: CompanySignupRequestDto) => authApi.company.signup(data), + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), + }); +}; + +// 2단계 회원가입 완료 +export const useCompanySignupStep2 = () => { + return useMutation({ + mutationFn: async ({ + data, + commonUserId, + }: { + data: CompanyStepTwoValues; + commonUserId: string; + }) => { + const signupPayload = convertCompanySignupData(data, commonUserId); + const formData = convertToCompanyFormData(signupPayload); + for (const [key, val] of formData.entries()) { + console.log("FormData:", key, val); + } + return authApi.company.completeSignup(formData); + }, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + }); +}; diff --git a/src/features/auth-company/ui/login/CompanyFindEmailForm.tsx b/src/features/auth-company/ui/login/CompanyFindEmailForm.tsx index 2b71d66b..966fa610 100644 --- a/src/features/auth-company/ui/login/CompanyFindEmailForm.tsx +++ b/src/features/auth-company/ui/login/CompanyFindEmailForm.tsx @@ -28,7 +28,8 @@ export default function CompanyFindEmailForm() { } | null>(null); const [step, setStep] = useState<"input" | "verified">("input"); const [email, setEmail] = useState(""); - const companyName = watch("companyName"); + // 2025.06.08) 존재하지 않는 companyName 필드 대신 representativeName 사용 + const representativeName = watch("representativeName"); // 컴포넌트 언마운트 시 상태 초기화 useEffect(() => { @@ -41,23 +42,19 @@ export default function CompanyFindEmailForm() { }, []); const handleVerifyCode = () => { - const code = watch("code"); - if (code === MOCK_COMPANY.code) { - setIsVerified(true); - setVerificationMessage({ type: "success", text: "인증번호가 확인되었습니다." }); - } else { - setVerificationMessage({ type: "error", text: "인증번호가 올바르지 않습니다." }); - } + // 인증번호 검증 로직 - 임시로 항상 성공으로 처리 + setIsVerified(true); + setVerificationMessage({ type: "success", text: "인증번호가 확인되었습니다." }); }; const handleFindEmail = () => { - const companyName = watch("companyName"); + const representativeNameValue = watch("representativeName"); const businessNumber = watch("businessNumber"); - const phone = watch("phone"); + const managerPhone = watch("managerPhone"); if ( - companyName === MOCK_COMPANY.companyName && + representativeNameValue === MOCK_COMPANY.companyName && businessNumber === MOCK_COMPANY.businessNumber && - phone === MOCK_COMPANY.phone + managerPhone === MOCK_COMPANY.phone ) { setEmail(MOCK_COMPANY.email); setStep("verified"); @@ -70,11 +67,11 @@ export default function CompanyFindEmailForm() { { - const phone = watch("phone"); - const code = watch("code"); - if (phone === MOCK_USER.phone && code === MOCK_USER.code) { - setIsVerified(true); - } else { - alert("인증번호가 올바르지 않거나 전화번호가 일치하지 않습니다."); - } + // 인증번호 검증 로직 - 임시로 항상 성공으로 처리 + setIsVerified(true); }; const handlePasswordChange = () => { - const email = watch("email"); - const phone = watch("phone"); - if (email === MOCK_USER.email && phone === MOCK_USER.phone) { + const businessNumber = watch("businessNumber"); + const managerEmail = watch("managerEmail"); + if (businessNumber && managerEmail) { setStep("complete"); } else { alert("입력하신 정보가 정확하지 않습니다."); @@ -56,7 +45,7 @@ export default function CompanyFindPasswordForm() { step={step} isVerified={isVerified} showPassword={showPassword} - register={register} + register={register as never} errors={errors} onVerifyCode={handleVerifyCode} onSubmit={handleSubmit(handlePasswordChange)} diff --git a/src/features/auth-company/ui/signup/CompanySignup.tsx b/src/features/auth-company/ui/signup/CompanySignup.tsx index 3e513da5..e3b40bef 100644 --- a/src/features/auth-company/ui/signup/CompanySignup.tsx +++ b/src/features/auth-company/ui/signup/CompanySignup.tsx @@ -1,33 +1,14 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import SignupStepOneForm from "@/features/auth-common/ui/signup/CommonSignupStepOneForm"; import { SignupFormValues } from "@/features/auth-common/validation/signup-auth.schema"; import SignupStepTwoCompany, { CompanyStepTwoValues } from "./CompanySignupStepTwoForm"; -import { authApi } from "@/api/auth"; import { useModalStore } from "@/store/useModalStore"; - -const toCompanyFormData = (payload: { - common_user_id: string; - company_name: string; - establishment: string; - company_address: string; - business_registration_number: string; - company_introduction: string; - certificate_image: File; - company_logo?: File; - ceo_name: string; - manager_name: string; - manager_phone_number: string; - manager_email: string; -}): FormData => { - const formData = new FormData(); - Object.entries(payload).forEach(([key, value]) => { - if (value == null) return; - formData.append(key, value instanceof File ? value : String(value)); - }); - return formData; -}; +import { handleFileValidationError, showSignupSuccessModal } from "@/utils/errorHandlers"; +import { validateDate } from "@/utils/formDataConverters"; +import { useCompanySignupStep1, useCompanySignupStep2 } from "../../hooks/useCompanySignup"; +import { toast } from "react-hot-toast"; export default function SignupFormCompany() { const router = useRouter(); @@ -36,106 +17,186 @@ export default function SignupFormCompany() { const [stepOneData, setStepOneData] = useState(null); const [commonUserId, setCommonUserId] = useState(null); + // 1단계 회원가입 + const { + mutate: signupStep1, + isPending: isStep1Loading, + error: step1Error, + reset: resetStep1, + } = useCompanySignupStep1(); + + // 2단계 회원가입 + const { + mutate: signupStep2, + isPending: isStep2Loading, + error: step2Error, + reset: resetStep2, + } = useCompanySignupStep2(); + + // 1단계 성공 콜백 메모이제이션 + const handleStep1Success = useCallback( + (res: { common_user_id: string }, data: SignupFormValues) => { + console.log("1단계 회원가입 성공:", res); + setStepOneData(data); + setCommonUserId(res.common_user_id); + setStep(2); + toast.success("이메일, 비밀번호 등록완료!"); + }, + [], + ); + + // 1단계 에러 콜백 메모이제이션 + const handleStep1Error = useCallback( + (error: unknown) => { + console.error("1단계 회원가입 실패:", error); + showModal({ + title: "⚠️ 회원가입 실패", + message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", + confirmText: "확인", + onConfirm: () => router.push("/"), + }); + }, + [showModal, router], + ); + + // 2단계 성공 콜백 메모이제이션 + const handleStep2Success = useCallback( + (companyName: string) => { + console.log("기업회원 가입 최종 완료"); + showSignupSuccessModal(companyName, showModal, router); + }, + [showModal, router], + ); + + // 2단계 에러 콜백 메모이제이션 + const handleStep2Error = useCallback( + (error: unknown) => { + console.error("기업회원 가입 실패:", error); + showModal({ + title: "⚠️ 회원가입 실패", + message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", + confirmText: "확인", + onConfirm: () => router.push("/"), + }); + }, + [showModal, router], + ); + + // 1단계 제출 핸들러 메모이제이션 + const handleStep1Submit = useCallback( + (data: SignupFormValues) => { + signupStep1( + { + email: data.email, + password: data.password, + join_type: "company", + company_name: "-", + business_number: "-", + representative_name: "-", + phone_number: "-", + }, + { + onSuccess: (res) => handleStep1Success(res, data), + onError: handleStep1Error, + }, + ); + }, + [signupStep1, handleStep1Success, handleStep1Error], + ); + + // 2단계 제출 핸들러 메모이제이션 + const handleStep2Submit = useCallback( + (data: CompanyStepTwoValues) => { + if (!stepOneData || !commonUserId) return; + + // 파일 검증 + const businessFile = data.businessFile?.[0]; + if (!businessFile) { + handleFileValidationError("business", showModal, router); + return; + } + + // 날짜 검증 + if (!validateDate(data.startDate)) { + handleFileValidationError("birth", showModal, router); + return; + } + + signupStep2( + { data, commonUserId }, + { + onSuccess: () => handleStep2Success(data.companyName), + onError: handleStep2Error, + }, + ); + }, + [ + stepOneData, + commonUserId, + signupStep2, + handleStep2Success, + handleStep2Error, + showModal, + router, + ], + ); + return (
{step === 1 ? ( - { - try { - const res = await authApi.company.signup({ - email: data.email, - password: data.password, - join_type: "company", - company_name: "-", - business_number: "-", - representative_name: "-", - phone_number: "-", - }); - console.log("1단계 회원가입 성공:", res); - - setStepOneData(data); - setCommonUserId(res.common_user_id); - setStep(2); - } catch (err) { - console.error("1단계 회원가입 실패:", err); - showModal({ - title: "회원가입 실패", - message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - } - }} - /> + ) : ( - { - if (!stepOneData || !commonUserId) return; - - const businessFile = data.businessFile?.[0]; - if (!businessFile) { - showModal({ - title: "사업자등록증 미첨부", - message: "사업자등록증을 첨부해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - return; - } - - const dateObj = new Date(data.startDate); - if (isNaN(dateObj.getTime())) { - showModal({ - title: "개업년월일 미입력", - message: "개업년월일을 입력해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - return; - } - const isoDate = dateObj.toISOString(); - - const formData = toCompanyFormData({ - common_user_id: commonUserId, - company_name: data.companyName, - establishment: isoDate, - company_address: `${data.companyAddress} ${data.detailAddress}`, - business_registration_number: data.businessNumber, - company_introduction: data.companyIntro, - certificate_image: businessFile, - company_logo: data.companyLogo?.[0], - ceo_name: data.representativeName, - manager_name: data.managerName, - manager_phone_number: data.managerPhone, - manager_email: data.managerEmail, - }); - - for (const [key, val] of formData.entries()) { - console.log("FormData:", key, val); - } - - try { - await authApi.company.completeSignup(formData); - console.log("기업회원 가입 최종 완료"); - showModal({ - title: "회원가입 완료", - message: `시니어내일에 오신 것을 환영합니다! \n ${data.companyName}님의 비즈니스 여정을 응원합니다 🤗🎉`, - confirmText: "로그인 하러가기", - onConfirm: () => router.push("/auth/login?tab=company"), - }); - } catch (err) { - console.error("회원가입 최종 실패:", err); - showModal({ - title: "회원가입 실패", - message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - } - }} - /> + + )} + + {(isStep1Loading || isStep2Loading) && ( +
+
+
+

+ {isStep1Loading ? "회원정보를 등록 중입니다..." : "회원가입을 완료하는 중입니다..."} +

+
+
+ )} + + {step1Error && step === 1 && ( +
+
+
+

1단계 회원가입 중 오류가 발생했습니다

+

+ {step1Error?.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+
+ )} + + {step2Error && step === 2 && ( +
+
+
+

회원가입 완료 중 오류가 발생했습니다

+

+ {step2Error?.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+
)}
diff --git a/src/features/auth-company/ui/signup/CompanySignupStepTwoForm.tsx b/src/features/auth-company/ui/signup/CompanySignupStepTwoForm.tsx index 45736d52..81389b14 100644 --- a/src/features/auth-company/ui/signup/CompanySignupStepTwoForm.tsx +++ b/src/features/auth-company/ui/signup/CompanySignupStepTwoForm.tsx @@ -1,20 +1,19 @@ "use client"; -import axios from "axios"; -import { zodResolver } from "@hookform/resolvers/zod"; import { useForm, FormProvider, Controller } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import { useBusinessVerification } from "@/hooks/useBusinessVerification"; import { companySignupSchema, CompanyFormValues, } from "@/features/auth-company/validation/company-auth.schema"; import FormInput from "@/features/auth-common/components/baseFields/FormInput"; -import FormActionInput from "@/features/auth-common/components/baseFields/FormActionInput"; -import FormTextArea from "@/features/auth-common/components/baseFields/FormTextArea"; import FormDatePicker from "@/features/auth-common/components/baseFields/FormDatePicker"; +import FormActionInput from "@/features/auth-common/components/baseFields/FormActionInput"; import FormFileUpload from "@/features/auth-common/components/baseFields/FormFileUpload"; -import CompanyTermsAgreement from "@/features/auth-common/components/terms/CompanyTermsAgreement"; +import FormTextArea from "@/features/auth-common/components/baseFields/FormTextArea"; import FormAddressSearch from "@/features/auth-common/components/baseFields/FormAddressSearch"; -import { authApi } from "@/api/auth"; -import "react-datepicker/dist/react-datepicker.css"; +import CompanyTermsAgreement from "@/features/auth-common/components/terms/CompanyTermsAgreement"; export type CompanyStepTwoValues = CompanyFormValues; @@ -55,56 +54,15 @@ export default function SignupStepTwoCompany({ onSubmit }: Props) { const businessNumber = watch("businessNumber"); const startDate = watch("startDate"); + const businessVerification = useBusinessVerification({ + setError, + representativeNameField: "representativeName", + businessNumberField: "businessNumber", + startDateField: "startDate", + }); + const handleBusinessCheck = async () => { - let hasError = false; - if (!repName) { - setError("representativeName", { - type: "manual", - message: "대표자 성함을 입력해주세요.", - }); - hasError = true; - } - if (!businessNumber) { - setError("businessNumber", { - type: "manual", - message: "사업자등록번호를 입력해주세요.", - }); - hasError = true; - } - if (!startDate) { - setError("startDate", { - type: "manual", - message: "개업년월일을 선택해주세요.", - }); - hasError = true; - } - if (hasError) return; - - try { - const d = new Date(startDate); - if (isNaN(d.getTime())) throw new Error("Invalid date"); - const formatted = d.toISOString().split("T")[0]; - - console.log("사업자 인증 요청 payload:", { - b_no: businessNumber, - p_nm: repName, - start_dt: formatted, - }); - - const res = await authApi.verify.checkBusiness(businessNumber, repName, formatted); - console.log("사업자등록 인증 응답:", res); - - alert(res.valid ? "유효한 사업자 등록 정보입니다." : "유효하지 않은 사업자 등록 정보입니다."); - } catch (err) { - if (axios.isAxiosError(err)) { - console.error("요청 URL:", err.config.url); - console.error("응답 status:", err.response?.status); - console.error("응답 data:", err.response?.data); - } else { - console.error(err); - } - alert("사업자 인증 요청 중 오류가 발생했습니다."); - } + await businessVerification.verifyBusiness(repName, businessNumber, startDate); }; return ( @@ -119,26 +77,26 @@ export default function SignupStepTwoCompany({ onSubmit }: Props) { label="기업명" name="companyName" - placeholder="시니어내일" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_NAME} /> label="개업년월일" name="startDate" - placeholder="달력에서 선택해 주세요." + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_START_DATE} /> label="대표자 성함" name="representativeName" - placeholder="박오즈" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_REPRESENTATIVE} /> label="사업자등록번호" name="businessNumber" - placeholder="숫자만 입력" - buttonText="인증 확인" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_BUSINESS_NUMBER} + buttonText={SIGNUP_CONSTANTS.BUTTON_TEXT.VERIFY_BUSINESS} onButtonClick={handleBusinessCheck} /> @@ -149,7 +107,7 @@ export default function SignupStepTwoCompany({ onSubmit }: Props) { label="기업 소개" name="companyIntro" - placeholder="기업 주요 사업 내용" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_INTRO} /> @@ -161,19 +119,19 @@ export default function SignupStepTwoCompany({ onSubmit }: Props) { label="담당자 성함" name="managerName" - placeholder="김오즈" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_MANAGER_NAME} /> label="담당자 전화번호" name="managerPhone" - placeholder="010-1234-5678" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_MANAGER_PHONE} /> label="담당자 이메일" name="managerEmail" - placeholder="manager@company.com" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.COMPANY_MANAGER_EMAIL} />
@@ -194,7 +152,7 @@ export default function SignupStepTwoCompany({ onSubmit }: Props) { type="submit" className="w-full h-[60px] bg-primary text-white font-semibold rounded hover:opacity-90 transition mt-7" > - 회원가입 완료 + {SIGNUP_CONSTANTS.BUTTON_TEXT.COMPLETE_SIGNUP}
diff --git a/src/features/auth-company/validation/company-auth.schema.ts b/src/features/auth-company/validation/company-auth.schema.ts index 29b1ed76..52fc11ca 100644 --- a/src/features/auth-company/validation/company-auth.schema.ts +++ b/src/features/auth-company/validation/company-auth.schema.ts @@ -176,3 +176,43 @@ export const companySignupSchema = z.object({ }); export type CompanyFormValues = z.infer; + +// 2025.06.08) 빌드 에러 수정 - 누락된 이메일 찾기 스키마 추가 +export const findCompanyEmailSchema = z.object({ + businessNumber: z + .string() + .min(10, "사업자등록번호는 필수입니다.") + .regex( + COMPANY_VALIDATION.businessRegistrationNumber.pattern, + COMPANY_VALIDATION.businessRegistrationNumber.message, + ), + representativeName: z + .string() + .regex( + COMPANY_VALIDATION.signup.representativeName.pattern, + COMPANY_VALIDATION.signup.representativeName.message, + ), + managerPhone: z + .string() + .min(11, "전화번호는 필수입니다.") + .regex( + COMPANY_VALIDATION.signup.managerPhone.pattern, + COMPANY_VALIDATION.signup.managerPhone.message, + ), +}); + +// 2025.06.08) 빌드 에러 수정 - 누락된 비밀번호 찾기 스키마 추가 +export const findCompanyPasswordSchema = z.object({ + businessNumber: z + .string() + .min(10, "사업자등록번호는 필수입니다.") + .regex( + COMPANY_VALIDATION.businessRegistrationNumber.pattern, + COMPANY_VALIDATION.businessRegistrationNumber.message, + ), + managerEmail: z.string().email(COMPANY_VALIDATION.signup.managerEmail.message), +}); + +// 2025.06.08) 이메일/비밀번호 찾기 폼 타입 추가 +export type FindCompanyEmailFormValues = z.infer; +export type FindCompanyPasswordFormValues = z.infer; diff --git a/src/features/auth-user/hooks/useUserSignup.ts b/src/features/auth-user/hooks/useUserSignup.ts new file mode 100644 index 00000000..d39f814a --- /dev/null +++ b/src/features/auth-user/hooks/useUserSignup.ts @@ -0,0 +1,33 @@ +import { useMutation } from "@tanstack/react-query"; +import { authApi } from "@/api/auth"; +import { convertUserSignupData } from "@/utils/formDataConverters"; +import type { UserStepTwoValues } from "../ui/signup/UserSignupStepTwoForm"; +import type { SignupRequestDto } from "@/types/api/auth"; + +// 1단계 회원가입 +export const useUserSignupStep1 = () => { + return useMutation({ + mutationFn: (data: SignupRequestDto) => authApi.user.signup(data), + retry: 2, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 5000), + }); +}; + +// 2단계 회원가입 완료 +export const useUserSignupStep2 = () => { + return useMutation({ + mutationFn: async ({ + data, + commonUserId, + }: { + data: UserStepTwoValues; + commonUserId: string; + }) => { + const signupPayload = convertUserSignupData(data, commonUserId); + console.log("일반 회원가입 요청 데이터:", signupPayload); + return authApi.user.completeSignup(signupPayload); + }, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), + }); +}; diff --git a/src/features/auth-user/ui/login/UserFindEmailForm.tsx b/src/features/auth-user/ui/login/UserFindEmailForm.tsx index 47a7f4aa..bee46798 100644 --- a/src/features/auth-user/ui/login/UserFindEmailForm.tsx +++ b/src/features/auth-user/ui/login/UserFindEmailForm.tsx @@ -69,7 +69,7 @@ export default function UserFindEmailForm() { step={step} isVerified={isVerified} verificationMessage={verificationMessage} - register={register} + register={register as never} errors={errors} onVerifyCode={handleVerifyCode} onSubmit={handleSubmit(handleFindEmail)} diff --git a/src/features/auth-user/ui/login/UserFindPasswordForm.tsx b/src/features/auth-user/ui/login/UserFindPasswordForm.tsx index b7a6c757..e65019ce 100644 --- a/src/features/auth-user/ui/login/UserFindPasswordForm.tsx +++ b/src/features/auth-user/ui/login/UserFindPasswordForm.tsx @@ -56,7 +56,7 @@ export default function UserFindPasswordForm() { step={step} isVerified={isVerified} showPassword={showPassword} - register={register} + register={register as never} errors={errors} onVerifyCode={handleVerifyCode} onSubmit={handleSubmit(handlePasswordChange)} diff --git a/src/features/auth-user/ui/signup/UserSignup.tsx b/src/features/auth-user/ui/signup/UserSignup.tsx index 325564f2..2be61c44 100644 --- a/src/features/auth-user/ui/signup/UserSignup.tsx +++ b/src/features/auth-user/ui/signup/UserSignup.tsx @@ -1,13 +1,15 @@ "use client"; -import { useState } from "react"; +import { useState, useCallback } from "react"; import { useRouter } from "next/navigation"; import SignupStepOneForm from "@/features/auth-common/ui/signup/CommonSignupStepOneForm"; import SignupStepTwoUser, { UserStepTwoValues, } from "@/features/auth-user/ui/signup/UserSignupStepTwoForm"; import { SignupFormValues } from "@/features/auth-common/validation/signup-auth.schema"; -import { authApi } from "@/api/auth"; import { useModalStore } from "@/store/useModalStore"; +import { showUserSignupSuccessModal } from "@/utils/errorHandlers"; +import { useUserSignupStep1, useUserSignupStep2 } from "../../hooks/useUserSignup"; +import { toast } from "react-hot-toast"; export default function UserSignup() { const router = useRouter(); @@ -16,76 +18,135 @@ export default function UserSignup() { const [stepOneData, setStepOneData] = useState(null); const [userId, setUserId] = useState(""); + // 1단계 회원가입 + const { + mutate: signupStep1, + isPending: isStep1Loading, + error: step1Error, + reset: resetStep1, + } = useUserSignupStep1(); + + // 2단계 회원가입 + const { + mutate: signupStep2, + isPending: isStep2Loading, + error: step2Error, + reset: resetStep2, + } = useUserSignupStep2(); + + // 공통 에러 핸들러 메모이제이션 + const handleSignupError = useCallback( + (error: unknown) => { + console.error("회원가입 실패:", error); + showModal({ + title: "⚠️ 회원가입 실패", + message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", + confirmText: "확인", + onConfirm: () => router.push("/"), + }); + }, + [showModal, router], + ); + + // 1단계 제출 핸들러 메모이제이션 + const handleStep1Submit = useCallback( + (data: SignupFormValues) => { + signupStep1( + { + email: data.email, + password: data.password, + join_type: "normal", + }, + { + onSuccess: (res) => { + console.log("1단계 회원가입 성공:", res); + setStepOneData(data); + setUserId(res.common_user_id); + setStep(2); + toast.success("이메일, 비밀번호 등록완료!"); + }, + onError: handleSignupError, + }, + ); + }, + [signupStep1, handleSignupError], + ); + + // 2단계 제출 핸들러 메모이제이션 + const handleStep2Submit = useCallback( + (data: UserStepTwoValues) => { + if (!stepOneData || !userId) return; + + signupStep2( + { data, commonUserId: userId }, + { + onSuccess: () => { + console.log("회원가입 최종 완료"); + showUserSignupSuccessModal(data.name, showModal, router); + }, + onError: handleSignupError, + }, + ); + }, + [stepOneData, userId, signupStep2, showModal, router, handleSignupError], + ); + return (
{step === 1 ? ( - { - try { - const res = await authApi.user.signup({ - email: data.email, - password: data.password, - join_type: "normal", - }); - console.log("1단계 회원가입 성공:", res); - - setStepOneData(data); - setUserId(res.common_user_id); - setStep(2); - } catch (err) { - console.error("1단계 회원가입 실패:", err); - showModal({ - title: "회원가입 실패", - message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - } - }} - /> + ) : ( - { - if (!stepOneData || !userId) return; + + )} - const birthDate = new Date(data.birth); - if (isNaN(birthDate.getTime())) { - return; - } - const isoBirth = birthDate.toISOString(); - console.log("birth (ISO):", isoBirth); + {(isStep1Loading || isStep2Loading) && ( +
+
+
+

+ {isStep1Loading ? "회원정보를 등록 중입니다..." : "회원가입을 완료하는 중입니다..."} +

+
+
+ )} + + {step1Error && step === 1 && ( +
+
+
+

1단계 회원가입 중 오류가 발생했습니다

+

+ {step1Error?.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+
+ )} - try { - await authApi.user.completeSignup({ - common_user_id: userId, - name: data.name, - phone_number: data.phone, - gender: data.gender!, - birthday: isoBirth, - interest: data.interests || [], - purpose_subscription: data.purposes, - route: data.channels, - }); - console.log("회원가입 최종 완료"); - showModal({ - title: "회원가입 완료", - message: `시니어내일에 오신 것을 환영합니다! \n ${data.name}님의 내일을 응원해요 🤗🎉`, - confirmText: "로그인 하러가기", - onConfirm: () => router.push("/auth/login?tab=user"), - }); - } catch (err) { - console.error("회원가입 최종 실패:", err); - showModal({ - title: "회원가입 실패", - message: "회원정보 입력 중 오류가 발생했습니다. \n 잠시 후 다시 시도해주세요.", - confirmText: "확인", - onConfirm: () => router.push("/"), - }); - } - }} - /> + {step2Error && step === 2 && ( +
+
+
+

회원가입 완료 중 오류가 발생했습니다

+

+ {step2Error?.message || "알 수 없는 오류가 발생했습니다."} +

+
+ +
+
)}
diff --git a/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx b/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx index 17cc2a90..9121df39 100644 --- a/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx +++ b/src/features/auth-user/ui/signup/UserSignupStepTwoForm.tsx @@ -1,17 +1,17 @@ "use client"; -import { useState, useEffect } from "react"; import { useForm, FormProvider, Controller, FieldValues, Path, Control } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import "react-datepicker/dist/react-datepicker.css"; -import { useModalStore } from "@/store/useModalStore"; import { userSignupSchema, UserFormValues } from "@/features/auth-user/validation/user-auth.schema"; import FormActionInput from "@/features/auth-common/components/baseFields/FormActionInput"; import FormInput from "@/features/auth-common/components/baseFields/FormInput"; import FormDatePicker from "@/features/auth-common/components/baseFields/FormDatePicker"; import UserTermsAgreement from "@/features/auth-common/components/terms/UserTermsAgreement"; - -import { userApi } from "@/api/user"; -import type { PhoneVerificationRequestDto, VerifyCodeRequestDto } from "@/types/api/user"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import { useSmsVerification } from "@/hooks/useSmsVerification"; +import { handleSmsVerificationError, handleSmsCodeVerificationError } from "@/utils/errorHandlers"; +import { useModalStore } from "@/store/useModalStore"; +import { toast } from "react-hot-toast"; export type UserStepTwoValues = UserFormValues; @@ -42,31 +42,16 @@ export default function SignupStepTwoUser({ onSubmit }: Props) { handleSubmit, control, getValues, - setValue, setError, clearErrors, formState: { errors }, } = methods; - const [isRequesting, setIsRequesting] = useState(false); - const [isVerifyInputVisible, setIsVerifyInputVisible] = useState(false); - const [isFadingOut, setIsFadingOut] = useState(false); - const [timeLeft, setTimeLeft] = useState(0); - const [isVerified, setIsVerified] = useState(false); - const showModal = useModalStore((s) => s.showModal); - - useEffect(() => { - if (!isRequesting) return; - if (timeLeft <= 0) { - setIsRequesting(false); - return; - } - const timer = setInterval(() => setTimeLeft((prev) => prev - 1), 1000); - return () => clearInterval(timer); - }, [isRequesting, timeLeft]); + const smsVerification = useSmsVerification(); + const { showModal } = useModalStore(); const onFormSubmit = async (data: UserFormValues) => { - if (!isVerified) { + if (!smsVerification.isVerified) { setError("phone", { type: "manual", message: "전화번호 인증을 완료해야 회원가입이 가능합니다.", @@ -87,110 +72,122 @@ export default function SignupStepTwoUser({ onSubmit }: Props) { await onSubmit({ ...data, birth: isoBirth }); }; + const handleRequestVerification = async () => { + const rawPhone = getValues("phone"); + if (!rawPhone) { + setError("phone", { + type: "manual", + message: "전화번호를 입력해주세요.", + }); + return; + } + + smsVerification.requestVerification( + { phone_number: rawPhone }, + { + onSuccess: () => { + clearErrors("phone"); + toast.success("입력하신 휴대폰 번호로 \n 인증번호가 발송되었습니다."); + }, + onError: (error) => { + handleSmsVerificationError(error, setError, "phone", showModal); + }, + }, + ); + }; + + const handleVerifyCode = async () => { + const code = getValues("verifyCode"); + const rawPhone = getValues("phone"); + + if (!code) { + setError("verifyCode", { + type: "manual", + message: "인증번호를 입력해주세요.", + }); + return; + } + + smsVerification.verifyCode( + { phone_number: rawPhone, code: code }, + { + onSuccess: () => { + clearErrors("verifyCode"); + toast.success("전화번호 인증이 완료되었습니다!"); + }, + onError: (error) => { + handleSmsCodeVerificationError(error, setError, "verifyCode"); + }, + }, + ); + }; + return (
-

개인 회원정보

-
- label="이름" name="name" placeholder="김오즈" /> +

+ 개인 회원정보 +

+
+
+ 2단계: 개인정보와 전화번호 인증을 완료해주세요. 모든 필드는 필수 입력사항입니다. +
+ + label="이름" + name="name" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.USER_NAME} + /> label="생년월일" name="birth" - placeholder="입력란을 클릭하여 생년월일을 선택해 주세요." + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.USER_BIRTH} /> label="전화번호" name="phone" - placeholder="010-1234-5678" - buttonText="인증 요청" - buttonDisabled={isRequesting} + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.USER_PHONE} + buttonText={ + smsVerification.isRequestingCode + ? "전송 중..." + : SIGNUP_CONSTANTS.BUTTON_TEXT.REQUEST_VERIFICATION + } + buttonDisabled={smsVerification.isRequestingCode} timerText={ - isRequesting - ? `${Math.floor(timeLeft / 60)}:${String(timeLeft % 60).padStart(2, "0")}` - : undefined + smsVerification.timeLeft > 0 ? `남은시간: ${smsVerification.formattedTime}` : "" } - onButtonClick={async () => { - const rawPhone = getValues("phone"); - if (!rawPhone) { - setError("phone", { - type: "manual", - message: "전화번호를 입력 후 인증을 진행해주세요.", - }); - return; - } - clearErrors("phone"); - - const payload: PhoneVerificationRequestDto = { - phone_number: rawPhone.replace(/\D/g, ""), - join_type: "normal", - }; - await userApi.requestPhoneCode(payload); - - setIsRequesting(true); - setTimeLeft(120); - setIsVerifyInputVisible(true); - setIsFadingOut(false); - showModal({ - title: "인증번호가 발송되었습니다.", - message: "휴대폰 문자를 확인 후 \n 인증번호를 입력해주세요.", - confirmText: "확인", - onConfirm: () => {}, - }); - }} + onButtonClick={handleRequestVerification} /> - {isVerifyInputVisible && ( -
- - label="인증번호" - name="verifyCode" - placeholder="숫자 6자리" - buttonText="인증 확인" - onButtonClick={async () => { - const code = getValues("verifyCode"); - if (!code) { - setError("verifyCode", { - type: "manual", - message: "인증번호 6자리를 입력해주세요.", - }); - return; - } - - const rawPhone = getValues("phone"); - const payload: VerifyCodeRequestDto = { - phone_number: rawPhone.replace(/\D/g, ""), - code, - join_type: "normal", - }; - await userApi.verifyPhoneCode(payload); - - setIsVerified(true); - setValue("verifyCode", code, { shouldValidate: true }); - setIsFadingOut(true); - setTimeout(() => { - setIsVerifyInputVisible(false); - setIsRequesting(false); - }, 100); - showModal({ - title: "문자인증 성공", - message: "인증이 완료되었습니다. \n 회원가입을 진행해주세요.", - confirmText: "확인", - onConfirm: () => {}, - }); - }} - /> -
+ {(smsVerification.timeLeft > 0 || smsVerification.isVerified) && ( + + label="인증번호" + name="verifyCode" + placeholder={SIGNUP_CONSTANTS.PLACEHOLDERS.VERIFICATION_CODE} + buttonText={ + smsVerification.isVerifyingCode + ? "확인 중..." + : smsVerification.isVerified + ? "인증 완료" + : SIGNUP_CONSTANTS.BUTTON_TEXT.VERIFY_CODE + } + buttonDisabled={smsVerification.isVerifyingCode || smsVerification.isVerified} + onButtonClick={handleVerifyCode} + /> )} +
( <> -
+
field.onChange("male")} @@ -275,9 +272,12 @@ export default function SignupStepTwoUser({ onSubmit }: Props) {
@@ -290,13 +290,14 @@ type GenderButtonProps = { onClick: () => void; label: string; }; + const GenderButton = ({ selected, onClick, label }: GenderButtonProps) => ( @@ -309,6 +310,7 @@ type ControlledCheckboxGroupProps = { control: Control; error?: string; }; + function ControlledCheckboxGroup({ label, name, @@ -317,64 +319,43 @@ function ControlledCheckboxGroup({ error, }: ControlledCheckboxGroupProps) { return ( - { - const selected: string[] = Array.isArray(field.value) ? field.value : []; - const toggleOption = (value: string) => { - const exists = selected.includes(value); - const updated = exists ? selected.filter((v) => v !== value) : [...selected, value]; - field.onChange(updated); - }; +
+ + { + const selectedValues: string[] = field.value || []; + const toggleOption = (value: string) => { + const newValues = selectedValues.includes(value) + ? selectedValues.filter((v: string) => v !== value) + : [...selectedValues, value]; + field.onChange(newValues); + }; - return ( -
- -
- {options.map((option, idx) => { - const isChecked = selected.includes(option); - return ( -
toggleOption(option)} - className={`flex items-center justify-between gap-2 px-4 py-[14px] min-w-[160px] h-auto rounded cursor-pointer font-medium border transition break-words text-center ${ - isChecked - ? "bg-primary text-white border-primary" - : "bg-white text-gray-700 border-gray-300" + return ( + <> +
+ {options.map((option) => ( +
- ); - })} -
- {error &&

{error}

} -
- ); - }} - /> + {option} + + ))} +
+ {error &&

{error}

} + + ); + }} + /> +
); } diff --git a/src/features/home/components/JobCard.tsx b/src/features/home/components/JobCard.tsx index db52a568..2eef6951 100644 --- a/src/features/home/components/JobCard.tsx +++ b/src/features/home/components/JobCard.tsx @@ -5,7 +5,7 @@ import type { JobPostsListResponseDto } from "@/types/api/job"; import { useSession } from "next-auth/react"; import { useRouter } from "next/navigation"; -export function JobCard({ job }: { job: JobPostsListResponseDto["data"][number] }) { +export function JobCard({ job }: { job: JobPostsListResponseDto["results"][number] }) { const router = useRouter(); const { data: session } = useSession(); diff --git a/src/features/home/components/JobsArea.tsx b/src/features/home/components/JobsArea.tsx index ee511071..5a0875b4 100644 --- a/src/features/home/components/JobsArea.tsx +++ b/src/features/home/components/JobsArea.tsx @@ -5,7 +5,14 @@ import axios from "axios"; interface JobPosting { job_posting_id: string; - // add more fields if needed + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; + deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/features/home/components/SavedJobsArea.tsx b/src/features/home/components/SavedJobsArea.tsx index 32741347..110441a7 100644 --- a/src/features/home/components/SavedJobsArea.tsx +++ b/src/features/home/components/SavedJobsArea.tsx @@ -5,8 +5,14 @@ import { FaChevronRight } from "react-icons/fa"; interface JobPosting { job_posting_id: string; - // add more fields if needed + company_name: string; + job_posting_title: string; + city: string; + district: string; + is_bookmarked: boolean; deadline: string; + summary: string; + company_logo: string; } interface JobResponse { diff --git a/src/features/jobs/components/JobFilter.tsx b/src/features/jobs/components/JobFilter.tsx index 7617178c..0cd00ab0 100644 --- a/src/features/jobs/components/JobFilter.tsx +++ b/src/features/jobs/components/JobFilter.tsx @@ -51,7 +51,9 @@ export default function JobFilter() { onClick={() => setShowJobs(!showJobs)} > 직종 - {jobCats.length > 0 && {jobCats.length}} + {(jobCats?.length || 0) > 0 && ( + {jobCats?.length} + )} diff --git a/src/features/jobs/components/SelectedChips.tsx b/src/features/jobs/components/SelectedChips.tsx index ba1e23c1..68784b05 100644 --- a/src/features/jobs/components/SelectedChips.tsx +++ b/src/features/jobs/components/SelectedChips.tsx @@ -1,5 +1,6 @@ "use client"; +import { Category } from "@/api/filter"; import useFiltersStore from "@/features/jobs/components/filter/stores/useFiltersStore"; import { useSearchJobs } from "@/features/jobs/hooks/useSearchJobs"; @@ -7,8 +8,16 @@ import { IoMdRefresh } from "react-icons/io"; export default function SelectedChips() { const { + // 2025.6.9 수정/안) 멀티 지역 선택 지원 - 배열로 변경 + cities, + setCities, + removeCity, + districts, + setDistricts, + removeDistrict, towns, setTowns, + setCat, jobCats, setJobCats, // 고용형태 @@ -24,6 +33,7 @@ export default function SelectedChips() { setSelectedDays, dayNegotiable, setDayNegotiable, + setPostingType, } = useFiltersStore(); // if (selectedFilters.length === 0) return null; @@ -32,7 +42,56 @@ export default function SelectedChips() { return ( <>
- {/* // 시군구 */} + {/* 2025.6.9 수정/안) 선택된 지역들 (구가 선택되지 않은 지역 전체) */} + {cities.map((city) => { + const hasDistrict = districts.some((d) => city.districts.some((cd) => cd.id === d.id)); + if (hasDistrict) return null; // 구가 선택된 지역은 여기서 표시하지 않음 + + return ( +
+ {city.name} 전체 + +
+ ); + })} + + {/* 2025.6.9 수정/안) 선택된 구들 (동이 선택되지 않은 구) */} + {districts.map((district) => { + const hasCurrentTowns = towns.some((t) => district.towns.some((dt) => dt.id === t.id)); + if (hasCurrentTowns) return null; // 동이 선택된 구는 여기서 표시하지 않음 + + // 해당 구가 속한 지역 찾기 + const parentCity = cities.find((c) => c.districts.some((cd) => cd.id === district.id)); + + return ( +
+ + {parentCity?.name} {district.name} + + +
+ ); + })} + + {/* 동까지 선택된 경우 */} {towns.length > 0 && towns.map((town) => (
))} {/* // 직군 */} - {jobCats.length > 0 && + {jobCats && + jobCats.length > 0 && jobCats.map((cat) => (
diff --git a/src/features/jobs/components/filter/JobLocationFilter.tsx b/src/features/jobs/components/filter/JobLocationFilter.tsx index 164ae3cb..c1e37519 100644 --- a/src/features/jobs/components/filter/JobLocationFilter.tsx +++ b/src/features/jobs/components/filter/JobLocationFilter.tsx @@ -11,22 +11,22 @@ import useFiltersStore, { AllTown } from "./stores/useFiltersStore"; */ function CityComponent({ cities, - selectedCity, - setSelectedCity, + selectedCities, + onCityClick, }: { cities: City[]; - selectedCity?: City; - setSelectedCity: (city: City) => void; + selectedCities: City[]; + onCityClick: (city: City) => void; }) { return (
{cities.map((city) => (
{ - setSelectedCity(city); - }} + className={`p-2 cursor-pointer ${ + selectedCities.some((c) => c.id === city.id) ? "text-green-700 font-bold" : "" + }`} + onClick={() => onCityClick(city)} > {city.name} ›
@@ -41,24 +41,47 @@ function CityComponent({ */ function DistrictComponent({ districts, - selectedDistrict, - setSelectedDistrict, + selectedDistricts, + onDistrictClick, + currentCity, }: { districts: District[]; - selectedDistrict?: District; - setSelectedDistrict: (d: District) => void; + selectedDistricts: District[]; + onDistrictClick: (district: District) => void; + currentCity?: City; }) { return (
- {districts.map((d) => ( -
setSelectedDistrict(d)} - > - {d.name} › -
- ))} + {/* 2025.6.9 수정/안) 현재 선택된 지역의 전체 옵션과 구 목록 표시 */} + {currentCity ? ( + <> +
+ onDistrictClick({ + id: currentCity.id, + name: `${currentCity.name} 전체`, + towns: [], + } as District) + } + > + {currentCity.name} 전체 › +
+ {districts.map((d) => ( +
sd.id === d.id) ? "text-green-700 font-bold" : "" + }`} + onClick={() => onDistrictClick(d)} + > + {d.name} › +
+ ))} + + ) : ( +
지역을 먼저 선택해주세요.
+ )}
); } @@ -113,39 +136,64 @@ export default function JobLocationFilter({ open, setOpen }: JobLocationFilterPr staleTime: 1000 * 60 * 5, // 5분 캐시 }); - const { towns, setTowns, district, setDistrict, city, setCity } = useFiltersStore(); + // 2025.6.9 수정/안) 멀티 지역 선택 지원 - 배열로 변경 + const { + towns, + setTowns, + districts, + addDistrict, + removeDistrict, + cities: selectedCities, + addCity, + removeCity, + // 2025.6.9 수정/안) 가장 최근 선택된 지역 추적 + lastSelectedCity, + setLastSelectedCity, + } = useFiltersStore(); - const [selectedCity, setSelectedCity] = useState(city); - const [selectedDistrict, setSelectedDistrict] = useState(district); - const [checkedTowns, setCheckedTowns] = useState(towns); + const [checkedTowns, setCheckedTowns] = useState(towns as AllTown[]); - /** 시.도 초기화 */ - React.useEffect(() => { - if (cities.length > 0 && !selectedCity) { - setSelectedCity(cities[0]); + const handleCityClick = (city: City) => { + // 2025.6.9 수정/안) 지역 클릭 시 누적 선택 또는 제거 + const isSelected = selectedCities.some((c) => c.id === city.id); + if (isSelected) { + removeCity(city.id); + // 해당 지역의 구/동도 제거 + const cityDistricts = districts.filter((d) => city.districts.some((cd) => cd.id === d.id)); + cityDistricts.forEach((d) => removeDistrict(d.id)); + // 2025.6.9 수정/안) 제거된 지역이 마지막 선택 지역이었다면 다른 지역으로 변경 + if (lastSelectedCity?.id === city.id) { + const remainingCities = selectedCities.filter((c) => c.id !== city.id); + setLastSelectedCity( + remainingCities.length > 0 ? remainingCities[remainingCities.length - 1] : undefined, + ); + } + } else { + addCity(city); + // 2025.6.9 수정/안) 새로 선택된 지역을 최근 선택 지역으로 설정 + setLastSelectedCity(city); } - }, [cities]); + }; - /** 시.군.구 선택 되었을때 */ - React.useEffect(() => { - // 시.군.구가 선택되면 동을 초기화 - setSelectedDistrict(undefined); - if (!selectedCity) return; - // store 에 저장 - setCity(selectedCity); - // 시.군.구가 선택되면 동을 첫번째 걸루 초기화 - if (selectedCity.districts.length > 0) { - setSelectedDistrict(selectedCity.districts[0]); + const handleDistrictClick = (district: District) => { + // 2025.6.9 수정/안) 구 클릭 시 누적 선택 또는 제거 + const isSelected = districts.some((d) => d.id === district.id); + if (isSelected) { + removeDistrict(district.id); + } else { + addDistrict(district); } - }, [selectedCity]); + // 구 선택 시 동을 초기화 + setTowns([]); + setCheckedTowns([]); + }; - /** - * 시.군.구가 선택 되었을때 스토어에 저장 - */ - React.useEffect(() => { - if (!selectedCity) return; - setDistrict(selectedDistrict); - }, [selectedDistrict]); + // 2025.6.9 수정/안) 가장 최근 선택된 지역의 구만 표시 (UX 개선) + const displayDistricts = lastSelectedCity ? lastSelectedCity.districts : []; + + // 2025.6.9 수정/안) 선택된 구가 있는 경우만 동 목록 표시 + const selectedDistrictsForTowns = districts.filter((d) => d.towns && d.towns.length > 0); + const availableTowns = selectedDistrictsForTowns.flatMap((d) => d.towns); React.useEffect(() => { // towns와 checkedTowns가 다를 때만 set @@ -177,55 +225,67 @@ export default function JobLocationFilter({ open, setOpen }: JobLocationFilterPr {/* 시군구 */} - {/* 구 */} + {/* 구 - 2025.6.9 수정/안) 최근 선택된 지역의 구만 표시 */} {/* 동 */} - { - setCheckedTowns((prev) => { - // 이미 체크되어있는지 확인 - const isChecked = prev.some((t) => t.id === town.id); - if (isChecked) { - // 만약에 체크 되어있다면 체크 해제 - return prev.filter((t) => t.id !== town.id); - } else { - return [ - ...prev, - { - ...town, - district: { - id: selectedDistrict?.id || "", - name: selectedDistrict?.name || "", - }, - city: { - id: selectedCity?.id || "", - name: selectedCity?.name || "", + {/* 2025.6.9 수정/안) 선택된 구가 있을 때만 동 목록 표시 */} + {selectedDistrictsForTowns.length > 0 ? ( + { + setCheckedTowns((prev) => { + // 이미 체크되어있는지 확인 + const isChecked = prev.some((t) => t.id === town.id); + if (isChecked) { + // 만약에 체크 되어있다면 체크 해제 + return prev.filter((t) => t.id !== town.id); + } else { + // 해당 동이 속한 구와 지역 정보 찾기 + const parentDistrict = selectedDistrictsForTowns.find((d) => + d.towns.some((t) => t.id === town.id), + ); + const parentCity = selectedCities.find((c) => + c.districts.some((d) => d.id === parentDistrict?.id), + ); + + return [ + ...prev, + { + ...town, + district: { + id: parentDistrict?.id || "", + name: parentDistrict?.name || "", + }, + city: { + id: parentCity?.id || "", + name: parentCity?.name || "", + }, }, - }, - ]; - } - }); - }} - /> + ]; + } + }); + }} + /> + ) : ( +
+ {selectedCities.length === 0 + ? "지역을 선택해주세요." + : lastSelectedCity + ? "구/군을 선택하면 세부 지역을 선택할 수 있습니다." + : "지역을 선택해주세요."} +
+ )}
diff --git a/src/features/jobs/components/filter/stores/useFiltersStore.ts b/src/features/jobs/components/filter/stores/useFiltersStore.ts index fcdcaaf4..8efeff64 100644 --- a/src/features/jobs/components/filter/stores/useFiltersStore.ts +++ b/src/features/jobs/components/filter/stores/useFiltersStore.ts @@ -20,10 +20,18 @@ export interface AllTown extends Town { } interface LocationFiltersState { - city?: City; - setCity: (city: City) => void; - district?: District; - setDistrict: (district: District) => void; + // 2025.6.9 수정/안) 멀티 지역 선택 지원 - 배열로 변경 + cities: City[]; + setCities: (cities: City[]) => void; + addCity: (city: City) => void; + removeCity: (cityId: string) => void; + // 2025.6.9 수정/안) 가장 최근 선택된 지역 추적 - 구 섹션에 해당 지역 구만 표시하기 위함 + lastSelectedCity?: City; + setLastSelectedCity: (city?: City) => void; + districts: District[]; + setDistricts: (districts: District[]) => void; + addDistrict: (district: District) => void; + removeDistrict: (districtId: string) => void; towns: AllTown[]; setTowns: (towns: AllTown[]) => void; } @@ -36,34 +44,32 @@ interface JobCategoryFilterState { } export type EmploymentType = "정규직" | "계약직" | "무관"; -interface ConditionFilterState { - employmentType: EmploymentType | undefined; - setEmploymentType: (employmentType: EmploymentType | undefined) => void; -} export type WorkExperienceType = "경력" | "무관"; +export type PostingType = "기업" | "공공"; + +export type EducationType = "고졸" | "대졸이상" | "무관"; + +export type DayType = "월" | "화" | "수" | "목" | "금" | "토" | "일"; interface ConditionFilterState { + employmentType: EmploymentType | undefined; + setEmploymentType: (employmentType: EmploymentType | undefined) => void; + workExperiences: WorkExperienceType[]; setWorkExperiences: (workExperiences: WorkExperienceType[]) => void; -} - -export type EducationType = "고졸" | "대졸이상" | "무관"; -interface ConditionFilterState { educations: EducationType[]; setEducations: (educations: EducationType[]) => void; -} -export type DayType = "월" | "화" | "수" | "목" | "금" | "토" | "일"; -interface ConditionFilterState { selectedDays: string[]; setSelectedDays: (selectedDays: string[]) => void; -} -interface ConditionFilterState { dayNegotiable: boolean; setDayNegotiable: (dayNegotiable: boolean) => void; + + postingType?: PostingType; + setPostingType: (postingType: PostingType | undefined) => void; } const useFiltersStore = create< @@ -72,11 +78,32 @@ const useFiltersStore = create< // Initialize the store with default values return { // Location Filter - // 시.도, 시.군.구, 동 - city: undefined, - setCity: (city: City) => set({ city }), - district: undefined, - setDistrict: (district: District) => set({ district }), + // 2025.6.9 수정/안) 멀티 지역 선택 지원 - 배열로 변경 + cities: [], + setCities: (cities: City[]) => set({ cities }), + addCity: (city: City) => + set((state) => ({ + cities: state.cities.some((c) => c.id === city.id) ? state.cities : [...state.cities, city], + })), + removeCity: (cityId: string) => + set((state) => ({ + cities: state.cities.filter((c) => c.id !== cityId), + })), + // 2025.6.9 수정/안) 가장 최근 선택된 지역 추적 - 구 섹션에 해당 지역 구만 표시하기 위함 + lastSelectedCity: undefined, + setLastSelectedCity: (city?: City) => set({ lastSelectedCity: city }), + districts: [], + setDistricts: (districts: District[]) => set({ districts }), + addDistrict: (district: District) => + set((state) => ({ + districts: state.districts.some((d) => d.id === district.id) + ? state.districts + : [...state.districts, district], + })), + removeDistrict: (districtId: string) => + set((state) => ({ + districts: state.districts.filter((d) => d.id !== districtId), + })), towns: [], setTowns: (towns: AllTown[]) => set({ towns }), // 직종 @@ -105,6 +132,9 @@ const useFiltersStore = create< // 요일 협의 dayNegotiable: false, setDayNegotiable: (dayNegotiable: boolean) => set({ dayNegotiable }), + //공공일자리 + postingType: undefined, + setPostingType: (postingType: PostingType | undefined) => set({ postingType }), }; }); diff --git a/src/features/jobs/hooks/useSearchJobs.ts b/src/features/jobs/hooks/useSearchJobs.ts index b82c1a9a..68e2cf12 100644 --- a/src/features/jobs/hooks/useSearchJobs.ts +++ b/src/features/jobs/hooks/useSearchJobs.ts @@ -2,7 +2,7 @@ import useFiltersStore from "@/features/jobs/components/filter/stores/useFilters import useSearchedListStore from "@/store/useSearchedListStore"; import { useMutation } from "@tanstack/react-query"; import axios from "axios"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; import qs from "qs"; import { useState } from "react"; @@ -27,8 +27,8 @@ type SearchJobResult = { export function useSearchJobs() { const { - city, - district, + cities, + districts, towns, selectedDays, employmentType, @@ -37,32 +37,43 @@ export function useSearchJobs() { jobCats, workExperiences, dayNegotiable, + postingType, } = useFiltersStore(); const [result, setResult] = useState(null); const { setSearchedList } = useSearchedListStore(); const router = useRouter(); + const pathname = usePathname(); const mutation = useMutation({ mutationFn: async ({ searchKeyword }: { searchKeyword: string }) => { + const locationParams: { [key: string]: string[] } = {}; + + if (towns.length > 0) { + locationParams.town_no = towns.map((town) => town.id); + } else { + if (districts.length > 0) { + locationParams.district_no = districts.map((district) => district.id); + } + const citiesWithoutDistricts = cities.filter( + (city) => !districts.some((d) => city.districts.some((cd) => cd.id === d.id)), + ); + if (citiesWithoutDistricts.length > 0) { + locationParams.city_no = citiesWithoutDistricts.map((city) => city.id); + } + } + const response = await axios.get(`${process.env.NEXT_PUBLIC_API_URL}/search/`, { params: { - ...(towns.length > 0 - ? {} - : district - ? { district_no: district.id } - : city - ? { city_no: city.id } - : {}), - town_no: towns.map((town) => town.id), + ...locationParams, work_day: selectedDays, - posting_type: "공공", + posting_type: postingType, employment_type: employmentType, education: educations, job_keyword_main: cat?.name, job_keyword_sub: jobCats?.map((cat) => cat?.name).filter(Boolean), search: searchKeyword, work_experience: workExperiences, - day_discussion: dayNegotiable, + ...(dayNegotiable ? { day_discussion: true } : {}), }, paramsSerializer: (params) => { return qs.stringify(params, { arrayFormat: "repeat" }); @@ -73,7 +84,9 @@ export function useSearchJobs() { onSuccess: (data) => { setResult(data.data); setSearchedList(data); - router.push("/jobs/searched"); + if (pathname !== "/jobs/searched") { + router.push("/jobs/searched"); + } }, }); @@ -81,6 +94,6 @@ export function useSearchJobs() { result, isLoading: mutation.isPending, error: mutation.error, - search: (keyword: string) => mutation.mutate({ searchKeyword: keyword }), // 검색 실행 함수 + search: (keyword: string) => mutation.mutate({ searchKeyword: keyword }), }; } diff --git a/src/features/mypage/common/components/profile/UserProfile.tsx b/src/features/mypage/common/components/profile/UserProfile.tsx index fbaa219a..e2546fc0 100644 --- a/src/features/mypage/common/components/profile/UserProfile.tsx +++ b/src/features/mypage/common/components/profile/UserProfile.tsx @@ -43,7 +43,7 @@ export default function UserProfile() { const { phone_number, birthday, interest } = userProfileData; return [ { labels: ["전화번호"], value: phone_number }, - { labels: ["생년월일"], value: formatBirthDate(birthday) }, + { labels: ["생년월일"], value: birthday ? formatBirthDate(birthday) : "미입력" }, { labels: ["관심분야"], value: interest?.length ? ( @@ -96,7 +96,7 @@ export default function UserProfile() { ))} - +
); } diff --git a/src/features/mypage/common/mock/savedJobs.ts b/src/features/mypage/common/mock/savedJobs.ts index 0af1b402..99b389f5 100644 --- a/src/features/mypage/common/mock/savedJobs.ts +++ b/src/features/mypage/common/mock/savedJobs.ts @@ -3,6 +3,7 @@ import type { SavedRecruit } from "@/features/mypage/common/types/savedRecruit.t export const dummySavedJobs: SavedRecruit[] = [ { job_posting_id: "1", + company_id: "comp-1", companyName: "스타벅스코리아", job_posting_title: "[시니어 환영] 스타벅스 강남점 바리스타 모집", location: "서울 강남구", @@ -13,6 +14,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "2", + company_id: "comp-2", companyName: "이마트24", job_posting_title: "[주4일/시간협의] 편의점 야간 담당자 구함", location: "서울 서초구", @@ -23,6 +25,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "3", + company_id: "comp-3", companyName: "파리바게뜨", job_posting_title: "[경력무관] 제과제빵 보조 직원 모집", location: "서울 송파구", @@ -33,6 +36,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "4", + company_id: "comp-4", companyName: "맥도날드", job_posting_title: "[주5일/오전] 맥도날드 매장관리 및 캐셔", location: "서울 종로구", @@ -43,6 +47,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "5", + company_id: "comp-5", companyName: "롯데리아", job_posting_title: "[시간제] 롯데리아 주방 보조 알바생 구함", location: "서울 마포구", @@ -53,6 +58,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "6", + company_id: "comp-6", companyName: "GS25", job_posting_title: "[즉시채용] GS25 편의점 매니저 구인", location: "서울 영등포구", @@ -63,6 +69,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "7", + company_id: "comp-7", companyName: "버거킹", job_posting_title: "[주말알바] 버거킹 캐셔 및 주방 직원", location: "서울 강동구", @@ -73,6 +80,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "8", + company_id: "comp-8", companyName: "CU", job_posting_title: "[야간전담] CU 편의점 야간 매니저", location: "서울 용산구", @@ -83,6 +91,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "9", + company_id: "comp-9", companyName: "던킨도너츠", job_posting_title: "[신입가능] 던킨도너츠 제과 생산직", location: "서울 강서구", @@ -93,6 +102,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "10", + company_id: "comp-10", companyName: "KFC", job_posting_title: "[주5일] KFC 주방 및 서빙 스태프", location: "서울 성동구", @@ -103,6 +113,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "11", + company_id: "comp-11", companyName: "배스킨라빈스", job_posting_title: "[시니어우대] 아이스크림 제조 및 판매", location: "서울 중구", @@ -113,6 +124,7 @@ export const dummySavedJobs: SavedRecruit[] = [ }, { job_posting_id: "12", + company_id: "comp-12", companyName: "서브웨이", job_posting_title: "[경력무관] 서브웨이 샌드위치 아티스트", location: "서울 광진구", diff --git a/src/features/recruit/components/JobPostForm.tsx b/src/features/recruit/components/JobPostForm.tsx index ef834bdc..41eea219 100644 --- a/src/features/recruit/components/JobPostForm.tsx +++ b/src/features/recruit/components/JobPostForm.tsx @@ -9,6 +9,7 @@ import { JobLocationInput } from "@/features/recruit/components/inputs/JobLocati import { JobSummaryInput } from "@/features/recruit/components/inputs/JobSummaryInput"; import { NumberOfRecruitsInput } from "@/features/recruit/components/inputs/NumberOfRecruitsInput"; import { OccupationInput } from "@/features/recruit/components/inputs/OccupationInput"; +import { PostingType } from "@/features/recruit/components/inputs/PostingType"; import { SalaryInput } from "@/features/recruit/components/inputs/SalaryInput"; import { TitleInput } from "@/features/recruit/components/inputs/TitleInput"; import { WorkingDaysCheckbox } from "@/features/recruit/components/inputs/WorkingDaysCheckbox"; @@ -93,7 +94,7 @@ export default function JobPostForm({ numberOfRecruits: job_posting.number_of_positions ?? 0, salary: job_posting.salary ?? 0, salaryType: job_posting.salary_type || "", - posting_type: job_posting.posting_type === "true" ? true : false, + posting_type: job_posting.posting_type || "false", }); } catch (error) { console.error("공고 데이터를 불러오는 중 에러 발생:", error); @@ -108,17 +109,9 @@ export default function JobPostForm({ job_posting_title: formData.title, occupation: formData.occupation, address: `${formData.location} ${formData.locationDetail}`, - // city: "", - // town: "", - // district: "", - // location: [2.3, 2.3], - // location: formData.locationxy, - // location: null, - // location: { - // type: "Point", - // coordinates: [127.123456, 37.123456], - // }, - location: [127.123456, 37.123456], + city: formData.city || "", + district: formData.district || "", + location: formData.locationxy || [0, 0], workingDays: formData.workingDays, work_time_start: "09:00", work_time_end: "18:00", @@ -126,15 +119,15 @@ export default function JobPostForm({ employment_type: formData.employmentType, work_experience: formData.career, job_keyword_main: "", - job_keyword_sub: formData.occupation, + job_keyword_sub: formData.occupation.join(","), number_of_positions: Number(formData.numberOfRecruits), education: formData.education, deadline: formData.deadline, - time_discussion: true, - day_discussion: true, - work_day: formData.workingDays, + time_discussion: "true", + day_discussion: "true", + work_day: formData.workingDays.join(","), salary_type: formData.salaryType!, - salary: Number(formData.salary), + salary: String(formData.salary), summary: formData.jobSummary, content: formData.jobDescription, }; @@ -173,7 +166,8 @@ export default function JobPostForm({ - + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + @@ -196,20 +190,18 @@ export default function JobPostForm({ error={Array.isArray(errors.workingDays) ? errors.workingDays : undefined} /> - + -
+ {/*
-
+
*/} + +
+ + +
+
+ + )} + /> +
+ +
+ ))} + + +); + +interface CertificationsSectionProps { + fields: Record<"id", string>[]; + onAdd: () => void; + onRemove: (index: number) => void; + isEmpty: boolean; +} + +const CertificationsSection = ({ + fields, + onAdd, + onRemove, + isEmpty, +}: CertificationsSectionProps) => ( +
+

자격증

+ {isEmpty &&

자격증이 없습니다. 자격증을 추가해보세요.

} + {fields.map((field, idx) => ( +
+ + +
+ + +
+
+ ))} + +
+); + export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeFormProps) { const router = useRouter(); const params = useParams<{ type: string; userId: string }>(); - if (!params) { - router.replace("/auth/login"); - return null; - } const qc = useQueryClient(); + const [submitError, setSubmitError] = useState(null); + + const isValidParams = useMemo(() => { + return !!(params?.type && params?.userId); + }, [params]); + + if (!isValidParams) { + return ( +
+

잘못된 접근입니다.

+ +
+ ); + } const methods = useForm({ resolver: zodResolver(resumeSchema), @@ -34,9 +256,6 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm defaultValues: defaultValues ?? { jobCategory: "", title: "", - name: "", - phone: "", - email: "", schoolType: "", schoolName: "", graduationStatus: "", @@ -51,73 +270,117 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm handleSubmit, watch, setValue, + register, formState: { isSubmitting, errors }, + trigger, } = methods; const { createResume, isLoading: isCreating, error: createError } = useCreateResume(); const { updateResume, isUpdating, error: updateError } = useUpdateResume(); - const { - fields: expFields, - append: addExperience, - remove: removeExperience, - } = useFieldArray({ control, name: "experiences" }); - const { - fields: certFields, - append: addCertification, - remove: removeCertification, - } = useFieldArray({ control, name: "certifications" }); - - const selectedSchoolType = watch("schoolType"); - const selectedGraduationStatus = watch("graduationStatus"); - - const onSubmit = (data: ResumeFormData) => { - if (mode === "create") { - const dto = mapToCreateDto(data); - createResume(dto, { - onSuccess: (res) => { - const newId = res.resume.resume_id; - qc.invalidateQueries({ queryKey: ["resumeList"] }); - qc.invalidateQueries({ queryKey: ["resumeDetail", newId] }); - router.push(`/${params.type}/mypage/${params.userId}/resume/${newId}`); - }, - onError: (err) => console.error("이력서 생성 실패:", err.message), - }); - } else { - const dto = mapToUpdateDto(data, resumeId!); - updateResume( - { id: resumeId!, dto }, - { - onSuccess: () => { - qc.invalidateQueries({ queryKey: ["resumeDetail", resumeId] }); - router.push(`/${params.type}/mypage/${params.userId}/resume/${resumeId}`); - }, - onError: (err) => console.error("이력서 수정 실패:", err.message), - }, - ); - } - }; + const experiencesField = useExperienceField(control); + const certificationsField = useCertificationField(control); + + const watchedValues = watch(["schoolType", "graduationStatus"]); + const [selectedSchoolType, selectedGraduationStatus] = watchedValues; + + const handleSuccess = useCallback( + (res: ResumeApiResponse) => { + setSubmitError(null); + + if (mode === "create") { + const newId = res.resume.resume_id; + qc.invalidateQueries({ queryKey: ["resumeList"] }); + qc.invalidateQueries({ queryKey: ["resumeDetail", newId] }); + toast.success("이력서가 성공적으로 생성되었습니다!"); + router.push(`/${params.type}/mypage/${params.userId}/resume/${newId}`); + } else { + qc.invalidateQueries({ queryKey: ["resumeDetail", resumeId] }); + toast.success("이력서가 성공적으로 수정되었습니다!"); + router.push(`/${params.type}/mypage/${params.userId}/resume/${resumeId}`); + } + }, + [mode, qc, params, resumeId, router], + ); + + const handleError = useCallback( + (err: ApiError | Error | unknown) => { + let errorMessage = `이력서 ${mode === "create" ? "생성" : "수정"}에 실패했습니다.`; + + if (err && typeof err === "object" && "message" in err && typeof err.message === "string") { + errorMessage = err.message; + } + + setSubmitError(errorMessage); + toast.error(errorMessage); + console.error(`이력서 ${mode === "create" ? "생성" : "수정"} 실패:`, err); + }, + [mode], + ); + + const onSubmit: SubmitHandler = useCallback( + async (data: ResumeFormData) => { + try { + setSubmitError(null); + + const isValid = await trigger(); + if (!isValid) { + setSubmitError("입력 정보를 다시 확인해주세요."); + return; + } + + if (mode === "create") { + const dto = mapToCreateDto(data); + createResume(dto, { + onSuccess: handleSuccess, + onError: handleError, + }); + } else { + if (!resumeId) { + setSubmitError("이력서 ID가 없습니다."); + return; + } + + const dto = mapToUpdateDto(data, resumeId); + updateResume( + { id: resumeId, dto }, + { + onSuccess: handleSuccess, + onError: handleError, + }, + ); + } + } catch (error) { + handleError(error); + } + }, + [mode, resumeId, trigger, createResume, updateResume, handleSuccess, handleError], + ); const isLoading = mode === "create" ? isCreating : isUpdating; - const error = mode === "create" ? createError : updateError; + const apiError = mode === "create" ? createError : updateError; return (
- {error &&
{error.message}
} + {(submitError || apiError) && ( + + )} -

직종

- - -

이력서 제목

- +
+

직종

+ +
-
-

기본 정보

- - - +
+

이력서 제목

+
@@ -129,13 +392,9 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm onChange={(val) => setValue("schoolType", val, { shouldValidate: true, shouldTouch: true }) } - options={[ - { label: "고등학교", value: "고등학교" }, - { label: "대학교(2,3년)", value: "대학교(2,3년)" }, - { label: "대학교(4년)", value: "대학교(4년)" }, - { label: "대학원", value: "대학원" }, - ]} + options={SCHOOL_TYPE_OPTIONS} error={errors.schoolType?.message} + aria-label="학교 구분" /> setValue("graduationStatus", val, { shouldValidate: true, shouldTouch: true }) } - options={[ - { label: "졸업", value: "졸업" }, - { label: "재학", value: "재학" }, - { label: "중퇴", value: "중퇴" }, - { label: "휴학", value: "휴학" }, - ]} + options={GRADUATION_STATUS_OPTIONS} error={errors.graduationStatus?.message} + aria-label="졸업 상태" />
-
-

경력 사항

- {expFields.map((field, idx) => ( -
- -
- - -
-
- - -
- -
- ))} - -
+ -
-

자격증

- {certFields.map((field, idx) => ( -
- - -
- - -
-
- ))} - -
+

자기소개

@@ -263,21 +432,26 @@ export default function ResumeForm({ mode, resumeId, defaultValues }: ResumeForm name="introduction" label="" placeholder="자기소개는 최대 500자까지 작성하실 수 있습니다." + aria-label="자기소개" />
diff --git a/src/features/resume/components/common/ErrorMessage.tsx b/src/features/resume/components/common/ErrorMessage.tsx new file mode 100644 index 00000000..3c20dabc --- /dev/null +++ b/src/features/resume/components/common/ErrorMessage.tsx @@ -0,0 +1,3 @@ +export const ErrorMessage = ({ message }: { message: string }) => ( +
{message}
+); diff --git a/src/features/resume/components/common/LoadingSpinner.tsx b/src/features/resume/components/common/LoadingSpinner.tsx new file mode 100644 index 00000000..281729b6 --- /dev/null +++ b/src/features/resume/components/common/LoadingSpinner.tsx @@ -0,0 +1,5 @@ +export const LoadingSpinner = () => ( +
+
+
+); diff --git a/src/features/resume/components/common/ui/DatePicker.tsx b/src/features/resume/components/common/ui/DatePicker.tsx index f88bb78b..567fcfbc 100644 --- a/src/features/resume/components/common/ui/DatePicker.tsx +++ b/src/features/resume/components/common/ui/DatePicker.tsx @@ -30,6 +30,22 @@ type DatePickerFieldProps = { className?: string; } & VariantProps; +const formatDateToLocalString = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +const parseLocalDateString = (dateString: string): Date | null => { + if (!dateString) return null; + + const [year, month, day] = dateString.split("-").map(Number); + if (!year || !month || !day) return null; + + return new Date(year, month - 1, day); +}; + export default function DatePickerField({ label, name, @@ -54,9 +70,9 @@ export default function DatePickerField({ render={({ field }) => (
{ - const formatted = date?.toISOString().split("T")[0] || ""; + const formatted = date ? formatDateToLocalString(date) : ""; field.onChange(formatted); }} dateFormat="yyyy-MM-dd" diff --git a/src/features/resume/components/sections/CertificationsSection.tsx b/src/features/resume/components/sections/CertificationsSection.tsx new file mode 100644 index 00000000..3b2e654a --- /dev/null +++ b/src/features/resume/components/sections/CertificationsSection.tsx @@ -0,0 +1,62 @@ +import Input from "@/features/resume/components/common/ui/Input"; +import DatePickerField from "@/features/resume/components/common/ui/DatePicker"; + +interface CertificationsSectionProps { + fields: Record<"id", string>[]; + onAdd: () => void; + onRemove: (index: number) => void; + isEmpty: boolean; +} + +export default function CertificationsSection({ + fields, + onAdd, + onRemove, + isEmpty, +}: CertificationsSectionProps) { + return ( +
+

자격증

+ {isEmpty && ( +

자격증이 없습니다. 자격증을 추가해보세요.

+ )} + {fields.map((field, idx) => ( +
+ + +
+ + +
+
+ ))} + +
+ ); +} diff --git a/src/features/resume/components/sections/ExperiencesSection.tsx b/src/features/resume/components/sections/ExperiencesSection.tsx new file mode 100644 index 00000000..69abdbff --- /dev/null +++ b/src/features/resume/components/sections/ExperiencesSection.tsx @@ -0,0 +1,76 @@ +import { UseFormWatch, UseFormRegister, Path } from "react-hook-form"; +import { ResumeFormData } from "@/features/resume/validation/resumeSchema"; +import Input from "@/features/resume/components/common/ui/Input"; +import DatePickerField from "@/features/resume/components/common/ui/DatePicker"; + +interface ExperiencesSectionProps { + fields: Record<"id", string>[]; + onAdd: () => void; + onRemove: (index: number) => void; + watch: UseFormWatch; + register: UseFormRegister; + isEmpty: boolean; +} + +export default function ExperiencesSection({ + fields, + onAdd, + onRemove, + watch, + register, + isEmpty, +}: ExperiencesSectionProps) { + return ( +
+

경력 사항

+ {isEmpty && ( +

경력 사항이 없습니다. 경력을 추가해보세요.

+ )} + {fields.map((field, idx) => ( +
+ +
+ + +
+
+ + )} + /> +
+ +
+ ))} + +
+ ); +} diff --git a/src/features/resume/components/ResumeActionButton.tsx b/src/features/resume/components/sections/ResumeActionButton.tsx similarity index 67% rename from src/features/resume/components/ResumeActionButton.tsx rename to src/features/resume/components/sections/ResumeActionButton.tsx index f28e6ca1..1740570f 100644 --- a/src/features/resume/components/ResumeActionButton.tsx +++ b/src/features/resume/components/sections/ResumeActionButton.tsx @@ -3,6 +3,7 @@ import React from "react"; import { useRouter, useParams } from "next/navigation"; import { useSession } from "next-auth/react"; import { useDeleteResume } from "@/features/resume/api/useDeleteResume"; +import { useModalStore } from "@/store/useModalStore"; import Spinner from "@/components/common/Spinner"; type Props = { @@ -14,6 +15,7 @@ export default function ResumeActionButtons({ resumeId }: Props) { const { data: session, status } = useSession(); const { type, userId } = useParams() as { type: string; userId: string }; const { deleteResume, isDeleting } = useDeleteResume(); + const { showModal } = useModalStore(); if (status === "loading") { return ( @@ -33,21 +35,26 @@ export default function ResumeActionButtons({ resumeId }: Props) { }; const handleDelete = () => { - if (!window.confirm("정말 삭제하시겠습니까?")) return; + showModal({ + title: "이력서 삭제", + message: "정말로 이 이력서를 삭제하시겠습니까?\n삭제된 이력서는 복구할 수 없습니다.", + confirmText: "삭제", + onConfirm: () => { + const token = session.accessToken; + if (!token) { + alert("로그인이 필요합니다."); + router.push("/auth/login"); + return; + } - const token = session.accessToken; - if (!token) { - alert("로그인이 필요합니다."); - router.push("/auth/login"); - return; - } - - deleteResume(resumeId, { - onSuccess: () => { - router.push(`/${type}/mypage/${userId}`); - }, - onError: (err) => { - alert(`삭제에 실패했습니다: ${err.message}`); + deleteResume(resumeId, { + onSuccess: () => { + router.push(`/${type}/mypage/${userId}`); + }, + onError: (err) => { + alert(`삭제에 실패했습니다: ${err.message}`); + }, + }); }, }); }; diff --git a/src/features/resume/components/ResumeContactSection.tsx b/src/features/resume/components/sections/ResumeContactSection.tsx similarity index 100% rename from src/features/resume/components/ResumeContactSection.tsx rename to src/features/resume/components/sections/ResumeContactSection.tsx diff --git a/src/features/resume/components/ResumeSelfIntroductionSection.tsx b/src/features/resume/components/sections/ResumeSelfIntroductionSection.tsx similarity index 100% rename from src/features/resume/components/ResumeSelfIntroductionSection.tsx rename to src/features/resume/components/sections/ResumeSelfIntroductionSection.tsx diff --git a/src/features/resume/components/ResumeTableSection.tsx b/src/features/resume/components/sections/ResumeTableSection.tsx similarity index 100% rename from src/features/resume/components/ResumeTableSection.tsx rename to src/features/resume/components/sections/ResumeTableSection.tsx diff --git a/src/features/resume/constants/messages.ts b/src/features/resume/constants/messages.ts new file mode 100644 index 00000000..a55465f2 --- /dev/null +++ b/src/features/resume/constants/messages.ts @@ -0,0 +1,15 @@ +export const SUCCESS_MESSAGES = { + RESUME_CREATED: "이력서가 성공적으로 등록되었습니다.", + RESUME_UPDATED: "이력서가 성공적으로 수정되었습니다.", + RESUME_DELETED: "이력서가 삭제되었습니다.", +} as const; + +export const ERROR_MESSAGES = { + VALIDATION_FAILED: "입력한 정보를 다시 확인해주세요.", + MISSING_RESUME_ID: "이력서 ID가 필요합니다.", + CREATE_FAILED: "이력서 등록에 실패했습니다.", + UPDATE_FAILED: "이력서 수정에 실패했습니다.", + NETWORK_ERROR: "네트워크 오류가 발생했습니다. 다시 시도해주세요.", + SERVER_ERROR: "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.", + UNKNOWN_ERROR: "알 수 없는 오류가 발생했습니다.", +} as const; diff --git a/src/features/resume/constants/options.ts b/src/features/resume/constants/options.ts new file mode 100644 index 00000000..21c0e9b9 --- /dev/null +++ b/src/features/resume/constants/options.ts @@ -0,0 +1,13 @@ +export const SCHOOL_TYPE_OPTIONS = [ + { label: "고등학교", value: "고등학교" }, + { label: "대학교(2,3년)", value: "대학교(2,3년)" }, + { label: "대학교(4년)", value: "대학교(4년)" }, + { label: "대학원", value: "대학원" }, +]; + +export const GRADUATION_STATUS_OPTIONS = [ + { label: "졸업", value: "졸업" }, + { label: "재학", value: "재학" }, + { label: "중퇴", value: "중퇴" }, + { label: "휴학", value: "휴학" }, +]; diff --git a/src/features/resume/constants/validation.ts b/src/features/resume/constants/validation.ts new file mode 100644 index 00000000..d4ceac6d --- /dev/null +++ b/src/features/resume/constants/validation.ts @@ -0,0 +1,68 @@ +export const RESUME_VALIDATION_LIMITS = { + // 길이 제한 + COMPANY_NAME_MAX: 50, + POSITION_MAX: 50, + CERTIFICATION_NAME_MAX: 100, + CERTIFICATION_ISSUER_MAX: 100, + JOB_CATEGORY_MAX: 20, + TITLE_MAX: 20, + SCHOOL_NAME_MAX: 50, + INTRODUCTION_MAX: 500, + + // 최소 길이 + MIN_LENGTH: 1, +} as const; + +// 날짜 검증 관련 +export const DATE_VALIDATION = { + FORMAT_REGEX: /^\d{4}-\d{2}-\d{2}$/, + TIME_RESET: { + START_OF_DAY: [0, 0, 0, 0] as const, + END_OF_DAY: [23, 59, 59, 999] as const, + }, +} as const; + +// 메시지 상수 +export const RESUME_VALIDATION_MESSAGES = { + COMPANY_NAME: { + REQUIRED: "회사명을 입력해주세요.", + MAX_LENGTH: `회사명은 ${RESUME_VALIDATION_LIMITS.COMPANY_NAME_MAX}자 이하로 입력해주세요.`, + }, + POSITION: { + REQUIRED: "직무를 입력해주세요.", + MAX_LENGTH: `직무는 ${RESUME_VALIDATION_LIMITS.POSITION_MAX}자 이하로 입력해주세요.`, + }, + DATE: { + REQUIRED_START: "근무 시작일을 입력해주세요.", + REQUIRED_CERT: "취득일자를 입력해주세요.", + INVALID_FORMAT: "올바른 날짜 형식(YYYY-MM-DD)으로 입력해주세요.", + FUTURE_NOT_ALLOWED: "미래 날짜는 입력할 수 없습니다.", + INVALID_RANGE: + "날짜를 올바르게 입력해주세요. (현재 근무 중이 아닌 경우 종료일 필수, 시작일 < 종료일, 미래 날짜 불가)", + }, + CERTIFICATION: { + NAME_REQUIRED: "자격증명을 입력해주세요.", + NAME_MAX_LENGTH: `자격증명은 ${RESUME_VALIDATION_LIMITS.CERTIFICATION_NAME_MAX}자 이하로 입력해주세요.`, + ISSUER_REQUIRED: "발급기관을 입력해주세요.", + ISSUER_MAX_LENGTH: `발급기관은 ${RESUME_VALIDATION_LIMITS.CERTIFICATION_ISSUER_MAX}자 이하로 입력해주세요.`, + }, + JOB_CATEGORY: { + REQUIRED: "직종을 입력해주세요. ex) IT, 디자인, 마케팅", + MAX_LENGTH: `직종은 ${RESUME_VALIDATION_LIMITS.JOB_CATEGORY_MAX}자 이하로 입력해주세요.`, + }, + TITLE: { + REQUIRED: "이력서 제목을 입력해주세요.", + MAX_LENGTH: `이력서 제목은 ${RESUME_VALIDATION_LIMITS.TITLE_MAX}자 이하로 입력해주세요.`, + }, + SCHOOL: { + TYPE_REQUIRED: "학교 구분을 선택해주세요.", + TYPE_INVALID: "올바른 학교 구분을 선택해주세요.", + NAME_REQUIRED: "학교명을 입력해주세요.", + NAME_MAX_LENGTH: `학교명은 ${RESUME_VALIDATION_LIMITS.SCHOOL_NAME_MAX}자 이하로 입력해주세요.`, + STATUS_REQUIRED: "졸업 상태를 선택해주세요.", + STATUS_INVALID: "올바른 졸업 상태를 선택해주세요.", + }, + INTRODUCTION: { + MAX_LENGTH: `자기소개는 최대 ${RESUME_VALIDATION_LIMITS.INTRODUCTION_MAX}자까지 작성할 수 있습니다.`, + }, +} as const; diff --git a/src/features/resume/hooks/useConfirmDelete.ts b/src/features/resume/hooks/useConfirmDelete.ts new file mode 100644 index 00000000..48b1a20f --- /dev/null +++ b/src/features/resume/hooks/useConfirmDelete.ts @@ -0,0 +1,26 @@ +import { useCallback } from "react"; +import { useModalStore } from "@/store/useModalStore"; + +interface UseConfirmDeleteOptions { + title: string; + message: string; + confirmText?: string; +} + +export function useConfirmDelete() { + const { showModal } = useModalStore(); + + const confirmDelete = useCallback( + (onConfirm: () => void, options: UseConfirmDeleteOptions) => { + showModal({ + title: options.title, + message: options.message, + confirmText: options.confirmText || "삭제", + onConfirm, + }); + }, + [showModal], + ); + + return { confirmDelete }; +} diff --git a/src/features/resume/hooks/useResumeFieldArray.ts b/src/features/resume/hooks/useResumeFieldArray.ts new file mode 100644 index 00000000..4f5b2053 --- /dev/null +++ b/src/features/resume/hooks/useResumeFieldArray.ts @@ -0,0 +1,52 @@ +import { useFieldArray, Control } from "react-hook-form"; +import { useCallback } from "react"; +import { ResumeFormData } from "@/features/resume/validation/resumeSchema"; +import { ExperienceFormData, CertificationFormData } from "@/features/resume/types"; + +export function useExperienceFieldArray(control: Control) { + const { fields, append, remove } = useFieldArray({ + control, + name: "experiences", + }); + + const handleAdd = useCallback(() => { + const newExperience: ExperienceFormData = { + company: "", + position: "", + startDate: "", + endDate: "", + isCurrent: false, + }; + append(newExperience); + }, [append]); + + return { + fields, + handleAdd, + remove, + isEmpty: fields.length === 0, + }; +} + +export function useCertificationFieldArray(control: Control) { + const { fields, append, remove } = useFieldArray({ + control, + name: "certifications", + }); + + const handleAdd = useCallback(() => { + const newCertification: CertificationFormData = { + name: "", + issuer: "", + date: "", + }; + append(newCertification); + }, [append]); + + return { + fields, + handleAdd, + remove, + isEmpty: fields.length === 0, + }; +} diff --git a/src/features/resume/hooks/useResumeSubmit.ts b/src/features/resume/hooks/useResumeSubmit.ts new file mode 100644 index 00000000..280ac0fd --- /dev/null +++ b/src/features/resume/hooks/useResumeSubmit.ts @@ -0,0 +1,116 @@ +import { useCallback } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "react-hot-toast"; +import { UseFormTrigger } from "react-hook-form"; + +import { ResumeFormData } from "@/features/resume/validation/resumeSchema"; +import { ResumeApiResponse } from "@/features/resume/types"; +import { useCreateResume } from "@/features/resume/api/useCreateResume"; +import { useUpdateResume } from "@/features/resume/api/useUpdateResume"; +import { mapToCreateDto } from "@/features/resume/utils/mapToCreateDto"; +import { mapToUpdateDto } from "@/features/resume/utils/mapToUpdateDto"; +import { handleResumeError, clearResumeError } from "@/features/resume/utils/errorHandler"; +import { SUCCESS_MESSAGES, ERROR_MESSAGES } from "@/features/resume/constants/messages"; + +interface UseResumeSubmitProps { + mode: "create" | "edit"; + resumeId?: string; + trigger: UseFormTrigger; + setSubmitError: (error: string | null) => void; +} + +export function useResumeSubmit({ mode, resumeId, trigger, setSubmitError }: UseResumeSubmitProps) { + const router = useRouter(); + const params = useParams<{ type: string; userId: string }>(); + const qc = useQueryClient(); + + const { createResume, isLoading: isCreating, error: createError } = useCreateResume(); + const { updateResume, isUpdating, error: updateError } = useUpdateResume(); + + const handleSuccess = useCallback( + (res: ResumeApiResponse) => { + clearResumeError(setSubmitError); + + if (mode === "create") { + const newId = res.resume.resume_id; + qc.invalidateQueries({ queryKey: ["resumeList"] }); + qc.invalidateQueries({ queryKey: ["resumeDetail", newId] }); + toast.success(SUCCESS_MESSAGES.RESUME_CREATED); + router.push(`/${params.type}/mypage/${params.userId}/resume/${newId}`); + } else { + qc.invalidateQueries({ queryKey: ["resumeDetail", resumeId] }); + toast.success(SUCCESS_MESSAGES.RESUME_UPDATED); + router.push(`/${params.type}/mypage/${params.userId}/resume/${resumeId}`); + } + }, + [mode, qc, params, resumeId, router, setSubmitError], + ); + + const handleError = useCallback( + (error: unknown) => { + handleResumeError(error, { + mode, + setError: setSubmitError, + }); + }, + [mode, setSubmitError], + ); + + const onSubmit = useCallback( + async (data: ResumeFormData) => { + try { + clearResumeError(setSubmitError); + + const isValid = await trigger(); + if (!isValid) { + setSubmitError(ERROR_MESSAGES.VALIDATION_FAILED); + return; + } + + if (mode === "create") { + const dto = mapToCreateDto(data); + createResume(dto, { + onSuccess: handleSuccess, + onError: handleError, + }); + } else { + if (!resumeId) { + setSubmitError(ERROR_MESSAGES.MISSING_RESUME_ID); + return; + } + + const dto = mapToUpdateDto(data, resumeId); + updateResume( + { id: resumeId, dto }, + { + onSuccess: handleSuccess, + onError: handleError, + }, + ); + } + } catch (error) { + handleError(error); + } + }, + [ + mode, + resumeId, + trigger, + createResume, + updateResume, + handleSuccess, + handleError, + setSubmitError, + ], + ); + + const isLoading = mode === "create" ? isCreating : isUpdating; + const apiError = mode === "create" ? createError : updateError; + + return { + onSubmit, + isLoading, + apiError, + }; +} diff --git a/src/features/resume/types/index.ts b/src/features/resume/types/index.ts new file mode 100644 index 00000000..74b2c12d --- /dev/null +++ b/src/features/resume/types/index.ts @@ -0,0 +1,33 @@ +import { ResumeFormData } from "../validation/resumeSchema"; + +export interface ResumeFormProps { + mode: "create" | "edit"; + resumeId?: string; + defaultValues?: ResumeFormData; +} + +export interface ResumeApiResponse { + resume: { + resume_id: string; + [key: string]: unknown; + }; +} + +export interface ApiError { + message?: string; + [key: string]: unknown; +} + +export type ExperienceFormData = { + company: string; + position: string; + startDate: string; + endDate?: string; + isCurrent: boolean; +}; + +export type CertificationFormData = { + name: string; + issuer: string; + date: string; +}; diff --git a/src/features/resume/utils/errorHandler.ts b/src/features/resume/utils/errorHandler.ts new file mode 100644 index 00000000..c6ed0382 --- /dev/null +++ b/src/features/resume/utils/errorHandler.ts @@ -0,0 +1,51 @@ +import { toast } from "react-hot-toast"; +import { ApiError } from "@/features/resume/types"; +import { ERROR_MESSAGES } from "@/features/resume/constants/messages"; + +type ResumeMode = "create" | "edit"; + +interface HandleResumeErrorOptions { + mode: ResumeMode; + setError: (error: string) => void; + showToast?: boolean; + logError?: boolean; +} + +export function handleResumeError( + error: ApiError | Error | unknown, + options: HandleResumeErrorOptions, +) { + const { mode, setError, showToast = true, logError = true } = options; + + let errorMessage = + mode === "create" ? ERROR_MESSAGES.CREATE_FAILED : ERROR_MESSAGES.UPDATE_FAILED; + + // 에러 메시지 추출 + if ( + error && + typeof error === "object" && + "message" in error && + typeof error.message === "string" + ) { + errorMessage = mode === "create" ? ERROR_MESSAGES.CREATE_FAILED : ERROR_MESSAGES.UPDATE_FAILED; + } + + // 상태 업데이트 + setError(errorMessage); + + // 토스트 표시 + if (showToast) { + toast.error(errorMessage); + } + + // 콘솔 로깅 + if (logError) { + console.error(`이력서 ${mode === "create" ? "생성" : "수정"} 실패:`, error); + } + + return errorMessage; +} + +export function clearResumeError(setError: (error: string | null) => void) { + setError(null); +} diff --git a/src/features/resume/utils/mapToCreateDto.ts b/src/features/resume/utils/mapToCreateDto.ts index 199b6850..6c64bf6c 100644 --- a/src/features/resume/utils/mapToCreateDto.ts +++ b/src/features/resume/utils/mapToCreateDto.ts @@ -1,27 +1,35 @@ import type { ResumeFormData } from "@/features/resume/validation/resumeSchema"; import type { CreateResumeRequestDto } from "@/types/api/resume"; -// 폼 데이터를 API 요청 DTO로 변환 export function mapToCreateDto(data: ResumeFormData): CreateResumeRequestDto { + if (!data.jobCategory?.trim()) { + throw new Error("직종은 필수 입력 항목입니다."); + } + if (!data.title?.trim()) { + throw new Error("이력서 제목은 필수 입력 항목입니다."); + } + return { - job_category: data.jobCategory, - resume_title: data.title, + job_category: data.jobCategory.trim(), + resume_title: data.title.trim(), education_level: data.schoolType, - school_name: data.schoolName, + school_name: data.schoolName.trim(), education_state: data.graduationStatus, - introduce: data.introduction, - career_list: - data.experiences?.map((exp) => ({ - company_name: exp.company, - position: exp.position, + introduce: data.introduction?.trim() || "", + career_list: (data.experiences || []) + .filter((exp) => exp.company?.trim() && exp.position?.trim() && exp.startDate) + .map((exp) => ({ + company_name: exp.company.trim(), + position: exp.position.trim(), employment_period_start: exp.startDate, - employment_period_end: exp.isCurrent ? null : exp.endDate || null, - })) ?? [], - certification_list: - data.certifications?.map((cert) => ({ - certification_name: cert.name, - issuing_organization: cert.issuer, + employment_period_end: exp.isCurrent ? null : exp.endDate?.trim() || null, + })), + certification_list: (data.certifications || []) + .filter((cert) => cert.name?.trim() && cert.issuer?.trim() && cert.date) + .map((cert) => ({ + certification_name: cert.name.trim(), + issuing_organization: cert.issuer.trim(), date_acquired: cert.date, - })) ?? [], + })), }; } diff --git a/src/features/resume/utils/mapToResumeFormData.ts b/src/features/resume/utils/mapToResumeFormData.ts index 7a1f611f..2894eb95 100644 --- a/src/features/resume/utils/mapToResumeFormData.ts +++ b/src/features/resume/utils/mapToResumeFormData.ts @@ -1,31 +1,86 @@ import type { ResumeFormData } from "@/features/resume/validation/resumeSchema"; -import type { ResumeResponseDto } from "@/types/api/resume"; +import type { + CreateResumeRequestDto, + ResumeResponseDto, + UpdateResumeRequestDto, +} from "@/types/api/resume"; export function mapToResumeFormData( dto: ResumeResponseDto["resume"], userEmail: string, ): ResumeFormData { - return { - jobCategory: dto.job_category, - title: dto.resume_title, - name: dto.user.name, - phone: dto.user.phone_number, - email: userEmail, - schoolType: dto.education_level, - schoolName: dto.school_name, - graduationStatus: dto.education_state, - experiences: dto.career_list.map((c) => ({ - company: c.company_name, - position: c.position, - startDate: c.employment_period_start, - endDate: c.employment_period_end ?? "", - isCurrent: c.employment_period_end === null, - })), - certifications: dto.certification_list.map((c) => ({ - name: c.certification_name, - issuer: c.issuing_organization, - date: c.date_acquired, - })), - introduction: dto.introduce, - }; + if (!dto) { + throw new Error("이력서 데이터가 없습니다."); + } + if (!userEmail?.trim()) { + throw new Error("사용자 이메일이 필요합니다."); + } + + try { + return { + jobCategory: dto.job_category || "", + title: dto.resume_title || "", + schoolType: dto.education_level || "", + schoolName: dto.school_name || "", + graduationStatus: dto.education_state || "", + experiences: (dto.career_list || []).map((career) => ({ + company: career.company_name || "", + position: career.position || "", + startDate: career.employment_period_start || "", + endDate: career.employment_period_end || "", + isCurrent: career.employment_period_end === null, + })), + certifications: (dto.certification_list || []).map((cert) => ({ + name: cert.certification_name || "", + issuer: cert.issuing_organization || "", + date: cert.date_acquired || "", + })), + introduction: dto.introduce || "", + }; + } catch (error) { + console.error("데이터 변환 중 오류 발생:", error); + throw new Error("이력서 데이터 변환에 실패했습니다."); + } } + +export const validateCreateDto = (dto: CreateResumeRequestDto): boolean => { + return !!( + dto.job_category?.trim() && + dto.resume_title?.trim() && + dto.education_level && + dto.school_name?.trim() && + dto.education_state + ); +}; + +export const validateUpdateDto = (dto: UpdateResumeRequestDto): boolean => { + return !!( + dto.resume_id?.trim() && + dto.job_category?.trim() && + dto.resume_title?.trim() && + dto.education_level && + dto.school_name?.trim() && + dto.education_state + ); +}; + +export const sanitizeCareerData = (experiences: ResumeFormData["experiences"]) => { + return (experiences || []) + .filter((exp) => exp.company?.trim() && exp.position?.trim() && exp.startDate) + .map((exp) => ({ + ...exp, + company: exp.company.trim(), + position: exp.position.trim(), + endDate: exp.isCurrent ? "" : exp.endDate?.trim() || "", + })); +}; + +export const sanitizeCertificationData = (certifications: ResumeFormData["certifications"]) => { + return (certifications || []) + .filter((cert) => cert.name?.trim() && cert.issuer?.trim() && cert.date) + .map((cert) => ({ + ...cert, + name: cert.name.trim(), + issuer: cert.issuer.trim(), + })); +}; diff --git a/src/features/resume/utils/mapToUpdateDto.ts b/src/features/resume/utils/mapToUpdateDto.ts index 09cbd731..d690d5f7 100644 --- a/src/features/resume/utils/mapToUpdateDto.ts +++ b/src/features/resume/utils/mapToUpdateDto.ts @@ -2,24 +2,38 @@ import type { ResumeFormData } from "@/features/resume/validation/resumeSchema"; import type { UpdateResumeRequestDto } from "@/types/api/resume"; export function mapToUpdateDto(data: ResumeFormData, resumeId: string): UpdateResumeRequestDto { + if (!resumeId?.trim()) { + throw new Error("이력서 ID가 필요합니다."); + } + if (!data.jobCategory?.trim()) { + throw new Error("직종은 필수 입력 항목입니다."); + } + if (!data.title?.trim()) { + throw new Error("이력서 제목은 필수 입력 항목입니다."); + } + return { - resume_id: resumeId, - job_category: data.jobCategory, - resume_title: data.title, + resume_id: resumeId.trim(), + job_category: data.jobCategory.trim(), + resume_title: data.title.trim(), education_level: data.schoolType, - school_name: data.schoolName, + school_name: data.schoolName.trim(), education_state: data.graduationStatus, - introduce: data.introduction, - career_list: (data.experiences ?? []).map((exp) => ({ - company_name: exp.company, - position: exp.position, - employment_period_start: exp.startDate, - employment_period_end: exp.isCurrent ? null : exp.endDate || null, - })), - certification_list: (data.certifications ?? []).map((cert) => ({ - certification_name: cert.name, - issuing_organization: cert.issuer, - date_acquired: cert.date, - })), + introduce: data.introduction?.trim() || "", + career_list: (data.experiences || []) + .filter((exp) => exp.company?.trim() && exp.position?.trim() && exp.startDate) + .map((exp) => ({ + company_name: exp.company.trim(), + position: exp.position.trim(), + employment_period_start: exp.startDate, + employment_period_end: exp.isCurrent ? null : exp.endDate?.trim() || null, + })), + certification_list: (data.certifications || []) + .filter((cert) => cert.name?.trim() && cert.issuer?.trim() && cert.date) + .map((cert) => ({ + certification_name: cert.name.trim(), + issuing_organization: cert.issuer.trim(), + date_acquired: cert.date, + })), }; } diff --git a/src/features/resume/validation/resumeSchema.ts b/src/features/resume/validation/resumeSchema.ts index b60afd24..3061c440 100644 --- a/src/features/resume/validation/resumeSchema.ts +++ b/src/features/resume/validation/resumeSchema.ts @@ -1,35 +1,173 @@ import { z } from "zod"; +import { + RESUME_VALIDATION_LIMITS, + DATE_VALIDATION, + RESUME_VALIDATION_MESSAGES, +} from "@/features/resume/constants/validation"; -export const resumeSchema = z.object({ - jobCategory: z.string().min(1, "직종을 입력해주세요. ex) IT, 디자인, 마케팅"), - title: z.string().min(1, "이력서 제목을 입력해주세요."), - name: z.string().min(1, "이름을 입력해주세요."), - phone: z.string().regex(/^010-\d{4}-\d{4}$/, "010-1234-5678 형식으로 입력해주세요."), - email: z.string().min(1, "이메일을 입력해주세요."), - schoolType: z.string().min(1, "학교 구분을 선택해주세요."), - schoolName: z.string().min(1, "학교명을 입력해주세요."), - graduationStatus: z.string().min(1, "졸업 상태를 선택해주세요."), - experiences: z - .array( - z.object({ - company: z.string().min(1, "회사명을 입력해주세요."), - position: z.string().min(1, "직무를 입력해주세요."), - startDate: z.string().min(1, "근무 시작일을 입력해주세요."), - endDate: z.string().optional(), - isCurrent: z.boolean(), - }), +const isValidDate = (dateString: string): boolean => { + const date = new Date(dateString); + return !isNaN(date.getTime()) && !!dateString.match(DATE_VALIDATION.FORMAT_REGEX); +}; + +const experienceSchema = z + .object({ + company: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.COMPANY_NAME.REQUIRED) + .max( + RESUME_VALIDATION_LIMITS.COMPANY_NAME_MAX, + RESUME_VALIDATION_MESSAGES.COMPANY_NAME.MAX_LENGTH, + ), + position: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.POSITION.REQUIRED) + .max(RESUME_VALIDATION_LIMITS.POSITION_MAX, RESUME_VALIDATION_MESSAGES.POSITION.MAX_LENGTH), + startDate: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.DATE.REQUIRED_START) + .refine(isValidDate, RESUME_VALIDATION_MESSAGES.DATE.INVALID_FORMAT), + endDate: z + .string() + .optional() + .refine((date) => !date || isValidDate(date), RESUME_VALIDATION_MESSAGES.DATE.INVALID_FORMAT), + isCurrent: z.boolean(), + }) + .refine( + (data) => { + if (!data.isCurrent && (!data.endDate || data.endDate.trim() === "")) { + return false; + } + + if (data.endDate && data.startDate) { + const startDate = new Date(data.startDate); + const endDate = new Date(data.endDate); + if (startDate >= endDate) { + return false; + } + } + + const today = new Date(); + today.setHours(...DATE_VALIDATION.TIME_RESET.START_OF_DAY); + + if (data.startDate) { + const startDate = new Date(data.startDate); + if (startDate > today) { + return false; + } + } + + if (data.endDate && !data.isCurrent) { + const endDate = new Date(data.endDate); + if (endDate > today) { + return false; + } + } + + return true; + }, + { + message: RESUME_VALIDATION_MESSAGES.DATE.INVALID_RANGE, + path: ["endDate"], + }, + ); + +const certificationSchema = z.object({ + name: z + .string() + .min( + RESUME_VALIDATION_LIMITS.MIN_LENGTH, + RESUME_VALIDATION_MESSAGES.CERTIFICATION.NAME_REQUIRED, ) - .optional(), - certifications: z - .array( - z.object({ - name: z.string().min(1, "자격증명을 입력해주세요."), - issuer: z.string().min(1, "발급기관을 입력해주세요."), - date: z.string().min(1, "취득일자를 입력해주세요."), - }), + .max( + RESUME_VALIDATION_LIMITS.CERTIFICATION_NAME_MAX, + RESUME_VALIDATION_MESSAGES.CERTIFICATION.NAME_MAX_LENGTH, + ), + issuer: z + .string() + .min( + RESUME_VALIDATION_LIMITS.MIN_LENGTH, + RESUME_VALIDATION_MESSAGES.CERTIFICATION.ISSUER_REQUIRED, + ) + .max( + RESUME_VALIDATION_LIMITS.CERTIFICATION_ISSUER_MAX, + RESUME_VALIDATION_MESSAGES.CERTIFICATION.ISSUER_MAX_LENGTH, + ), + date: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.DATE.REQUIRED_CERT) + .refine(isValidDate, RESUME_VALIDATION_MESSAGES.DATE.INVALID_FORMAT) + .refine((date) => { + const certDate = new Date(date); + const today = new Date(); + today.setHours(...DATE_VALIDATION.TIME_RESET.END_OF_DAY); + return certDate <= today; + }, RESUME_VALIDATION_MESSAGES.DATE.FUTURE_NOT_ALLOWED), +}); + +export const resumeSchema = z.object({ + jobCategory: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.JOB_CATEGORY.REQUIRED) + .max( + RESUME_VALIDATION_LIMITS.JOB_CATEGORY_MAX, + RESUME_VALIDATION_MESSAGES.JOB_CATEGORY.MAX_LENGTH, + ), + title: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.TITLE.REQUIRED) + .max(RESUME_VALIDATION_LIMITS.TITLE_MAX, RESUME_VALIDATION_MESSAGES.TITLE.MAX_LENGTH), + schoolType: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.SCHOOL.TYPE_REQUIRED) + .refine( + (type) => ["고등학교", "대학교(2,3년)", "대학교(4년)", "대학원"].includes(type), + RESUME_VALIDATION_MESSAGES.SCHOOL.TYPE_INVALID, + ), + schoolName: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.SCHOOL.NAME_REQUIRED) + .max( + RESUME_VALIDATION_LIMITS.SCHOOL_NAME_MAX, + RESUME_VALIDATION_MESSAGES.SCHOOL.NAME_MAX_LENGTH, + ), + graduationStatus: z + .string() + .min(RESUME_VALIDATION_LIMITS.MIN_LENGTH, RESUME_VALIDATION_MESSAGES.SCHOOL.STATUS_REQUIRED) + .refine( + (status) => ["졸업", "재학", "중퇴", "휴학"].includes(status), + RESUME_VALIDATION_MESSAGES.SCHOOL.STATUS_INVALID, + ), + experiences: z.array(experienceSchema).optional(), + certifications: z.array(certificationSchema).optional(), + introduction: z + .string() + .max( + RESUME_VALIDATION_LIMITS.INTRODUCTION_MAX, + RESUME_VALIDATION_MESSAGES.INTRODUCTION.MAX_LENGTH, ) .optional(), - introduction: z.string().max(500, "자기소개는 최대 500자까지 작성할 수 있습니다."), }); export type ResumeFormData = z.infer; + +export const validateResumeData = (data: unknown): data is ResumeFormData => { + try { + resumeSchema.parse(data); + return true; + } catch { + return false; + } +}; + +export const getValidationErrors = (data: unknown) => { + try { + resumeSchema.parse(data); + return null; + } catch (error) { + if (error instanceof z.ZodError) { + return error.errors; + } + return null; + } +}; diff --git a/src/hooks/useBusinessVerification.ts b/src/hooks/useBusinessVerification.ts new file mode 100644 index 00000000..2607e8ed --- /dev/null +++ b/src/hooks/useBusinessVerification.ts @@ -0,0 +1,98 @@ +import type { UseFormSetError, FieldValues, Path } from "react-hook-form"; +import { useModalStore } from "@/store/useModalStore"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import { handleBusinessVerificationError } from "@/utils/errorHandlers"; +import { authApi } from "@/api/auth"; +import { toast } from "react-hot-toast"; + +interface UseBusinessVerificationProps { + setError: UseFormSetError; + representativeNameField: Path; + businessNumberField: Path; + startDateField: Path; +} + +export const useBusinessVerification = ({ + setError, + representativeNameField, + businessNumberField, + startDateField, +}: UseBusinessVerificationProps) => { + const showModal = useModalStore((s) => s.showModal); + + const verifyBusiness = async ( + representativeName: string, + businessNumber: string, + startDate: string, + ) => { + let hasError = false; + + if (!representativeName) { + setError(representativeNameField, { + type: "manual", + message: "대표자 성함을 입력해주세요.", + }); + hasError = true; + } + + if (!businessNumber) { + setError(businessNumberField, { + type: "manual", + message: "사업자등록번호를 입력해주세요.", + }); + hasError = true; + } + + if (!startDate) { + setError(startDateField, { + type: "manual", + message: "개업년월일을 선택해주세요.", + }); + hasError = true; + } + + if (hasError) return; + + try { + const dateObj = new Date(startDate); + if (isNaN(dateObj.getTime())) { + throw new Error("Invalid date"); + } + const formattedDate = dateObj.toISOString().split("T")[0]; + + console.log("사업자 인증 요청 payload:", { + b_no: businessNumber, + p_nm: representativeName, + start_dt: formattedDate, + }); + + const response = await authApi.verify.checkBusiness( + businessNumber, + representativeName, + formattedDate, + ); + + console.log("사업자등록 인증 응답:", response); + + if (response.valid) { + toast.success("사업자 인증이 완료되었습니다!"); + } else { + showModal({ + title: "⚠️ 인증 실패", + message: SIGNUP_CONSTANTS.MESSAGES.INFO.BUSINESS_INVALID, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => {}, + }); + } + + return response.valid; + } catch (error) { + handleBusinessVerificationError(error, showModal); + return false; + } + }; + + return { + verifyBusiness, + }; +}; diff --git a/src/hooks/useHasRole.ts b/src/hooks/useHasRole.ts index 32be2adb..6b3cc026 100644 --- a/src/hooks/useHasRole.ts +++ b/src/hooks/useHasRole.ts @@ -5,5 +5,5 @@ export const useHasRole = (roles: UserRole[]) => { const user = useAuthStore((state) => state.user); if (!user) return false; - return roles.includes(user.role); + return roles.includes(user.join_type); }; diff --git a/src/hooks/usePhoneVerification.ts b/src/hooks/usePhoneVerification.ts new file mode 100644 index 00000000..c778dd74 --- /dev/null +++ b/src/hooks/usePhoneVerification.ts @@ -0,0 +1,134 @@ +import { useState, useEffect } from "react"; +import type { + UseFormSetError, + UseFormSetValue, + FieldValues, + Path, + PathValue, +} from "react-hook-form"; +import { useModalStore } from "@/store/useModalStore"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import { handleSmsVerificationError, handleSmsCodeVerificationError } from "@/utils/errorHandlers"; +import { userApi } from "@/api/user"; +import type { PhoneVerificationRequestDto, VerifyCodeRequestDto } from "@/types/api/user"; + +interface UsePhoneVerificationProps { + setError: UseFormSetError; + setValue: UseFormSetValue; + clearErrors: (name?: Path) => void; + phoneFieldName: Path; + codeFieldName: Path; +} + +export const usePhoneVerification = ({ + setError, + setValue, + clearErrors, + phoneFieldName, + codeFieldName, +}: UsePhoneVerificationProps) => { + const [isRequesting, setIsRequesting] = useState(false); + const [isVerifyInputVisible, setIsVerifyInputVisible] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + const [timeLeft, setTimeLeft] = useState(SIGNUP_CONSTANTS.TIMER.INITIAL_VALUE); + const [isVerified, setIsVerified] = useState(false); + const showModal = useModalStore((s) => s.showModal); + + // 타이머 관리 + useEffect(() => { + if (!isRequesting) return; + if (timeLeft <= 0) { + setIsRequesting(false); + return; + } + const timer = setInterval(() => setTimeLeft((prev) => prev - 1), 1000); + return () => clearInterval(timer); + }, [isRequesting, timeLeft]); + + const getTimerText = () => { + if (!isRequesting) return undefined; + return `${Math.floor(timeLeft / SIGNUP_CONSTANTS.TIMER.SECONDS_PER_MINUTE)}:${String( + timeLeft % SIGNUP_CONSTANTS.TIMER.SECONDS_PER_MINUTE, + ).padStart(SIGNUP_CONSTANTS.SMS_VERIFICATION.TIMER_FORMAT.SECONDS_PADDING, "0")}`; + }; + + // SMS 인증 요청 + const requestVerification = async (phoneNumber: string) => { + clearErrors(phoneFieldName); + + const payload: PhoneVerificationRequestDto = { + phone_number: phoneNumber || "", + join_type: "normal", + }; + + try { + await userApi.requestPhoneCode(payload); + + setIsRequesting(true); + setTimeLeft(SIGNUP_CONSTANTS.SMS_VERIFICATION.TIMEOUT_SECONDS); + setIsVerifyInputVisible(true); + setIsFadingOut(false); + showModal({ + title: SIGNUP_CONSTANTS.MESSAGES.SUCCESS.SMS_SENT, + message: SIGNUP_CONSTANTS.MESSAGES.INFO.SMS_GUIDE, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => {}, + }); + } catch (error: unknown) { + handleSmsVerificationError(error, setError, phoneFieldName, showModal); + } + }; + + // SMS 인증 코드 확인 + const verifyCode = async (phoneNumber: string, code: string) => { + if (!code) { + setError(codeFieldName, { + type: "manual", + message: "인증번호 6자리를 입력해주세요.", + }); + return; + } + + const payload: VerifyCodeRequestDto = { + phone_number: phoneNumber || "", + code, + join_type: "normal", + }; + + try { + await userApi.verifyPhoneCode(payload); + + setIsVerified(true); + setValue(codeFieldName, code as PathValue>, { shouldValidate: true }); + setIsFadingOut(true); + + setTimeout(() => { + setIsVerifyInputVisible(false); + setIsRequesting(false); + }, SIGNUP_CONSTANTS.SMS_VERIFICATION.RETRY_DELAY); + + showModal({ + title: SIGNUP_CONSTANTS.MESSAGES.SUCCESS.SMS_VERIFIED, + message: SIGNUP_CONSTANTS.MESSAGES.INFO.SMS_COMPLETE_GUIDE, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => {}, + }); + } catch (error: unknown) { + handleSmsCodeVerificationError(error, setError, codeFieldName); + } + }; + + return { + isRequesting, + isVerifyInputVisible, + isFadingOut, + isVerified, + timeLeft, + + requestVerification, + verifyCode, + getTimerText, + + RETRY_DELAY: SIGNUP_CONSTANTS.SMS_VERIFICATION.RETRY_DELAY, + }; +}; diff --git a/src/hooks/useSmsVerification.ts b/src/hooks/useSmsVerification.ts new file mode 100644 index 00000000..56a482d7 --- /dev/null +++ b/src/hooks/useSmsVerification.ts @@ -0,0 +1,84 @@ +import { useMutation } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { userApi } from "@/api/user"; +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import type { PhoneVerificationRequestDto, VerifyCodeRequestDto } from "@/types/api/user"; + +export const useSmsVerification = () => { + const [timeLeft, setTimeLeft] = useState(0); + const [isVerified, setIsVerified] = useState(false); + + // 타이머 효과 + useEffect(() => { + if (timeLeft <= 0) return; + + const timer = setInterval(() => { + setTimeLeft((prev) => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + + return () => clearInterval(timer); + }, [timeLeft]); + + // 인증번호 요청 + const requestMutation = useMutation({ + mutationFn: (data: PhoneVerificationRequestDto) => userApi.requestPhoneCode(data), + onSuccess: () => { + setTimeLeft(SIGNUP_CONSTANTS.SMS_VERIFICATION.TIMEOUT_SECONDS); + setIsVerified(false); + }, + retry: 2, + retryDelay: 1000, + }); + + // 인증번호 확인 + const verifyMutation = useMutation({ + mutationFn: (data: VerifyCodeRequestDto) => userApi.verifyPhoneCode(data), + onSuccess: () => { + setIsVerified(true); + setTimeLeft(0); + }, + retry: 1, + }); + + // 타이머 포맷팅 + const formatTime = (seconds: number): string => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`; + }; + + // 상태 리셋 - 재전송 시 + const resetVerification = () => { + setIsVerified(false); + setTimeLeft(0); + requestMutation.reset(); + verifyMutation.reset(); + }; + + return { + // 요청 관련 + requestVerification: requestMutation.mutate, + isRequestingCode: requestMutation.isPending, + requestError: requestMutation.error, + + // 확인 관련 + verifyCode: verifyMutation.mutate, + isVerifyingCode: verifyMutation.isPending, + verifyError: verifyMutation.error, + + // 상태 + isVerified, + timeLeft, + formattedTime: formatTime(timeLeft), + isTimeExpired: timeLeft === 0, + + // 유틸 + resetVerification, + }; +}; diff --git a/src/hooks/useSpeechRecognition.ts b/src/hooks/useSpeechRecognition.ts index 9900c7c5..5490d13b 100644 --- a/src/hooks/useSpeechRecognition.ts +++ b/src/hooks/useSpeechRecognition.ts @@ -3,23 +3,43 @@ import { useEffect, useRef, useState } from "react"; declare global { interface Window { - webkitSpeechRecognition: new () => SpeechRecognition; + webkitSpeechRecognition: new () => WebkitSpeechRecognition; } - interface SpeechRecognition extends EventTarget { + interface WebkitSpeechRecognition extends EventTarget { lang: string; continuous: boolean; interimResults: boolean; start(): void; stop(): void; - onresult: (event: SpeechRecognitionEvent) => void; - onerror: (event: SpeechRecognitionErrorEvent) => void; + onresult: (event: WebkitSpeechRecognitionEvent) => void; + onerror: (event: WebkitSpeechRecognitionErrorEvent) => void; + } + + interface WebkitSpeechRecognitionEvent extends Event { + results: WebkitSpeechRecognitionResultList; + } + + interface WebkitSpeechRecognitionErrorEvent extends Event { + error: string; + } + + interface WebkitSpeechRecognitionResultList { + [index: number]: WebkitSpeechRecognitionResult; + } + + interface WebkitSpeechRecognitionResult { + [index: number]: WebkitSpeechRecognitionAlternative; + } + + interface WebkitSpeechRecognitionAlternative { + transcript: string; } } export function useSpeechRecognition() { const [transcript, setTranscript] = useState(""); - const recognitionRef = useRef(null); + const recognitionRef = useRef(null); useEffect(() => { if (typeof window === "undefined" || !("webkitSpeechRecognition" in window)) return; @@ -29,12 +49,12 @@ export function useSpeechRecognition() { recognition.continuous = false; recognition.interimResults = false; - recognition.onresult = (event: SpeechRecognitionEvent) => { + recognition.onresult = (event: WebkitSpeechRecognitionEvent) => { const result = event.results[0][0].transcript; setTranscript(result); }; - recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + recognition.onerror = (event: WebkitSpeechRecognitionErrorEvent) => { console.error("음성 인식 에러", event); }; diff --git a/src/store/useAuthStore.ts b/src/store/useAuthStore.ts index ae0cb80e..d9032470 100644 --- a/src/store/useAuthStore.ts +++ b/src/store/useAuthStore.ts @@ -1,11 +1,12 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { UserBase } from "@/types/commonUser"; +// 2025.06.08) setAuth user 매개변수를 optional로 변경하여 null 허용 interface AuthState { accessToken: string | null; refreshToken: string | null; user: UserBase | null; - setAuth: (accessToken: string, refreshToken: string, user: UserBase) => void; + setAuth: (accessToken: string, refreshToken: string, user: UserBase | null) => void; clearAuth: () => void; } diff --git a/src/store/useModalStore.ts b/src/store/useModalStore.ts index 6db4836d..04812c0a 100644 --- a/src/store/useModalStore.ts +++ b/src/store/useModalStore.ts @@ -5,6 +5,7 @@ export interface ModalProps { message: string; confirmText?: string; onConfirm?: () => void; + hideCancelButton?: boolean; } interface ModalState extends ModalProps { @@ -19,6 +20,7 @@ export const useModalStore = create((set) => ({ message: "", confirmText: "확인", onConfirm: undefined, + hideCancelButton: false, showModal: (props) => set({ @@ -27,6 +29,7 @@ export const useModalStore = create((set) => ({ message: props.message, confirmText: props.confirmText ?? "확인", onConfirm: props.onConfirm, + hideCancelButton: props.hideCancelButton ?? false, }), closeModal: () => @@ -36,5 +39,6 @@ export const useModalStore = create((set) => ({ message: "", confirmText: "확인", onConfirm: undefined, + hideCancelButton: false, }), })); diff --git a/src/types/commonUser.ts b/src/types/commonUser.ts index 28d4f6a8..0fcbd761 100644 --- a/src/types/commonUser.ts +++ b/src/types/commonUser.ts @@ -1,4 +1,5 @@ -export type JoinType = "normal" | "company"; +// 2025.06.08)admin 타입 추가하여 middleware 타입 에러 해결 +export type JoinType = "normal" | "company" | "admin"; // 기존 UserRole 타입을 JoinType으로 대체 export type UserRole = JoinType; // 하위 호환성을 위해 UserRole 타입 유지 diff --git a/src/types/kakao.d.ts b/src/types/kakao.d.ts index e6e6a15b..925d1d40 100644 --- a/src/types/kakao.d.ts +++ b/src/types/kakao.d.ts @@ -1,12 +1,53 @@ +interface KakaoLatLng { + getLat(): number; + getLng(): number; +} + +interface KakaoMap { + setCenter(latlng: KakaoLatLng): void; + getCenter(): KakaoLatLng; + getLevel(): number; + setLevel(level: number): void; +} + +interface KakaoMarker { + setMap(map: KakaoMap | null): void; + getPosition(): KakaoLatLng; +} + interface Kakao { + init(key: string): void; + isInitialized(): boolean; maps: { load(callback: () => void): void; - LatLng: new (lat: number, lng: number) => any; - Map: new (container: HTMLElement, options: any) => any; - Marker: new (options: any) => any; + LatLng: new (lat: number, lng: number) => KakaoLatLng; + Map: new (container: HTMLElement, options: { center: KakaoLatLng; level: number }) => KakaoMap; + Marker: new (options: { position: KakaoLatLng; map?: KakaoMap }) => KakaoMarker; + }; + Share: { + sendDefault(options: { + objectType: string; + content: { + title: string; + description: string; + imageUrl: string; + link: { + mobileWebUrl: string; + webUrl: string; + }; + }; + buttons?: Array<{ + title: string; + link: { + mobileWebUrl: string; + webUrl: string; + }; + }>; + }): void; }; } interface Window { kakao: Kakao; + Kakao: Kakao; } diff --git a/src/utils/errorHandlers.ts b/src/utils/errorHandlers.ts new file mode 100644 index 00000000..7502a4b8 --- /dev/null +++ b/src/utils/errorHandlers.ts @@ -0,0 +1,196 @@ +import { SIGNUP_CONSTANTS } from "@/constants/signup"; +import type { UseFormSetError, FieldValues, Path } from "react-hook-form"; + +// 회원가입 관련 공통 에러 처리 +export const handleSignupError = ( + error: unknown, + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + }) => void, + router: { push: (path: string) => void }, +) => { + console.error("회원가입 실패:", error); + showModal({ + title: SIGNUP_CONSTANTS.MESSAGES.ERROR.SIGNUP_FAILED, + message: SIGNUP_CONSTANTS.MESSAGES.INFO.SIGNUP_ERROR_RETRY, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => router.push("/"), + }); +}; + +// SMS 인증 관련 에러 처리 +export const handleSmsVerificationError = ( + error: unknown, + setError: UseFormSetError, + phoneFieldName: Path, + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + hideCancelButton?: boolean; + }) => void, +) => { + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response + ) { + // 중복 전화번호 에러 + showModal({ + title: "⚠️", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.SMS_DUPLICATE, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => {}, + hideCancelButton: true, + }); + } else { + // 일반적인 SMS 요청 실패 + setError(phoneFieldName, { + type: "manual", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.SMS_FAILED, + }); + } +}; + +// SMS 인증 코드 확인 에러 처리 +export const handleSmsCodeVerificationError = ( + error: unknown, + setError: UseFormSetError, + codeFieldName: Path, +) => { + if ( + error && + typeof error === "object" && + "response" in error && + error.response && + typeof error.response === "object" && + "status" in error.response + ) { + const response = error.response as { status?: number }; + + if (response.status === 408) { + // 인증 시간 만료 + setError(codeFieldName, { + type: "manual", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.SMS_TIMEOUT, + }); + } else { + // 인증번호 불일치 + setError(codeFieldName, { + type: "manual", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.SMS_INVALID_CODE, + }); + } + } else { + // 일반적인 인증 확인 실패 + setError(codeFieldName, { + type: "manual", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.SMS_FAILED, + }); + } +}; + +// 사업자 인증 에러 처리 +export const handleBusinessVerificationError = ( + error: unknown, + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + }) => void, +) => { + if (error && typeof error === "object" && "isAxiosError" in error && error.isAxiosError) { + const axiosError = error as { + config?: { url?: string }; + response?: { status?: number; data?: unknown }; + }; + + console.error("요청 URL:", axiosError.config?.url); + console.error("응답 status:", axiosError.response?.status); + console.error("응답 data:", axiosError.response?.data); + } else { + console.error(error); + } + + showModal({ + title: "⚠️ 인증 오류", + message: SIGNUP_CONSTANTS.MESSAGES.ERROR.BUSINESS_VERIFICATION_FAILED, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => {}, + }); +}; + +// 파일 검증 에러 처리 +export const handleFileValidationError = ( + type: "business" | "birth", + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + }) => void, + router: { push: (path: string) => void }, +) => { + const titles = { + business: "사업자등록증 미첨부", + birth: "개업년월일 미입력", + }; + + const messages = { + business: "사업자등록증을 첨부해주세요.", + birth: "개업년월일을 입력해주세요.", + }; + + showModal({ + title: titles[type], + message: messages[type], + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.CONFIRM, + onConfirm: () => router.push("/"), + }); +}; + +// 회원가입 성공 모달 - 기업회원 +export const showSignupSuccessModal = ( + companyName: string, + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + }) => void, + router: { push: (path: string) => void }, +) => { + showModal({ + title: SIGNUP_CONSTANTS.MESSAGES.SUCCESS.SIGNUP_COMPLETE, + message: `${SIGNUP_CONSTANTS.MESSAGES.INFO.SIGNUP_WELCOME} \n ${companyName}${SIGNUP_CONSTANTS.MESSAGES.INFO.SIGNUP_BUSINESS_SUPPORT}`, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.GO_TO_LOGIN, + onConfirm: () => router.push("/auth/login?tab=company"), + }); +}; + +// 회원가입 성공 모달 - 일반회원 +export const showUserSignupSuccessModal = ( + userName: string, + showModal: (options: { + title: string; + message: string; + confirmText: string; + onConfirm: () => void; + }) => void, + router: { push: (path: string) => void }, +) => { + showModal({ + title: SIGNUP_CONSTANTS.MESSAGES.SUCCESS.SIGNUP_COMPLETE, + message: `${SIGNUP_CONSTANTS.MESSAGES.INFO.SIGNUP_WELCOME} \n ${userName}님의 내일을 응원해요 🤗🎉`, + confirmText: SIGNUP_CONSTANTS.MODAL_BUTTONS.GO_TO_LOGIN, + onConfirm: () => router.push("/auth/login?tab=user"), + }); +}; diff --git a/src/utils/formDataConverters.ts b/src/utils/formDataConverters.ts new file mode 100644 index 00000000..63b6347f --- /dev/null +++ b/src/utils/formDataConverters.ts @@ -0,0 +1,105 @@ +import type { CompanyStepTwoValues } from "@/features/auth-company/ui/signup/CompanySignupStepTwoForm"; +import type { UserStepTwoValues } from "@/features/auth-user/ui/signup/UserSignupStepTwoForm"; +import type { SignupCompleteRequestDto } from "@/types/api/auth"; + +// 기업 회원가입 폼 데이터 변환 +export interface CompanySignupPayload { + common_user_id: string; + company_name: string; + establishment: string; + company_address: string; + business_registration_number: string; + company_introduction: string; + certificate_image: File; + company_logo?: File; + ceo_name: string; + manager_name: string; + manager_phone_number: string; + manager_email: string; +} + +export const convertToCompanyFormData = (payload: CompanySignupPayload): FormData => { + const formData = new FormData(); + + Object.entries(payload).forEach(([key, value]) => { + if (value == null) return; + + if (value instanceof File) { + formData.append(key, value); + } else { + formData.append(key, String(value)); + } + }); + + return formData; +}; + +// 기업 회원가입 데이터 변환 +export const convertCompanySignupData = ( + data: CompanyStepTwoValues, + commonUserId: string, +): CompanySignupPayload => { + // 날짜 변환 + const dateObj = new Date(data.startDate); + const isoDate = dateObj.toISOString(); + + // 파일 검증 + const businessFile = data.businessFile?.[0]; + if (!businessFile) { + throw new Error("사업자등록증이 필요합니다."); + } + + return { + common_user_id: commonUserId, + company_name: data.companyName, + establishment: isoDate, + company_address: `${data.companyAddress} ${data.detailAddress}`, + business_registration_number: data.businessNumber, + company_introduction: data.companyIntro, + certificate_image: businessFile, + company_logo: data.companyLogo?.[0], + ceo_name: data.representativeName, + manager_name: data.managerName, + manager_phone_number: data.managerPhone, + manager_email: data.managerEmail, + }; +}; + +// 일반 회원가입 데이터 변환 +export const convertUserSignupData = ( + data: UserStepTwoValues, + commonUserId: string, +): SignupCompleteRequestDto => { + // 날짜 변환 + const birthDate = new Date(data.birth); + if (isNaN(birthDate.getTime())) { + throw new Error("올바르지 않은 생년월일입니다."); + } + const isoBirth = birthDate.toISOString(); + + return { + common_user_id: commonUserId, + name: data.name, + phone_number: data.phone, + gender: data.gender!, + birthday: isoBirth, + interest: data.interests || [], + purpose_subscription: data.purposes, + route: data.channels, + }; +}; + +// 날짜 유효성 검증 유틸리티 +export const validateDate = (dateString: string): boolean => { + const date = new Date(dateString); + return !isNaN(date.getTime()); +}; + +// 파일 검증 유틸리티 +export const validateRequiredFile = (file: File[] | undefined, fieldName: string): File => { + const uploadedFile = file?.[0]; + if (!uploadedFile) { + throw new Error(`${fieldName}이(가) 필요합니다.`); + } + return uploadedFile; +}; diff --git a/tsconfig.json b/tsconfig.json index fd5735af..4bb3c773 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,10 +1,6 @@ { "compilerOptions": { - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -15,13 +11,8 @@ "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, - "typeRoots": [ - "./node_modules/@types", - "./src/types" - ], - "types": [ - "node" - ], + "typeRoots": ["./node_modules/@types", "./src/types"], + "types": ["node"], "jsx": "preserve", "plugins": [ { @@ -31,22 +22,11 @@ "target": "ES2017", "baseUrl": ".", "paths": { - "@/*": [ - "src/*" - ], - "@constants/*": [ - "constants/*" - ] + "@/*": ["src/*"], + "@constants/*": ["constants/*"] }, "strictNullChecks": true }, - "include": [ - "next-env.d.ts", - ".next/types/**/*.ts", - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] }