diff --git a/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx b/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx index 7b97e940..02a2b536 100644 --- a/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx +++ b/apps/web/src/app/university/application/_components/ApplicationBottomActionBar.tsx @@ -3,12 +3,15 @@ import BlockBtn from "@/components/button/BlockBtn"; type ApplicationBottomActionBarProps = { label: string; onClick: () => void; + disabled?: boolean; }; -const ApplicationBottomActionBar = ({ label, onClick }: ApplicationBottomActionBarProps) => { +const ApplicationBottomActionBar = ({ label, onClick, disabled = false }: ApplicationBottomActionBarProps) => { return (
- {label} + + {label} +
); }; diff --git a/apps/web/src/app/university/application/apply/ApplyPageContent.tsx b/apps/web/src/app/university/application/apply/ApplyPageContent.tsx index 266cbe8a..5b23ce44 100644 --- a/apps/web/src/app/university/application/apply/ApplyPageContent.tsx +++ b/apps/web/src/app/university/application/apply/ApplyPageContent.tsx @@ -1,7 +1,7 @@ "use client"; import { useRouter } from "next/navigation"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { usePostSubmitApplication } from "@/apis/applications"; import { useGetMyGpaScore, useGetMyLanguageTestScore } from "@/apis/Scores"; import { useUniversitySearch } from "@/apis/universities"; @@ -41,7 +41,8 @@ const ApplyPageContent = () => { // TODO: 서버의 모학교 미인증 에러 코드가 확정되면 GPA 조회 실패 시 학교 인증으로 보내는 fallback을 추가한다. const { data: gpaScoreData } = useGetMyGpaScore({ enabled: shouldFetchGpaScore }); const { data: languageTestScoreList = [] } = useGetMyLanguageTestScore(); - const { mutate: postSubmitApplication } = usePostSubmitApplication({ + const isSubmitLockedRef = useRef(false); + const { mutate: postSubmitApplication, isPending: isSubmitApplicationPending } = usePostSubmitApplication({ onSuccess: () => { setStep(99); }, @@ -70,6 +71,10 @@ const ApplyPageContent = () => { }; const handleSubmit = async () => { + if (isSubmitLockedRef.current || isSubmitApplicationPending) { + return; + } + if (curGpaScore === null) { showIconToast("logo", "GPA를 선택해주세요."); return; @@ -89,13 +94,21 @@ const ApplyPageContent = () => { return; } - postSubmitApplication({ - gpaScoreId: curGpaScore, - languageTestScoreId: curLanguageTestScore, - universityChoiceRequest: { - choices: selectedUniversityIds, + isSubmitLockedRef.current = true; + postSubmitApplication( + { + gpaScoreId: curGpaScore, + languageTestScoreId: curLanguageTestScore, + universityChoiceRequest: { + choices: selectedUniversityIds, + }, + }, + { + onSettled: () => { + isSubmitLockedRef.current = false; + }, }, - }); + ); }; const gpaScoreList = gpaScoreData?.gpaScoreStatusResponseList ?? []; @@ -154,6 +167,7 @@ const ApplyPageContent = () => { .filter(Boolean) as ListUniversity[] } onNext={handleSubmit} + isSubmitting={isSubmitApplicationPending} /> )} {step === 99 && } diff --git a/apps/web/src/app/university/application/apply/ConfirmStep.tsx b/apps/web/src/app/university/application/apply/ConfirmStep.tsx index cd8fa7a6..94386890 100644 --- a/apps/web/src/app/university/application/apply/ConfirmStep.tsx +++ b/apps/web/src/app/university/application/apply/ConfirmStep.tsx @@ -7,6 +7,7 @@ import ApplicationBottomActionBar from "../_components/ApplicationBottomActionBa type ConfirmStepProps = { universityList: ListUniversity[]; onNext: () => void; + isSubmitting?: boolean; }; type ConfirmUniversityCardProps = { @@ -38,7 +39,7 @@ const ConfirmUniversityCard = ({ universityList }: ConfirmUniversityCardProps) = ); }; -const ConfirmStep = ({ universityList, onNext }: ConfirmStepProps) => { +const ConfirmStep = ({ universityList, onNext, isSubmitting = false }: ConfirmStepProps) => { return (
@@ -55,7 +56,7 @@ const ConfirmStep = ({ universityList, onNext }: ConfirmStepProps) => {
- +
); }; diff --git a/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx b/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx index 59b21999..a641d549 100644 --- a/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx +++ b/apps/web/src/app/university/score/submit/gpa/GpaSubmitForm.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useRef, useState } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { usePostGpaScore } from "@/apis/Scores"; import { getSchoolEmailVerificationPath } from "@/app/my/school-email/_lib/returnTo"; @@ -22,7 +22,8 @@ const GpaSubmitForm = () => { const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const [showResult, setShowResult] = useState(false); const [submittedData, setSubmittedData] = useState(null); - const { mutateAsync: postGpaScore } = usePostGpaScore(); + const isSubmitLockedRef = useRef(false); + const { mutateAsync: postGpaScore, isPending: isPostGpaScorePending } = usePostGpaScore(); // 2. react-hook-form 설정 const { @@ -31,12 +32,13 @@ const GpaSubmitForm = () => { control, watch, reset, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, } = useForm({ resolver: zodResolver(gpaSchema), mode: "onChange", }); const selectedFile = watch("file"); + const isSubmitDisabled = !isValid || isSubmitting || isPostGpaScorePending; useEffect(() => { if (!isAuthInitialized || !isAuthenticated || homeUniversityId !== null) { @@ -52,18 +54,27 @@ const GpaSubmitForm = () => { // 3. 폼 제출 핸들러 const onSubmit: SubmitHandler = async (data) => { - await postGpaScore({ - gpaScoreRequest: { - gpa: Number(data.gpa), - gpaCriteria: Number(data.gpaCriteria), - issueDate: "2025-01-01", // TODO: 실제 날짜 데이터로 변경 - }, - file: data.file[0], - }); - // 성공 시, 결과 화면에 보여줄 데이터를 저장하고 상태 변경 - setSubmittedData(data); - setShowResult(true); - reset(); + if (isSubmitLockedRef.current) return; + + isSubmitLockedRef.current = true; + try { + await postGpaScore({ + gpaScoreRequest: { + gpa: Number(data.gpa), + gpaCriteria: Number(data.gpaCriteria), + issueDate: "2025-01-01", // TODO: 실제 날짜 데이터로 변경 + }, + file: data.file[0], + }); + // 성공 시, 결과 화면에 보여줄 데이터를 저장하고 상태 변경 + setSubmittedData(data); + setShowResult(true); + reset(); + } catch (_error) { + // 실패 토스트는 React Query 전역 onError에서 단일 처리 + } finally { + isSubmitLockedRef.current = false; + } }; if (showResult && submittedData) { @@ -175,9 +186,9 @@ const GpaSubmitForm = () => { diff --git a/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx b/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx index 18e3072c..219ec305 100644 --- a/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx +++ b/apps/web/src/app/university/score/submit/language-test/LanguageTestSubmitForm.tsx @@ -4,7 +4,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import clsx from "clsx"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { Suspense, useState } from "react"; +import { Suspense, useRef, useState } from "react"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { usePostLanguageTestScore } from "@/apis/Scores"; import SubmitLinkTab from "@/components/score/SubmitLinkTab"; @@ -17,7 +17,8 @@ import { type LanguageTestFormData, languageTestSchema } from "./_lib/schema"; const LanguageTestSubmitForm = () => { const router = useRouter(); const [showResult, setShowResult] = useState(false); - const { mutateAsync: postLanguageTestScore } = usePostLanguageTestScore(); + const isSubmitLockedRef = useRef(false); + const { mutateAsync: postLanguageTestScore, isPending: isPostLanguageTestScorePending } = usePostLanguageTestScore(); const [submittedData, setSubmittedData] = useState(null); // 3. react-hook-form 설정 @@ -27,16 +28,20 @@ const LanguageTestSubmitForm = () => { control, watch, reset, - formState: { errors, isValid }, + formState: { errors, isSubmitting, isValid }, } = useForm({ resolver: zodResolver(languageTestSchema), mode: "onChange", // 입력값이 변경될 때마다 유효성 검사 }); const selectedFile = watch("file"); + const isSubmitDisabled = !isValid || isSubmitting || isPostLanguageTestScorePending; // 4. 폼 제출 핸들러 const onSubmit: SubmitHandler = async (data) => { + if (isSubmitLockedRef.current) return; + + isSubmitLockedRef.current = true; try { await postLanguageTestScore({ languageTestScoreRequest: { @@ -53,6 +58,8 @@ const LanguageTestSubmitForm = () => { setSubmittedData(data); } catch (_error) { // 실패 토스트는 React Query 전역 onError에서 단일 처리 + } finally { + isSubmitLockedRef.current = false; } }; @@ -162,9 +169,9 @@ const LanguageTestSubmitForm = () => {