From b22ca7b8a09a99f6dd9b831272adafcbed162302 Mon Sep 17 00:00:00 2001 From: sasha Date: Tue, 13 May 2025 11:10:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?SelectedChips=20=EB=B0=8F=20JobConditionsFi?= =?UTF-8?q?lter=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=97=90=20postingT?= =?UTF-8?q?ype=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=ED=99=94=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84,=20useSearchJobs=20=ED=9B=85=EC=97=90=EC=84=9C=20post?= =?UTF-8?q?ingType=EC=9D=84=20=EC=82=AC=EC=9A=A9=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=A1=B0=EA=B1=B4=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jobs/components/SelectedChips.tsx | 10 +++++++ .../components/filter/JobConditionsFilter.tsx | 21 ++++++++++++++- .../filter/stores/useFiltersStore.ts | 27 ++++++++++--------- src/features/jobs/hooks/useSearchJobs.ts | 3 ++- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/src/features/jobs/components/SelectedChips.tsx b/src/features/jobs/components/SelectedChips.tsx index ba1e23c..a7da52b 100644 --- a/src/features/jobs/components/SelectedChips.tsx +++ b/src/features/jobs/components/SelectedChips.tsx @@ -7,8 +7,13 @@ import { IoMdRefresh } from "react-icons/io"; export default function SelectedChips() { const { + city, + setCity, + district, + setDistrict, towns, setTowns, + setCat, jobCats, setJobCats, // 고용형태 @@ -24,6 +29,7 @@ export default function SelectedChips() { setSelectedDays, dayNegotiable, setDayNegotiable, + setPostingType, } = useFiltersStore(); // if (selectedFilters.length === 0) return null; @@ -165,13 +171,17 @@ export default function SelectedChips() { + )} + {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 539b599..558ba51 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 8fbcb73..c8d04b3 100644 --- a/src/app/[type]/mypage/[userId]/resume/page.tsx +++ b/src/app/[type]/mypage/[userId]/resume/page.tsx @@ -4,16 +4,24 @@ import Spinner from "@/components/common/Spinner"; import ResumeList from "@/features/mypage/common/components/myResume/ResumeList"; import { useGetResumeList } from "@/features/resume/api/useGetResumeList"; +// 2025.06.08) params 타입 변경 (Promise 타입으로 변경됨) interface ResumeListPageProps { - params: { + params: Promise<{ type: string; userId: string; - }; + }>; } -export default function ResumeListPage({ params }: ResumeListPageProps) { - const { type, userId } = params; +// 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 ; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts index 87a6e17..4226055 100644 --- a/src/app/api/auth/[...nextauth]/route.ts +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -100,6 +100,9 @@ function createCredentialsProvider(id: string, name: string, loginFn: LoginFn) { join_type: { label: "가입 유형", type: "text" }, }, async authorize(credentials) { + if (!credentials) { + throw new Error("인증 정보가 없습니다."); + } return authorizeUserLogin({ credentials, loginFn, @@ -108,7 +111,8 @@ function createCredentialsProvider(id: string, name: string, loginFn: LoginFn) { }); } -export const authOptions: NextAuthOptions = { +// 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), diff --git a/src/app/jobs/(jobs-category)/by-field/page.tsx b/src/app/jobs/(jobs-category)/by-field/page.tsx index 23825b3..ebfd897 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 97a9cf8..895cd2f 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 f319489..24b94c5 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 ecd399e..6adf8cf 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 ba50566..e69e334 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 0000000..5d99888 --- /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 0000000..7da3a2e --- /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 d514190..7f0a792 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 660d7f4..4ef26a5 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 6a330d7..b564584 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 53657e0..7f7ea38 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 ea80536..8268655 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 fb824ac..472cf9b 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/mock/auth.mock.ts b/src/features/auth-common/mock/auth.mock.ts index d9f7e38..1551414 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 b6d595b..1e5625a 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 0000000..b00028e --- /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 c5439b7..8176360 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 3e513da..e3b40be 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 45736d5..81389b1 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 2c258e1..eeed713 100644 --- a/src/features/auth-company/validation/company-auth.schema.ts +++ b/src/features/auth-company/validation/company-auth.schema.ts @@ -160,3 +160,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 0000000..d39f814 --- /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 47a7f4a..bee4679 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 b7a6c75..e65019c 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 325564f..2be61c4 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 17cc2a9..9121df3 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 db52a56..2eef695 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 ee51107..5a0875b 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 3274134..110441a 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 7617178..0cd00ab 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 a7da52b..68784b0 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,10 +8,13 @@ import { IoMdRefresh } from "react-icons/io"; export default function SelectedChips() { const { - city, - setCity, - district, - setDistrict, + // 2025.6.9 수정/안) 멀티 지역 선택 지원 - 배열로 변경 + cities, + setCities, + removeCity, + districts, + setDistricts, + removeDistrict, towns, setTowns, setCat, @@ -38,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/mypage/common/mock/savedJobs.ts b/src/features/mypage/common/mock/savedJobs.ts index 0af1b40..99b389f 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 0bd8fe4..41eea21 100644 --- a/src/features/recruit/components/JobPostForm.tsx +++ b/src/features/recruit/components/JobPostForm.tsx @@ -94,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); @@ -109,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", @@ -127,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, }; @@ -174,7 +166,8 @@ export default function JobPostForm({ - + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + @@ -197,10 +190,7 @@ export default function JobPostForm({ error={Array.isArray(errors.workingDays) ? errors.workingDays : undefined} /> - + diff --git a/src/features/recruit/components/RecruiteList.tsx b/src/features/recruit/components/RecruiteList.tsx index e362db6..217456d 100644 --- a/src/features/recruit/components/RecruiteList.tsx +++ b/src/features/recruit/components/RecruiteList.tsx @@ -72,7 +72,7 @@ export default function RecruiteList() {
    {paginatedJobs.map((job) => ( -
  • +
  • {job.job_posting_title}
    diff --git a/src/features/recruit/components/inputs/JobLocationInput.tsx b/src/features/recruit/components/inputs/JobLocationInput.tsx index 626c97a..83bd969 100644 --- a/src/features/recruit/components/inputs/JobLocationInput.tsx +++ b/src/features/recruit/components/inputs/JobLocationInput.tsx @@ -6,28 +6,19 @@ import { JobPostFormValues } from "@/features/recruit/schemas/jobPostSchema"; import { useEffect } from "react"; import { FieldError, UseFormRegister, UseFormSetValue, UseFormWatch } from "react-hook-form"; -declare global { - interface Window { - daum: { - Postcode: new (options: { - oncomplete: (data: DaumPostcodeData) => void; - width?: string; - height?: string; - }) => { - embed?: (element: HTMLElement) => void; - open?: () => void; - }; +// DaumPostcode 타입이 다른 파일에서 정의되어 있으므로 여기서는 any로 우회 +declare const window: Window & { + daum: { + Postcode: new (options: { + oncomplete: (data: unknown) => void; + width?: string; + height?: string; + }) => { + embed?: (element: HTMLElement) => void; + open?: () => void; }; - } -} - -interface DaumPostcodeData { - address: string; - addressType: string; - bname: string; - buildingName: string; - zonecode: string; -} + }; +}; export function JobLocationInput({ register, @@ -72,8 +63,8 @@ export function JobLocationInput({ document.body.appendChild(elementLayer); new window.daum.Postcode({ - oncomplete: async function (data: DaumPostcodeData) { - const address = data.address; + oncomplete: async function (data: unknown) { + const address = (data as { address: string }).address; setValue("location", address); try { @@ -88,8 +79,14 @@ export function JobLocationInput({ const result = await res.json(); const coords = result.documents?.[0]; if (coords) { - setValue("latitude", parseFloat(coords.y)); - setValue("longitude", parseFloat(coords.x)); + (setValue as (field: string, value: number) => void)( + "latitude", + parseFloat(coords.y), + ); + (setValue as (field: string, value: number) => void)( + "longitude", + parseFloat(coords.x), + ); } } catch (e) { console.error("좌표 변환 실패:", e); @@ -101,33 +98,41 @@ export function JobLocationInput({ height: "100%", }).embed(elementLayer); } else { - new window.daum.Postcode({ - oncomplete: async function (data: DaumPostcodeData) { - const address = data.address; - setValue("location", address); + ( + new window.daum.Postcode({ + oncomplete: async function (data: unknown) { + const address = (data as { address: string }).address; + setValue("location", address); - try { - const res = await fetch( - `https://dapi.kakao.com/v2/local/search/address.json?query=${encodeURIComponent(address)}`, - { - headers: { - Authorization: `KakaoAK ${process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY}`, + try { + const res = await fetch( + `https://dapi.kakao.com/v2/local/search/address.json?query=${encodeURIComponent(address)}`, + { + headers: { + Authorization: `KakaoAK ${process.env.NEXT_PUBLIC_KAKAO_REST_API_KEY}`, + }, }, - }, - ); - const result = await res.json(); - const coords = result.documents?.[0]; - if (coords) { - setValue("latitude", parseFloat(coords.y)); - setValue("longitude", parseFloat(coords.x)); + ); + const result = await res.json(); + const coords = result.documents?.[0]; + if (coords) { + (setValue as (field: string, value: number) => void)( + "latitude", + parseFloat(coords.y), + ); + (setValue as (field: string, value: number) => void)( + "longitude", + parseFloat(coords.x), + ); + } + } catch (e) { + console.error("좌표 변환 실패:", e); } - } catch (e) { - console.error("좌표 변환 실패:", e); - } - }, - width: "100%", - height: "100%", - }).open(); + }, + width: "100%", + height: "100%", + }) as unknown as { open: () => void } + ).open(); } }; diff --git a/src/features/recruit/components/inputs/WorkingDaysCheckbox.tsx b/src/features/recruit/components/inputs/WorkingDaysCheckbox.tsx index 1fca5e0..dc3dad3 100644 --- a/src/features/recruit/components/inputs/WorkingDaysCheckbox.tsx +++ b/src/features/recruit/components/inputs/WorkingDaysCheckbox.tsx @@ -10,7 +10,7 @@ export function WorkingDaysCheckbox({ error?: FieldError | FieldError[] | undefined; }) { return ( - +
    {["월", "화", "수", "목", "금", "토", "일", "요일협의"].map((day) => (