From 563c3f0452c7788bcc36acc40311a4d1fd6eba91 Mon Sep 17 00:00:00 2001 From: kangkibong Date: Thu, 24 Oct 2024 21:55:43 +0900 Subject: [PATCH 1/4] =?UTF-8?q?7,=208=EC=A3=BC=EC=B0=A8=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EB=AC=BC=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update README.md * Refactor/#47 6주차 코드리뷰 리팩토링 (#48) * refactor: separate style and prop-related constants * refactor: remove lambda function * refactor: restructure SignUp and RecruitmentHeader components * feat: separate Button and Text component of SignIn * feat: add SignUpText component * refactor: remove auth page's barrel file * Feat/#42, #43 지원자 목록 페이지 및 팝업 구현 (#51) * feat: 지원자 목록 페이지 구현 * feat: 지원자 목록 페이지 스토리북 생성 * refactor: ApplicantList 테이블 컴포넌트 분리 * feat: Applicants path 설정 * feat: 지원자 목록 페이지에 계약 관련 팝업 추가 * fix: 이미지 경로 수정 및 불필요한 태그 제거 * refactor: MyAccount 페이지 구조 변경 및 CompanyRecruitments로 파일명 변경 - 기존 MyAccount 페이지를 CompanyRecruitments로 이름 변경 - 새로운 MyAccount 페이지 구현을 위해 기존 페이지의 역할 변경 * refactor: visaRegistration 관련 파일 구조 변경 - visaRegistration 페이지를 src/pages/employee에서 src/pages로 이동 - 관련 기능을 src/features/employee/visaRegistration에서 src/features/visaRegistration으로 이동 * feat: Table 컴포넌트 구현 * feat: 변경된 고용주 마이페이지 구현 * refactor: 회사 관련 공통 기능을 features/companies로 이동 및 CompanyInfo 수정 * refactor: CompanyRecruitments 페이지 이름을 MyCompany로 변경 * feat: EmployerMyAccount path 설정 * refactor: 불필요한 코드 삭제 및 폴더명 일관성 있게 변경 * Feat/#50 Select 컴포넌트 구현 (#53) * feat: add Select component * feat: add useGlobalSelect and useSelect * refactor: move directory * refactor: EmployerMyAccount 페이지에서 mock 데이터 분리 및 코드 정리 * refactor: visaRegistration 및 applicants 페이지의 mock, style 파일 분리 * refactor: RecruitmentList 컴포넌트 리팩토링 및 RecruitmentsTable 분리 * refactor: CompanyInfo 반응형 디자인 수정 * refactor: 외국인 번호 및 비자 발급 일자 등록 페이지 스타일 수정 * refactor: Header 컴포넌트의 닉네임 버튼을 사용자 프로필 이미지로 변경 * Refactor/#54 Modal 컴포넌트 재설계 (#55) * chore: add loadable component package * feat: implement modal management system with context and dynamic loading * refactor: 코드 리뷰 반영 - formValid를 useMemo로 관리 - validateForeignerNumber 함수를 별도 파일로 분리 * Feat/#56 메인 페이지 API 연동 (#57) * chore: setting mockServiceWorker * feat: add useFetchRecruitments hooks and recruitmentsMockHandler * feat: add useFetchSlides hooks and slidesMockHandler * feat: add Spinner component * feat: add AsyncBoundary component * chore: add msw-storybook-addon * Feat/#58 OAuth 구글 로그인 구현 (#59) * feat: add useGoogleOAuth hook * feat: add Loading page * chore: add MemoryRouter to decorators * Feat/#60 가입자 정보 선택 API 연동 (#61) * feat: add useRegister hook * fix: change role prop value * style: Button 컴포넌트 Props 이름변경 theme->design * feat: 근로자마이페이지 아이콘 설정 * feat: 근로자 마이페이지 구현 * feat: 근로자마이페이지 라우터 설정 * style: Button props 이름 변경 * feat: msw 세팅 및 API path 작성 * feat: 구인글 등록 API 연결 및 msw 세팅 * fix: 구인글 업로드 mock 핸들러 수정 * feat: 근로자 마이페이지 mock 핸들러 추가 * feat: 근로자 마이페이지 API 연결 및 msw 설정 * feat: 이력서 페이지 구현 (#63) - react-hook-form 을 사용했습니다. - api 명세서에 맞게 이름,주소,번호,경력,자기소개,한국어실력을 필수값으로 받게 했습니다. Co-authored-by: kangkibong * fix: change button prop * feat: add GitHub Actions workflow for linting and type checking --------- Co-authored-by: yimsebin Co-authored-by: Kim Jian <81233665+KimJi-An@users.noreply.github.com> Co-authored-by: KimJi-An Co-authored-by: LEE YONGJIN <129606040+tteokbokki-master@users.noreply.github.com> --- .env.development | 3 + .github/workflows/lint.yaml | 43 +++ .storybook/preview.tsx | 17 +- README.md | 43 ++- eslint.config.js | 2 + package-lock.json | 140 ++++++++- package.json | 12 + public/mockServiceWorker.js | 280 +++++++++++++++++ src/apis/apiPath.ts | 11 + src/apis/auth/hooks/useAuthFetch.ts | 0 src/apis/auth/mock/index.mock.ts | 1 - src/apis/auth/mutations/useGoogleOAuth.tsx | 81 +++++ src/apis/auth/mutations/useRegister.tsx | 17 + src/apis/auth/path.ts | 6 + src/apis/auth/types/request.ts | 7 + src/apis/auth/types/response.ts | 9 + .../employee/hooks/useGetMyApplication.ts | 18 ++ .../employee/mock/getMyApplication.mock.ts | 9 + src/apis/employer/hooks/usePostNotice.ts | 32 ++ src/apis/employer/mock/postNotice.mock.ts | 10 + .../home/mocks/recruitmentsMockHandler.ts} | 26 +- src/apis/home/mocks/slidesMockHandler.ts | 19 ++ src/apis/home/path.ts | 6 + src/apis/home/queries/queryKeys.ts | 4 + .../home/queries/useFetchRecruitments.tsx | 18 ++ src/apis/home/queries/useFetchSlides.tsx | 18 ++ src/apis/home/types/response.ts | 15 + src/apis/instance.ts | 48 +++ src/assets/icons/arrow/big-right-blue.svg | 3 + src/assets/icons/arrow/down-blue.svg | 3 + src/assets/icons/employeePage/bag.svg | 1 + src/assets/icons/employeePage/card.svg | 1 + src/assets/icons/employeePage/pen.svg | 1 + src/assets/icons/recruitmentInfo/area.svg | 4 + src/assets/icons/recruitmentInfo/salary.svg | 11 + .../CompanyInfo => assets/images}/coupang.png | Bin src/assets/images/profile-image.svg | 4 + src/components/common/AsyncBoundary/index.tsx | 30 ++ .../common/Button/index.stories.tsx | 7 +- src/components/common/Button/index.tsx | 19 +- src/components/common/Icon/Arrow.ts | 4 + src/components/common/Icon/EmployeePage.ts | 11 + src/components/common/Icon/index.ts | 2 + .../common/InnerContainer/index.tsx | 31 +- src/components/common/List/index.tsx | 2 +- src/components/common/Modal/Modals.tsx | 30 ++ .../common/Modal/hooks/useModals.ts | 19 ++ src/components/common/Modal/index.context.tsx | 18 ++ src/components/common/Modal/index.tsx | 5 +- .../common/Select/hooks/useGlobalSelect.ts | 25 ++ .../common/Select/hooks/useSelect.ts | 20 ++ .../common/Select/index.context.tsx | 35 +++ .../common/Select/index.stories.tsx | 109 +++++++ src/components/common/Select/index.tsx | 13 + .../common/Select/sub-components/Content.tsx | 19 ++ .../common/Select/sub-components/Option.tsx | 36 +++ .../common/Select/sub-components/Root.tsx | 15 + .../common/Select/sub-components/Trigger.tsx | 26 ++ .../common/Spinner/index.stories.tsx | 14 + src/components/common/Spinner/index.tsx | 27 ++ src/components/common/Table/index.stories.tsx | 38 +++ src/components/common/Table/index.style.ts | 33 ++ src/components/common/Table/index.tsx | 38 +++ src/components/common/index.ts | 4 + src/components/providers/Modals.provider.tsx | 29 ++ src/components/providers/index.provider.tsx | 15 +- .../ApplicantsTable/index.styles.ts | 37 +++ .../ApplicantList/ApplicantsTable/index.tsx | 58 ++++ .../ContractModal/ModalText/index.styles.ts | 13 + .../ContractModal/ModalText/index.tsx | 44 +++ .../ApplicantList/ContractModal/index.mock.ts | 5 + .../ContractModal/index.styles.ts | 15 + .../ApplicantList/ContractModal/index.tsx | 39 +++ .../applicants/ApplicantList/index.tsx | 23 ++ src/features/auth/RoleModal.tsx | 34 -- src/features/auth/RoleSelection.tsx | 16 - .../auth/SignIn/components/SignInButton.tsx | 20 ++ .../auth/SignIn/components/SignInText.tsx | 41 +++ .../auth/SignUp/components/RoleSelection.tsx | 13 + .../auth/SignUp/components/SignUpText.tsx | 41 +++ .../SignUp/components/common/RoleModal.tsx | 42 +++ .../common}/RoleSelector/index.config.tsx | 5 +- .../components/common/RoleSelector/index.tsx | 60 ++++ src/features/auth/components/RoleModal.tsx | 34 -- .../auth/components/RoleSelection.tsx | 16 - .../auth/components/RoleSelector/index.tsx | 43 --- .../companies/CompanyInfo/index.styles.ts | 46 +++ src/features/companies/CompanyInfo/index.tsx | 32 ++ .../CompaniesTable/index.styles.ts | 26 ++ .../CompanyList/CompaniesTable/index.tsx | 45 +++ src/features/companies/CompanyList/index.tsx | 23 ++ src/features/employee/myPage/CardButton.tsx | 24 ++ .../employee/myPage/EmployeeProfile.tsx | 62 ++++ .../employee/myPage/MyRecruitCard.tsx | 74 +++++ .../employee/myPage/MyRecruitList.tsx | 16 + src/features/employer/CompanyInfo/index.tsx | 138 --------- .../employer/MyRecruitments/index.tsx | 116 ------- src/features/home/Employer.tsx | 39 --- src/features/home/RecruitmentList.tsx | 43 --- src/features/home/Worker.tsx | 6 - .../home/components/ConditionalRenderer.tsx | 9 + src/features/home/components/Employer.tsx | 36 +++ .../components/RecruitmentCard/Button.tsx | 12 - .../RecruitmentCard/CompanyImage.tsx | 28 -- .../RecruitmentCard/CompanyName.tsx | 25 -- .../components/RecruitmentCard/Detail.tsx | 25 -- .../home/components/RecruitmentCard/Title.tsx | 26 -- .../home/components/RecruitmentHeader.tsx | 57 ---- src/features/home/components/Worker.tsx | 8 + .../home/components/{ => common}/Banner.tsx | 29 +- .../RecruitmentCard/index.context.tsx} | 6 +- .../RecruitmentCard/index.tsx} | 34 +- .../RecruitmentCard/sub-components/Button.tsx | 17 + .../sub-components/CompanyImage.tsx | 25 ++ .../sub-components/CompanyName.tsx | 22 ++ .../RecruitmentCard/sub-components/Detail.tsx | 22 ++ .../sub-components}/Salary.tsx | 6 +- .../RecruitmentCard/sub-components/Title.tsx | 23 ++ .../RecruitmentCard/sub-components}/index.ts | 0 .../components/common/RecruitmentFilter.tsx | 45 +++ .../common/RecruitmentHeader/index.styles.ts | 33 ++ .../common/RecruitmentHeader/index.tsx | 17 + .../components/common/RecruitmentList.tsx | 40 +++ .../Header/components/LanguageFilter.tsx | 40 +++ src/features/layout/Header/index.stories.tsx | 3 +- src/features/layout/Header/index.styles.ts | 110 +++++++ src/features/layout/Header/index.tsx | 155 ++-------- src/features/layout/index.tsx | 8 +- .../RecruitmentInfo/index.styles.ts | 93 ++++++ .../recruitments/RecruitmentInfo/index.tsx | 50 +++ .../RecruitmentsTable/index.styles.ts | 47 +++ .../RecruitmentsTable/index.tsx | 46 +++ .../recruitments/RecruitmentList/index.tsx | 23 ++ src/features/visaRegistration/index.styles.ts | 41 +++ .../{employee => }/visaRegistration/index.tsx | 59 +--- .../validateForeignerNumber.ts | 4 + src/main.tsx | 3 + src/mocks/browser.ts | 4 + src/mocks/handlers.ts | 11 + src/mocks/index.ts | 0 src/mocks/setupMockServiceWorker.ts | 6 + src/pages/applicants/index.mock.ts | 28 ++ src/pages/applicants/index.stories.tsx | 14 + src/pages/applicants/index.tsx | 31 ++ .../apply/applyguide/ApplyGuide.stories.tsx | 8 - src/pages/auth/Loading/index.tsx | 18 ++ src/pages/auth/SignIn/index.styles.ts | 20 ++ src/pages/auth/SignIn/index.tsx | 82 +---- src/pages/auth/SignUp/index.tsx | 64 +--- src/pages/auth/index.ts | 2 - .../EmployeeContract/EmployeeContract.tsx | 288 +++++++++-------- .../EmployerContract/EmployerContract.tsx | 292 +++++++++--------- .../myPage/EmployeeMyPage.stories.tsx | 14 + src/pages/employee/myPage/EmployeeMyPage.tsx | 55 ++++ src/pages/employee/myPage/data/index.mock.ts | 36 +++ src/pages/employee/visaRegistration/index.tsx | 56 ---- src/pages/employer/myAccount/index.tsx | 57 ---- src/pages/employer/postNotice/PostNotice.tsx | 109 ++++--- src/pages/home/index.tsx | 25 +- src/pages/myAccount/employer/index.mock.ts | 20 ++ .../employer}/index.stories.tsx | 4 +- src/pages/myAccount/employer/index.styles.ts | 30 ++ src/pages/myAccount/employer/index.tsx | 22 ++ src/pages/myCompany/index.mock.ts | 42 +++ src/pages/myCompany/index.stories.tsx | 14 + src/pages/myCompany/index.styles.ts | 55 ++++ src/pages/myCompany/index.tsx | 39 +++ src/pages/resume/Resume.tsx | 164 ++++++++++ src/pages/resume/ResumeInput.tsx | 111 +++++++ src/pages/resume/ResumeType.ts | 8 + .../visaRegistration/index.stories.tsx | 0 src/pages/visaRegistration/index.styles.ts | 37 +++ src/pages/visaRegistration/index.tsx | 27 ++ src/routes/path.ts | 14 +- src/routes/router.tsx | 23 +- src/store/index.ts | 0 src/store/selectStore.ts | 22 ++ src/types/index.d.ts | 38 ++- src/types/select.ts | 5 + 179 files changed, 4466 insertions(+), 1542 deletions(-) create mode 100644 .env.development create mode 100644 .github/workflows/lint.yaml create mode 100644 public/mockServiceWorker.js create mode 100644 src/apis/apiPath.ts delete mode 100644 src/apis/auth/hooks/useAuthFetch.ts delete mode 100644 src/apis/auth/mock/index.mock.ts create mode 100644 src/apis/auth/mutations/useGoogleOAuth.tsx create mode 100644 src/apis/auth/mutations/useRegister.tsx create mode 100644 src/apis/auth/path.ts create mode 100644 src/apis/auth/types/request.ts create mode 100644 src/apis/auth/types/response.ts create mode 100644 src/apis/employee/hooks/useGetMyApplication.ts create mode 100644 src/apis/employee/mock/getMyApplication.mock.ts create mode 100644 src/apis/employer/hooks/usePostNotice.ts create mode 100644 src/apis/employer/mock/postNotice.mock.ts rename src/{pages/home/data/index.mock.ts => apis/home/mocks/recruitmentsMockHandler.ts} (71%) create mode 100644 src/apis/home/mocks/slidesMockHandler.ts create mode 100644 src/apis/home/path.ts create mode 100644 src/apis/home/queries/queryKeys.ts create mode 100644 src/apis/home/queries/useFetchRecruitments.tsx create mode 100644 src/apis/home/queries/useFetchSlides.tsx create mode 100644 src/apis/home/types/response.ts create mode 100644 src/assets/icons/arrow/big-right-blue.svg create mode 100644 src/assets/icons/arrow/down-blue.svg create mode 100644 src/assets/icons/employeePage/bag.svg create mode 100644 src/assets/icons/employeePage/card.svg create mode 100644 src/assets/icons/employeePage/pen.svg create mode 100644 src/assets/icons/recruitmentInfo/area.svg create mode 100644 src/assets/icons/recruitmentInfo/salary.svg rename src/{features/employer/CompanyInfo => assets/images}/coupang.png (100%) create mode 100644 src/assets/images/profile-image.svg create mode 100644 src/components/common/AsyncBoundary/index.tsx create mode 100644 src/components/common/Icon/EmployeePage.ts create mode 100644 src/components/common/Modal/Modals.tsx create mode 100644 src/components/common/Modal/hooks/useModals.ts create mode 100644 src/components/common/Modal/index.context.tsx create mode 100644 src/components/common/Select/hooks/useGlobalSelect.ts create mode 100644 src/components/common/Select/hooks/useSelect.ts create mode 100644 src/components/common/Select/index.context.tsx create mode 100644 src/components/common/Select/index.stories.tsx create mode 100644 src/components/common/Select/index.tsx create mode 100644 src/components/common/Select/sub-components/Content.tsx create mode 100644 src/components/common/Select/sub-components/Option.tsx create mode 100644 src/components/common/Select/sub-components/Root.tsx create mode 100644 src/components/common/Select/sub-components/Trigger.tsx create mode 100644 src/components/common/Spinner/index.stories.tsx create mode 100644 src/components/common/Spinner/index.tsx create mode 100644 src/components/common/Table/index.stories.tsx create mode 100644 src/components/common/Table/index.style.ts create mode 100644 src/components/common/Table/index.tsx create mode 100644 src/components/providers/Modals.provider.tsx create mode 100644 src/features/applicants/ApplicantList/ApplicantsTable/index.styles.ts create mode 100644 src/features/applicants/ApplicantList/ApplicantsTable/index.tsx create mode 100644 src/features/applicants/ApplicantList/ContractModal/ModalText/index.styles.ts create mode 100644 src/features/applicants/ApplicantList/ContractModal/ModalText/index.tsx create mode 100644 src/features/applicants/ApplicantList/ContractModal/index.mock.ts create mode 100644 src/features/applicants/ApplicantList/ContractModal/index.styles.ts create mode 100644 src/features/applicants/ApplicantList/ContractModal/index.tsx create mode 100644 src/features/applicants/ApplicantList/index.tsx delete mode 100644 src/features/auth/RoleModal.tsx delete mode 100644 src/features/auth/RoleSelection.tsx create mode 100644 src/features/auth/SignIn/components/SignInButton.tsx create mode 100644 src/features/auth/SignIn/components/SignInText.tsx create mode 100644 src/features/auth/SignUp/components/RoleSelection.tsx create mode 100644 src/features/auth/SignUp/components/SignUpText.tsx create mode 100644 src/features/auth/SignUp/components/common/RoleModal.tsx rename src/features/auth/{components => SignUp/components/common}/RoleSelector/index.config.tsx (81%) create mode 100644 src/features/auth/SignUp/components/common/RoleSelector/index.tsx delete mode 100644 src/features/auth/components/RoleModal.tsx delete mode 100644 src/features/auth/components/RoleSelection.tsx delete mode 100644 src/features/auth/components/RoleSelector/index.tsx create mode 100644 src/features/companies/CompanyInfo/index.styles.ts create mode 100644 src/features/companies/CompanyInfo/index.tsx create mode 100644 src/features/companies/CompanyList/CompaniesTable/index.styles.ts create mode 100644 src/features/companies/CompanyList/CompaniesTable/index.tsx create mode 100644 src/features/companies/CompanyList/index.tsx create mode 100644 src/features/employee/myPage/CardButton.tsx create mode 100644 src/features/employee/myPage/EmployeeProfile.tsx create mode 100644 src/features/employee/myPage/MyRecruitCard.tsx create mode 100644 src/features/employee/myPage/MyRecruitList.tsx delete mode 100644 src/features/employer/CompanyInfo/index.tsx delete mode 100644 src/features/employer/MyRecruitments/index.tsx delete mode 100644 src/features/home/Employer.tsx delete mode 100644 src/features/home/RecruitmentList.tsx delete mode 100644 src/features/home/Worker.tsx create mode 100644 src/features/home/components/ConditionalRenderer.tsx create mode 100644 src/features/home/components/Employer.tsx delete mode 100644 src/features/home/components/RecruitmentCard/Button.tsx delete mode 100644 src/features/home/components/RecruitmentCard/CompanyImage.tsx delete mode 100644 src/features/home/components/RecruitmentCard/CompanyName.tsx delete mode 100644 src/features/home/components/RecruitmentCard/Detail.tsx delete mode 100644 src/features/home/components/RecruitmentCard/Title.tsx delete mode 100644 src/features/home/components/RecruitmentHeader.tsx create mode 100644 src/features/home/components/Worker.tsx rename src/features/home/components/{ => common}/Banner.tsx (70%) rename src/features/home/components/{RecruitmentCard/RecruitmentCard.context.tsx => common/RecruitmentCard/index.context.tsx} (83%) rename src/features/home/components/{RecruitmentCard/RecruitmentCard.tsx => common/RecruitmentCard/index.tsx} (60%) create mode 100644 src/features/home/components/common/RecruitmentCard/sub-components/Button.tsx create mode 100644 src/features/home/components/common/RecruitmentCard/sub-components/CompanyImage.tsx create mode 100644 src/features/home/components/common/RecruitmentCard/sub-components/CompanyName.tsx create mode 100644 src/features/home/components/common/RecruitmentCard/sub-components/Detail.tsx rename src/features/home/components/{RecruitmentCard => common/RecruitmentCard/sub-components}/Salary.tsx (51%) create mode 100644 src/features/home/components/common/RecruitmentCard/sub-components/Title.tsx rename src/features/home/components/{RecruitmentCard => common/RecruitmentCard/sub-components}/index.ts (100%) create mode 100644 src/features/home/components/common/RecruitmentFilter.tsx create mode 100644 src/features/home/components/common/RecruitmentHeader/index.styles.ts create mode 100644 src/features/home/components/common/RecruitmentHeader/index.tsx create mode 100644 src/features/home/components/common/RecruitmentList.tsx create mode 100644 src/features/layout/Header/components/LanguageFilter.tsx create mode 100644 src/features/layout/Header/index.styles.ts create mode 100644 src/features/recruitments/RecruitmentInfo/index.styles.ts create mode 100644 src/features/recruitments/RecruitmentInfo/index.tsx create mode 100644 src/features/recruitments/RecruitmentList/RecruitmentsTable/index.styles.ts create mode 100644 src/features/recruitments/RecruitmentList/RecruitmentsTable/index.tsx create mode 100644 src/features/recruitments/RecruitmentList/index.tsx create mode 100644 src/features/visaRegistration/index.styles.ts rename src/features/{employee => }/visaRegistration/index.tsx (63%) create mode 100644 src/features/visaRegistration/validateForeignerNumber.ts create mode 100644 src/mocks/browser.ts create mode 100644 src/mocks/handlers.ts delete mode 100644 src/mocks/index.ts create mode 100644 src/mocks/setupMockServiceWorker.ts create mode 100644 src/pages/applicants/index.mock.ts create mode 100644 src/pages/applicants/index.stories.tsx create mode 100644 src/pages/applicants/index.tsx create mode 100644 src/pages/auth/Loading/index.tsx create mode 100644 src/pages/auth/SignIn/index.styles.ts delete mode 100644 src/pages/auth/index.ts create mode 100644 src/pages/employee/myPage/EmployeeMyPage.stories.tsx create mode 100644 src/pages/employee/myPage/EmployeeMyPage.tsx create mode 100644 src/pages/employee/myPage/data/index.mock.ts delete mode 100644 src/pages/employee/visaRegistration/index.tsx delete mode 100644 src/pages/employer/myAccount/index.tsx create mode 100644 src/pages/myAccount/employer/index.mock.ts rename src/pages/{employer/myAccount => myAccount/employer}/index.stories.tsx (73%) create mode 100644 src/pages/myAccount/employer/index.styles.ts create mode 100644 src/pages/myAccount/employer/index.tsx create mode 100644 src/pages/myCompany/index.mock.ts create mode 100644 src/pages/myCompany/index.stories.tsx create mode 100644 src/pages/myCompany/index.styles.ts create mode 100644 src/pages/myCompany/index.tsx create mode 100644 src/pages/resume/Resume.tsx create mode 100644 src/pages/resume/ResumeInput.tsx create mode 100644 src/pages/resume/ResumeType.ts rename src/pages/{employee => }/visaRegistration/index.stories.tsx (100%) create mode 100644 src/pages/visaRegistration/index.styles.ts create mode 100644 src/pages/visaRegistration/index.tsx delete mode 100644 src/store/index.ts create mode 100644 src/store/selectStore.ts create mode 100644 src/types/select.ts diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..ad2db98 --- /dev/null +++ b/.env.development @@ -0,0 +1,3 @@ +VITE_BASE_URL=/ +VITE_GOOGLE_AUTH_CLIENT_ID=268837811477-28b8i24r1sb0aroltho84ia6jecj74h7.apps.googleusercontent.com +VITE_GOOGLE_AUTH_REDIRECT_URI=http://localhost:5173/loading \ No newline at end of file diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..1d4852e --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,43 @@ +name: Lint and Type Check + +on: + pull_request: + # 모든 브랜치에 대해 PR 시 실행 + branches: + - '**' + push: + # 모든 브랜치에 대해 푸시 시 실행 + branches: + - '**' + +jobs: + lint: + name: Lint and Type Check + runs-on: ubuntu-latest + + steps: + # Checkout the repository + - name: Checkout repository + uses: actions/checkout@v3 + + # Set up Node.js environment + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + # Install dependencies + - name: Install dependencies + run: npm ci + + # Run ESLint to check for linting errors + - name: Run ESLint + run: npm run lint + + # Run Prettier to check formatting + - name: Run Prettier Check + run: npm run format -- --check + + # Run TypeScript compiler to check for type errors + - name: Run TypeScript Check + run: npm run tsc diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 7ea2fad..477112a 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,6 +1,11 @@ import React from 'react'; import type { Preview } from '@storybook/react'; import AppProviders from '../src/components/providers/index.provider'; +import { initialize, mswLoader } from 'msw-storybook-addon'; +import { handlers } from '../src/mocks/handlers'; +import { MemoryRouter } from 'react-router-dom'; + +initialize(); const preview: Preview = { parameters: { @@ -10,15 +15,21 @@ const preview: Preview = { date: /Date$/i, }, }, + msw: { + handlers: [...handlers], + }, }, tags: ['autodocs'], decorators: [ (Story) => ( - - - + + + + + ), ], + loaders: [mswLoader], }; export default preview; diff --git a/README.md b/README.md index 9c670e9..0b3ddf6 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ -# 🍪 내가 먹은 쿠키 - 18조 FE \ No newline at end of file +# 🍪 내가 먹은 쿠키 - 18조 FE + +## 🙋‍♂️ 6주차 코드리뷰 질문 +- 하나의 파일 내에서 함수 표현식과 선언식을 같이 사용해도 되는지 궁금합니다. +- 기존 Weekly 브랜치에 components 경로에 `RoleModal` 컴포넌트가 있다고 가정하면, 해당 Weekly브랜치를 통해 새로 분기된 Feat 브랜치에 기존 components에 `RoleModal` 컴포넌트를 features 경로에 옮기고 두 브랜치를 머지하게되면 두개의 파일이 생기게 됩니다. 이를 해결하려면 어떻게 해야하나요? + +## 🙋‍♂️ 5주차 코드리뷰 질문 +- 하나의 페이지 내에서만 여러번 사용되는 공통 컴포넌트의 경우, components/common 폴더에 공통 컴포넌트로 만들어 취급하는 것이 좋은지, 혹은 해당 페이지 코드 파일이 위치한 폴더에 컴포넌트를 만들거나 해당 페이지 코드 파일 하단에 작성하는 등 colocation 원칙을 적용해서 가까이 위치시키는 것이 좋을지 궁금합니다. +- `Header` 컴포넌트에서 다른 theme을 가진 버튼들에 공통된 스타일을 적용하면서, 특정 버튼에만 추가적인 스타일을 주는 작업을 했습니다. 아래와 같이 각 버튼에 공통적으로 적용될 스타일을 `commonButtonStyles`로 정의하고, `theme=default`인 버튼에만 추가 스타일을 적용해보았는데, 제가 구현한 방식보다 더 괜찮은 방법이 있는지 궁금합니다. +```jsx +const commonButtonStyles = css` + white-space: nowrap; + border-radius: 4px; +`; +``` +``` + + + +``` +- 태블릿(`768px`)과 모바일(`480px`)에서 반응형을 고려하여 `breakpoints`를 정의하였고, 이를 보다 명시적으로 활용하기 위해 `responsiveStyles` 함수를 구현했습니다. +멘토님께서는 보통 반응형 스타일링을 구현할 때 어떤 방식을 사용하시나요? +혹시 제가 사용한 `responsiveStyles` 함수보다 효율적이거나 코드의 가독성을 높일 수 있는 더 나은 방법이 있을까요? 멘토님이 추천하는 방법이나 일반적으로 사용되는 best practice 또한 궁금합니다. +- 현재 `Modal` 컴포넌트를 사용할 때마다 `useToggle` 커스텀 훅을 함께 사용해야 해서, 모달을 제어하기 위한 코드가 흩어져 있는 느낌입니다. 이렇게 되면 모달 관련 로직으로 인해서 단일 책임 원칙에 어긋난다는 생각이 들곤 합니다. +보다 나은 방식으로 `Modal` 컴포넌트를 동작시킬 수 있는 방법이 있을까요? `useToggle`처럼 모달을 제어하는 로직을 간소화하고, 모달 컴포넌트 자체가 스스로 상태를 관리하거나 쉽게 제어 가능한 형태로 구현할 수 있는지 궁금합니다. + +## 🙋‍♂️ 4주차 코드리뷰 질문 +- `Modal` 컴포넌트를 구현하면서 텍스트 부분과 버튼 부분에 들어갈 내용은 개발할 때 코드를 작성하는 사람이 자유롭게 작성하여 구성할 수 있도록 `textChildren`과 `buttonChildren`만으로 구성하였는데, 더 적합하거나 지향하는 방식이 있을까요 diff --git a/eslint.config.js b/eslint.config.js index e9c92e0..94efdac 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -21,6 +21,8 @@ export default tseslint.config( ...reactHooks.configs.recommended.rules, 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], 'react-refresh/only-export-components': 'off', + 'react-hooks/rules-of-hooks': 'off', + '@typescript-eslint/no-explicit-any': 'off', }, }, ); diff --git a/package-lock.json b/package-lock.json index 462ba8b..6d70f51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,10 +12,14 @@ "@emotion/css": "^11.13.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@loadable/component": "^5.16.4", + "@react-oauth/google": "^0.12.1", "@tanstack/react-query": "^5.56.2", + "axios": "^1.7.7", "csstype": "^3.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-error-boundary": "^4.1.2", "react-hook-form": "^7.53.0", "react-router-dom": "^6.26.2", "zustand": "^4.5.5" @@ -31,6 +35,7 @@ "@storybook/react": "^8.3.0", "@storybook/react-vite": "^8.3.0", "@storybook/test": "^8.3.0", + "@types/loadable__component": "^5.13.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-slick": "^0.23.13", @@ -46,6 +51,7 @@ "husky": "^9.1.6", "lint-staged": "^15.2.10", "msw": "^2.4.6", + "msw-storybook-addon": "^2.0.3", "prettier": "3.3.3", "react-slick": "^0.30.2", "slick-carousel": "^1.8.1", @@ -1351,6 +1357,26 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@loadable/component": { + "version": "5.16.4", + "resolved": "https://registry.npmjs.org/@loadable/component/-/component-5.16.4.tgz", + "integrity": "sha512-fJWxx9b5WHX90QKmizo9B+es2so8DnBthI1mbflwCoOyvzEwxiZ/SVDCTtXEnHG72/kGBdzr297SSIekYtzSOQ==", + "dependencies": { + "@babel/runtime": "^7.12.18", + "hoist-non-react-statics": "^3.3.1", + "react-is": "^16.12.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "react": "^16.3.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/@mdx-js/react": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.0.1.tgz", @@ -1442,6 +1468,15 @@ "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true }, + "node_modules/@react-oauth/google": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.12.1.tgz", + "integrity": "sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@remix-run/router": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", @@ -2955,6 +2990,15 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/loadable__component": { + "version": "5.13.9", + "resolved": "https://registry.npmjs.org/@types/loadable__component/-/loadable__component-5.13.9.tgz", + "integrity": "sha512-QWOtIkwZqHNdQj3nixQ8oyihQiTMKZLk/DNuvNxMSbTfxf47w+kqcbnxlUeBgAxdOtW0Dh48dTAIp83iJKtnrQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/lodash": { "version": "4.17.7", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.7.tgz", @@ -3600,6 +3644,11 @@ "node": ">=4" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -3615,6 +3664,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axios": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", + "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/babel-plugin-macros": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", @@ -4033,6 +4092,17 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", @@ -4192,6 +4262,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -5185,6 +5263,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -5194,6 +5291,19 @@ "is-callable": "^1.1.3" } }, + "node_modules/form-data": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6370,7 +6480,6 @@ "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, "engines": { "node": ">= 0.6" } @@ -6379,7 +6488,6 @@ "version": "2.1.35", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, "dependencies": { "mime-db": "1.52.0" }, @@ -6489,6 +6597,18 @@ } } }, + "node_modules/msw-storybook-addon": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/msw-storybook-addon/-/msw-storybook-addon-2.0.3.tgz", + "integrity": "sha512-CzHmGO32JeOPnyUnRWnB0PFTXCY1HKfHiEB/6fYoUYiFm2NYosLjzs9aBd3XJUryYEN0avJqMNh7nCRDxE5JjQ==", + "dev": true, + "dependencies": { + "is-node-process": "^1.0.1" + }, + "peerDependencies": { + "msw": "^2.0.0" + } + }, "node_modules/msw/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -7086,6 +7206,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -7265,6 +7390,17 @@ "integrity": "sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==", "dev": true }, + "node_modules/react-error-boundary": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.1.2.tgz", + "integrity": "sha512-GQDxZ5Jd+Aq/qUxbCm1UtzmL/s++V7zKgE8yMktJiCQXCCFZnMZh9ng+6/Ne6PjNSXH0L9CjeOEREfRnq6Duag==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-hook-form": { "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", diff --git a/package.json b/package.json index 4f9a70b..a5a4db4 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "lint-staged": { "**/*.{tsx,ts,jsx,js}": [ + "bash -c tsc -p tsconfig.json --noEmit", "eslint --fix --cache", "prettier --write --cache" ] @@ -27,10 +28,14 @@ "@emotion/css": "^11.13.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", + "@loadable/component": "^5.16.4", + "@react-oauth/google": "^0.12.1", "@tanstack/react-query": "^5.56.2", + "axios": "^1.7.7", "csstype": "^3.1.3", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-error-boundary": "^4.1.2", "react-hook-form": "^7.53.0", "react-router-dom": "^6.26.2", "zustand": "^4.5.5" @@ -46,6 +51,7 @@ "@storybook/react": "^8.3.0", "@storybook/react-vite": "^8.3.0", "@storybook/test": "^8.3.0", + "@types/loadable__component": "^5.13.9", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/react-slick": "^0.23.13", @@ -61,6 +67,7 @@ "husky": "^9.1.6", "lint-staged": "^15.2.10", "msw": "^2.4.6", + "msw-storybook-addon": "^2.0.3", "prettier": "3.3.3", "react-slick": "^0.30.2", "slick-carousel": "^1.8.1", @@ -74,5 +81,10 @@ "extends": [ "plugin:storybook/recommended" ] + }, + "msw": { + "workerDirectory": [ + "public" + ] } } diff --git a/public/mockServiceWorker.js b/public/mockServiceWorker.js new file mode 100644 index 0000000..0eeeb55 --- /dev/null +++ b/public/mockServiceWorker.js @@ -0,0 +1,280 @@ +/* tslint:disable */ + +/** + * Mock Service Worker. + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const PACKAGE_VERSION = '2.4.6'; +const INTEGRITY_CHECKSUM = '26357c79639bfa20d64c0efca2a87423'; +const IS_MOCKED_RESPONSE = Symbol('isMockedResponse'); +const activeClientIds = new Set(); + +self.addEventListener('install', function () { + self.skipWaiting(); +}); + +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('message', async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + switch (event.data) { + case 'KEEPALIVE_REQUEST': { + sendToClient(client, { + type: 'KEEPALIVE_RESPONSE', + }); + break; + } + + case 'INTEGRITY_CHECK_REQUEST': { + sendToClient(client, { + type: 'INTEGRITY_CHECK_RESPONSE', + payload: { + packageVersion: PACKAGE_VERSION, + checksum: INTEGRITY_CHECKSUM, + }, + }); + break; + } + + case 'MOCK_ACTIVATE': { + activeClientIds.add(clientId); + + sendToClient(client, { + type: 'MOCKING_ENABLED', + payload: true, + }); + break; + } + + case 'MOCK_DEACTIVATE': { + activeClientIds.delete(clientId); + break; + } + + case 'CLIENT_CLOSED': { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener('fetch', function (event) { + const { request } = event; + + // Bypass navigation requests. + if (request.mode === 'navigate') { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = crypto.randomUUID(); + event.respondWith(handleRequest(event, requestId)); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const responseClone = response.clone(); + + sendToClient( + client, + { + type: 'RESPONSE', + payload: { + requestId, + isMockedResponse: IS_MOCKED_RESPONSE in response, + type: responseClone.type, + status: responseClone.status, + statusText: responseClone.statusText, + body: responseClone.body, + headers: Object.fromEntries(responseClone.headers.entries()), + }, + }, + [responseClone.body], + ); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === 'top-level') { + return client; + } + + const allClients = await self.clients.matchAll({ + type: 'window', + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === 'visible'; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const requestClone = request.clone(); + + function passthrough() { + const headers = Object.fromEntries(requestClone.headers.entries()); + + // Remove internal MSW request header so the passthrough request + // complies with any potential CORS preflight checks on the server. + // Some servers forbid unknown request headers. + delete headers['x-msw-intention']; + + return fetch(requestClone, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const requestBuffer = await request.arrayBuffer(); + const clientMessage = await sendToClient( + client, + { + type: 'REQUEST', + payload: { + id: requestId, + url: request.url, + mode: request.mode, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: requestBuffer, + keepalive: request.keepalive, + }, + }, + [requestBuffer], + ); + + switch (clientMessage.type) { + case 'MOCK_RESPONSE': { + return respondWithMock(clientMessage.data); + } + + case 'PASSTHROUGH': { + return passthrough(); + } + } + + return passthrough(); +} + +function sendToClient(client, message, transferrables = []) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2].concat(transferrables.filter(Boolean))); + }); +} + +async function respondWithMock(response) { + // Setting response status code to 0 is a no-op. + // However, when responding with a "Response.error()", the produced Response + // instance will have status code set to 0. Since it's not possible to create + // a Response instance with status code 0, handle that use-case separately. + if (response.status === 0) { + return Response.error(); + } + + const mockedResponse = new Response(response.body, response); + + Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, { + value: true, + enumerable: true, + }); + + return mockedResponse; +} diff --git a/src/apis/apiPath.ts b/src/apis/apiPath.ts new file mode 100644 index 0000000..e4fc864 --- /dev/null +++ b/src/apis/apiPath.ts @@ -0,0 +1,11 @@ +export const APIPath = { + postNotice: '/api/recruitments', + allApplication: '/api/application/all', + signEmployeeContract: '/api/contract', + makeEmployerContract: '/api/categories', + downloadContract: '/api/contract/:applyId/download', +}; + +export const getDynamicAPIPath = { + downloadContract: (applyId: number) => APIPath.downloadContract.replace(':applyId', applyId.toString()), +}; diff --git a/src/apis/auth/hooks/useAuthFetch.ts b/src/apis/auth/hooks/useAuthFetch.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/apis/auth/mock/index.mock.ts b/src/apis/auth/mock/index.mock.ts deleted file mode 100644 index 056a6e2..0000000 --- a/src/apis/auth/mock/index.mock.ts +++ /dev/null @@ -1 +0,0 @@ -// 데이터 관련된 diff --git a/src/apis/auth/mutations/useGoogleOAuth.tsx b/src/apis/auth/mutations/useGoogleOAuth.tsx new file mode 100644 index 0000000..63ae45b --- /dev/null +++ b/src/apis/auth/mutations/useGoogleOAuth.tsx @@ -0,0 +1,81 @@ +import { clientInstance } from '@apis/instance'; +import { useMutation } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { AUTH_PATH } from '../path'; +import { OAuthRequest } from '../types/request'; +import { OAuthResponse } from '../types/response'; +import { useNavigate } from 'react-router-dom'; +import { useCallback, useState, useEffect } from 'react'; +import ROUTE_PATH from '@/routes/path'; + +const clientId = import.meta.env.VITE_GOOGLE_AUTH_CLIENT_ID; +const redirectUri = import.meta.env.VITE_GOOGLE_AUTH_REDIRECT_URI; + +const getAccessTokenFromUrl = () => { + const hashParams = new URLSearchParams(window.location.hash.substring(1)); + return hashParams.get('access_token'); +}; + +const postOAuth = async ({ token }: OAuthRequest): Promise => { + const res = await clientInstance.post(AUTH_PATH.OAUTH, { token }); + + const accessToken = res.headers['authorization']; + if (!accessToken) { + throw new Error('Authorization header is missing in the response'); + } + + return { + accessToken, + type: res.data.type, + profileImage: res.data.profileImage, + }; +}; + +export function useGoogleOAuth(): { + isLoading: boolean; + redirectToGoogleLogin: () => void; +} { + const [isLoading, setIsLoading] = useState(false); + const navigate = useNavigate(); + + const redirectToGoogleLogin = useCallback(() => { + const googleAuthUrl = `https://accounts.google.com/o/oauth2/v2/auth?scope=https%3A//www.googleapis.com/auth/drive.metadata.readonly&include_granted_scopes=true&response_type=token&state=state_parameter_passthrough_value&redirect_uri=${redirectUri}&client_id=${clientId}`; + + window.location.href = googleAuthUrl; + }, []); + + const { mutate: handleLoginPost, status } = useMutation({ + mutationFn: postOAuth, + onSuccess: (data) => { + const { accessToken, type } = data; + + localStorage.setItem('token', accessToken); + + if (type === 'first') { + navigate(ROUTE_PATH.AUTH.SIGN_UP); + } else { + navigate(ROUTE_PATH.HOME); + } + + window.location.reload(); + }, + onError: (error) => { + console.error('Error during login:', error); + setIsLoading(false); + }, + }); + + const isMutating = status === 'pending'; + + useEffect(() => { + const token = getAccessTokenFromUrl(); + if (token) { + setIsLoading(true); + handleLoginPost({ token }); + } else { + console.log('로그인 재시도하세요.'); + } + }, [handleLoginPost]); + + return { isLoading: isLoading || isMutating, redirectToGoogleLogin }; +} diff --git a/src/apis/auth/mutations/useRegister.tsx b/src/apis/auth/mutations/useRegister.tsx new file mode 100644 index 0000000..e3ad42e --- /dev/null +++ b/src/apis/auth/mutations/useRegister.tsx @@ -0,0 +1,17 @@ +import { clientInstance } from '@apis/instance'; +import { useMutation, UseMutationResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { AUTH_PATH } from '../path'; +import { RegisterRequest } from '../types/request'; +import { RegisterResponse } from '../types/response'; + +const postRegister = async ({ type }: RegisterRequest): Promise => { + const res = await clientInstance.post(AUTH_PATH.REGISTER, { type }); + return res.data; +}; + +export const useRegister = (): UseMutationResult => { + return useMutation({ + mutationFn: postRegister, + }); +}; diff --git a/src/apis/auth/path.ts b/src/apis/auth/path.ts new file mode 100644 index 0000000..4205fc1 --- /dev/null +++ b/src/apis/auth/path.ts @@ -0,0 +1,6 @@ +const BASE_URL = '/api'; + +export const AUTH_PATH = { + OAUTH: `${BASE_URL}/oauth`, + REGISTER: `${BASE_URL}/register`, +}; diff --git a/src/apis/auth/types/request.ts b/src/apis/auth/types/request.ts new file mode 100644 index 0000000..86161ab --- /dev/null +++ b/src/apis/auth/types/request.ts @@ -0,0 +1,7 @@ +export interface OAuthRequest { + token: string; +} + +export interface RegisterRequest { + type: 'employee' | 'employer'; +} diff --git a/src/apis/auth/types/response.ts b/src/apis/auth/types/response.ts new file mode 100644 index 0000000..68d4e49 --- /dev/null +++ b/src/apis/auth/types/response.ts @@ -0,0 +1,9 @@ +export interface OAuthResponse { + accessToken: string; + type: 'first' | 'employee' | 'employer'; + profileImage: string; +} + +export interface RegisterResponse { + status: boolean; +} diff --git a/src/apis/employee/hooks/useGetMyApplication.ts b/src/apis/employee/hooks/useGetMyApplication.ts new file mode 100644 index 0000000..d19caa5 --- /dev/null +++ b/src/apis/employee/hooks/useGetMyApplication.ts @@ -0,0 +1,18 @@ +import { APIPath } from '@/apis/apiPath'; +import { clientInstance } from '@/apis/instance'; +import { useQuery } from '@tanstack/react-query'; + +export const getMyApplicationPath = () => `${APIPath.allApplication}`; + +const myApplicationQueryKey = [getMyApplicationPath()]; + +export const getMyApplication = async () => { + const response = await clientInstance.get(getMyApplicationPath()); + return response.data; +}; + +export const useGetMyApplication = () => + useQuery({ + queryKey: myApplicationQueryKey, + queryFn: getMyApplication, + }); diff --git a/src/apis/employee/mock/getMyApplication.mock.ts b/src/apis/employee/mock/getMyApplication.mock.ts new file mode 100644 index 0000000..4217cbf --- /dev/null +++ b/src/apis/employee/mock/getMyApplication.mock.ts @@ -0,0 +1,9 @@ +import { http, HttpResponse } from 'msw'; +import { getMyApplicationPath } from '../hooks/useGetMyApplication'; +import { myRecruitList } from '@/pages/employee/myPage/data/index.mock'; + +export const EmployeePageMockHandler = [ + http.get(getMyApplicationPath(), () => { + return HttpResponse.json(myRecruitList); + }), +]; diff --git a/src/apis/employer/hooks/usePostNotice.ts b/src/apis/employer/hooks/usePostNotice.ts new file mode 100644 index 0000000..b54af4a --- /dev/null +++ b/src/apis/employer/hooks/usePostNotice.ts @@ -0,0 +1,32 @@ +import { APIPath } from '@/apis/apiPath'; +import { clientInstance } from '@/apis/instance'; +import { useMutation } from '@tanstack/react-query'; + +export type NoticeRequestData = { + title?: string; + companyScale?: string; + area?: string; + salary?: string; + workDuration?: string; + workDays?: string; + workType?: string; + workHours?: string; + requestedCareer?: string; + majorBusiness?: string; + eligibilityCriteria?: string; + preferredConditions?: string; + employerName?: string; + companyName?: string; +}; + +export const getPostNoticePath = () => `${APIPath.postNotice}`; + +export const postNotice = async (req: NoticeRequestData) => { + const response = await clientInstance.post(getPostNoticePath(), req); + return response.data; +}; + +export const FetchPostNotice = () => + useMutation({ + mutationFn: postNotice, + }); diff --git a/src/apis/employer/mock/postNotice.mock.ts b/src/apis/employer/mock/postNotice.mock.ts new file mode 100644 index 0000000..40e2a8b --- /dev/null +++ b/src/apis/employer/mock/postNotice.mock.ts @@ -0,0 +1,10 @@ +import { http, HttpResponse } from 'msw'; +import { getPostNoticePath } from '../hooks/usePostNotice'; + +export const noticesMockHandler = [ + http.post(getPostNoticePath(), async ({ request }) => { + // 가로챈 요청 바디를 JSON으로 읽기 + const req = await request.json(); + return HttpResponse.json(req, { status: 201 }); + }), +]; diff --git a/src/pages/home/data/index.mock.ts b/src/apis/home/mocks/recruitmentsMockHandler.ts similarity index 71% rename from src/pages/home/data/index.mock.ts rename to src/apis/home/mocks/recruitmentsMockHandler.ts index 17da861..8243ff8 100644 --- a/src/pages/home/data/index.mock.ts +++ b/src/apis/home/mocks/recruitmentsMockHandler.ts @@ -1,22 +1,14 @@ -export const images = [ - { - id: 1, - imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', - }, - { - id: 2, - imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', - }, - { - id: 3, - imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', - }, +import { http, HttpResponse } from 'msw'; +import { HOME_PATH } from '../path'; + +export const recruitmentsMockHandler = [ + http.get(HOME_PATH.RECRUITMENTS, () => HttpResponse.json(RECRUITMENTS_RESPONSE_DATA)), ]; -export const recruitmentList = [ +const RECRUITMENTS_RESPONSE_DATA = [ { recruitmentId: 1, - image: + imageUrl: 'https://img.freepik.com/free-photo/low-angle-view-of-skyscrapers_1359-1105.jpg?size=626&ext=jpg&ga=GA1.1.1297763733.1727740800&semt=ais_hybrid', koreanTitle: '제목', vietnameseTitle: '제목', @@ -27,7 +19,7 @@ export const recruitmentList = [ }, { recruitmentId: 2, - image: + imageUrl: 'https://img.freepik.com/free-photo/low-angle-view-of-skyscrapers_1359-1105.jpg?size=626&ext=jpg&ga=GA1.1.1297763733.1727740800&semt=ais_hybrid', koreanTitle: '제목', vietnameseTitle: '제목', @@ -38,7 +30,7 @@ export const recruitmentList = [ }, { recruitmentId: 3, - image: + imageUrl: 'https://img.freepik.com/free-photo/low-angle-view-of-skyscrapers_1359-1105.jpg?size=626&ext=jpg&ga=GA1.1.1297763733.1727740800&semt=ais_hybrid', koreanTitle: '제목', vietnameseTitle: '제목', diff --git a/src/apis/home/mocks/slidesMockHandler.ts b/src/apis/home/mocks/slidesMockHandler.ts new file mode 100644 index 0000000..1bf8c26 --- /dev/null +++ b/src/apis/home/mocks/slidesMockHandler.ts @@ -0,0 +1,19 @@ +import { http, HttpResponse } from 'msw'; +import { HOME_PATH } from '../path'; + +export const slidesMockHandler = [http.get(HOME_PATH.SLIDER, () => HttpResponse.json(SLIDER_RESPONSE_DATA))]; + +const SLIDER_RESPONSE_DATA = [ + { + id: 1, + imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', + }, + { + id: 2, + imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', + }, + { + id: 3, + imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg', + }, +]; diff --git a/src/apis/home/path.ts b/src/apis/home/path.ts new file mode 100644 index 0000000..74ad5b1 --- /dev/null +++ b/src/apis/home/path.ts @@ -0,0 +1,6 @@ +const BASE_URL = '/api'; + +export const HOME_PATH = { + RECRUITMENTS: `${BASE_URL}/recruitments`, + SLIDER: `${BASE_URL}/slides`, +}; diff --git a/src/apis/home/queries/queryKeys.ts b/src/apis/home/queries/queryKeys.ts new file mode 100644 index 0000000..ffb872c --- /dev/null +++ b/src/apis/home/queries/queryKeys.ts @@ -0,0 +1,4 @@ +export const QUERY_KEYS = { + RECRUITMENTS: 'recruitments', + SLIDES: 'slides', +}; diff --git a/src/apis/home/queries/useFetchRecruitments.tsx b/src/apis/home/queries/useFetchRecruitments.tsx new file mode 100644 index 0000000..7bb8bb8 --- /dev/null +++ b/src/apis/home/queries/useFetchRecruitments.tsx @@ -0,0 +1,18 @@ +import { AxiosError } from 'axios'; +import { RecruitmentResponse } from '../types/response'; +import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; +import { clientInstance } from '@apis/instance'; +import { HOME_PATH } from '../path'; +import { QUERY_KEYS } from './queryKeys'; + +const getRecruitments = async (): Promise => { + const res = await clientInstance.get(HOME_PATH.RECRUITMENTS); + return res.data; +}; + +export const useFetchRecruitments = (): UseSuspenseQueryResult => { + return useSuspenseQuery({ + queryKey: [QUERY_KEYS.RECRUITMENTS], + queryFn: () => getRecruitments(), + }); +}; diff --git a/src/apis/home/queries/useFetchSlides.tsx b/src/apis/home/queries/useFetchSlides.tsx new file mode 100644 index 0000000..adfcba0 --- /dev/null +++ b/src/apis/home/queries/useFetchSlides.tsx @@ -0,0 +1,18 @@ +import { AxiosError } from 'axios'; +import { useSuspenseQuery, UseSuspenseQueryResult } from '@tanstack/react-query'; +import { clientInstance } from '@apis/instance'; +import { HOME_PATH } from '../path'; +import { SlidesResponse } from '../types/response'; +import { QUERY_KEYS } from './queryKeys'; + +const getSlides = async (): Promise => { + const res = await clientInstance.get(HOME_PATH.SLIDER); + return res.data; +}; + +export const useFetchSlides = (): UseSuspenseQueryResult => { + return useSuspenseQuery({ + queryKey: [QUERY_KEYS.SLIDES], + queryFn: () => getSlides(), + }); +}; diff --git a/src/apis/home/types/response.ts b/src/apis/home/types/response.ts new file mode 100644 index 0000000..8b6990a --- /dev/null +++ b/src/apis/home/types/response.ts @@ -0,0 +1,15 @@ +export type RecruitmentResponse = { + recruitmentId: number; + imageUrl: string; + koreanTitle: string; + vietnameseTitle: string; + companyName: string; + salary: number; + workHours: string; + area: string; +}; + +export type SlidesResponse = { + id: number; + imageUrl: string; +}; diff --git a/src/apis/instance.ts b/src/apis/instance.ts index e69de29..8258c92 100644 --- a/src/apis/instance.ts +++ b/src/apis/instance.ts @@ -0,0 +1,48 @@ +import axios, { AxiosError, AxiosInstance } from 'axios'; +import { QueryClient } from '@tanstack/react-query'; + +const BASE_URL = import.meta.env.VITE_BASE_URL; + +const setInterceptors = (instance: AxiosInstance) => { + instance.interceptors.response.use( + (response) => response, + (error) => { + console.log('interceptor > error', error); + return Promise.reject(error); + }, + ); + + instance.interceptors.request.use( + (config) => { + const token = localStorage.getItem('token'); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; + }, + (error: AxiosError) => { + console.log('interceptor > error', error); + Promise.reject(error); + }, + ); +}; + +const createInstance = () => { + const instance = axios.create({ + baseURL: BASE_URL, + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + }); + + setInterceptors(instance); + + return instance; +}; + +export const queryClient = new QueryClient(); + +export const clientInstance = createInstance(); diff --git a/src/assets/icons/arrow/big-right-blue.svg b/src/assets/icons/arrow/big-right-blue.svg new file mode 100644 index 0000000..6fdba45 --- /dev/null +++ b/src/assets/icons/arrow/big-right-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/arrow/down-blue.svg b/src/assets/icons/arrow/down-blue.svg new file mode 100644 index 0000000..4d0f94d --- /dev/null +++ b/src/assets/icons/arrow/down-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/employeePage/bag.svg b/src/assets/icons/employeePage/bag.svg new file mode 100644 index 0000000..8af5d8d --- /dev/null +++ b/src/assets/icons/employeePage/bag.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/employeePage/card.svg b/src/assets/icons/employeePage/card.svg new file mode 100644 index 0000000..43a2d44 --- /dev/null +++ b/src/assets/icons/employeePage/card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/employeePage/pen.svg b/src/assets/icons/employeePage/pen.svg new file mode 100644 index 0000000..05eb8ee --- /dev/null +++ b/src/assets/icons/employeePage/pen.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/recruitmentInfo/area.svg b/src/assets/icons/recruitmentInfo/area.svg new file mode 100644 index 0000000..71ba9d1 --- /dev/null +++ b/src/assets/icons/recruitmentInfo/area.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/recruitmentInfo/salary.svg b/src/assets/icons/recruitmentInfo/salary.svg new file mode 100644 index 0000000..63504a7 --- /dev/null +++ b/src/assets/icons/recruitmentInfo/salary.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/features/employer/CompanyInfo/coupang.png b/src/assets/images/coupang.png similarity index 100% rename from src/features/employer/CompanyInfo/coupang.png rename to src/assets/images/coupang.png diff --git a/src/assets/images/profile-image.svg b/src/assets/images/profile-image.svg new file mode 100644 index 0000000..3e57953 --- /dev/null +++ b/src/assets/images/profile-image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/common/AsyncBoundary/index.tsx b/src/components/common/AsyncBoundary/index.tsx new file mode 100644 index 0000000..116c847 --- /dev/null +++ b/src/components/common/AsyncBoundary/index.tsx @@ -0,0 +1,30 @@ +import type { ComponentProps, Ref } from 'react'; +import { forwardRef, Suspense, useImperativeHandle, useRef, ReactNode } from 'react'; +import type { ErrorBoundaryProps } from 'react-error-boundary'; +import { ErrorBoundary } from 'react-error-boundary'; + +type Props = { + children: ReactNode; + rejectedFallback?: ErrorBoundaryProps['fallback']; + pendingFallback?: ComponentProps['fallback']; +}; + +interface ResetRef { + reset?(): void; +} + +const AsyncBoundary = forwardRef(({ pendingFallback, rejectedFallback, children }: Props, resetRef: Ref) => { + const ref = useRef(null); + + useImperativeHandle(resetRef, () => ({ + reset: () => ref.current?.resetErrorBoundary(), + })); + + return ( + }> + }>{children} + + ); +}); + +export default AsyncBoundary; diff --git a/src/components/common/Button/index.stories.tsx b/src/components/common/Button/index.stories.tsx index e908acd..d666695 100644 --- a/src/components/common/Button/index.stories.tsx +++ b/src/components/common/Button/index.stories.tsx @@ -7,7 +7,7 @@ const meta = { tags: ['autodocs'], args: { children: '버튼', - theme: 'default', + design: 'default', }, render: (props) => + ); +}; + +const Icon = styled.div` + display: flex; + align-items: center; +`; diff --git a/src/components/common/Spinner/index.stories.tsx b/src/components/common/Spinner/index.stories.tsx new file mode 100644 index 0000000..6cde837 --- /dev/null +++ b/src/components/common/Spinner/index.stories.tsx @@ -0,0 +1,14 @@ +import { Meta, StoryObj } from '@storybook/react'; +import Spinner from '.'; + +const meta: Meta = { + title: 'common/Spinner', + component: Spinner, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/components/common/Spinner/index.tsx b/src/components/common/Spinner/index.tsx new file mode 100644 index 0000000..909c4e7 --- /dev/null +++ b/src/components/common/Spinner/index.tsx @@ -0,0 +1,27 @@ +import { css } from '@emotion/react'; + +const spinnerStyle = css` + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + border: 4px solid rgba(0, 0, 0, 0.1); + border-left-color: #09f; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + + @keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } +`; + +export default function Spinner() { + return
; +} diff --git a/src/components/common/Table/index.stories.tsx b/src/components/common/Table/index.stories.tsx new file mode 100644 index 0000000..19a068e --- /dev/null +++ b/src/components/common/Table/index.stories.tsx @@ -0,0 +1,38 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Table, Td, Th } from '.'; + +const meta: Meta = { + title: 'common/Table', + component: Table, + tags: ['autodocs'], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ( + + + + + + + + + + + + + + + + + + + + +
heading 1heading 2heading 3
data 1data 2data 3
data 4data 5data 6
+ ), +}; diff --git a/src/components/common/Table/index.style.ts b/src/components/common/Table/index.style.ts new file mode 100644 index 0000000..163f117 --- /dev/null +++ b/src/components/common/Table/index.style.ts @@ -0,0 +1,33 @@ +import { palettes } from '@/assets/styles/global/palettes'; +import { responsiveStyle } from '@/utils/responsive'; +import { css } from '@emotion/react'; + +export const TableStyle = css` + ${responsiveStyle({ + default: { + width: '100%', + fontSize: '16px', + borderCollapse: 'collapse', + wordWrap: 'break-word', + }, + tablet: { + fontSize: '15px', + }, + })} +`; + +export const ThStyle = css` + padding: 10px 30px; + background-color: ${palettes.backgroundGray}; + border: none; + border-top: 1px solid ${palettes.black}; + border-bottom: 1px solid ${palettes.black}; + text-align: left; +`; + +export const TdStyle = css` + padding: 20px 30px; + border: none; + border-bottom: 1px solid ${palettes.borderGray}; + text-align: left; +`; diff --git a/src/components/common/Table/index.tsx b/src/components/common/Table/index.tsx new file mode 100644 index 0000000..7b81a38 --- /dev/null +++ b/src/components/common/Table/index.tsx @@ -0,0 +1,38 @@ +import { HTMLAttributes, ReactNode } from 'react'; +import { TableStyle, TdStyle, ThStyle } from './index.style'; + +type TableProps = { + children: ReactNode; +} & HTMLAttributes; + +type ThProps = { + children?: ReactNode; +} & HTMLAttributes; + +type TdProps = { + children: ReactNode; +} & HTMLAttributes; + +export function Table({ children, ...rest }: TableProps) { + return ( + + {children} +
+ ); +} + +export function Th({ children, ...rest }: ThProps) { + return ( + + {children} + + ); +} + +export function Td({ children, ...rest }: TdProps) { + return ( + + {children} + + ); +} diff --git a/src/components/common/index.ts b/src/components/common/index.ts index cfae363..3d45851 100644 --- a/src/components/common/index.ts +++ b/src/components/common/index.ts @@ -8,3 +8,7 @@ export { default as Typo } from './Typo'; export { default as Icon } from './Icon'; export { default as List } from './List'; export { default as Image } from './Image'; +export { default as Select } from './Select'; +export { default as AsyncBoundary } from './AsyncBoundary'; +export { default as Spinner } from './Spinner'; +export { Table, Th, Td } from './Table'; diff --git a/src/components/providers/Modals.provider.tsx b/src/components/providers/Modals.provider.tsx new file mode 100644 index 0000000..7beca6b --- /dev/null +++ b/src/components/providers/Modals.provider.tsx @@ -0,0 +1,29 @@ +import { ModalsDispatchContext, ModalsStateContext } from '../common/Modal/index.context'; +import { PropsWithChildren, useState, useMemo } from 'react'; + +interface ModalState { + Component: React.ComponentType; + props: any; +} + +const ModalsProvider = ({ children }: PropsWithChildren) => { + const [openedModals, setOpenedModals] = useState([]); + + const open = (Component: React.ComponentType, props: any) => { + setOpenedModals((modals) => [...modals, { Component, props }]); + }; + + const close = (Component: React.ComponentType) => { + setOpenedModals((modals) => modals.filter((modal) => modal.Component !== Component)); + }; + + const dispatch = useMemo(() => ({ open, close }), []); + + return ( + + {children} + + ); +}; + +export default ModalsProvider; diff --git a/src/components/providers/index.provider.tsx b/src/components/providers/index.provider.tsx index 4b12809..36597b8 100644 --- a/src/components/providers/index.provider.tsx +++ b/src/components/providers/index.provider.tsx @@ -1,6 +1,19 @@ import { ReactNode } from 'react'; import GlobalStylesProvider from './GlobalStylesProvider/index.provider'; +import ModalsProvider from './Modals.provider'; +import Modals from '../common/Modal/Modals'; +import { QueryClientProvider } from '@tanstack/react-query'; +import { queryClient } from '@/apis/instance'; export default function AppProviders({ children }: { children: ReactNode }) { - return {children}; + return ( + + + + {children} + + + + + ); } diff --git a/src/features/applicants/ApplicantList/ApplicantsTable/index.styles.ts b/src/features/applicants/ApplicantList/ApplicantsTable/index.styles.ts new file mode 100644 index 0000000..41faa55 --- /dev/null +++ b/src/features/applicants/ApplicantList/ApplicantsTable/index.styles.ts @@ -0,0 +1,37 @@ +import { responsiveStyle } from '@/utils/responsive'; +import { css } from '@emotion/react'; + +export const buttonsCellStyle = css` + ${responsiveStyle({ + default: { + width: '600px', + }, + tablet: { + width: '280px', + }, + mobile: { + width: '200px', + }, + })} +`; + +export const buttonGroupStyle = css` + ${responsiveStyle({ + tablet: { + flexDirection: 'column', + alignItems: 'stretch', + gap: '10px', + }, + })} +`; + +export const buttonStyle = css` + ${responsiveStyle({ + default: { + whiteSpace: 'nowrap', + }, + mobile: { + fontSize: '15px', + }, + })} +`; diff --git a/src/features/applicants/ApplicantList/ApplicantsTable/index.tsx b/src/features/applicants/ApplicantList/ApplicantsTable/index.tsx new file mode 100644 index 0000000..945bedc --- /dev/null +++ b/src/features/applicants/ApplicantList/ApplicantsTable/index.tsx @@ -0,0 +1,58 @@ +import { Button, Flex, List, Table, Td, Th } from '@/components/common'; +import { useState } from 'react'; +import ContractModal from '../ContractModal'; +import { ApplicantData } from '@/types'; +import { buttonGroupStyle, buttonsCellStyle, buttonStyle } from './index.styles'; + +type Props = { + applicantList: ApplicantData[]; +}; + +export default function ApplicantsTable({ applicantList }: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleOpenModal = () => { + setIsModalOpen(true); + }; + + const handleCloseModal = () => { + setIsModalOpen(false); + }; + + return ( + <> + + + + + + + + + + + ( + + + + + + + )} + /> + +
이름국적한국어 실력
{applicant.name}{applicant.applicantNation}{applicant.korean} + + + + + +
+ + + ); +} diff --git a/src/features/applicants/ApplicantList/ContractModal/ModalText/index.styles.ts b/src/features/applicants/ApplicantList/ContractModal/ModalText/index.styles.ts new file mode 100644 index 0000000..5966ad7 --- /dev/null +++ b/src/features/applicants/ApplicantList/ContractModal/ModalText/index.styles.ts @@ -0,0 +1,13 @@ +export const titleStyle = { + fontWeight: 'bold', + marginBottom: '20px', +}; + +export const paragraphStyle = { + marginBottom: '20px', +}; + +export const headingStyle = { + ...titleStyle, + marginTop: '20px', +}; diff --git a/src/features/applicants/ApplicantList/ContractModal/ModalText/index.tsx b/src/features/applicants/ApplicantList/ContractModal/ModalText/index.tsx new file mode 100644 index 0000000..6b8dcce --- /dev/null +++ b/src/features/applicants/ApplicantList/ContractModal/ModalText/index.tsx @@ -0,0 +1,44 @@ +import { palettes } from '@/assets/styles/global/palettes'; +import { ForeignerData } from '@/types'; +import { Typo } from '@/components/common'; +import { headingStyle, paragraphStyle, titleStyle } from './index.styles'; + +type Props = Pick; + +export default function ModalText({ foreignerIdNumber, visaGenerateDate }: Props) { + return ( + <> + + ✅ 고용주님께 드리는 주의사항 + + + 불법 체류자를 고용할 시 최대 200만원의 범칙금이 부과될 수 있습니다. + +
+ + 지원자 정보 + + + 외국인 등록 번호 : {foreignerIdNumber} +
+ 비자 발급 일자 : {visaGenerateDate} +
+
이 지원자의 정보를 활용하여  + + 하이코리아 + + 에서 지원자에 대한 +
+ 불법 체류 여부를 검증할 수 있습니다. +
+
+ 안전한 고용을 위해 확인 후 진행하는 것을 권장합니다. +
+ + ); +} diff --git a/src/features/applicants/ApplicantList/ContractModal/index.mock.ts b/src/features/applicants/ApplicantList/ContractModal/index.mock.ts new file mode 100644 index 0000000..8093bf3 --- /dev/null +++ b/src/features/applicants/ApplicantList/ContractModal/index.mock.ts @@ -0,0 +1,5 @@ +export const foreigner = { + foreignerIdNumber: '123456-1234567', + visaGenerateDate: '2000-00-00', + visaExpiryDate: '2000-00-00', +}; diff --git a/src/features/applicants/ApplicantList/ContractModal/index.styles.ts b/src/features/applicants/ApplicantList/ContractModal/index.styles.ts new file mode 100644 index 0000000..42599dd --- /dev/null +++ b/src/features/applicants/ApplicantList/ContractModal/index.styles.ts @@ -0,0 +1,15 @@ +import { palettes } from '@/assets/styles/global/palettes'; +import { css } from '@emotion/react'; + +export const modalStyle = { + padding: '15px', +}; + +export const customButtonStyle = css` + background-color: ${palettes.blue}; + color: ${palettes.white}; +`; + +export const buttonTextStyle = { + color: `${palettes.white}`, +}; diff --git a/src/features/applicants/ApplicantList/ContractModal/index.tsx b/src/features/applicants/ApplicantList/ContractModal/index.tsx new file mode 100644 index 0000000..0afbe7b --- /dev/null +++ b/src/features/applicants/ApplicantList/ContractModal/index.tsx @@ -0,0 +1,39 @@ +import { Button, Flex, Icon, Modal, Typo } from '@/components/common'; +import ModalText from './ModalText'; +import { foreigner } from './index.mock'; +import { buttonTextStyle, customButtonStyle, modalStyle } from './index.styles'; + +interface ContractModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function ContractModal({ isOpen, onClose }: ContractModalProps) { + return ( + <> + {isOpen && ( + + } + buttonChildren={ + + + + + } + /* onClose 부분 추후 수정 예정 */ + onClose={onClose} + style={modalStyle} + /> + )} + + ); +} diff --git a/src/features/applicants/ApplicantList/index.tsx b/src/features/applicants/ApplicantList/index.tsx new file mode 100644 index 0000000..a355b49 --- /dev/null +++ b/src/features/applicants/ApplicantList/index.tsx @@ -0,0 +1,23 @@ +import { Flex, Typo } from '@/components/common'; +import ApplicantsTable from './ApplicantsTable'; +import { ApplicantData } from '@/types'; + +type Props = { + applicantList: ApplicantData[]; +}; + +export default function ApplicantList({ applicantList }: Props) { + return ( + + + + 지원자 목록 + + + 총 {applicantList.length}건 + + + + + ); +} diff --git a/src/features/auth/RoleModal.tsx b/src/features/auth/RoleModal.tsx deleted file mode 100644 index 52e9cac..0000000 --- a/src/features/auth/RoleModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Modal, Typo, Flex, Button } from '@components/common'; -import { ReactNode } from 'react'; - -type Props = { - content: ReactNode; - onClose: () => void; -}; - -export default function RoleModal({ content, onClose }: Props) { - return ( - - - 정보를 입력해주세요.
-
- {content} -
-
- * 추후 마이페이지에서 수정 할 수 있습니다. -
-
-
- } - buttonChildren={ - - - - - } - onClose={onClose} - /> - ); -} diff --git a/src/features/auth/RoleSelection.tsx b/src/features/auth/RoleSelection.tsx deleted file mode 100644 index 55a16ec..0000000 --- a/src/features/auth/RoleSelection.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Flex } from '@components/common'; -import RoleSelector from './components/RoleSelector'; -import { ReactNode } from 'react'; - -type Props = { - onRoleSelect: (modalContent: ReactNode) => void; -}; - -export default function RoleSelection({ onRoleSelect }: Props) { - return ( - - - - - ); -} diff --git a/src/features/auth/SignIn/components/SignInButton.tsx b/src/features/auth/SignIn/components/SignInButton.tsx new file mode 100644 index 0000000..3e060ab --- /dev/null +++ b/src/features/auth/SignIn/components/SignInButton.tsx @@ -0,0 +1,20 @@ +import { useGoogleOAuth } from '@/apis/auth/mutations/useGoogleOAuth'; +import { Flex, Typo, Button, Icon } from '@components/common'; + +const FLEX_GAP_CONFIG = { x: '12px' }; +const BUTTON_STYLE = { fontWeight: '300' }; + +export function SignInButton() { + const { redirectToGoogleLogin } = useGoogleOAuth(); + + return ( + + ); +} diff --git a/src/features/auth/SignIn/components/SignInText.tsx b/src/features/auth/SignIn/components/SignInText.tsx new file mode 100644 index 0000000..cdcfd42 --- /dev/null +++ b/src/features/auth/SignIn/components/SignInText.tsx @@ -0,0 +1,41 @@ +import { Flex, Typo } from '@components/common'; +import { responsiveStyle } from '@utils/responsive'; + +const flexStyle = responsiveStyle({ + default: { + marginBottom: '72px', + }, + tablet: { + marginBottom: '56px', + }, + mobile: { + marginBottom: '42px', + alignItems: 'center', + }, +}); + +const headingTypoStyle = responsiveStyle({ + default: { + marginBottom: '24px', + }, + tablet: { fontSize: '32px' }, + mobile: { fontSize: '28px' }, +}); + +const paragraphTypoStyle = responsiveStyle({ + tablet: { fontSize: '16px' }, + mobile: { fontSize: '14px' }, +}); + +export function SignInText() { + return ( + + + 지금 바로 시작하세요. 🚀 + + + 안정적이고 투명한 고용 관계의 시작, 지금 바로 경험해보세요! + + + ); +} diff --git a/src/features/auth/SignUp/components/RoleSelection.tsx b/src/features/auth/SignUp/components/RoleSelection.tsx new file mode 100644 index 0000000..d6dac7a --- /dev/null +++ b/src/features/auth/SignUp/components/RoleSelection.tsx @@ -0,0 +1,13 @@ +import { Flex } from '@components/common'; +import RoleSelector from './common/RoleSelector'; + +const FLEX_GAP_CONFIG = { x: '30px' }; + +export default function RoleSelection() { + return ( + + + + + ); +} diff --git a/src/features/auth/SignUp/components/SignUpText.tsx b/src/features/auth/SignUp/components/SignUpText.tsx new file mode 100644 index 0000000..9ae4c27 --- /dev/null +++ b/src/features/auth/SignUp/components/SignUpText.tsx @@ -0,0 +1,41 @@ +import { Flex, Typo } from '@/components/common'; +import { responsiveStyle } from '@/utils/responsive'; + +const titleStyle = responsiveStyle({ + default: { + marginBottom: '38px', + }, + tablet: { + marginBottom: '28px', + }, + mobile: { + marginBottom: '20px', + fontSize: '20px', + }, +}); + +const descriptionTitle = responsiveStyle({ + default: { + marginBottom: '38px', + }, + tablet: { + marginBottom: '28px', + }, + mobile: { + marginBottom: '20px', + fontSize: '16px', + }, +}); + +export default function SignUpText() { + return ( + + + 가입자 정보 선택 + + + 대상에 해당하는 가입자 정보를 선택해주세요. + + + ); +} diff --git a/src/features/auth/SignUp/components/common/RoleModal.tsx b/src/features/auth/SignUp/components/common/RoleModal.tsx new file mode 100644 index 0000000..55ccaa0 --- /dev/null +++ b/src/features/auth/SignUp/components/common/RoleModal.tsx @@ -0,0 +1,42 @@ +import { Modal, Typo, Flex, Button } from '@components/common'; +import { ReactNode } from 'react'; + +const DEFAULT_CSS = { marginBottom: '12px' }; +const FLEX_GAP_CONFIG = { x: '16px' }; + +type Props = { + content: ReactNode; + onSubmit: () => void; + onClose: () => void; +}; + +export default function RoleModal({ content, onSubmit, onClose }: Props) { + return ( + + + 정보를 입력해주세요. + + + {content} + + + * 추후 마이페이지에서 수정 할 수 있습니다. + + + } + buttonChildren={ + + + + + } + onClose={onClose} + /> + ); +} diff --git a/src/features/auth/components/RoleSelector/index.config.tsx b/src/features/auth/SignUp/components/common/RoleSelector/index.config.tsx similarity index 81% rename from src/features/auth/components/RoleSelector/index.config.tsx rename to src/features/auth/SignUp/components/common/RoleSelector/index.config.tsx index d725a5a..463c935 100644 --- a/src/features/auth/components/RoleSelector/index.config.tsx +++ b/src/features/auth/SignUp/components/common/RoleSelector/index.config.tsx @@ -1,5 +1,6 @@ import { responsiveStyle } from '@utils/responsive'; import { Icon } from '@components/common'; +import ROUTE_PATH from '@/routes/path'; export const roleConfig = { employer: { @@ -20,8 +21,9 @@ export const roleConfig = { 이력서 정보를 등록하러 가실까요? ), + toNavigate: ROUTE_PATH.HOME, // 이력서 등록 페이지로 변경 }, - worker: { + employee: { icon: ( ), + toNavigate: ROUTE_PATH.HOME, // 회사 등록 페이지로 변경 }, }; diff --git a/src/features/auth/SignUp/components/common/RoleSelector/index.tsx b/src/features/auth/SignUp/components/common/RoleSelector/index.tsx new file mode 100644 index 0000000..e13b54f --- /dev/null +++ b/src/features/auth/SignUp/components/common/RoleSelector/index.tsx @@ -0,0 +1,60 @@ +import { Card, Flex, Typo } from '@components/common'; +import { roleConfig } from './index.config'; +import { bounceAnimation } from '@assets/styles/animations'; +import { responsiveStyle } from '@utils/responsive'; +import useModals from '@components/common/Modal/hooks/useModals'; +import { modals } from '@/components/common/Modal/Modals'; +import { useRegister } from '@/apis/auth/mutations/useRegister'; +import { useNavigate } from 'react-router-dom'; +import ROUTE_PATH from '@/routes/path'; + +const cardStyle = responsiveStyle({ + default: { padding: '60px 120px', cursor: 'pointer' }, + mobile: { padding: '16px 32px' }, +}); + +const iconStyle = responsiveStyle({ + default: { marginBottom: '24px' }, + mobile: { + marginBottom: '10px', + }, +}); + +type Props = { + role: 'employer' | 'employee'; +}; + +export default function RoleSelector({ role }: Props) { + const navigate = useNavigate(); + const { openModal } = useModals(); + const { mutate } = useRegister(); + + const handleRegister = () => { + mutate( + { type: role }, + { + onSuccess: () => { + handleOpenModal(); + }, + }, + ); + }; + + const handleOpenModal = () => + openModal(modals.roleModal, { + content: roleConfig[role].modalContent, + onSubmit: () => navigate(roleConfig[role].toNavigate), + onClose: () => navigate(ROUTE_PATH.HOME), + }); + + return ( + + +
{roleConfig[role].icon}
+ + {roleConfig[role].text} + +
+
+ ); +} diff --git a/src/features/auth/components/RoleModal.tsx b/src/features/auth/components/RoleModal.tsx deleted file mode 100644 index 52e9cac..0000000 --- a/src/features/auth/components/RoleModal.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Modal, Typo, Flex, Button } from '@components/common'; -import { ReactNode } from 'react'; - -type Props = { - content: ReactNode; - onClose: () => void; -}; - -export default function RoleModal({ content, onClose }: Props) { - return ( - - - 정보를 입력해주세요.
-
- {content} -
-
- * 추후 마이페이지에서 수정 할 수 있습니다. -
-
- - } - buttonChildren={ - - - - - } - onClose={onClose} - /> - ); -} diff --git a/src/features/auth/components/RoleSelection.tsx b/src/features/auth/components/RoleSelection.tsx deleted file mode 100644 index 4037afb..0000000 --- a/src/features/auth/components/RoleSelection.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Flex } from '@components/common'; -import RoleSelector from './RoleSelector'; -import { ReactNode } from 'react'; - -type Props = { - onRoleSelect: (modalContent: ReactNode) => void; -}; - -export default function RoleSelection({ onRoleSelect }: Props) { - return ( - - - - - ); -} diff --git a/src/features/auth/components/RoleSelector/index.tsx b/src/features/auth/components/RoleSelector/index.tsx deleted file mode 100644 index 610d12f..0000000 --- a/src/features/auth/components/RoleSelector/index.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Card, Flex, Typo } from '@components/common'; -import { roleConfig } from './index.config'; -import { bounceAnimation } from '@assets/styles/animations'; -import { ReactNode } from 'react'; -import { responsiveStyle } from '@utils/responsive'; - -type Props = { - role: 'employer' | 'worker'; - onClick: (modalContent: ReactNode) => void; -}; - -export default function RoleSelector({ role, onClick }: Props) { - return ( - onClick(roleConfig[role].modalContent)} - > - -
- {roleConfig[role].icon} -
- - {roleConfig[role].text} - -
-
- ); -} diff --git a/src/features/companies/CompanyInfo/index.styles.ts b/src/features/companies/CompanyInfo/index.styles.ts new file mode 100644 index 0000000..f6f9fc0 --- /dev/null +++ b/src/features/companies/CompanyInfo/index.styles.ts @@ -0,0 +1,46 @@ +import { responsiveStyle } from '@/utils/responsive'; +import { css } from '@emotion/react'; + +export const infoWrapperStyle = css` + ${responsiveStyle({ + default: { + maxWidth: '60%', + flexDirection: 'column', + justifyContent: 'space-between', + gap: '15px', + }, + tablet: { + alignItems: 'flex-start', + margin: '10px 0 20px 0', + width: '100%', + padding: '0 40px', + }, + mobile: { + padding: '0', + }, + })} +`; + +export const companyNameStyle = { + fontWeight: 'bold', +}; + +export const infoStyle = css` + ${responsiveStyle({ + default: { + alignItems: 'center', + gap: '30px', + }, + tablet: { + flexDirection: 'row', + justifyContent: 'center', + width: '100%', + gap: '10px', + }, + mobile: { + flexDirection: 'column', + alignItems: 'center', + gap: '10px', + }, + })} +`; diff --git a/src/features/companies/CompanyInfo/index.tsx b/src/features/companies/CompanyInfo/index.tsx new file mode 100644 index 0000000..ad90e69 --- /dev/null +++ b/src/features/companies/CompanyInfo/index.tsx @@ -0,0 +1,32 @@ +import { Flex, Typo } from '@/components/common'; +import { CompanyData } from '@/types'; +import IndustryIcon from '@assets/icons/companyInfo/industry.svg?react'; +import BrandIcon from '@assets/icons/companyInfo/brand.svg?react'; +import RevenueIcon from '@assets/icons/companyInfo/revenue.svg?react'; +import { companyNameStyle, infoStyle, infoWrapperStyle } from './index.styles'; + +type Props = Pick; + +export default function CompanyInfo({ name, industryOccupation, brand, revenuePerYear }: Props) { + return ( + + + {name} + + + + + {industryOccupation} + + + + {brand} + + + + {revenuePerYear} 원 + + + + ); +} diff --git a/src/features/companies/CompanyList/CompaniesTable/index.styles.ts b/src/features/companies/CompanyList/CompaniesTable/index.styles.ts new file mode 100644 index 0000000..49cddde --- /dev/null +++ b/src/features/companies/CompanyList/CompaniesTable/index.styles.ts @@ -0,0 +1,26 @@ +import { responsiveStyle } from '@/utils/responsive'; +import { css } from '@emotion/react'; + +export const cellStyle = css` + ${responsiveStyle({ + tablet: { + flexDirection: 'column', + }, + })} +`; + +export const imageSize = { + width: '277px', + height: '110px', +}; + +export const imageStyle = css` + height: 100%; + + ${responsiveStyle({ + tablet: { + margin: '0 auto', + width: '50%', + }, + })} +`; diff --git a/src/features/companies/CompanyList/CompaniesTable/index.tsx b/src/features/companies/CompanyList/CompaniesTable/index.tsx new file mode 100644 index 0000000..4e2433a --- /dev/null +++ b/src/features/companies/CompanyList/CompaniesTable/index.tsx @@ -0,0 +1,45 @@ +import { Button, Flex, Icon, List, Image, Table, Th, Td } from '@/components/common'; +import { CompanyData } from '@/types'; +import CompanyInfo from '@/features/companies/CompanyInfo'; +import { cellStyle, imageSize, imageStyle } from './index.styles'; + +type Props = { + companyList: CompanyData[]; +}; + +export default function CompaniesTable({ companyList }: Props) { + return ( + + + + + + + + ( + + + + )} + /> + +
회사 정보
+ + + + + + + +
+ ); +} diff --git a/src/features/companies/CompanyList/index.tsx b/src/features/companies/CompanyList/index.tsx new file mode 100644 index 0000000..3975af0 --- /dev/null +++ b/src/features/companies/CompanyList/index.tsx @@ -0,0 +1,23 @@ +import { Flex, Typo } from '@/components/common'; +import CompaniesTable from './CompaniesTable'; +import { CompanyData } from '@/types'; + +type Props = { + companyList: CompanyData[]; +}; + +export default function CompanyList({ companyList }: Props) { + return ( + + + + 내 회사 + + + 총 {companyList.length}곳 + + + + + ); +} diff --git a/src/features/employee/myPage/CardButton.tsx b/src/features/employee/myPage/CardButton.tsx new file mode 100644 index 0000000..fc788ba --- /dev/null +++ b/src/features/employee/myPage/CardButton.tsx @@ -0,0 +1,24 @@ +import { Button } from '@/components/common'; +import { ReactNode } from 'react'; + +type Props = { + design?: 'default' | 'outlined' | 'textbutton' | 'deactivate'; + children: ReactNode; +}; + +export default function CardButton({ design, children }: Props) { + return ( + + ); +} diff --git a/src/features/employee/myPage/EmployeeProfile.tsx b/src/features/employee/myPage/EmployeeProfile.tsx new file mode 100644 index 0000000..3805f0a --- /dev/null +++ b/src/features/employee/myPage/EmployeeProfile.tsx @@ -0,0 +1,62 @@ +import { Card, Image, Typo, Button } from '@/components/common'; +import styled from '@emotion/styled'; + +export default function EmployeeProfile({ + profileImage = 'https://img.freepik.com/free-photo/user-profile-icon-front-side-with-white-background_187299-40010.jpg?t=st=1729752570~exp=1729756170~hmac=4313719023c412dd92883d97ce79956fadf541e11d8cc3a4ef05150f301f5e7f&w=740', + name = '홍길동', + description = '소개합니당', +}) { + return ( + + + + + + {name} + + + {description} + + + + + + ); +} + +const ProfileSection = styled.div` + display: flex; + justify-content: start; + align-items: center; +`; + +const TextSection = styled.div` + display: flex; + flex-direction: column; + justify-content: start; + margin-left: 20px; +`; diff --git a/src/features/employee/myPage/MyRecruitCard.tsx b/src/features/employee/myPage/MyRecruitCard.tsx new file mode 100644 index 0000000..6f094af --- /dev/null +++ b/src/features/employee/myPage/MyRecruitCard.tsx @@ -0,0 +1,74 @@ +import { Card, Image, Typo, Button } from '@/components/common'; +import styled from '@emotion/styled'; +import { MyRecruitListProps, StateProps } from '@/types'; + +type DesignProps = { + design: 'default' | 'outlined' | 'textbutton' | 'deactivate'; + text: string; +}; + +function getStateStyle(state: StateProps): DesignProps { + switch (state) { + case '근로계약서 서명하기': + return { design: 'default', text: '근로계약서 서명하기' }; + case '채용 마감': + return { design: 'deactivate', text: '채용 마감' }; + case '지원서 검토중': + return { design: 'outlined', text: '지원서 검토중' }; + case '채용 완료': + return { design: 'deactivate', text: '채용 완료' }; + default: + return { design: 'deactivate', text: '알 수 없음' }; // 상태가 정의되지 않은 경우 + } +} + +type Props = { + myRecruit: MyRecruitListProps; +}; + +export default function MyRecruitCard({ myRecruit }: Props) { + const { image, title, area, state } = myRecruit; + const buttonStyle = getStateStyle(state); + + return ( + + + + + {title} + + {area} + + + + ); +} + +const TextSection = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + flex: 1; + padding: 0 10px; +`; diff --git a/src/features/employee/myPage/MyRecruitList.tsx b/src/features/employee/myPage/MyRecruitList.tsx new file mode 100644 index 0000000..8fad8c5 --- /dev/null +++ b/src/features/employee/myPage/MyRecruitList.tsx @@ -0,0 +1,16 @@ +import { List } from '@/components/common'; +import { MyRecruitListProps } from '@/types'; +import MyRecruitCard from './MyRecruitCard'; + +type Props = { + myRecruitList: MyRecruitListProps[]; +}; + +export default function MyRecruitList({ myRecruitList }: Props) { + return ( + } + /> + ); +} diff --git a/src/features/employer/CompanyInfo/index.tsx b/src/features/employer/CompanyInfo/index.tsx deleted file mode 100644 index 1d92db4..0000000 --- a/src/features/employer/CompanyInfo/index.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { Button, Flex, Typo, Icon } from '@/components/common'; -import styled from '@emotion/styled'; -import IndustryIcon from '@assets/icons/companyInfo/industry.svg?react'; -import BrandIcon from '@assets/icons/companyInfo/brand.svg?react'; -import RevenueIcon from '@assets/icons/companyInfo/revenue.svg?react'; -import { responsiveStyle } from '@utils/responsive'; - -interface CompanyInfoProps { - company: string; - industryOccupation: string; - brand: string; - revenuePerYear: string; - logo: string; -} - -export default function CompanyInfo({ company, industryOccupation, brand, revenuePerYear, logo }: CompanyInfoProps) { - return ( - - - - - {company} - - - - - - {industryOccupation} - - - - - - {brand} - - - - - - {revenuePerYear} 원 - - - - - - - ); -} - -const CompanyFlex = styled(Flex)` - ${responsiveStyle({ - default: { - justifyContent: 'space-between', - alignItems: 'center', - gap: '100px', - }, - tablet: { - flexDirection: 'column', - justifyContent: 'center', - alignItems: 'center', - gap: '15px', - }, - mobile: { - flexDirection: 'column', - }, - })} -`; - -const Logo = styled.img` - width: 280px; - height: auto; - - ${responsiveStyle({ - tablet: { - margin: '0 auto', - width: '50%', - }, - mobile: { - width: '70%', - }, - })} -`; - -const InfoFlex = styled(Flex)` - ${responsiveStyle({ - default: { - flexDirection: 'column', - justifyContent: 'space-between', - gap: '10px', - }, - tablet: { - alignItems: 'center', - margin: '10px 0 20px 0', - width: '70%', - }, - })} -`; - -const InfoGroup = styled(Flex)` - ${responsiveStyle({ - default: { - alignItems: 'center', - gap: '30px', - }, - tablet: { - flexDirection: 'row', - justifyContent: 'center', - width: '80%', - gap: '20px', - }, - mobile: { - flexDirection: 'column', - alignItems: 'center', - gap: '10px', - }, - })} -`; - -const infoStyle = { - color: '#474C54', - whiteSpace: 'nowrap', -}; - -const customButtonStyle = { - backgroundColor: '#0A65CC', - color: '#fff', - borderRadius: '4px', - whiteSpace: 'nowrap', -}; diff --git a/src/features/employer/MyRecruitments/index.tsx b/src/features/employer/MyRecruitments/index.tsx deleted file mode 100644 index 0ad8140..0000000 --- a/src/features/employer/MyRecruitments/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { responsiveStyle } from '@utils/responsive'; -import { Button, Flex, Typo } from '@/components/common'; -import styled from '@emotion/styled'; - -interface RecruitmentProps { - koreanTitle: string; - area: string; - koreanDetailedDescription: string; -} - -interface RecruitmentsListProps { - recruitmentsList: RecruitmentProps[]; -} - -export default function MyRecruitments({ recruitmentsList }: RecruitmentsListProps) { - return ( - - - - 내 공고글 - - - 총 {recruitmentsList.length}건 - - - - - - 근무지 - - - 공고 제목 - - - {recruitmentsList.map((recruitment, index) => ( - - - {recruitment.area} - - - - - {recruitment.koreanTitle} - - - {recruitment.koreanDetailedDescription} - - - - - - - - - ))} - - - ); -} - -const tableHeaderStyle = { - padding: '10px 25px', - backgroundColor: '#F1F2F4', - borderTop: '1px solid #000', - borderBottom: '1px solid #000', -}; - -const tableHeaderTitleStyle = { - color: '#474C54', - fontWeight: 'bold', -}; - -const RecruitmentItem = styled(Flex)` - align-items: center; - border-bottom: 1px solid #e4e5e8; - padding: 30px 25px; -`; - -const recruitmentTitleStyle = { - color: '#0A65CC', -}; - -const DetailsFlex = styled(Flex)` - width: 70%; - gap: 10px; - - ${responsiveStyle({ - tablet: { - flexDirection: 'column', - alignItems: 'center', - gap: '15px', - }, - })} -`; - -const ButtonGroup = styled(Flex)` - justify-content: flex-end; - align-items: center; - gap: 15px; - - ${responsiveStyle({ - tablet: { - justifyContent: 'flex-start', - }, - mobile: { - flexDirection: 'column', - gap: '10px', - alignItems: 'stretch', - }, - })} -`; - -const customButtonStyle = { - backgroundColor: '#F1F2F4', - whiteSpace: 'nowrap', -}; diff --git a/src/features/home/Employer.tsx b/src/features/home/Employer.tsx deleted file mode 100644 index 61815c1..0000000 --- a/src/features/home/Employer.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Flex, Typo, Button } from '@/components/common'; -import Banner from './components/Banner'; -import { images } from '@pages/home/data/index.mock'; -import { responsiveStyle } from '@utils/responsive'; - -export default function Employer() { - return ( - <> - - - - {`사장님,\n 공고 등록은 하셨나요? 🤔`} - - - - - - ); -} diff --git a/src/features/home/RecruitmentList.tsx b/src/features/home/RecruitmentList.tsx deleted file mode 100644 index 3ff2d0f..0000000 --- a/src/features/home/RecruitmentList.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { List, Flex } from '@/components/common'; -import RecruitmentCard from './components/RecruitmentCard/RecruitmentCard'; -import { RecruitmentItem } from '@/types'; -import { responsiveStyle } from '@utils/responsive'; - -type Props = { - recruitmentList: RecruitmentItem[]; -}; - -export default function RecruitmentList({ recruitmentList }: Props) { - return ( - - ( - - - - - - - - - )} - /> - - ); -} diff --git a/src/features/home/Worker.tsx b/src/features/home/Worker.tsx deleted file mode 100644 index f709d60..0000000 --- a/src/features/home/Worker.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import Banner from './components/Banner'; -import { images } from '@/pages/home/data/index.mock'; - -export default function Worker() { - return ; -} diff --git a/src/features/home/components/ConditionalRenderer.tsx b/src/features/home/components/ConditionalRenderer.tsx new file mode 100644 index 0000000..dd9f460 --- /dev/null +++ b/src/features/home/components/ConditionalRenderer.tsx @@ -0,0 +1,9 @@ +import Employer from './Employer'; +import Worker from './Worker'; + +export default function ConditionalRenderer() { + if (localStorage.getItem('userType') === 'employee') { + return ; + } + return ; +} diff --git a/src/features/home/components/Employer.tsx b/src/features/home/components/Employer.tsx new file mode 100644 index 0000000..89b4622 --- /dev/null +++ b/src/features/home/components/Employer.tsx @@ -0,0 +1,36 @@ +import { Flex, Typo, Button } from '@/components/common'; +import Banner from './common/Banner'; +import { responsiveStyle } from '@utils/responsive'; + +const defaultImage = [ + { id: 1, imageUrl: 'https://www.v-on.kr/wp-content/uploads/2022/06/IT_twi001t2302755-1024x683.jpg' }, +]; + +const headerStyle = responsiveStyle({ + default: { + marginBottom: '40px', + whiteSpace: 'pre-line', + textAlign: 'center', + }, + tablet: { + fontSize: '36px', + marginBottom: '32px', + }, + mobile: { + fontSize: '28px', + marginBottom: '28px', + }, +}); + +export default function Employer() { + return ( + + + + {`사장님,\n 공고 등록은 하셨나요? 🤔`} + + + + + ); +} diff --git a/src/features/home/components/RecruitmentCard/Button.tsx b/src/features/home/components/RecruitmentCard/Button.tsx deleted file mode 100644 index 5750f5d..0000000 --- a/src/features/home/components/RecruitmentCard/Button.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { Typo, Icon, Flex } from '@components/common'; - -export default function Detail() { - return ( - - - Read More - - - - ); -} diff --git a/src/features/home/components/RecruitmentCard/CompanyImage.tsx b/src/features/home/components/RecruitmentCard/CompanyImage.tsx deleted file mode 100644 index d1b210f..0000000 --- a/src/features/home/components/RecruitmentCard/CompanyImage.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Image } from '@components/common'; -import { useRecruitmentCardContext } from './RecruitmentCard.context'; -import { responsiveStyle } from '@utils/responsive'; - -export default function CompanyImage() { - const { recruitment } = useRecruitmentCardContext(); - - return ( - - ); -} diff --git a/src/features/home/components/RecruitmentCard/CompanyName.tsx b/src/features/home/components/RecruitmentCard/CompanyName.tsx deleted file mode 100644 index fdefd9e..0000000 --- a/src/features/home/components/RecruitmentCard/CompanyName.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Typo } from '@components/common'; -import { useRecruitmentCardContext } from './RecruitmentCard.context'; -import { responsiveStyle } from '@utils/responsive'; - -export default function CompanyName() { - const { recruitment } = useRecruitmentCardContext(); - - return ( - - {recruitment.companyName} - - ); -} diff --git a/src/features/home/components/RecruitmentCard/Detail.tsx b/src/features/home/components/RecruitmentCard/Detail.tsx deleted file mode 100644 index 4b00214..0000000 --- a/src/features/home/components/RecruitmentCard/Detail.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Typo } from '@components/common'; -import { useRecruitmentCardContext } from './RecruitmentCard.context'; -import { responsiveStyle } from '@utils/responsive'; - -export default function Detail() { - const { recruitment } = useRecruitmentCardContext(); - - return ( - - {recruitment.area} / {recruitment.workHours} - - ); -} diff --git a/src/features/home/components/RecruitmentCard/Title.tsx b/src/features/home/components/RecruitmentCard/Title.tsx deleted file mode 100644 index 6efeffa..0000000 --- a/src/features/home/components/RecruitmentCard/Title.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Typo } from '@components/common'; -import { useRecruitmentCardContext } from './RecruitmentCard.context'; -import { responsiveStyle } from '@utils/responsive'; - -export default function Title() { - const { recruitment } = useRecruitmentCardContext(); - - return ( - - {recruitment.koreanTitle} / {recruitment.vietnameseTitle} - - ); -} diff --git a/src/features/home/components/RecruitmentHeader.tsx b/src/features/home/components/RecruitmentHeader.tsx deleted file mode 100644 index 7c16b52..0000000 --- a/src/features/home/components/RecruitmentHeader.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Flex, Typo } from '@components/common'; -import { responsiveStyle } from '@utils/responsive'; - -export default function RecruitmentHeader() { - return ( - -
- - 어떤 일자리를 구하시나요? - - - 조건을 선택하고 원하는 일자리를 골라보세요. - -
-
- ); -} diff --git a/src/features/home/components/Worker.tsx b/src/features/home/components/Worker.tsx new file mode 100644 index 0000000..16418f3 --- /dev/null +++ b/src/features/home/components/Worker.tsx @@ -0,0 +1,8 @@ +import Banner from './common/Banner'; +import { useFetchSlides } from '@/apis/home/queries/useFetchSlides'; + +export default function Worker() { + const { data: images } = useFetchSlides(); + + return ; +} diff --git a/src/features/home/components/Banner.tsx b/src/features/home/components/common/Banner.tsx similarity index 70% rename from src/features/home/components/Banner.tsx rename to src/features/home/components/common/Banner.tsx index c6e1188..c885434 100644 --- a/src/features/home/components/Banner.tsx +++ b/src/features/home/components/common/Banner.tsx @@ -4,13 +4,9 @@ import Slider from 'react-slick'; import 'slick-carousel/slick/slick.css'; import 'slick-carousel/slick/slick-theme.css'; import styled from '@emotion/styled'; -import { BannerItem } from '@/types'; +import { SlidesResponse } from '@/apis/home/types/response'; -type Props = { - images: BannerItem[]; - isSlider?: boolean; - children?: ReactNode; -}; +const BANNER_SIZE_CONFIG = { width: '100%', height: '400px' }; const settings = { infinite: true, @@ -21,27 +17,32 @@ const settings = { autoplaySpeed: 3000, }; +type Props = { + images: SlidesResponse[]; + isSlider?: boolean; + children?: ReactNode; +}; + export default function Banner({ images, isSlider = false, children }: Props) { return ( -
+
{isSlider ? ( {images.map((image) => ( - + ))} ) : ( - + )} {children && {children}} -
+
); } -const bannerImageSize = { - width: '100%', - height: '400px', -}; +const Section = styled.section` + position: relative; +`; const ChildrenContent = styled.div` position: absolute; diff --git a/src/features/home/components/RecruitmentCard/RecruitmentCard.context.tsx b/src/features/home/components/common/RecruitmentCard/index.context.tsx similarity index 83% rename from src/features/home/components/RecruitmentCard/RecruitmentCard.context.tsx rename to src/features/home/components/common/RecruitmentCard/index.context.tsx index 8299f08..fc90938 100644 --- a/src/features/home/components/RecruitmentCard/RecruitmentCard.context.tsx +++ b/src/features/home/components/common/RecruitmentCard/index.context.tsx @@ -1,8 +1,8 @@ import { createContext, ReactNode, useContext } from 'react'; -import { RecruitmentItem } from '@/types'; +import { RecruitmentResponse } from '@/apis/home/types/response'; type RecruitmentCardContextProps = { - recruitment: RecruitmentItem; + recruitment: RecruitmentResponse; }; const RecruitmentCardContext = createContext(undefined); @@ -16,7 +16,7 @@ export const useRecruitmentCardContext = () => { }; interface ProviderProps { - recruitment: RecruitmentItem; + recruitment: RecruitmentResponse; children: ReactNode; } diff --git a/src/features/home/components/RecruitmentCard/RecruitmentCard.tsx b/src/features/home/components/common/RecruitmentCard/index.tsx similarity index 60% rename from src/features/home/components/RecruitmentCard/RecruitmentCard.tsx rename to src/features/home/components/common/RecruitmentCard/index.tsx index d1f98a8..5021d5e 100644 --- a/src/features/home/components/RecruitmentCard/RecruitmentCard.tsx +++ b/src/features/home/components/common/RecruitmentCard/index.tsx @@ -1,34 +1,30 @@ -import { RecruitmentItem } from '@/types'; import { bounceAnimation } from '@assets/styles/animations'; import { Card } from '@components/common'; import { ReactNode } from 'react'; -import { RecruitmentCardContextProvider } from './RecruitmentCard.context'; -import { Title, Button, CompanyName, CompanyImage, Detail, Salary } from '.'; +import { RecruitmentCardContextProvider } from './index.context'; +import { Title, Button, CompanyName, CompanyImage, Detail, Salary } from './sub-components'; import { responsiveStyle } from '@utils/responsive'; +import { RecruitmentResponse } from '@/apis/home/types/response'; + +const recruitmentCardStyle = responsiveStyle({ + default: { + padding: '24px', + cursor: 'pointer', + }, + mobile: { + padding: '16px', + }, +}); type Props = { - recruitment: RecruitmentItem; + recruitment: RecruitmentResponse; children: ReactNode; }; export default function RecruitmentCard({ recruitment, children }: Props) { return ( - + {children} diff --git a/src/features/home/components/common/RecruitmentCard/sub-components/Button.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/Button.tsx new file mode 100644 index 0000000..e3825fd --- /dev/null +++ b/src/features/home/components/common/RecruitmentCard/sub-components/Button.tsx @@ -0,0 +1,17 @@ +import { Typo, Icon, Flex } from '@components/common'; + +const FLEX_GAP_CONFIG = { x: '8px' }; +const BUTTON_CONTAINER_STYLE = { cursor: 'pointer' }; + +export default function Button() { + return ( + + + Read More + +
+ +
+
+ ); +} diff --git a/src/features/home/components/common/RecruitmentCard/sub-components/CompanyImage.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/CompanyImage.tsx new file mode 100644 index 0000000..a1c828c --- /dev/null +++ b/src/features/home/components/common/RecruitmentCard/sub-components/CompanyImage.tsx @@ -0,0 +1,25 @@ +import { Image } from '@components/common'; +import { useRecruitmentCardContext } from '../index.context'; +import { responsiveStyle } from '@utils/responsive'; + +const IMAGE_SIZE_CONFIG = { + width: '350px', + height: '275px', +}; + +const companyImageStyle = responsiveStyle({ + default: { + marginBottom: '24px', + }, + mobile: { + marginBottom: '18px', + minWidth: '300px', + minHeight: '250px', + }, +}); + +export default function CompanyImage() { + const { recruitment } = useRecruitmentCardContext(); + + return ; +} diff --git a/src/features/home/components/common/RecruitmentCard/sub-components/CompanyName.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/CompanyName.tsx new file mode 100644 index 0000000..7875f4f --- /dev/null +++ b/src/features/home/components/common/RecruitmentCard/sub-components/CompanyName.tsx @@ -0,0 +1,22 @@ +import { Typo } from '@components/common'; +import { useRecruitmentCardContext } from '../index.context'; +import { responsiveStyle } from '@utils/responsive'; + +const companyNameStyle = responsiveStyle({ + default: { + marginBottom: '4px', + }, + mobile: { + fontSize: '12px', + }, +}); + +export default function CompanyName() { + const { recruitment } = useRecruitmentCardContext(); + + return ( + + {recruitment.companyName} + + ); +} diff --git a/src/features/home/components/common/RecruitmentCard/sub-components/Detail.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/Detail.tsx new file mode 100644 index 0000000..26ca3e6 --- /dev/null +++ b/src/features/home/components/common/RecruitmentCard/sub-components/Detail.tsx @@ -0,0 +1,22 @@ +import { Typo } from '@components/common'; +import { useRecruitmentCardContext } from '../index.context'; +import { responsiveStyle } from '@utils/responsive'; + +const detailStyle = responsiveStyle({ + default: { + marginBottom: '16px', + }, + mobile: { + fontSize: '14px', + }, +}); + +export default function Detail() { + const { recruitment } = useRecruitmentCardContext(); + + return ( + + {recruitment.area} / {recruitment.workHours} + + ); +} diff --git a/src/features/home/components/RecruitmentCard/Salary.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/Salary.tsx similarity index 51% rename from src/features/home/components/RecruitmentCard/Salary.tsx rename to src/features/home/components/common/RecruitmentCard/sub-components/Salary.tsx index 2808423..e48d31b 100644 --- a/src/features/home/components/RecruitmentCard/Salary.tsx +++ b/src/features/home/components/common/RecruitmentCard/sub-components/Salary.tsx @@ -1,11 +1,13 @@ import { Typo } from '@components/common'; -import { useRecruitmentCardContext } from './RecruitmentCard.context'; +import { useRecruitmentCardContext } from '../index.context'; + +const SALARY_STYLE = { marginBottom: '4px' }; export default function Salary() { const { recruitment } = useRecruitmentCardContext(); return ( - + {recruitment.salary} ); diff --git a/src/features/home/components/common/RecruitmentCard/sub-components/Title.tsx b/src/features/home/components/common/RecruitmentCard/sub-components/Title.tsx new file mode 100644 index 0000000..f2eb19a --- /dev/null +++ b/src/features/home/components/common/RecruitmentCard/sub-components/Title.tsx @@ -0,0 +1,23 @@ +import { Typo } from '@components/common'; +import { useRecruitmentCardContext } from '../index.context'; +import { responsiveStyle } from '@utils/responsive'; + +const titleStyle = responsiveStyle({ + default: { + marginBottom: '12px', + }, + mobile: { + marginBottom: '8px', + fontSize: '16px', + }, +}); + +export default function Title() { + const { recruitment } = useRecruitmentCardContext(); + + return ( + + {recruitment.koreanTitle} / {recruitment.vietnameseTitle} + + ); +} diff --git a/src/features/home/components/RecruitmentCard/index.ts b/src/features/home/components/common/RecruitmentCard/sub-components/index.ts similarity index 100% rename from src/features/home/components/RecruitmentCard/index.ts rename to src/features/home/components/common/RecruitmentCard/sub-components/index.ts diff --git a/src/features/home/components/common/RecruitmentFilter.tsx b/src/features/home/components/common/RecruitmentFilter.tsx new file mode 100644 index 0000000..300b652 --- /dev/null +++ b/src/features/home/components/common/RecruitmentFilter.tsx @@ -0,0 +1,45 @@ +import theme from '@/assets/theme'; +import { Select, Icon, List } from '@/components/common'; +import useSelect from '@/components/common/Select/hooks/useSelect'; + +const filterOptions = [ + { + value: 'all', + text: '전체', + action: () => console.log('All clicked'), + }, + { + value: 'age', + text: '나이', + action: () => console.log('Age clicked'), + }, + { + value: 'area', + text: '지역', + action: () => console.log('Area clicked'), + }, +]; + +const triggerStyle = { minWidth: '80px', fontSize: '16px', fontWeight: '700', color: theme.palettes.blue }; + +export default function RecruitmentFilter() { + const { selectedOption, handleSelect } = useSelect(filterOptions[0]); + + return ( + + } css={triggerStyle}> + {selectedOption.text} + + + ( + handleSelect(option)}> + {option.text} + + )} + /> + + + ); +} diff --git a/src/features/home/components/common/RecruitmentHeader/index.styles.ts b/src/features/home/components/common/RecruitmentHeader/index.styles.ts new file mode 100644 index 0000000..cf7af1b --- /dev/null +++ b/src/features/home/components/common/RecruitmentHeader/index.styles.ts @@ -0,0 +1,33 @@ +import { responsiveStyle } from '@utils/responsive'; + +export const flexStyle = responsiveStyle({ + default: { + marginBottom: '60px', + }, + mobile: { + marginBottom: '32px', + justifyContent: 'center', + }, +}); + +export const divStyle = responsiveStyle({ + mobile: { + textAlign: 'center', + }, +}); + +export const headerTypoStyle = responsiveStyle({ + default: { + marginBottom: '16px', + }, + mobile: { + marginBottom: '12px', + fontSize: '28px', + }, +}); + +export const subheaderTypoStyle = responsiveStyle({ + mobile: { + fontSize: '18px', + }, +}); diff --git a/src/features/home/components/common/RecruitmentHeader/index.tsx b/src/features/home/components/common/RecruitmentHeader/index.tsx new file mode 100644 index 0000000..f07a692 --- /dev/null +++ b/src/features/home/components/common/RecruitmentHeader/index.tsx @@ -0,0 +1,17 @@ +import { Flex, Typo } from '@components/common'; +import { flexStyle, divStyle, headerTypoStyle, subheaderTypoStyle } from './index.styles'; + +export default function RecruitmentHeader() { + return ( + +
+ + 어떤 일자리를 구하시나요? + + + 조건을 선택하고 원하는 일자리를 골라보세요. + +
+
+ ); +} diff --git a/src/features/home/components/common/RecruitmentList.tsx b/src/features/home/components/common/RecruitmentList.tsx new file mode 100644 index 0000000..bf0243f --- /dev/null +++ b/src/features/home/components/common/RecruitmentList.tsx @@ -0,0 +1,40 @@ +import { List, Flex, Spinner, AsyncBoundary } from '@/components/common'; +import RecruitmentCard from './RecruitmentCard'; +import { responsiveStyle } from '@utils/responsive'; +import { useFetchRecruitments } from '@/apis/home/queries/useFetchRecruitments'; + +const listContainerStyle = responsiveStyle({ + default: { + gap: '32px', + }, + tablet: { + gap: '26px', + }, + mobile: { + gap: '18px', + }, +}); + +export default function RecruitmentList() { + const { data: recruitmentList } = useFetchRecruitments(); + + return ( + }> + + ( + + + + + + + + + )} + /> + + + ); +} diff --git a/src/features/layout/Header/components/LanguageFilter.tsx b/src/features/layout/Header/components/LanguageFilter.tsx new file mode 100644 index 0000000..8882c5a --- /dev/null +++ b/src/features/layout/Header/components/LanguageFilter.tsx @@ -0,0 +1,40 @@ +import theme from '@/assets/theme'; +import { Select, Icon, List } from '@/components/common'; +import useGlobalSelect from '@/components/common/Select/hooks/useGlobalSelect'; + +const triggerStyle = { minWidth: '80px', fontSize: '16px', fontWeight: '700', color: theme.palettes.blue }; + +const languageOptions = [ + { + value: 'korean', + text: '한국어', + action: () => console.log('한국어'), + }, + { + value: 'vietnamese', + text: '베트남어', + action: () => console.log('베트남어'), + }, +]; + +export default function LanguageFilter() { + const { selectedOption, handleSelect } = useGlobalSelect(languageOptions[0]); + + return ( + + } css={triggerStyle}> + {selectedOption.text} + + + ( + handleSelect(option)}> + {option.text} + + )} + /> + + + ); +} diff --git a/src/features/layout/Header/index.stories.tsx b/src/features/layout/Header/index.stories.tsx index 82fd609..8a140e9 100644 --- a/src/features/layout/Header/index.stories.tsx +++ b/src/features/layout/Header/index.stories.tsx @@ -1,5 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import Header from '.'; +import profileImage from '@assets/images/profile-image.svg'; const meta: Meta = { title: 'features/layout/Header', @@ -12,5 +13,5 @@ export default meta; type Story = StoryObj; export const Default: Story = { - render: () =>
, + render: () =>
, }; diff --git a/src/features/layout/Header/index.styles.ts b/src/features/layout/Header/index.styles.ts new file mode 100644 index 0000000..5166a54 --- /dev/null +++ b/src/features/layout/Header/index.styles.ts @@ -0,0 +1,110 @@ +import { palettes } from '@/assets/styles/global/palettes'; +import { responsiveStyle } from '@/utils/responsive'; +import styled from '@emotion/styled'; +import LogoIcon from '@assets/images/hirehigher-logo.svg?react'; +import { css } from '@emotion/react'; + +export const HeaderContainer = styled.header` + display: flex; + justify-content: center; + align-items: center; + background-color: ${palettes.white}; + height: 88px; + + ${responsiveStyle({ + tablet: { + flexDirection: 'column', + height: 'auto', + padding: '10px', + }, + mobile: { + padding: '5px', + }, + })} +`; + +export const LogoImg = styled(LogoIcon)` + cursor: pointer; + height: auto; + + ${responsiveStyle({ + tablet: { + width: '150px', + }, + mobile: { + width: '160px', + }, + })} +`; + +export const flexStyle = css` + width: 100%; + max-width: 1300px; + + ${responsiveStyle({ + tablet: { + flexDirection: 'column', + }, + })} +`; + +export const menuIconStyle = css` + display: none; + + svg { + cursor: pointer; + } + + ${responsiveStyle({ + mobile: { + display: 'flex', + }, + })} +`; + +type NavProps = { + open: boolean; +}; + +export const Nav = styled.nav` + display: flex; + align-items: center; + gap: 35px; + transition: all 0.3s linear; + + ${responsiveStyle({ + tablet: { + width: '100%', + justifyContent: 'center', + marginTop: '10px', + }, + mobile: { + flexDirection: 'column', + alignItems: 'stretch', + }, + })} + + @media (max-width: 480px) { + max-height: ${({ open }) => (open ? '420px' : '0')}; + opacity: ${({ open }) => (open ? '1' : '0')}; + visibility: ${({ open }) => (open ? 'visible' : 'hidden')}; + } +`; + +export const commonButtonStyle = { + whiteSpace: 'nowrap', + borderRadius: '4px', +}; + +export const imageStyle = css` + width: 40px; + height: 40px; + border-radius: 50%; + cursor: pointer; +`; + +export const customButtonStyle = { + ...commonButtonStyle, + backgroundColor: `${palettes.blue}`, + color: `${palettes.white}`, +}; diff --git a/src/features/layout/Header/index.tsx b/src/features/layout/Header/index.tsx index 1649f15..e459eeb 100644 --- a/src/features/layout/Header/index.tsx +++ b/src/features/layout/Header/index.tsx @@ -1,145 +1,44 @@ -import styled from '@emotion/styled'; -import LogoIcon from '@assets/images/hirehigher-logo.svg?react'; import CloseIcon from '@assets/icons/navigation/menu-close.svg?react'; import MenuIcon from '@assets/icons/navigation/menu-open.svg?react'; import Button from '@components/common/Button'; -import { Flex } from '@/components/common'; -import { responsiveStyle } from '@utils/responsive'; +import { Flex, Image } from '@/components/common'; import useToggle from '@/hooks/useToggle'; - -interface NavProps { - open: boolean; -} - -export default function Header() { +import LanguageFilter from './components/LanguageFilter'; +import { + commonButtonStyle, + customButtonStyle, + flexStyle, + HeaderContainer, + imageStyle, + LogoImg, + menuIconStyle, + Nav, +} from './index.styles'; +import { UserData } from '@/types'; + +type Props = Pick; + +export default function Header({ profileImage }: Props) { const [menuOpen, toggleMenu] = useToggle(); return ( - + - {menuOpen ? : } + + {menuOpen ? : } + - + ); } - -const HeaderContainer = styled.header` - display: flex; - justify-content: center; - align-items: center; - background-color: #fff; - height: 88px; - - ${responsiveStyle({ - tablet: { - flexDirection: 'column', - height: 'auto', - padding: '10px', - }, - mobile: { - padding: '5px', - }, - })} -`; - -const LogoImg = styled(LogoIcon)` - cursor: pointer; - height: auto; - - ${responsiveStyle({ - tablet: { - width: '150px', - }, - mobile: { - width: '160px', - }, - })} -`; - -const StyledFlex = styled(Flex)` - justify-content: space-between; - align-items: center; - width: 100%; - max-width: 1300px; - - ${responsiveStyle({ - tablet: { - flexDirection: 'column', - }, - })} -`; - -const MobileMenuIcon = styled(Flex)` - display: none; - justify-content: flex-end; - - svg { - cursor: pointer; - } - - ${responsiveStyle({ - mobile: { - display: 'flex', - }, - })} -`; - -const Nav = styled.nav` - display: flex; - align-items: center; - gap: 15px; - transition: all 0.3s linear; - - ${responsiveStyle({ - tablet: { - width: '100%', - justifyContent: 'center', - marginTop: '10px', - }, - mobile: { - flexDirection: 'column', - alignItems: 'stretch', - }, - })} - - @media (max-width: 480px) { - max-height: ${({ open }) => (open ? '420px' : '0')}; - opacity: ${({ open }) => (open ? '1' : '0')}; - visibility: ${({ open }) => (open ? 'visible' : 'hidden')}; - } -`; - -const Dropdown = styled.select` - padding: 10px 20px; - color: #0a65cc; - cursor: pointer; - text-align: center; - font-size: 16px; - border: none; -`; - -const commonButtonStyle = { - whiteSpace: 'nowrap', - borderRadius: '4px', -}; - -const customButtonStyle = { - ...commonButtonStyle, - backgroundColor: '#0a65cc', - color: '#fff', -}; diff --git a/src/features/layout/index.tsx b/src/features/layout/index.tsx index 3a5b6f5..99550f4 100644 --- a/src/features/layout/index.tsx +++ b/src/features/layout/index.tsx @@ -1,11 +1,17 @@ import { ReactNode } from 'react'; import Footer from './Footer'; import Header from './Header'; +import profileImage from '@assets/images/profile-image.svg'; + +const initialData = { + type: 'employer', + profileImage: profileImage, +}; export default function Layout({ children }: { children: ReactNode }) { return ( <> -
+
{children}