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 = () => {