Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/api/schools.ts
Original file line number Diff line number Diff line change
@@ -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<SchoolResponse>(
`/api/v1/members/me/school/${schoolId}`,
);

return data;
}
32 changes: 32 additions & 0 deletions src/pages/EmailCert/EmailCertPage.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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};
Expand Down
62 changes: 60 additions & 2 deletions src/pages/EmailCert/EmailCertPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown> =>
typeof value === 'object' && value !== null;

Expand Down Expand Up @@ -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<number | null>(null);
const [isSelectingSchool, setIsSelectingSchool] = useState(false);
const [initialStatusPending, setInitialStatusPending] = useState(true);

const parsedEmail = useMemo(() => parseSchoolEmail(email), [email]);
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -169,6 +197,11 @@ export default function EmailCertPage() {
};

const handleSend = useCallback(async () => {
if (!isSchoolSelected) {
notify.warning('학교를 먼저 선택해 주세요.');
return;
}

const { localPart, domain } = parsedEmail;

if (!localPart) {
Expand Down Expand Up @@ -239,6 +272,7 @@ export default function EmailCertPage() {
);
}
}, [
isSchoolSelected,
navigateToRedirect,
parsedEmail,
sendMutation,
Expand All @@ -248,6 +282,11 @@ export default function EmailCertPage() {
]);

const handleVerify = useCallback(async () => {
if (!isSchoolSelected) {
notify.warning('학교를 먼저 선택해 주세요.');
return;
}

const { localPart, domain } = parsedEmail;

if (!localPart) {
Expand Down Expand Up @@ -286,6 +325,7 @@ export default function EmailCertPage() {
}
}, [
code,
isSchoolSelected,
navigateToRedirect,
parsedEmail,
setEmailCertBypassed,
Expand All @@ -308,6 +348,23 @@ export default function EmailCertPage() {
주세요. 인증을 완료하면 계속 이용할 수 있습니다.
</S.Description>

<S.Field>
<S.Label as="p">학교 선택</S.Label>
<S.Row>
<S.SchoolButton
type="button"
onClick={() => {
void handleSelectSchool(BUSAN_NATIONAL_UNIVERSITY_ID);
}}
selected={selectedSchoolId === BUSAN_NATIONAL_UNIVERSITY_ID}
disabled={isSelectingSchool}
>
부산대학교
</S.SchoolButton>
</S.Row>
<S.Hint>학교 선택 후 이메일 인증을 진행할 수 있습니다.</S.Hint>
</S.Field>

<S.Field>
<S.Label htmlFor="cert-email">학교 이메일 주소</S.Label>
<S.Input
Expand All @@ -325,6 +382,7 @@ export default function EmailCertPage() {
autoCapitalize="none"
autoCorrect="off"
inputMode="email"
disabled={!isSchoolSelected}
/>
<S.Hint id="email-hint">
학교 이메일 주소 또는 아이디를 입력해 주세요.
Expand Down
20 changes: 20 additions & 0 deletions src/tests/certification/EmailCertPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,23 @@ afterEach(() => {
});

describe('EmailCertPage', () => {
const selectBusanUniversity = async () => {
fireEvent.click(screen.getByRole('button', { name: '부산대학교' }));

await waitFor(() => {
expect(screen.getByLabelText('학교 이메일 주소')).not.toBeDisabled();
});
};

it('이메일 전송 후 쿨다운 시간을 표시한다', async () => {
renderEmailCertPage();

await waitFor(() => {
expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument();
});

await selectBusanUniversity();

fireEvent.change(screen.getByLabelText('학교 이메일 주소'), {
target: { value: 'user@pusan.ac.kr' },
});
Expand All @@ -108,6 +118,8 @@ describe('EmailCertPage', () => {
expect(screen.getByLabelText('email-cert-page')).toBeInTheDocument();
});

await selectBusanUniversity();

fireEvent.change(screen.getByLabelText('학교 이메일 주소'), {
target: { value: 'user' },
});
Expand All @@ -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' },
});
Expand All @@ -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' },
});
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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' },
});
Expand Down
6 changes: 6 additions & 0 deletions src/tests/routing/routeGuards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
Expand Down