diff --git a/apps/web/src/apis/apply/api.ts b/apps/web/src/apis/apply/api.ts index 9ba6fb95c..4b91bd39e 100644 --- a/apps/web/src/apis/apply/api.ts +++ b/apps/web/src/apis/apply/api.ts @@ -3,12 +3,12 @@ import axios from "axios"; import { applicationStatusResponseSchema, - memberProfileResponseSchema, + memberMeResponseSchema, memberProfileInitialStatusResponseSchema, questionResponseSchema, answersResponseSchema, type ApplicationStatusResponseSchema, - type MemberProfileResponseSchema, + type MemberMeResponseSchema, type MemberProfileInitialStatusResponseSchema, type QuestionResponseSchema, type AnswersResponseSchema, @@ -39,18 +39,16 @@ export const applyApi = { memberProfileInitialStatusResponseSchema, ), - getStatus: (email: string) => { - const params = new URLSearchParams({ email }); - return httpClient.get( - `${API_ENDPOINT.applyStatus}?${params.toString()}`, + getStatus: () => + httpClient.get( + API_ENDPOINT.applyStatus, applicationStatusResponseSchema, - ); - }, + ), - getProfile: () => - httpClient.get( - API_ENDPOINT.memberProfile, - memberProfileResponseSchema, + getMe: () => + httpClient.get( + API_ENDPOINT.memberMe, + memberMeResponseSchema, ), updateProfile: (data: MemberProfilePayload) => @@ -66,10 +64,8 @@ export const applyApi = { getDraft: () => httpClient.get(API_ENDPOINT.draft, answersResponseSchema), - saveDraft: (jobFamily: JobFamily, answers: AnswersPayload) => { - const params = new URLSearchParams({ jobFamily }); - return httpClient.post(`${API_ENDPOINT.draft}?${params.toString()}`, answers); - }, + saveDraft: (answers: AnswersPayload) => + httpClient.post(API_ENDPOINT.draft, answers), deleteDraft: () => httpClient.delete(API_ENDPOINT.draft), diff --git a/apps/web/src/apis/apply/index.ts b/apps/web/src/apis/apply/index.ts index a99a7c838..091da7b4d 100644 --- a/apps/web/src/apis/apply/index.ts +++ b/apps/web/src/apis/apply/index.ts @@ -10,13 +10,13 @@ export { export { // 스키마 applicationStatusResponseSchema, - memberProfileResponseSchema, + memberMeResponseSchema, memberProfileInitialStatusResponseSchema, questionResponseSchema, answersResponseSchema, // 스키마 타입 type ApplicationStatusResponseSchema, - type MemberProfileResponseSchema, + type MemberMeResponseSchema, type MemberProfileInitialStatusResponseSchema, type QuestionResponseSchema, type AnswersResponseSchema, diff --git a/apps/web/src/apis/apply/queryKeys.ts b/apps/web/src/apis/apply/queryKeys.ts index ee3de97e0..007c00652 100644 --- a/apps/web/src/apis/apply/queryKeys.ts +++ b/apps/web/src/apis/apply/queryKeys.ts @@ -7,7 +7,7 @@ export const applyQueryKeys = { all: ["apply"] as const, status: { all: () => [...applyQueryKeys.all, "status"] as const, - byEmail: (email: string) => [...applyQueryKeys.status.all(), "email", email] as const, + current: () => [...applyQueryKeys.status.all(), "current"] as const, }, questions: { all: () => [...applyQueryKeys.all, "questions"] as const, @@ -49,15 +49,15 @@ export const applyMutationKeys = { export const applyQueries = { status: { - byEmail: (email: string) => ({ - queryKey: applyQueryKeys.status.byEmail(email), - queryFn: () => applyApi.getStatus(email), + current: () => ({ + queryKey: applyQueryKeys.status.current(), + queryFn: () => applyApi.getStatus(), }), }, profile: { me: () => ({ queryKey: applyQueryKeys.profile.me(), - queryFn: applyApi.getProfile, + queryFn: applyApi.getMe, }), initialStatus: () => ({ queryKey: applyQueryKeys.profile.initialStatus(), diff --git a/apps/web/src/apis/apply/schemas.ts b/apps/web/src/apis/apply/schemas.ts index 79cd04087..0f1296ec1 100644 --- a/apps/web/src/apis/apply/schemas.ts +++ b/apps/web/src/apis/apply/schemas.ts @@ -65,16 +65,16 @@ export const interestedDomainSchema = z.enum([ "HR", ]); -export const memberProfileResponseSchema = z.object({ +export const memberMeResponseSchema = z.object({ + id: z.number(), name: z.string(), - phoneNumber: z.string(), careerDetails: careerDetailsSchema, region: regionSchema, experiencePeriod: experiencePeriodSchema, interestedDomains: z.array(interestedDomainSchema), }); -export type MemberProfileResponseSchema = z.infer; +export type MemberMeResponseSchema = z.infer; export const questionInputTypeSchema = z.enum(["TEXT", "URL", "FILE", "SELECT"]); diff --git a/apps/web/src/constants/apiEndpoint.ts b/apps/web/src/constants/apiEndpoint.ts index 0d6a97792..b6104cee1 100644 --- a/apps/web/src/constants/apiEndpoint.ts +++ b/apps/web/src/constants/apiEndpoint.ts @@ -5,7 +5,7 @@ export const API_ENDPOINT = { verifyEmailCode: "/auth/code", checkEmailExists: "/auth/login/exist", applyMember: "/members/apply", - memberProfile: "/members/profile", + memberMe: "/members/me", memberProfileInitialStatus: "/members/profile/initial/status", pinLogin: "/auth/login/pin", registerMember: "/members/apply", diff --git a/apps/web/src/constants/applyMessages.tsx b/apps/web/src/constants/applyMessages.tsx index d83a59fc6..32072aa73 100644 --- a/apps/web/src/constants/applyMessages.tsx +++ b/apps/web/src/constants/applyMessages.tsx @@ -67,4 +67,16 @@ export const APPLY_DIALOG = { ), primaryAction: "확인", }, + jobFamilyMismatch: (savedJobFamilyKorean: string, currentJobFamilyKorean: string) => ({ + header: `${savedJobFamilyKorean} 지원서가 임시저장되어 있어요`, + body: ( + <> + {currentJobFamilyKorean}로 새로 지원하시면 +
+ 기존 {savedJobFamilyKorean} 지원서는 사라져요. + + ), + primaryAction: `지원서 이어서 작성하기`, + secondaryAction: `새로 지원하기`, + }), }; diff --git a/apps/web/src/features/apply/steps/IdentityVerificationStep.tsx b/apps/web/src/features/apply/steps/IdentityVerificationStep.tsx index 4ff65c67e..42bc33ae1 100644 --- a/apps/web/src/features/apply/steps/IdentityVerificationStep.tsx +++ b/apps/web/src/features/apply/steps/IdentityVerificationStep.tsx @@ -3,11 +3,17 @@ import { useEffect, useState } from "react"; import { Controller } from "react-hook-form"; import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; +import { applyApi, type JobFamily } from "@/apis/apply"; import { APPLY_DIALOG, APPLY_MESSAGE } from "@/constants/applyMessages.tsx"; -import { APPLY_TITLE } from "@/constants/applyPageData"; +import { findJobFamilyOption, APPLY_TITLE } from "@/constants/applyPageData"; import { PATH } from "@/constants/path"; import { ApplyStepLayout } from "@/features/shared/components"; -import { useCheckApplyStatusMutation, usePinLoginMutation } from "@/hooks/apply"; +import { + useCheckApplyStatusMutation, + useDeleteDraftMutation, + useMemberProfileMutation, + usePinLoginMutation, +} from "@/hooks/apply"; import { useApplyEmailForm } from "@/hooks/useApplyEmailForm"; import { useApplyPinForm } from "@/hooks/useApplyPinForm"; import type { ContinueWritingFunnelSteps } from "@/types/funnel"; @@ -33,14 +39,21 @@ interface IdentityVerificationStepProps { ) => void; } -export function IdentityVerificationStep({ - context, - dispatch, -}: IdentityVerificationStepProps) { +interface JobFamilyMismatchDialog { + isOpen: boolean; + savedJobFamily: JobFamily | null; +} + +export function IdentityVerificationStep({ context, dispatch }: IdentityVerificationStepProps) { const location = useLocation(); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [isSubmittedDialogOpen, setIsSubmittedDialogOpen] = useState(false); + const [mismatchDialog, setMismatchDialog] = useState({ + isOpen: false, + savedJobFamily: null, + }); + const [verifiedEmail, setVerifiedEmail] = useState(""); //PIN 재설정 후 돌아왔을 때 파라미터 const isPinResetSuccess = searchParams.get("pinReset") === "success"; @@ -72,20 +85,28 @@ export function IdentityVerificationStep({ toastController.basic("PIN 재설정 완료", "새로운 PIN을 입력해 본인 확인을 다시 진행해주세요."); // 3. URL 파라미터 정리 - setSearchParams(prev => { - prev.delete("pinReset"); - prev.delete("email"); - return prev; - }, { replace: true }); + setSearchParams( + prev => { + prev.delete("pinReset"); + prev.delete("email"); + return prev; + }, + { replace: true }, + ); }, [isPinResetSuccess, prefillEmail, setEmailValue, setSearchParams]); const email = watchEmail("email"); const isFormValid = emailFormState.isValid && pinFormState.isValid; - const { mutate: checkApplyStatusMutate, isPending: isCheckingStatus } = useCheckApplyStatusMutation(); + const [isChangingJobFamily, setIsChangingJobFamily] = useState(false); + + const { mutate: checkApplyStatusMutate, isPending: isCheckingStatus } = + useCheckApplyStatusMutation(); + const { mutateAsync: deleteDraftAsync } = useDeleteDraftMutation(); + const { mutateAsync: updateProfileAsync } = useMemberProfileMutation(); const handleCheckApplyStatus = (userEmail: string) => { - checkApplyStatusMutate(userEmail, { + checkApplyStatusMutate(undefined, { onSuccess: data => { if (data.result === "PROFILE_NOT_REGISTERED") { dispatch("goToProfile", userEmail); @@ -97,9 +118,30 @@ export function IdentityVerificationStep({ return; } - // CONTINUE (TEMP_SAVED 또는 JOINED) - toastController.positive(APPLY_MESSAGE.success.continueWriting); - dispatch("goToApply", userEmail); + // CONTINUE (TEMP_SAVED 또는 JOINED) → draft 확인 + void applyApi + .getDraft() + .then(draft => { + // 파트 불일치 체크: draft에 저장된 jobFamily와 현재 접근한 jobFamily가 다른 경우 + if (draft.jobFamily != null && draft.jobFamily !== context.jobFamily) { + setVerifiedEmail(userEmail); + setMismatchDialog({ + isOpen: true, + savedJobFamily: draft.jobFamily, + }); + return; + } + + // 같은 파트 또는 draft 없음 → 이어서 작성 + toastController.positive(APPLY_MESSAGE.success.continueWriting); + dispatch("goToApply", userEmail); + }) + .catch((error: unknown) => { + // draft 조회 실패 시에도 이어서 작성 가능 (빈 폼으로 시작) + handleError(error, "임시저장 데이터 조회 실패"); + toastController.positive(APPLY_MESSAGE.success.continueWriting); + dispatch("goToApply", userEmail); + }); }, onError: error => { handleError(error, "지원 상태 확인 실패"); @@ -132,6 +174,56 @@ export function IdentityVerificationStep({ void navigate(`${PATH.resetPin}?returnTo=${encodeURIComponent(returnTo)}`); }; + // 파트 불일치 다이얼로그: "기존 파트 지원서 이어서 작성하기" 선택 + const handleContinueSavedDraft = () => { + if (mismatchDialog.savedJobFamily) { + void navigate(`${PATH.applyContinue}/${mismatchDialog.savedJobFamily}`); + } + setMismatchDialog({ isOpen: false, savedJobFamily: null }); + }; + + // 파트 불일치 다이얼로그: "새로운 파트로 지원하기" 선택 + const handleStartNewApplication = async () => { + setIsChangingJobFamily(true); + setMismatchDialog({ isOpen: false, savedJobFamily: null }); + + try { + // 1. 현재 프로필 백업 + const profile = await applyApi.getMe(); + + // 2. 기존 draft + 프로필 삭제 + await deleteDraftAsync(); + + // 3. 프로필 복원 (새로운 jobFamily로) + await updateProfileAsync({ + name: profile.name, + phoneNumber: "01012345678", // TODO: getMe 응답에 phoneNumber가 없어서 임시 처리 + careerDetails: profile.careerDetails, + region: profile.region, + experiencePeriod: profile.experiencePeriod, + interestedDomains: profile.interestedDomains, + jobFamily: context.jobFamily, + }); + + // 4. 지원서 작성으로 이동 + toastController.positive(APPLY_MESSAGE.success.continueWriting); + dispatch("goToApply", verifiedEmail); + } catch (error) { + handleError(error, "파트 변경 실패"); + toastController.destructive("파트 변경에 실패했습니다. 다시 시도해주세요."); + } finally { + setIsChangingJobFamily(false); + } + }; + + // 다이얼로그 메시지 생성 + const mismatchDialogContent = mismatchDialog.savedJobFamily + ? APPLY_DIALOG.jobFamilyMismatch( + findJobFamilyOption(mismatchDialog.savedJobFamily).korean, + findJobFamilyOption(context.jobFamily).korean, + ) + : null; + return ( setIsSubmittedDialogOpen(false), }} /> + + {mismatchDialogContent && ( + + !open && + !isChangingJobFamily && + setMismatchDialog({ isOpen: false, savedJobFamily: null }) + } + header={mismatchDialogContent.header} + body={mismatchDialogContent.body} + primaryAction={{ + children: mismatchDialogContent.primaryAction, + onClick: handleContinueSavedDraft, + disabled: isChangingJobFamily, + }} + secondaryAction={{ + children: isChangingJobFamily ? "변경 중..." : mismatchDialogContent.secondaryAction, + onClick: () => void handleStartNewApplication(), + disabled: isChangingJobFamily, + }} + /> + )} ); } diff --git a/apps/web/src/features/apply/steps/registration/RegistrationStep.tsx b/apps/web/src/features/apply/steps/registration/RegistrationStep.tsx index 76929c6e6..66aeaa14d 100644 --- a/apps/web/src/features/apply/steps/registration/RegistrationStep.tsx +++ b/apps/web/src/features/apply/steps/registration/RegistrationStep.tsx @@ -79,11 +79,8 @@ export function RegistrationStep({ context, onNext, onBack }: RegistrationStepPr //지원서 임시 저장 const handleSaveDraft = useCallback(() => { - saveDraftMutate({ - jobFamily, - answers: { answers, portfolios: formattedPortfolios }, - }); - }, [saveDraftMutate, answers, formattedPortfolios, jobFamily]); + saveDraftMutate({ answers, portfolios: formattedPortfolios }); + }, [saveDraftMutate, answers, formattedPortfolios]); //지원서 제출 const handleSubmit = useCallback(() => { diff --git a/apps/web/src/features/apply/steps/registration/useRegistrationFormWithDraft.ts b/apps/web/src/features/apply/steps/registration/useRegistrationFormWithDraft.ts index 6783fcaa9..8c55ad56c 100644 --- a/apps/web/src/features/apply/steps/registration/useRegistrationFormWithDraft.ts +++ b/apps/web/src/features/apply/steps/registration/useRegistrationFormWithDraft.ts @@ -1,14 +1,10 @@ import type { Dispatch, SetStateAction } from "react"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { formatDraftPortfolios } from "./utils"; import type { JobFamily, Question } from "@/apis/apply"; -import { - useDeleteDraftMutation, - useDraftSuspenseQuery, - useQuestionsSuspenseQuery, -} from "@/hooks/apply"; +import { useDraftSuspenseQuery, useQuestionsSuspenseQuery } from "@/hooks/apply"; import type { AnswersByQuestionId, PortfolioFile } from "@/types/apis/application"; interface UseRegistrationFormWithDraftReturn { @@ -42,11 +38,11 @@ export function useRegistrationFormWithDraft( ): UseRegistrationFormWithDraftReturn { const { data: questionsData } = useQuestionsSuspenseQuery(jobFamily); const { data: draftData } = useDraftSuspenseQuery(); - const { mutate: deleteDraft } = useDeleteDraftMutation(); const questions: Question[] = questionsData.questionResponses; - // draft의 jobFamily와 현재 선택한 jobFamily가 다르면 draft 무시 + // draft의 jobFamily와 현재 선택한 jobFamily가 다르면 클라이언트에서만 초기화 + // (서버 draft는 유지 - DELETE /apply/temp는 프로필까지 삭제하므로 호출하지 않음) const isJobFamilyMismatch = draftData?.jobFamily != null && draftData.jobFamily !== jobFamily; @@ -63,13 +59,6 @@ export function useRegistrationFormWithDraft( return formatDraftPortfolios(draftData.portfolios); }); - // jobFamily 불일치 시 서버의 draft 삭제 - useEffect(() => { - if (isJobFamilyMismatch) { - deleteDraft(); - } - }, [isJobFamilyMismatch, deleteDraft]); - return { questions, portfolios, diff --git a/apps/web/src/hooks/apply/useCheckApplyStatusMutation.ts b/apps/web/src/hooks/apply/useCheckApplyStatusMutation.ts index c31a5bec0..212b8659e 100644 --- a/apps/web/src/hooks/apply/useCheckApplyStatusMutation.ts +++ b/apps/web/src/hooks/apply/useCheckApplyStatusMutation.ts @@ -9,7 +9,7 @@ type CheckApplyStatusResult = export function useCheckApplyStatusMutation() { return useMutation({ - mutationFn: async (email: string): Promise => { + mutationFn: async (): Promise => { // 1. 프로필 등록 여부 확인 const isProfileRegistered = await applyApi.getProfileInitialStatus(); @@ -18,7 +18,7 @@ export function useCheckApplyStatusMutation() { } // 2. 프로필 등록된 경우에만 지원 상태 확인 - const { status } = await applyApi.getStatus(email); + const { status } = await applyApi.getStatus(); if (status === "SUBMITTED") { return { result: "SUBMITTED" }; diff --git a/apps/web/src/hooks/apply/useSaveDraftMutation.ts b/apps/web/src/hooks/apply/useSaveDraftMutation.ts index a42a09f37..b7c6cb3fe 100644 --- a/apps/web/src/hooks/apply/useSaveDraftMutation.ts +++ b/apps/web/src/hooks/apply/useSaveDraftMutation.ts @@ -2,16 +2,11 @@ import { toastController } from "@ject/jds"; import { captureException } from "@sentry/react"; import { useMutation, useQueryClient, type UseMutationOptions } from "@tanstack/react-query"; -import { applyApi, applyMutationKeys, applyQueryKeys, type JobFamily } from "@/apis/apply"; +import { applyApi, applyMutationKeys, applyQueryKeys } from "@/apis/apply"; import type { AnswersPayload } from "@/types/apis/application"; -interface SaveDraftVariables { - jobFamily: JobFamily; - answers: AnswersPayload; -} - type UseSaveDraftMutationOptions = Omit< - UseMutationOptions, + UseMutationOptions, "mutationKey" | "mutationFn" >; @@ -21,7 +16,7 @@ export function useSaveDraftMutation(options?: UseSaveDraftMutationOptions) { return useMutation({ mutationKey: applyMutationKeys.draft.save, - mutationFn: ({ jobFamily, answers }: SaveDraftVariables) => applyApi.saveDraft(jobFamily, answers), + mutationFn: (answers: AnswersPayload) => applyApi.saveDraft(answers), retry: 1, ...restOptions, onSuccess: (data, variables, onMutateResult, mutationContext) => { diff --git a/packages/jds/src/components/Input/TagField/tagField.styles.ts b/packages/jds/src/components/Input/TagField/tagField.styles.ts index 8363a886a..cd4a62c22 100644 --- a/packages/jds/src/components/Input/TagField/tagField.styles.ts +++ b/packages/jds/src/components/Input/TagField/tagField.styles.ts @@ -292,6 +292,12 @@ export const StyledTagInput = styled("input", { "&::placeholder": { color: theme.color.semantic.object.assistive, }, + + "&:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus, &:-webkit-autofill:active": { + WebkitTextFillColor: textColor, + WebkitBoxShadow: `0 0 0 1000px ${theme.color.semantic.surface.standard} inset`, + transition: "background-color 5000s ease-in-out 0s", + }, }; }); diff --git a/packages/jds/src/components/Input/TextField/textField.styles.ts b/packages/jds/src/components/Input/TextField/textField.styles.ts index b167640ce..76e82906b 100644 --- a/packages/jds/src/components/Input/TextField/textField.styles.ts +++ b/packages/jds/src/components/Input/TextField/textField.styles.ts @@ -265,6 +265,12 @@ export const StyledInput = styled("input", { "&::placeholder": { color: theme.color.semantic.object.assistive, }, + + "&:-webkit-autofill, &:-webkit-autofill:hover, &:-webkit-autofill:focus, &:-webkit-autofill:active": { + WebkitTextFillColor: textColor, + WebkitBoxShadow: `0 0 0 1000px ${theme.color.semantic.surface.standard} inset`, + transition: "background-color 5000s ease-in-out 0s", + }, }; });