From 1b115254d406a9629de30466e66d9016a2628d33 Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Tue, 23 Jun 2026 00:10:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=ED=95=99=EA=B5=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/apis/MyPage/api.ts | 18 +++++++++++++++ apps/web/src/apis/MyPage/index.ts | 4 ++++ .../src/apis/MyPage/postConfirmSchoolEmail.ts | 22 +++++++++++++++++++ apps/web/src/apis/MyPage/postSchoolEmail.ts | 15 +++++++++++++ apps/web/src/apis/queryKeys.ts | 1 + apps/web/src/types/myInfo.ts | 1 + 6 files changed, 61 insertions(+) create mode 100644 apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts create mode 100644 apps/web/src/apis/MyPage/postSchoolEmail.ts diff --git a/apps/web/src/apis/MyPage/api.ts b/apps/web/src/apis/MyPage/api.ts index 8586b26da..b2c9441eb 100644 --- a/apps/web/src/apis/MyPage/api.ts +++ b/apps/web/src/apis/MyPage/api.ts @@ -36,6 +36,14 @@ export interface PasswordPatchRequest { newPasswordConfirmation: string; } +export interface SchoolEmailPostRequest { + schoolEmail: string; +} + +export interface SchoolEmailConfirmPostRequest { + code: string; +} + export const myPageApi = { getProfile: async (): Promise => { const response: AxiosResponse = await axiosInstance.get("/my"); @@ -65,4 +73,14 @@ export const myPageApi = { const res = await axiosInstance.patch(`/my/interested-location`, data); return res.data; }, + + postSchoolEmail: async (data: SchoolEmailPostRequest): Promise => { + const res = await axiosInstance.post("/my/school-email", data); + return res.data; + }, + + postConfirmSchoolEmail: async (data: SchoolEmailConfirmPostRequest): Promise => { + const res = await axiosInstance.post("/my/school-email/confirm", data); + return res.data; + }, }; diff --git a/apps/web/src/apis/MyPage/index.ts b/apps/web/src/apis/MyPage/index.ts index f431945cc..e53760549 100644 --- a/apps/web/src/apis/MyPage/index.ts +++ b/apps/web/src/apis/MyPage/index.ts @@ -6,8 +6,12 @@ export { myPageApi, type PasswordPatchRequest, type ProfilePatchRequest, + type SchoolEmailConfirmPostRequest, + type SchoolEmailPostRequest, } from "./api"; export { default as useGetMyInfo } from "./getProfile"; export { default as usePatchInterestedRegionCountry } from "./patchInterestedRegionCountry"; export { default as usePatchMyPassword } from "./patchPassword"; export { default as usePatchMyInfo } from "./patchProfile"; +export { default as usePostConfirmSchoolEmail } from "./postConfirmSchoolEmail"; +export { default as usePostSchoolEmail } from "./postSchoolEmail"; diff --git a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts new file mode 100644 index 000000000..b8960474e --- /dev/null +++ b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { QueryKeys } from "../queryKeys"; +import { myPageApi, type SchoolEmailConfirmPostRequest } from "./api"; + +const usePostConfirmSchoolEmail = () => { + const queryClient = useQueryClient(); + + return useMutation, SchoolEmailConfirmPostRequest>({ + mutationKey: [QueryKeys.MyPage.schoolEmail, "confirm"], + mutationFn: (data) => myPageApi.postConfirmSchoolEmail(data), + meta: SKIP_GLOBAL_ERROR_TOAST_META, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [QueryKeys.MyPage.profile], + }); + }, + }); +}; + +export default usePostConfirmSchoolEmail; diff --git a/apps/web/src/apis/MyPage/postSchoolEmail.ts b/apps/web/src/apis/MyPage/postSchoolEmail.ts new file mode 100644 index 000000000..c63f419ec --- /dev/null +++ b/apps/web/src/apis/MyPage/postSchoolEmail.ts @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; +import { QueryKeys } from "../queryKeys"; +import { myPageApi, type SchoolEmailPostRequest } from "./api"; + +const usePostSchoolEmail = () => { + return useMutation, SchoolEmailPostRequest>({ + mutationKey: [QueryKeys.MyPage.schoolEmail, "post"], + mutationFn: (data) => myPageApi.postSchoolEmail(data), + meta: SKIP_GLOBAL_ERROR_TOAST_META, + }); +}; + +export default usePostSchoolEmail; diff --git a/apps/web/src/apis/queryKeys.ts b/apps/web/src/apis/queryKeys.ts index 3963ad5a5..626f1d088 100644 --- a/apps/web/src/apis/queryKeys.ts +++ b/apps/web/src/apis/queryKeys.ts @@ -51,6 +51,7 @@ export const QueryKeys = { interestedRegionCountry: "MyPage.interestedRegionCountry" as const, profile: "MyPage.profile" as const, password: "MyPage.password" as const, + schoolEmail: "MyPage.schoolEmail" as const, }, applications: { folder: "applications.folder" as const, diff --git a/apps/web/src/types/myInfo.ts b/apps/web/src/types/myInfo.ts index fc9923118..5da1dbf06 100644 --- a/apps/web/src/types/myInfo.ts +++ b/apps/web/src/types/myInfo.ts @@ -11,6 +11,7 @@ export interface BaseUserInfo { email: string; likedPostCount: number; likedMentorCount: number; + homeUniversityName: string | null; } export interface MyInfo { From 9483f68308c8750497c72353d9a470581b97dc77 Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Tue, 23 Jun 2026 00:31:25 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=ED=95=99=EA=B5=90=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SchoolEmailVerificationContent/index.tsx | 233 ++++++++++++++++++ apps/web/src/app/my/school-email/page.tsx | 22 ++ 2 files changed, 255 insertions(+) create mode 100644 apps/web/src/app/my/school-email/_ui/SchoolEmailVerificationContent/index.tsx create mode 100644 apps/web/src/app/my/school-email/page.tsx diff --git a/apps/web/src/app/my/school-email/_ui/SchoolEmailVerificationContent/index.tsx b/apps/web/src/app/my/school-email/_ui/SchoolEmailVerificationContent/index.tsx new file mode 100644 index 000000000..78890319b --- /dev/null +++ b/apps/web/src/app/my/school-email/_ui/SchoolEmailVerificationContent/index.tsx @@ -0,0 +1,233 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; +import { useGetMyInfo, usePostConfirmSchoolEmail, usePostSchoolEmail } from "@/apis/MyPage"; +import BlockBtn from "@/components/button/BlockBtn"; +import { Input } from "@/components/ui/Inputa"; +import { Label } from "@/components/ui/Label"; +import { Progress } from "@/components/ui/Progress"; +import { IconExpRed } from "@/public/svgs/ui"; + +const VERIFICATION_LIMIT_SECONDS = 300; +const CODE_LENGTH = 6; + +type VerificationStep = "email" | "code" | "done"; + +const formatTime = (seconds: number) => { + const minutes = Math.floor(seconds / 60) + .toString() + .padStart(2, "0"); + const restSeconds = (seconds % 60).toString().padStart(2, "0"); + + return `${minutes}:${restSeconds}`; +}; + +const SchoolEmailVerificationContent = () => { + const router = useRouter(); + const { data: myInfo } = useGetMyInfo(); + const postSchoolEmailMutation = usePostSchoolEmail(); + const postConfirmSchoolEmailMutation = usePostConfirmSchoolEmail(); + + const [step, setStep] = useState("email"); + const [schoolEmail, setSchoolEmail] = useState(""); + const [code, setCode] = useState(""); + const [remainingSeconds, setRemainingSeconds] = useState(VERIFICATION_LIMIT_SECONDS); + const [emailErrorMessage, setEmailErrorMessage] = useState(""); + const [codeErrorMessage, setCodeErrorMessage] = useState(""); + + const isEmailInputValid = useMemo(() => { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(schoolEmail); + }, [schoolEmail]); + const isCodeInputValid = code.length === CODE_LENGTH; + const isTimerExpired = remainingSeconds <= 0; + const progressValue = step === "email" ? 0 : step === "code" ? 50 : 100; + + useEffect(() => { + if (step !== "code" || remainingSeconds <= 0) return; + + const intervalId = window.setInterval(() => { + setRemainingSeconds((prevSeconds) => Math.max(prevSeconds - 1, 0)); + }, 1000); + + return () => window.clearInterval(intervalId); + }, [remainingSeconds, step]); + + useEffect(() => { + if (myInfo?.homeUniversityName) { + setStep("done"); + } + }, [myInfo?.homeUniversityName]); + + const sendVerificationEmail = () => { + if (!isEmailInputValid) { + setEmailErrorMessage("올바르지 않은 이메일 주소입니다."); + return; + } + + postSchoolEmailMutation.mutate( + { schoolEmail }, + { + onSuccess: () => { + setStep("code"); + setCode(""); + setEmailErrorMessage(""); + setCodeErrorMessage(""); + setRemainingSeconds(VERIFICATION_LIMIT_SECONDS); + }, + onError: () => { + setEmailErrorMessage("올바르지 않은 이메일 주소입니다."); + }, + }, + ); + }; + + const confirmVerificationCode = () => { + if (!isCodeInputValid || isTimerExpired) { + setCodeErrorMessage("올바르지 않은 인증번호입니다."); + return; + } + + postConfirmSchoolEmailMutation.mutate( + { code }, + { + onSuccess: () => { + setCodeErrorMessage(""); + setStep("done"); + }, + onError: () => { + setCodeErrorMessage("올바르지 않은 인증번호입니다."); + }, + }, + ); + }; + + const handleCodeChange = (event: React.ChangeEvent) => { + setCode(event.target.value.replace(/\D/g, "").slice(0, CODE_LENGTH)); + setCodeErrorMessage(""); + }; + + const renderErrorMessage = (message: string) => { + return ( + + +

{message}

+
+ ); + }; + + if (step === "done") { + return ( +
+
+
+
+
+

+ 학교 인증이 +
+ 완료되었어요! +

+
+ +
+
+ router.replace("/")}>홈으로 +
+
+
+ ); + } + + return ( +
+ + +
+

+ {step === "email" ? "학교 이메일을 통해" : "이메일로 발송한"} +
+ {step === "email" ? "학교 인증을 진행해주세요" : "인증번호를 입력해주세요"} +

+
+ +
+
+ + { + setSchoolEmail(event.target.value); + setEmailErrorMessage(""); + }} + /> + {emailErrorMessage && renderErrorMessage(emailErrorMessage)} +
+ + {step === "code" && ( +
+ +
+ + + {formatTime(remainingSeconds)} + +
+ {codeErrorMessage && renderErrorMessage(codeErrorMessage)} +
+ 인증번호가 오지 않는다면? + +
+
+ )} +
+ +
+
+ {step === "email" ? ( + + 인증번호 보내기 + + ) : ( + + 인증하기 + + )} +
+
+
+ ); +}; + +export default SchoolEmailVerificationContent; diff --git a/apps/web/src/app/my/school-email/page.tsx b/apps/web/src/app/my/school-email/page.tsx new file mode 100644 index 000000000..170e4a567 --- /dev/null +++ b/apps/web/src/app/my/school-email/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; + +import TopDetailNavigation from "@/components/layout/TopDetailNavigation"; +import { NO_INDEX_ROBOTS } from "@/utils/seo"; + +import SchoolEmailVerificationContent from "./_ui/SchoolEmailVerificationContent"; + +export const metadata: Metadata = { + title: "학교 인증하기", + robots: NO_INDEX_ROBOTS, +}; + +const SchoolEmailVerificationPage = () => { + return ( + <> + + + + ); +}; + +export default SchoolEmailVerificationPage; From 2a1b99bdcd4dc38a297c50743a6494d5a80bfbea Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Tue, 23 Jun 2026 00:39:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=ED=95=99=EA=B5=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts index b8960474e..0fa27d865 100644 --- a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts +++ b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts @@ -1,5 +1,6 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; +import { postReissueToken } from "@/apis/Auth"; import { SKIP_GLOBAL_ERROR_TOAST_META } from "@/lib/react-query/errorToastMeta"; import { QueryKeys } from "../queryKeys"; import { myPageApi, type SchoolEmailConfirmPostRequest } from "./api"; @@ -11,7 +12,8 @@ const usePostConfirmSchoolEmail = () => { mutationKey: [QueryKeys.MyPage.schoolEmail, "confirm"], mutationFn: (data) => myPageApi.postConfirmSchoolEmail(data), meta: SKIP_GLOBAL_ERROR_TOAST_META, - onSuccess: () => { + onSuccess: async () => { + await postReissueToken(); queryClient.invalidateQueries({ queryKey: [QueryKeys.MyPage.profile], }); From e0343a20dce41805630ba8973d00784d4f40d4db Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Tue, 23 Jun 2026 00:40:01 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=95=99=EA=B5=90=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EC=A0=90=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/my/_ui/MyProfileContent/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/my/_ui/MyProfileContent/index.tsx b/apps/web/src/app/my/_ui/MyProfileContent/index.tsx index ee82e5c47..2dd58bd55 100644 --- a/apps/web/src/app/my/_ui/MyProfileContent/index.tsx +++ b/apps/web/src/app/my/_ui/MyProfileContent/index.tsx @@ -28,7 +28,6 @@ const MyProfileContent = () => { const clientRole = useAuthStore((state) => state.clientRole); const { nickname, email, profileImageUrl } = profileData; - const isMentor = profileData.role === UserRole.MENTOR || profileData.role === UserRole.ADMIN; const viewAsMentor = clientRole ? clientRole === UserRole.MENTOR : isMentor; @@ -146,6 +145,8 @@ const MyProfileContent = () => {

계정 관리

+ } text="학교 인증" /> + } text="로그인한 계정" subText={email} /> } text="비밀번호 변경" /> From cbf357a1f2bb224ccaa03bfdbf396570e75a9642 Mon Sep 17 00:00:00 2001 From: yoonc01 Date: Tue, 23 Jun 2026 00:50:48 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=ED=95=99=EA=B5=90=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=86=A0=ED=81=B0=20=EA=B0=B1=EC=8B=A0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts index 0fa27d865..f1138b2cd 100644 --- a/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts +++ b/apps/web/src/apis/MyPage/postConfirmSchoolEmail.ts @@ -13,7 +13,12 @@ const usePostConfirmSchoolEmail = () => { mutationFn: (data) => myPageApi.postConfirmSchoolEmail(data), meta: SKIP_GLOBAL_ERROR_TOAST_META, onSuccess: async () => { - await postReissueToken(); + try { + await postReissueToken(); + } catch { + // 학교 인증은 이미 완료되었으므로 토큰 갱신 실패가 성공 흐름을 막지 않게 합니다. + } + queryClient.invalidateQueries({ queryKey: [QueryKeys.MyPage.profile], });