diff --git a/src/App.tsx b/src/App.tsx index 1b4f138f..77428f5e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'; import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { AxiosError } from 'axios'; -import { lazy, Suspense, useEffect, useRef, useState } from 'react'; +import { lazy, Suspense, useEffect, useState } from 'react'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; import Layout from '@components/Layout'; @@ -14,6 +14,7 @@ import { dark, light } from 'styles/theme.css'; import BigLoading from 'views/loadings/BigLoding'; import 'styles/reset.css'; +import useDialog from '@hooks/useDialog'; const SessionExpiredDialog = lazy(() => import('views/dialogs').then(({ SessionExpiredDialog }) => ({ default: SessionExpiredDialog })), @@ -55,7 +56,7 @@ const App = () => { // } // }, []); - const sessionRef = useRef(null); + const { ref: sessionExpiredDialogRef, handleShowDialog: handleShowSessionExpiredDialog } = useDialog(); const [isAmplitudeInitialized, setIsAmplitudeInitialized] = useState(false); const { isLight } = useTheme(); @@ -74,7 +75,7 @@ const App = () => { const axiosError = error as AxiosError; if (axiosError.response?.status === 401) { - sessionRef.current?.showModal(); + handleShowSessionExpiredDialog(); } else if (axiosError.response?.status === 500) { window.location.href = '/error'; } @@ -85,7 +86,7 @@ const App = () => { const axiosError = error as AxiosError; if (axiosError.response?.status === 401) { - sessionRef.current?.showModal(); + handleShowSessionExpiredDialog(); } else if (axiosError.response?.status === 500) { window.location.href = '/error'; } @@ -110,7 +111,7 @@ const App = () => { return ( <> - + diff --git a/src/common/hooks/useDialog.tsx b/src/common/hooks/useDialog.tsx new file mode 100644 index 00000000..b5096f28 --- /dev/null +++ b/src/common/hooks/useDialog.tsx @@ -0,0 +1,17 @@ +import { useRef } from 'react'; + +const useDialog = () => { + const ref = useRef(null); + + const handleShowDialog = () => { + ref.current?.showModal(); + }; + + const handleCloseDialog = () => { + ref.current?.close(); + }; + + return { ref, handleShowDialog, handleCloseDialog }; +}; + +export default useDialog; diff --git a/src/common/hooks/useEventListener.tsx b/src/common/hooks/useEventListener.tsx new file mode 100644 index 00000000..785be77d --- /dev/null +++ b/src/common/hooks/useEventListener.tsx @@ -0,0 +1,14 @@ +import { useEffect } from 'react'; + +type EventType = keyof WindowEventMap; +type EventListener = (event: Event) => void; + +const useEventListener = (eventName: EventType, callbackFn: EventListener) => { + useEffect(() => { + window.addEventListener(eventName, callbackFn); + + return () => window.removeEventListener(eventName, callbackFn); + }, [eventName, callbackFn]); +}; + +export default useEventListener; diff --git a/src/views/ApplyPage/components/ApplyCategory/index.tsx b/src/views/ApplyPage/components/ApplyCategory/index.tsx index d6cd2500..22857c72 100644 --- a/src/views/ApplyPage/components/ApplyCategory/index.tsx +++ b/src/views/ApplyPage/components/ApplyCategory/index.tsx @@ -8,10 +8,10 @@ import { CATEGORY } from './constant'; import { activeLinkStyleVar, categoryLinkStyleVar, categoryList, container, containerVar } from './style.css'; interface ApplyCategoryProps { - isReview: boolean; + isReview?: boolean; minIndex: number; } -const ApplyCategory = memo(({ isReview, minIndex }: ApplyCategoryProps) => { +const ApplyCategory = memo(({ minIndex, isReview = false }: ApplyCategoryProps) => { const { deviceType } = useDeviceType(); const { isScrollingDown, isScrollTop } = useScrollPosition(isReview ? 380 : 950); diff --git a/src/views/ApplyPage/components/ApplyHeader/index.tsx b/src/views/ApplyPage/components/ApplyHeader/index.tsx index 86ef1568..44b370bd 100644 --- a/src/views/ApplyPage/components/ApplyHeader/index.tsx +++ b/src/views/ApplyPage/components/ApplyHeader/index.tsx @@ -6,13 +6,13 @@ import { useRecruitingInfo } from 'contexts/RecruitingInfoProvider'; import { buttonWrapper, headerContainerVar } from './style.css'; interface ApplyHeaderProps { - isReview: boolean; + isReview?: boolean; isLoading?: boolean; onSaveDraft?: () => void; onSubmitData?: () => void; } -const ApplyHeader = ({ isReview, isLoading, onSaveDraft, onSubmitData }: ApplyHeaderProps) => { +const ApplyHeader = ({ isLoading, onSaveDraft, onSubmitData, isReview = false }: ApplyHeaderProps) => { const { deviceType } = useDeviceType(); const { recruitingInfo: { soptName, season, group, isMakers }, diff --git a/src/views/ApplyPage/components/ApplyInfo/index.tsx b/src/views/ApplyPage/components/ApplyInfo/index.tsx index e6e0851f..7bbec85a 100644 --- a/src/views/ApplyPage/components/ApplyInfo/index.tsx +++ b/src/views/ApplyPage/components/ApplyInfo/index.tsx @@ -19,7 +19,7 @@ import { } from './style.css'; import { APPLY_INFO } from '../../constant'; -const ApplyInfo = memo(({ isReview }: { isReview: boolean }) => { +const ApplyInfo = memo(({ isReview = false }: { isReview?: boolean }) => { const { deviceType } = useDeviceType(); const { recruitingInfo: { diff --git a/src/views/ApplyPage/components/BottomSection/index.tsx b/src/views/ApplyPage/components/BottomSection/index.tsx index d7a460b5..436b4ccf 100644 --- a/src/views/ApplyPage/components/BottomSection/index.tsx +++ b/src/views/ApplyPage/components/BottomSection/index.tsx @@ -9,11 +9,11 @@ import { SELECT_OPTIONS } from 'views/ApplyPage/constant'; import { doubleLineCheck, labelVar, line, sectionContainer } from './style.css'; interface BottomSectionProps { - isReview: boolean; + isReview?: boolean; knownPath?: string; } -const BottomSection = ({ isReview, knownPath }: BottomSectionProps) => { +const BottomSection = ({ knownPath, isReview = false }: BottomSectionProps) => { const { deviceType } = useDeviceType(); const { recruitingInfo: { isMakers }, diff --git a/src/views/ApplyPage/components/CommonSection/index.tsx b/src/views/ApplyPage/components/CommonSection/index.tsx index 3c3f8687..f676a217 100644 --- a/src/views/ApplyPage/components/CommonSection/index.tsx +++ b/src/views/ApplyPage/components/CommonSection/index.tsx @@ -8,13 +8,13 @@ import Info from '../Info'; import LinkInput from '../LinkInput'; interface CommonSectionProps { - isReview: boolean; + isReview?: boolean; refCallback: (elem: HTMLSelectElement) => void; questions?: Questions[]; commonQuestionsDraft?: Answers[]; } -const CommonSection = ({ isReview, refCallback, questions, commonQuestionsDraft }: CommonSectionProps) => { +const CommonSection = ({ refCallback, questions, commonQuestionsDraft, isReview = false }: CommonSectionProps) => { const { deviceType } = useDeviceType(); const commonQuestionsById = commonQuestionsDraft?.reduce( (acc, draft) => { diff --git a/src/views/ApplyPage/components/DefaultSection/index.tsx b/src/views/ApplyPage/components/DefaultSection/index.tsx index 4d89e73f..8334b4c6 100644 --- a/src/views/ApplyPage/components/DefaultSection/index.tsx +++ b/src/views/ApplyPage/components/DefaultSection/index.tsx @@ -107,12 +107,12 @@ const ProfileImage = ({ disabled, pic, deviceType }: ProfileImageProps) => { interface DefaultSectionProps { isMakers?: boolean; - isReview: boolean; + isReview?: boolean; refCallback?: (elem: HTMLSelectElement) => void; applicantDraft?: Applicant; } -const DefaultSection = ({ isMakers, isReview, refCallback, applicantDraft }: DefaultSectionProps) => { +const DefaultSection = ({ isMakers, refCallback, applicantDraft, isReview = false }: DefaultSectionProps) => { const { deviceType } = useDeviceType(); const { address, diff --git a/src/views/ApplyPage/components/PartSection/index.tsx b/src/views/ApplyPage/components/PartSection/index.tsx index da6cc953..3b6c0864 100644 --- a/src/views/ApplyPage/components/PartSection/index.tsx +++ b/src/views/ApplyPage/components/PartSection/index.tsx @@ -11,7 +11,7 @@ import Info from '../Info'; import LinkInput from '../LinkInput'; interface PartSectionProps { - isReview: boolean; + isReview?: boolean; refCallback: (elem: HTMLSelectElement) => void; part?: string; questions?: { @@ -24,12 +24,12 @@ interface PartSectionProps { } const PartSection = ({ - isReview, refCallback, part, questions, partQuestionsDraft, questionTypes, + isReview = false, }: PartSectionProps) => { const { deviceType } = useDeviceType(); const { getValues } = useFormContext(); diff --git a/src/views/ApplyPage/hooks/useBeforeExitPageAlert.tsx b/src/views/ApplyPage/hooks/useBeforeExitPageAlert.tsx new file mode 100644 index 00000000..6fa4978f --- /dev/null +++ b/src/views/ApplyPage/hooks/useBeforeExitPageAlert.tsx @@ -0,0 +1,13 @@ +import useEventListener from '@hooks/useEventListener'; + +/** 페이지 이탈 시 alert 띄우기 */ +const useBeforeExitPageAlert = () => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = ''; // Included for legacy support, e.g. Chrome/Edeg < 119; + }; + + useEventListener('beforeunload', handleBeforeUnload); +}; + +export default useBeforeExitPageAlert; diff --git a/src/views/ApplyPage/index.tsx b/src/views/ApplyPage/index.tsx index 45add81a..3b3b8f60 100644 --- a/src/views/ApplyPage/index.tsx +++ b/src/views/ApplyPage/index.tsx @@ -9,7 +9,6 @@ import useCheckBrowser from '@hooks/useCheckBrowser'; import useDate from '@hooks/useDate'; import useScrollToHash from '@hooks/useScrollToHash'; import { useDeviceType } from 'contexts/DeviceTypeProvider'; -import BigLoading from 'views/loadings/BigLoding'; import ApplyCategory from './components/ApplyCategory'; import ApplyHeader from './components/ApplyHeader'; @@ -19,6 +18,7 @@ import CommonSection from './components/CommonSection'; import DefaultSection from './components/DefaultSection'; import PartSection from './components/PartSection'; import { SELECT_OPTIONS } from './constant'; +import useBeforeExitPageAlert from './hooks/useBeforeExitPageAlert'; import useGetDraft from './hooks/useGetDraft'; import useGetQuestions from './hooks/useGetQuestions'; import useMutateDraft from './hooks/useMutateDraft'; @@ -26,6 +26,7 @@ import useMutateSubmit from './hooks/useMutateSubmit'; import { buttonWrapper, container, formContainerVar } from './style.css'; import type { ApplyRequest } from './types'; +import useDialog from '@hooks/useDialog'; const DraftDialog = lazy(() => import('views/dialogs').then(({ DraftDialog }) => ({ default: DraftDialog }))); const PreventApplyDialog = lazy(() => @@ -39,38 +40,47 @@ interface ApplyPageProps { } const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { + // 반응형 페이지 const { deviceType } = useDeviceType(); useCheckBrowser(); // 크롬 브라우저 권장 알럿 - const draftDialog = useRef(null); - const preventApplyDialog = useRef(null); - const submitDialog = useRef(null); - const sectionsRef = useRef([]); + // 2. 모달 관련 ref + const { ref: draftDialogRef, handleShowDialog: handleShowDraftDialog } = useDialog(); + const { ref: preventApplyDialogRef, handleShowDialog: handleShowPreventApplyDialog } = useDialog(); + const { + ref: submitDialogRef, + handleShowDialog: handleShowSubmitDialog, + handleCloseDialog: handleCloseSubmitDialog, + } = useDialog(); + // 3. category active 상태 관리 + useScrollToHash(); // scrollTo 카테고리 const [isInView, setIsInView] = useState([true, false, false]); - const [sectionsUpdated, setSectionsUpdated] = useState(false); - - const navigate = useNavigate(); - - const isReview = false; const minIndex = isInView.findIndex((value) => value === true); - useScrollToHash(); // scrollTo 카테고리 - - const { NoMoreApply, isLoading, isMakers } = useDate(); - const { draftData, draftIsLoading } = useGetDraft(); + // 4. 데이터 불러오기 + const { draftData } = useGetDraft(); const { applicant: applicantDraft, commonQuestions: commonQuestionsDraft, partQuestions: partQuestionsDraft, } = draftData?.data || {}; - - const { questionsData, questionsIsLoading } = useGetQuestions(applicantDraft); + const { questionsData } = useGetQuestions(applicantDraft); const { commonQuestions, partQuestions, questionTypes } = questionsData?.data || {}; - const { draftMutate, draftIsPending } = useMutateDraft({ onSuccess: () => draftDialog.current?.showModal() }); + // 5. 임시저장된 part data 붙이기 + useEffect(() => { + if (applicantDraft?.part) { + setValue('part', applicantDraft?.part); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [applicantDraft]); + + // 6. 데이터 보내기 + const { draftMutate, draftIsPending } = useMutateDraft({ onSuccess: handleShowDraftDialog }); const { submitMutate, submitIsPending } = useMutateSubmit({ onSuccess: onSetComplete }); + // 7. react hook form method 생성 const methods = useForm({ mode: 'onBlur' }); const { handleSubmit, @@ -102,12 +112,9 @@ const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { ...rest } = getValues(); - useEffect(() => { - if (applicantDraft?.part) { - setValue('part', applicantDraft?.part); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [applicantDraft]); + // 8. intersection observer 연결 + const sectionsRef = useRef([]); + const [sectionsUpdated, setSectionsUpdated] = useState(false); const refCallback = useCallback((element: HTMLSelectElement) => { if (element) { @@ -147,6 +154,9 @@ const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { }; }, [sectionsUpdated]); + // 9. 입력값 에러 관련 로직 + const navigate = useNavigate(); + useEffect(() => { if (errors.picture) { navigate('#default'); @@ -174,22 +184,13 @@ const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [errors.attendance, errors.personalInformation]); - useEffect(() => { - if (isReview) return; - - const handleBeforeUnload = (e: BeforeUnloadEvent) => { - e.preventDefault(); - e.returnValue = ''; // Included for legacy support, e.g. Chrome/Edeg < 119; - }; - - window.addEventListener('beforeunload', handleBeforeUnload); - - return () => window.removeEventListener('beforeunload', handleBeforeUnload); - }, [isReview]); + useBeforeExitPageAlert(); - if (questionsIsLoading || isLoading || draftIsLoading) return ; + // 11. 지원 기간 아니면 에러 페이지 띄우기 + const { NoMoreApply, isMakers } = useDate(); if (NoMoreApply) return ; + // 13. data 전송 로직 const selectedPartId = questionTypes?.find((type) => type.typeKr === getValues('part'))?.id; const partQuestionsData = partQuestions?.find((part) => part.recruitingQuestionTypeId === selectedPartId); const partQuestionIds = partQuestionsData?.questions.map((question) => question.id); @@ -197,7 +198,7 @@ const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { const handleSendData = (type: 'draft' | 'submit') => { if (NoMoreApply) { - preventApplyDialog.current?.showModal(); + handleShowPreventApplyDialog(); return; } @@ -278,13 +279,13 @@ const ApplyPage = ({ onSetComplete }: ApplyPageProps) => { const handleApplySubmit = () => { track('click-apply-submit'); - submitDialog.current?.showModal(); + handleShowSubmitDialog(); }; return ( - - - + <> + + { part, }} dataIsPending={submitIsPending} - ref={submitDialog} + ref={submitDialogRef} onSendData={() => { handleSendData('submit'); - submitDialog.current?.close(); + handleCloseSubmitDialog(); }} /> -
- handleSendData('draft')} - onSubmitData={handleSubmit(handleApplySubmit)} - /> - - -
- - - +
+ handleSendData('draft')} + onSubmitData={handleSubmit(handleApplySubmit)} /> - - {!isReview && ( + + + + + + +
- )} - -
-
- + +
+