From b6fcc3e1a4efd35ac8d9e7a8ca1bf246b30b0ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=86=90=EC=A0=95=ED=9B=88?= <129269335+SonJH7@users.noreply.github.com> Date: Fri, 7 Nov 2025 03:01:25 +0900 Subject: [PATCH] feat(email-cert): add school selection before verification --- src/api/schools.ts | 15 +++++ src/pages/EmailCert/EmailCertPage.styled.ts | 32 ++++++++++ src/pages/EmailCert/EmailCertPage.tsx | 62 ++++++++++++++++++- .../certification/EmailCertPage.test.tsx | 20 ++++++ src/tests/routing/routeGuards.test.tsx | 6 ++ 5 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/api/schools.ts diff --git a/src/api/schools.ts b/src/api/schools.ts new file mode 100644 index 0000000..427014c --- /dev/null +++ b/src/api/schools.ts @@ -0,0 +1,15 @@ +import { apiClient } from '@/api/core/axiosInstance'; + +export type SchoolResponse = { + id: number; + name: string; + domain: string; +}; + +export async function selectSchool(schoolId: number) { + const { data } = await apiClient.post( + `/api/v1/members/me/school/${schoolId}`, + ); + + return data; +} diff --git a/src/pages/EmailCert/EmailCertPage.styled.ts b/src/pages/EmailCert/EmailCertPage.styled.ts index 4a6fe71..b57c8de 100644 --- a/src/pages/EmailCert/EmailCertPage.styled.ts +++ b/src/pages/EmailCert/EmailCertPage.styled.ts @@ -70,6 +70,12 @@ export const Input = styled.input` outline: 3px solid rgba(33, 124, 249, 0.35); outline-offset: 2px; } + + &:disabled { + background: ${({ theme }) => theme.gray[100]}; + color: ${({ theme }) => theme.text.placeholder}; + cursor: not-allowed; + } `; export const Hint = styled.p` @@ -108,6 +114,32 @@ export const Secondary = styled(Primary)` color: ${({ theme }) => theme.text.default}; `; +export const SchoolButton = styled.button<{ selected: boolean }>` + flex: 1; + padding: ${({ theme }) => `${theme.spacing2} ${theme.spacing3}`}; + border-radius: 12px; + border: 1px solid + ${({ theme, selected }) => + selected ? theme.blue[700] : theme.border.default}; + background: ${({ theme, selected }) => + selected ? theme.blue[700] : theme.gray[100]}; + color: ${({ theme, selected }) => + selected ? theme.gray[0] : theme.text.sub}; + font-size: ${({ theme }) => theme.body1Bold.fontSize}; + font-weight: ${({ theme }) => theme.body1Bold.fontWeight}; + line-height: ${({ theme }) => theme.body1Bold.lineHeight}; + cursor: pointer; + transition: + background 0.2s ease, + color 0.2s ease, + border 0.2s ease; + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +`; + export const Notice = styled.p` margin: 0; color: ${({ theme }) => theme.text.sub}; diff --git a/src/pages/EmailCert/EmailCertPage.tsx b/src/pages/EmailCert/EmailCertPage.tsx index df305fe..e865c5e 100644 --- a/src/pages/EmailCert/EmailCertPage.tsx +++ b/src/pages/EmailCert/EmailCertPage.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { getCertificationStatus } from '@/api/certification'; +import { selectSchool } from '@/api/schools'; import RouteSkeleton from '@/components/RouteSkeleton'; import OriginTitleBar from '@/components/titleBar/originTitleBar'; import { @@ -31,6 +32,7 @@ import * as S from './EmailCertPage.styled'; const COOLDOWN_SECONDS = 45; const DEFAULT_SCHOOL_DOMAIN = 'pusan.ac.kr'; +const BUSAN_NATIONAL_UNIVERSITY_ID = 101; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; @@ -72,6 +74,8 @@ export default function EmailCertPage() { const [email, setEmail] = useState(''); const [code, setCode] = useState(''); const [cooldown, setCooldown] = useState(0); + const [selectedSchoolId, setSelectedSchoolId] = useState(null); + const [isSelectingSchool, setIsSelectingSchool] = useState(false); const [initialStatusPending, setInitialStatusPending] = useState(true); const parsedEmail = useMemo(() => parseSchoolEmail(email), [email]); @@ -131,10 +135,34 @@ export default function EmailCertPage() { }, [cooldown]); const hasEmailIdentifier = Boolean(parsedEmail.localPart); + const isSchoolSelected = Boolean(selectedSchoolId); const canSend = - hasEmailIdentifier && cooldown === 0 && !sendMutation.isPending; + isSchoolSelected && + hasEmailIdentifier && + cooldown === 0 && + !sendMutation.isPending; const canVerify = - hasEmailIdentifier && isValidCode(code) && !verifyMutation.isPending; + isSchoolSelected && + hasEmailIdentifier && + isValidCode(code) && + !verifyMutation.isPending; + + const handleSelectSchool = useCallback( + async (schoolId: number) => { + if (isSelectingSchool) return; + setIsSelectingSchool(true); + try { + const response = await selectSchool(schoolId); + setSelectedSchoolId(response.id); + notify.success(`${response.name} 선택이 완료되었어요.`); + } catch { + notify.error('학교 선택 중 오류가 발생했어요. 다시 시도해 주세요.'); + } finally { + setIsSelectingSchool(false); + } + }, + [isSelectingSchool], + ); const extractServerError = (error: unknown) => { if (!isAxiosError(error)) return { message: null, code: null }; @@ -169,6 +197,11 @@ export default function EmailCertPage() { }; const handleSend = useCallback(async () => { + if (!isSchoolSelected) { + notify.warning('학교를 먼저 선택해 주세요.'); + return; + } + const { localPart, domain } = parsedEmail; if (!localPart) { @@ -239,6 +272,7 @@ export default function EmailCertPage() { ); } }, [ + isSchoolSelected, navigateToRedirect, parsedEmail, sendMutation, @@ -248,6 +282,11 @@ export default function EmailCertPage() { ]); const handleVerify = useCallback(async () => { + if (!isSchoolSelected) { + notify.warning('학교를 먼저 선택해 주세요.'); + return; + } + const { localPart, domain } = parsedEmail; if (!localPart) { @@ -286,6 +325,7 @@ export default function EmailCertPage() { } }, [ code, + isSchoolSelected, navigateToRedirect, parsedEmail, setEmailCertBypassed, @@ -308,6 +348,23 @@ export default function EmailCertPage() { 주세요. 인증을 완료하면 계속 이용할 수 있습니다. + + 학교 선택 + + { + void handleSelectSchool(BUSAN_NATIONAL_UNIVERSITY_ID); + }} + selected={selectedSchoolId === BUSAN_NATIONAL_UNIVERSITY_ID} + disabled={isSelectingSchool} + > + 부산대학교 + + + 학교 선택 후 이메일 인증을 진행할 수 있습니다. + + 학교 이메일 주소 학교 이메일 주소 또는 아이디를 입력해 주세요. diff --git a/src/tests/certification/EmailCertPage.test.tsx b/src/tests/certification/EmailCertPage.test.tsx index 96a150f..2fdf67c 100644 --- a/src/tests/certification/EmailCertPage.test.tsx +++ b/src/tests/certification/EmailCertPage.test.tsx @@ -84,6 +84,14 @@ afterEach(() => { }); describe('EmailCertPage', () => { + const selectBusanUniversity = async () => { + fireEvent.click(screen.getByRole('button', { name: '부산대학교' })); + + await waitFor(() => { + expect(screen.getByLabelText('학교 이메일 주소')).not.toBeDisabled(); + }); + }; + it('이메일 전송 후 쿨다운 시간을 표시한다', async () => { renderEmailCertPage(); @@ -91,6 +99,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'user@pusan.ac.kr' }, }); @@ -108,6 +118,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'user' }, }); @@ -129,6 +141,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'limit@pusan.ac.kr' }, }); @@ -146,6 +160,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'user@pusan.ac.kr' }, }); @@ -174,6 +190,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.click(screen.getByRole('button', { name: 'email-cert-go-back' })); await waitFor(() => { @@ -214,6 +232,8 @@ describe('EmailCertPage', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + await selectBusanUniversity(); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'user@pusan.ac.kr' }, }); diff --git a/src/tests/routing/routeGuards.test.tsx b/src/tests/routing/routeGuards.test.tsx index ff417c6..fd363b5 100644 --- a/src/tests/routing/routeGuards.test.tsx +++ b/src/tests/routing/routeGuards.test.tsx @@ -139,6 +139,12 @@ describe('Route Guards', () => { expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument(); }); + fireEvent.click(screen.getByRole('button', { name: '부산대학교' })); + + await waitFor(() => { + expect(screen.getByLabelText('학교 이메일 주소')).not.toBeDisabled(); + }); + fireEvent.change(screen.getByLabelText('학교 이메일 주소'), { target: { value: 'user@pusan.ac.kr' }, });