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 0b3ddf6..64ab1ba 100644
--- a/README.md
+++ b/README.md
@@ -1,42 +1,60 @@
# 🍪 내가 먹은 쿠키 - 18조 FE
-## 🙋♂️ 6주차 코드리뷰 질문
-- 하나의 파일 내에서 함수 표현식과 선언식을 같이 사용해도 되는지 궁금합니다.
-- 기존 Weekly 브랜치에 components 경로에 `RoleModal` 컴포넌트가 있다고 가정하면, 해당 Weekly브랜치를 통해 새로 분기된 Feat 브랜치에 기존 components에 `RoleModal` 컴포넌트를 features 경로에 옮기고 두 브랜치를 머지하게되면 두개의 파일이 생기게 됩니다. 이를 해결하려면 어떻게 해야하나요?
+## 🙋♂️ 7, 8주차 코드리뷰 질문
+- MyRecruitList에서 ‘내가 지원한 공고’ 목록의 버튼 디자인은 API로 전달받는 값에 따라 달라집니다. 이를 반영하기 위해 getStateStyle 함수를 만들었고, 함수의 파라미터로는 API로 전달받는 값인 변수 state를 사용하고, 함수의 반환값은 버튼의 design과 버튼 내부 text로 설정하였습니다.
+```tsx
+ type MyRecruitListProps = {
+ title: string;
+ address: string;
+ salary: string;
+ image: string;
+ state: string;
+};
+
+ type designProps = {
+ design: 'default' | 'outlined' | 'textbutton' | 'deactivate';
+ text: string;
+ };
-## 🙋♂️ 5주차 코드리뷰 질문
-- 하나의 페이지 내에서만 여러번 사용되는 공통 컴포넌트의 경우, components/common 폴더에 공통 컴포넌트로 만들어 취급하는 것이 좋은지, 혹은 해당 페이지 코드 파일이 위치한 폴더에 컴포넌트를 만들거나 해당 페이지 코드 파일 하단에 작성하는 등 colocation 원칙을 적용해서 가까이 위치시키는 것이 좋을지 궁금합니다.
-- `Header` 컴포넌트에서 다른 theme을 가진 버튼들에 공통된 스타일을 적용하면서, 특정 버튼에만 추가적인 스타일을 주는 작업을 했습니다. 아래와 같이 각 버튼에 공통적으로 적용될 스타일을 `commonButtonStyles`로 정의하고, `theme=default`인 버튼에만 추가 스타일을 적용해보았는데, 제가 구현한 방식보다 더 괜찮은 방법이 있는지 궁금합니다.
-```jsx
-const commonButtonStyles = css`
- white-space: nowrap;
- border-radius: 4px;
-`;
+ function getStateStyle(state: MyRecruitListProps['state']): 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: '알 수 없음' }; // 상태가 정의되지 않은 경우
+ }
+ }
```
+
+그런데 desginProps의 design은 공통컴포넌트로 만들었던 button의 design 프롭스로 전달되는 값으로, 이미 button 컴포넌트 내부에서 design 도메인을 정의한 바가 있습니다. 만약 위 코드처럼 제가 작성한대로 한다면, button 컴포넌트에서 design부분을 수정한다면 위 코드도 직접 수정해야하는 불편함이 있을것 같다고 생각합니다. 혹시 공통컴포넌트에서 정의한 프롭스 도메인을 다른 부분에서 재사용할 수 있는 좋은 방법이 있을까요?
+getStateStyle의 인자로 받는 state는 MyRecruitListProps의 state와 같은 값을 공유하므로 위와 같이 작성했는데, 혹시 미리 정의한 프롭스 타입의 일부분을 사용하는 더 좋은 방법이 있을까요?
+아래와 같이 props 타입을 지정하고, 아래 타입을 가진 데이터를 mock데이터로 받아오는 코드를 작성했습니다.
+
+```tsx
+export type StateProps = '근로계약서 서명하기' | '채용 마감' | '지원서 검토중' | '채용 완료';
+
```
-
-
-
+위 코드에서 {myRecruitList}가 mock데이터인데, 이때 이 데이터가 타입에 맞지 않는다고, 특히 state 부분이 StateProps 값을 기대하는 반면 string 값이 들어온다고 타입오류가 발생합니다. 그래서 mock데이터 자체에 아래와 같이 타입을 지정하니 오류는 사라졌는데, 이부분에 진짜 API 데이터를 연결하게 되어도 안전할지 모르겠습니다.
+```tsx
+export const myRecruitList: MyRecruitListProps[] = [
+ {
+ id: 1,
+ image:
+ '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',
+ title: '제목',
+ area: '대전광역시 유성구',
+ state: '근로계약서 서명하기',
+ },
+ ...
```
-- 태블릿(`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) => ,
} satisfies Meta;
@@ -16,5 +16,6 @@ export default meta;
type Story = StoryObj;
export const Default: Story = {};
-export const Outlined: Story = { args: { theme: 'outlined' } };
-export const TextButton: Story = { args: { theme: 'textbutton' } };
+export const Outlined: Story = { args: { design: 'outlined' } };
+export const TextButton: Story = { args: { design: 'textbutton' } };
+export const DeactivateButton: Story = { args: { design: 'deactivate' } };
diff --git a/src/components/common/Button/index.tsx b/src/components/common/Button/index.tsx
index 1d59aa9..4e08944 100644
--- a/src/components/common/Button/index.tsx
+++ b/src/components/common/Button/index.tsx
@@ -1,14 +1,14 @@
import styled from '@emotion/styled';
-interface Props extends React.ButtonHTMLAttributes {
- theme?: 'default' | 'outlined' | 'textbutton';
+export interface Props extends React.ButtonHTMLAttributes {
+ design?: 'default' | 'outlined' | 'textbutton' | 'deactivate';
}
export default function Button({ children, ...props }: Props) {
return {children};
}
-const Wrapper = styled.button(
+const Wrapper = styled.button(
{
padding: '10px 40px',
fontSize: '16px',
@@ -20,8 +20,8 @@ const Wrapper = styled.button(
cursor: 'pointer',
transition: 'background-color 200ms',
},
- ({ theme = 'default' }) => {
- if (theme === 'outlined') {
+ ({ design = 'default' }) => {
+ if (design === 'outlined') {
return {
backgroundColor: '#fff',
color: '#0A65CC',
@@ -34,13 +34,20 @@ const Wrapper = styled.button(
};
}
- if (theme === 'textbutton') {
+ if (design === 'textbutton') {
return {
color: '#0A65CC',
backgroundColor: '#fff',
};
}
+ if (design === 'deactivate') {
+ return {
+ color: '#9A9A9A',
+ backgroundColor: '#D9D9D9',
+ };
+ }
+
return {
color: '#0A65CC',
backgroundColor: '#E7F0FA',
diff --git a/src/components/common/Icon/Arrow.ts b/src/components/common/Icon/Arrow.ts
index e7a5bcd..147b9d5 100644
--- a/src/components/common/Icon/Arrow.ts
+++ b/src/components/common/Icon/Arrow.ts
@@ -1,9 +1,13 @@
import RightWhite from '@assets/icons/arrow/right-white.svg?react';
import RightBlue from '@assets/icons/arrow/right-blue.svg?react';
+import DownBlue from '@assets/icons/arrow/down-blue.svg?react';
+import BigRightBlue from '@assets/icons/arrow/big-right-blue.svg?react';
const Arrow = {
RightWhite,
RightBlue,
+ DownBlue,
+ BigRightBlue,
};
export default Arrow;
diff --git a/src/components/common/Icon/EmployeePage.ts b/src/components/common/Icon/EmployeePage.ts
new file mode 100644
index 0000000..b5a3924
--- /dev/null
+++ b/src/components/common/Icon/EmployeePage.ts
@@ -0,0 +1,11 @@
+import Bag from '@assets/icons/employeePage/bag.svg?react';
+import Pen from '@assets/icons/employeePage/pen.svg?react';
+import Card from '@assets/icons/employeePage/card.svg?react';
+
+const EmployeePage = {
+ Bag,
+ Pen,
+ Card,
+};
+
+export default EmployeePage;
diff --git a/src/components/common/Icon/index.ts b/src/components/common/Icon/index.ts
index 7eef1b2..cd08c32 100644
--- a/src/components/common/Icon/index.ts
+++ b/src/components/common/Icon/index.ts
@@ -1,4 +1,5 @@
import Arrow from './Arrow';
+import EmployeePage from './EmployeePage';
import Role from './Role';
import Social from './Social';
@@ -6,6 +7,7 @@ const Icon = {
Role,
Social,
Arrow,
+ EmployeePage,
};
export default Icon;
diff --git a/src/components/common/InnerContainer/index.tsx b/src/components/common/InnerContainer/index.tsx
index 5240543..5a5253f 100644
--- a/src/components/common/InnerContainer/index.tsx
+++ b/src/components/common/InnerContainer/index.tsx
@@ -2,28 +2,27 @@ import { breakpoints } from '@assets/styles/global/breakpoints';
import { responsiveStyle } from '@utils/responsive';
import { HTMLAttributes, ReactNode } from 'react';
+const containerStyle = responsiveStyle({
+ default: {
+ maxWidth: '1300px',
+ margin: '0 auto',
+ },
+ tablet: {
+ maxWidth: breakpoints.tablet,
+ padding: '0 12px',
+ },
+ mobile: {
+ maxWidth: breakpoints.mobile,
+ },
+});
+
type Props = {
children: ReactNode;
} & HTMLAttributes;
export default function InnerContainer({ children, ...rest }: Props) {
return (
-
+
{children}
);
diff --git a/src/components/common/List/index.tsx b/src/components/common/List/index.tsx
index 78f0ec8..ba0efc0 100644
--- a/src/components/common/List/index.tsx
+++ b/src/components/common/List/index.tsx
@@ -6,5 +6,5 @@ interface Props
{
}
export default function List({ items, renderItem }: Props) {
- return <>{items.map((item) => renderItem(item))}>;
+ return <>{items.map(renderItem)}>;
}
diff --git a/src/components/common/Modal/Modals.tsx b/src/components/common/Modal/Modals.tsx
new file mode 100644
index 0000000..ff21b81
--- /dev/null
+++ b/src/components/common/Modal/Modals.tsx
@@ -0,0 +1,30 @@
+import { useContext } from 'react';
+import { ModalsDispatchContext, ModalsStateContext } from './index.context';
+import loadable from '@loadable/component';
+
+export interface ModalProps {
+ [key: string]: unknown;
+}
+
+export const modals = {
+ roleModal: loadable(() => import('@features/auth/SignUp/components/common/RoleModal')),
+};
+
+export default function Modals() {
+ const openedModals = useContext(ModalsStateContext);
+ const { close } = useContext(ModalsDispatchContext);
+
+ return openedModals.map((modal, index) => {
+ const { Component, props } = modal;
+ const { onSubmit, ...restProps } = props;
+
+ const handleClose = () => close(Component);
+
+ const handleSubmit = async () => {
+ if (typeof onSubmit === 'function') await onSubmit();
+ handleClose();
+ };
+
+ return ;
+ });
+}
diff --git a/src/components/common/Modal/hooks/useModals.ts b/src/components/common/Modal/hooks/useModals.ts
new file mode 100644
index 0000000..a56575d
--- /dev/null
+++ b/src/components/common/Modal/hooks/useModals.ts
@@ -0,0 +1,19 @@
+import { useContext } from 'react';
+import { ModalsDispatchContext } from '../index.context';
+
+export default function useModals() {
+ const { open, close } = useContext(ModalsDispatchContext);
+
+ const openModal = (Component: React.ComponentType, props: any) => {
+ open(Component, props);
+ };
+
+ const closeModal = (Component: React.ComponentType) => {
+ close(Component);
+ };
+
+ return {
+ openModal,
+ closeModal,
+ };
+}
diff --git a/src/components/common/Modal/index.context.tsx b/src/components/common/Modal/index.context.tsx
new file mode 100644
index 0000000..572c9d0
--- /dev/null
+++ b/src/components/common/Modal/index.context.tsx
@@ -0,0 +1,18 @@
+import { createContext } from 'react';
+
+interface ModalsDispatchContextProps {
+ open: (Component: React.ComponentType, props: any) => void;
+ close: (Component: React.ComponentType) => void;
+}
+
+export const ModalsDispatchContext = createContext({
+ open: () => {},
+ close: () => {},
+});
+
+interface ModalState {
+ Component: React.ComponentType;
+ props: any;
+}
+
+export const ModalsStateContext = createContext([]);
diff --git a/src/components/common/Modal/index.tsx b/src/components/common/Modal/index.tsx
index ae0e2df..6e60a74 100644
--- a/src/components/common/Modal/index.tsx
+++ b/src/components/common/Modal/index.tsx
@@ -4,14 +4,13 @@ import { ReactNode } from 'react';
export type Props = {
textChildren: ReactNode;
buttonChildren: ReactNode;
- borderRadius?: string;
onClose: () => void;
} & React.HTMLAttributes;
-const Modal = ({ textChildren, buttonChildren, borderRadius = '12px', onClose, ...props }: Props) => {
+const Modal = ({ textChildren, buttonChildren, onClose, ...props }: Props) => {
return (
- e.stopPropagation()}>
+ e.stopPropagation()}>
{textChildren}
{buttonChildren}
diff --git a/src/components/common/Select/hooks/useGlobalSelect.ts b/src/components/common/Select/hooks/useGlobalSelect.ts
new file mode 100644
index 0000000..41e22fc
--- /dev/null
+++ b/src/components/common/Select/hooks/useGlobalSelect.ts
@@ -0,0 +1,25 @@
+import { useSelectStore } from '@/store/selectStore';
+import { SelectOptionType } from '@/types/select';
+import { useEffect } from 'react';
+
+const useGlobalSelect = (initialOption: SelectOptionType) => {
+ const { selectedOption, setSelectedOption, initializeOption } = useSelectStore();
+
+ useEffect(() => {
+ if (!selectedOption) {
+ initializeOption(initialOption);
+ }
+ }, [selectedOption, initializeOption, initialOption]);
+
+ const handleSelect = (option: SelectOptionType) => {
+ setSelectedOption(option);
+ option.action();
+ };
+
+ return {
+ selectedOption: selectedOption || initialOption,
+ handleSelect,
+ };
+};
+
+export default useGlobalSelect;
diff --git a/src/components/common/Select/hooks/useSelect.ts b/src/components/common/Select/hooks/useSelect.ts
new file mode 100644
index 0000000..be47d81
--- /dev/null
+++ b/src/components/common/Select/hooks/useSelect.ts
@@ -0,0 +1,20 @@
+import { SelectOptionType } from '@/types/select';
+import { useState } from 'react';
+
+const useSelect = (defaultOption: SelectOptionType) => {
+ const [selectedOption, setSelectedOption] = useState(defaultOption);
+
+ const handleSelect = (option: SelectOptionType) => {
+ if (option.value !== selectedOption.value) {
+ setSelectedOption(option);
+ option.action();
+ }
+ };
+
+ return {
+ selectedOption,
+ handleSelect,
+ };
+};
+
+export default useSelect;
diff --git a/src/components/common/Select/index.context.tsx b/src/components/common/Select/index.context.tsx
new file mode 100644
index 0000000..8b1207f
--- /dev/null
+++ b/src/components/common/Select/index.context.tsx
@@ -0,0 +1,35 @@
+import useToggle from '@/hooks/useToggle';
+import { createContext, PropsWithChildren, useContext, useState } from 'react';
+
+interface SelectContextProps {
+ isOpen: boolean;
+ toggle: () => void;
+ selectedValue?: string;
+ onItemSelect: (value: string) => void;
+}
+
+const SelectContext = createContext(undefined);
+
+export const useSelectContext = () => {
+ const context = useContext(SelectContext);
+ if (!context) {
+ throw new Error('useSelectContext must be used within a useSelectContext');
+ }
+ return context;
+};
+
+export const SelectProvider = ({ children }: PropsWithChildren) => {
+ const [isOpen, toggle] = useToggle();
+ const [selectedValue, setSelectedValue] = useState(undefined);
+
+ const handleItemSelect = (value: string) => {
+ setSelectedValue(value);
+ toggle();
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/common/Select/index.stories.tsx b/src/components/common/Select/index.stories.tsx
new file mode 100644
index 0000000..0775338
--- /dev/null
+++ b/src/components/common/Select/index.stories.tsx
@@ -0,0 +1,109 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import Select from '.';
+import { SelectOptionType } from '@/types/select';
+import useGlobalSelect from './hooks/useGlobalSelect';
+import { Icon } from '@/components/common';
+
+const options: SelectOptionType[] = [
+ { value: 'korean', text: '한국어', action: () => console.log('한국어 선택') },
+ { value: 'english', text: '영어', action: () => console.log('영어 선택') },
+ { value: 'vietnamese', text: '베트남어', action: () => console.log('베트남어 선택') },
+];
+
+const meta: Meta = {
+ title: 'common/Select',
+ component: Select,
+ tags: ['autodocs'],
+ argTypes: {
+ icon: {
+ control: 'boolean',
+ description: '아이콘을 표시할지 여부를 결정',
+ },
+ options: {
+ control: 'object',
+ description: '선택할 수 있는 옵션 목록',
+ },
+ },
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => {
+ const { selectedOption, handleSelect } = useGlobalSelect(options[0]);
+
+ return (
+
+ }>
+ {selectedOption.text}
+
+
+ {options.map((option) => (
+ handleSelect(option)}>
+ {option.text}
+
+ ))}
+
+
+ );
+ },
+ args: {
+ icon: true,
+ options,
+ },
+};
+
+export const NoIcon: Story = {
+ render: (args) => {
+ const { selectedOption, handleSelect } = useGlobalSelect(options[0]);
+
+ return (
+
+ {selectedOption.text}
+
+ {options.map((option) => (
+ handleSelect(option)}>
+ {option.text}
+
+ ))}
+
+
+ );
+ },
+ args: {
+ icon: false,
+ options,
+ },
+};
+
+export const WithMultipleOptions: Story = {
+ render: (args) => {
+ const { selectedOption, handleSelect } = useGlobalSelect(options[0]);
+
+ return (
+
+ }>
+ {selectedOption.text}
+
+
+ {options.map((option) => (
+ handleSelect(option)}>
+ {option.text}
+
+ ))}
+
+
+ );
+ },
+ args: {
+ icon: true,
+ options: [
+ { value: 'korean', text: '한국어', action: () => console.log('한국어 선택') },
+ { value: 'english', text: '영어', action: () => console.log('영어 선택') },
+ { value: 'vietnamese', text: '베트남어', action: () => console.log('베트남어 선택') },
+ { value: 'japanese', text: '일본어', action: () => console.log('일본어 선택') },
+ ],
+ },
+};
diff --git a/src/components/common/Select/index.tsx b/src/components/common/Select/index.tsx
new file mode 100644
index 0000000..6c9f845
--- /dev/null
+++ b/src/components/common/Select/index.tsx
@@ -0,0 +1,13 @@
+import { SelectRoot as Root } from './sub-components/Root';
+import { SelectTrigger as Trigger } from './sub-components/Trigger';
+import { SelectContent as Content } from './sub-components/Content';
+import { SelectOption as Option } from './sub-components/Option';
+
+const Select = Object.assign({
+ Root,
+ Trigger,
+ Content,
+ Option,
+});
+
+export default Select;
diff --git a/src/components/common/Select/sub-components/Content.tsx b/src/components/common/Select/sub-components/Content.tsx
new file mode 100644
index 0000000..e0cfbed
--- /dev/null
+++ b/src/components/common/Select/sub-components/Content.tsx
@@ -0,0 +1,19 @@
+import styled from '@emotion/styled';
+import { useSelectContext } from '../index.context';
+
+export const SelectContent = ({ children }: { children: React.ReactNode }) => {
+ const { isOpen } = useSelectContext();
+
+ return {children};
+};
+
+export const Content = styled.div<{ isOpen: boolean }>`
+ position: absolute;
+ top: 100%;
+ left: 0;
+ background: ${({ theme }) => theme.palettes.white};
+ box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+ display: ${({ isOpen }) => (isOpen ? 'block' : 'none')};
+ min-width: 160px;
+ z-index: 1000;
+`;
diff --git a/src/components/common/Select/sub-components/Option.tsx b/src/components/common/Select/sub-components/Option.tsx
new file mode 100644
index 0000000..aef7a87
--- /dev/null
+++ b/src/components/common/Select/sub-components/Option.tsx
@@ -0,0 +1,36 @@
+import { PropsWithChildren } from 'react';
+import { useSelectContext } from '../index.context';
+import styled from '@emotion/styled';
+
+type Props = {
+ value: string;
+ onClick?: () => void;
+} & PropsWithChildren;
+
+export const SelectOption = ({ children, value, onClick, ...rest }: Props) => {
+ const { selectedValue, onItemSelect } = useSelectContext();
+
+ const handleOptionClick = () => {
+ if (onClick) onClick();
+ onItemSelect(value);
+ };
+
+ return (
+
+ );
+};
+
+export const Option = styled.div<{ isSelected: boolean }>`
+ padding: 8px 12px;
+ cursor: pointer;
+ color: ${({ theme, isSelected }) => (isSelected ? theme.palettes.blue : theme.palettes.black)};
+ font-weight: ${({ isSelected }) => (isSelected ? 700 : 400)};
+
+ &:hover {
+ background-color: #f1f1f1;
+ color: ${({ theme }) => theme.palettes.blue};
+ font-weight: 700;
+ }
+`;
diff --git a/src/components/common/Select/sub-components/Root.tsx b/src/components/common/Select/sub-components/Root.tsx
new file mode 100644
index 0000000..e1c6736
--- /dev/null
+++ b/src/components/common/Select/sub-components/Root.tsx
@@ -0,0 +1,15 @@
+import styled from '@emotion/styled';
+import { SelectProvider } from '../index.context';
+
+export const SelectRoot = ({ children }: { children: React.ReactNode }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+export const Root = styled.div`
+ position: relative;
+ display: inline-block;
+`;
diff --git a/src/components/common/Select/sub-components/Trigger.tsx b/src/components/common/Select/sub-components/Trigger.tsx
new file mode 100644
index 0000000..e67dcb2
--- /dev/null
+++ b/src/components/common/Select/sub-components/Trigger.tsx
@@ -0,0 +1,26 @@
+import { Flex } from '@components/common';
+import { useSelectContext } from '../index.context';
+import { PropsWithChildren, ReactNode } from 'react';
+import styled from '@emotion/styled';
+
+type Props = {
+ icon?: ReactNode;
+} & PropsWithChildren;
+
+export const SelectTrigger = ({ icon, children, ...rest }: Props) => {
+ const { toggle } = useSelectContext();
+
+ return (
+
+ );
+};
+
+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 1 |
+ heading 2 |
+ heading 3 |
+
+
+
+
+ data 1 |
+ data 2 |
+ data 3 |
+
+
+ data 4 |
+ data 5 |
+ data 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 (
+
+ );
+}
+
+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}
>
diff --git a/src/features/recruitments/RecruitmentInfo/index.styles.ts b/src/features/recruitments/RecruitmentInfo/index.styles.ts
new file mode 100644
index 0000000..20526ce
--- /dev/null
+++ b/src/features/recruitments/RecruitmentInfo/index.styles.ts
@@ -0,0 +1,93 @@
+import { palettes } from '@/assets/styles/global/palettes';
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const recruitmentFlexStyle = css`
+ ${responsiveStyle({
+ default: {
+ gap: '100px',
+ border: `1px solid ${palettes.borderGray}`,
+ borderRadius: '8px',
+ padding: '15px 30px',
+ },
+ tablet: {
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: '20px 30px',
+ gap: '30px',
+ },
+ mobile: {
+ padding: '30px',
+ },
+ })}
+`;
+
+export const ImageWrapper = styled.div`
+ width: 280px;
+ height: 120px;
+`;
+
+export const imageStyle = css`
+ height: 100%;
+
+ ${responsiveStyle({
+ tablet: {
+ margin: '0 auto',
+ width: '50%',
+ },
+ mobile: {
+ width: '70%',
+ },
+ })}
+`;
+
+export const imageSize = {
+ width: '277px',
+ height: 'auto',
+};
+
+export const infoFlexStyle = css`
+ ${responsiveStyle({
+ default: {
+ flexDirection: 'column',
+ justifyContent: 'space-between',
+ gap: '10px',
+ },
+ tablet: {
+ width: '80%',
+ alignItems: 'center',
+ },
+ mobile: {
+ gap: '15px',
+ },
+ })}
+`;
+
+export const infoGroupStyle = css`
+ ${responsiveStyle({
+ default: {
+ alignItems: 'center',
+ gap: '30px',
+ },
+ tablet: {
+ width: '80%',
+ gap: '20px',
+ justifyContent: 'center',
+ },
+ mobile: {
+ flexDirection: 'column',
+ gap: '10px',
+ },
+ })}
+`;
+
+export const buttonStyle = css`
+ background-color: ${palettes.blue};
+`;
+
+export const buttonTextStyle = {
+ fontWeight: '600',
+ whiteSpace: 'nowrap',
+};
diff --git a/src/features/recruitments/RecruitmentInfo/index.tsx b/src/features/recruitments/RecruitmentInfo/index.tsx
new file mode 100644
index 0000000..5918801
--- /dev/null
+++ b/src/features/recruitments/RecruitmentInfo/index.tsx
@@ -0,0 +1,50 @@
+import { Button, Flex, Icon, Image, Typo } from '@/components/common';
+import AreaIcon from '@assets/icons/recruitmentInfo/area.svg?react';
+import SalaryIcon from '@assets/icons/recruitmentInfo/salary.svg?react';
+import { RecruitmentItem } from '@/types';
+import {
+ buttonStyle,
+ buttonTextStyle,
+ imageSize,
+ imageStyle,
+ ImageWrapper,
+ infoFlexStyle,
+ infoGroupStyle,
+ recruitmentFlexStyle,
+} from './index.styles';
+
+type Props = Pick;
+
+export default function RecruitmentInfo({ image, companyName, koreanTitle, area, salary }: Props) {
+ return (
+
+
+
+
+
+ {companyName}
+
+ {koreanTitle}
+
+
+
+
+ {area}
+
+
+
+ 시급 {salary}
+
+
+
+
+
+
+ 자세히 보러가기
+
+
+
+
+
+ );
+}
diff --git a/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.styles.ts b/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.styles.ts
new file mode 100644
index 0000000..5b9a678
--- /dev/null
+++ b/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.styles.ts
@@ -0,0 +1,47 @@
+import { palettes } from '@/assets/styles/global/palettes';
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+
+export const recruitmentTitleStyle = {
+ color: `${palettes.blue}`,
+};
+
+export const recruitmentStyle = css`
+ ${responsiveStyle({
+ default: {
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: '20px 0',
+ },
+ tablet: {
+ flexDirection: 'column',
+ gap: '15px',
+ padding: '15px 0',
+ },
+ mobile: {
+ padding: '10px 0',
+ },
+ })}
+`;
+
+export const buttonGroupStyle = css`
+ ${responsiveStyle({
+ default: {
+ justifyContent: 'flex-end',
+ alignItems: 'center',
+ gap: '15px',
+ },
+ tablet: {
+ justifyContent: 'flex-start',
+ },
+ mobile: {
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ },
+ })}
+`;
+
+export const buttonStyle = css`
+ background-color: ${palettes.backgroundGray};
+ white-space: nowrap;
+`;
diff --git a/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.tsx b/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.tsx
new file mode 100644
index 0000000..fe906fe
--- /dev/null
+++ b/src/features/recruitments/RecruitmentList/RecruitmentsTable/index.tsx
@@ -0,0 +1,46 @@
+import { Button, Flex, List, Table, Td, Th, Typo } from '@/components/common';
+import { RecruitmentItem } from '@/types';
+import { buttonGroupStyle, buttonStyle, recruitmentStyle, recruitmentTitleStyle } from './index.styles';
+
+type Props = {
+ recruitmentList: RecruitmentItem[];
+};
+
+export default function RecruitmentsTable({ recruitmentList }: Props) {
+ return (
+
+
+
+ 근무지 |
+ 공고 제목 |
+
+
+
+ (
+
+ {recruitment.area} |
+
+
+
+
+ {recruitment.companyName}
+
+
+ {recruitment.koreanTitle}
+
+
+
+ 지원자 보러가기
+ 마감하기
+
+
+ |
+
+ )}
+ />
+
+
+ );
+}
diff --git a/src/features/recruitments/RecruitmentList/index.tsx b/src/features/recruitments/RecruitmentList/index.tsx
new file mode 100644
index 0000000..cb3ccdd
--- /dev/null
+++ b/src/features/recruitments/RecruitmentList/index.tsx
@@ -0,0 +1,23 @@
+import { Flex, Typo } from '@/components/common';
+import RecruitmentsTable from './RecruitmentsTable';
+import { RecruitmentItem } from '@/types';
+
+type Props = {
+ recruitmentList: RecruitmentItem[];
+};
+
+export default function RecruitmentList({ recruitmentList }: Props) {
+ return (
+
+
+
+ 내 공고글
+
+
+ 총 {recruitmentList.length}건
+
+
+
+
+ );
+}
diff --git a/src/features/visaRegistration/index.styles.ts b/src/features/visaRegistration/index.styles.ts
new file mode 100644
index 0000000..fa38d2a
--- /dev/null
+++ b/src/features/visaRegistration/index.styles.ts
@@ -0,0 +1,41 @@
+import { palettes } from '@/assets/styles/global/palettes';
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const Form = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 40px;
+ font-size: 16px;
+ font-weight: bold;
+ width: 100%;
+ max-width: 700px;
+ margin: 0 auto;
+
+ ${responsiveStyle({
+ tablet: {
+ gap: '20px',
+ padding: '0 20px',
+ },
+ mobile: {
+ padding: '0 15px',
+ },
+ })}
+`;
+
+export const inputStyle = css`
+ padding: 15px 20px;
+ width: 100%;
+`;
+
+export const buttonStyle = css`
+ background-color: ${palettes.blue};
+ color: ${palettes.white};
+ border-radius: 4px;
+`;
+
+export const ErrorMessage = styled.div`
+ color: red;
+ font-size: 13px;
+`;
diff --git a/src/features/employee/visaRegistration/index.tsx b/src/features/visaRegistration/index.tsx
similarity index 63%
rename from src/features/employee/visaRegistration/index.tsx
rename to src/features/visaRegistration/index.tsx
index c3e29c5..e9ac8da 100644
--- a/src/features/employee/visaRegistration/index.tsx
+++ b/src/features/visaRegistration/index.tsx
@@ -1,28 +1,21 @@
-import { responsiveStyle } from '@utils/responsive';
import { Button, Flex, Input, Modal } from '@/components/common';
-import styled from '@emotion/styled';
-import { ChangeEvent, useState } from 'react';
+import { ChangeEvent, useMemo, useState } from 'react';
+import { buttonStyle, ErrorMessage, Form, inputStyle } from './index.styles';
+import { validateForeignerNumber } from './validateForeignerNumber';
export default function VisaRegistrationForm() {
const [foreignerNumber, setForeignerNumber] = useState('');
const [visaGenerateDate, setVisaGenerateDate] = useState('');
const [error, setError] = useState('');
- const [isFormValid, setIsFormValid] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
-
- const validateForeignerNumber = (number: string) => {
- const regex = /^\d{6}-\d{7}$/;
- return regex.test(number);
- };
+ const formValid = useMemo(() => !error, [error]);
const handleForeignerNumberChange = (e: ChangeEvent) => {
const { value } = e.target;
if (!validateForeignerNumber(value) && value !== '') {
setError('올바른 형식으로 입력해주세요. (형식: 000000-0000000)');
- setIsFormValid(false);
} else {
setError('');
- setIsFormValid(true);
}
setForeignerNumber(value);
};
@@ -45,7 +38,7 @@ export default function VisaRegistrationForm() {
type="text"
value={foreignerNumber}
onChange={handleForeignerNumberChange}
- style={inputStyle}
+ css={inputStyle}
required
/>
{error && {error}}
@@ -56,12 +49,12 @@ export default function VisaRegistrationForm() {
type="date"
value={visaGenerateDate}
onChange={(e) => setVisaGenerateDate(e.target.value)}
- style={inputStyle}
+ css={inputStyle}
required
/>
-
+
등록하기
@@ -70,46 +63,10 @@ export default function VisaRegistrationForm() {
확인}
+ /* onClose 부분 추후 수정 예정 */
onClose={closeModal}
/>
)}
>
);
}
-
-const Form = styled.form`
- display: flex;
- flex-direction: column;
- gap: 40px;
- font-size: 16px;
- font-weight: bold;
- width: 100%;
- max-width: 700px;
- margin: 0 auto;
-
- ${responsiveStyle({
- tablet: {
- gap: '20px',
- padding: '0 20px',
- },
- mobile: {
- padding: '0 15px',
- },
- })}
-`;
-
-const inputStyle = {
- padding: '15px 20px',
- width: '100%',
-};
-
-const buttonStyle = {
- backgroundColor: '#0A65CC',
- color: '#fff',
- borderRadius: '4px',
-};
-
-const ErrorMessage = styled.div`
- color: red;
- font-size: 13px;
-`;
diff --git a/src/features/visaRegistration/validateForeignerNumber.ts b/src/features/visaRegistration/validateForeignerNumber.ts
new file mode 100644
index 0000000..56a901a
--- /dev/null
+++ b/src/features/visaRegistration/validateForeignerNumber.ts
@@ -0,0 +1,4 @@
+export const validateForeignerNumber = (number: string) => {
+ const regex = /^\d{6}-\d{7}$/;
+ return regex.test(number);
+};
diff --git a/src/main.tsx b/src/main.tsx
index 65bb32c..cd82fd2 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,5 +1,8 @@
import { createRoot } from 'react-dom/client';
import { RouterProvider } from 'react-router-dom';
import { router } from './routes/router.tsx';
+import { setupMockServiceWorker } from './mocks/setupMockServiceWorker.ts';
+
+setupMockServiceWorker();
createRoot(document.getElementById('root')!).render();
diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts
new file mode 100644
index 0000000..0a56427
--- /dev/null
+++ b/src/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw/browser';
+import { handlers } from './handlers';
+
+export const worker = setupWorker(...handlers);
diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts
new file mode 100644
index 0000000..d6cdae5
--- /dev/null
+++ b/src/mocks/handlers.ts
@@ -0,0 +1,11 @@
+import { recruitmentsMockHandler } from '@/apis/home/mocks/recruitmentsMockHandler';
+import { slidesMockHandler } from '@/apis/home/mocks/slidesMockHandler';
+import { EmployeePageMockHandler } from '@/apis/employee/mock/getMyApplication.mock';
+import { noticesMockHandler } from '@/apis/employer/mock/postNotice.mock';
+
+export const handlers = [
+ ...recruitmentsMockHandler,
+ ...slidesMockHandler,
+ ...noticesMockHandler,
+ ...EmployeePageMockHandler,
+];
diff --git a/src/mocks/index.ts b/src/mocks/index.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/mocks/setupMockServiceWorker.ts b/src/mocks/setupMockServiceWorker.ts
new file mode 100644
index 0000000..2130336
--- /dev/null
+++ b/src/mocks/setupMockServiceWorker.ts
@@ -0,0 +1,6 @@
+export async function setupMockServiceWorker() {
+ if (process.env.NODE_ENV === 'development') {
+ const { worker } = await import('./browser');
+ worker.start();
+ }
+}
diff --git a/src/pages/applicants/index.mock.ts b/src/pages/applicants/index.mock.ts
new file mode 100644
index 0000000..bb5d07f
--- /dev/null
+++ b/src/pages/applicants/index.mock.ts
@@ -0,0 +1,28 @@
+import CompanyImage from '@assets/images/coupang.png';
+
+export const recruitment = {
+ image: CompanyImage,
+ companyName: '쿠팡 유성점',
+ koreanTitle: '쿠팡 유성점에서 아르바이트 모집합니다.',
+ area: '대전 유성구',
+ salary: 50000000,
+};
+
+export const applicantList = [
+ {
+ userId: 1,
+ name: '이름1',
+ resumeId: 1,
+ applyId: 1,
+ applicantNation: '베트남',
+ korean: '중급',
+ },
+ {
+ userId: 2,
+ name: '이름2',
+ resumeId: 2,
+ applyId: 2,
+ applicantNation: '베트남',
+ korean: '고급',
+ },
+];
diff --git a/src/pages/applicants/index.stories.tsx b/src/pages/applicants/index.stories.tsx
new file mode 100644
index 0000000..3182219
--- /dev/null
+++ b/src/pages/applicants/index.stories.tsx
@@ -0,0 +1,14 @@
+import { Meta, StoryObj } from '@storybook/react';
+import Applicants from '.';
+
+const meta: Meta = {
+ title: 'pages/Applicants',
+ component: Applicants,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/pages/applicants/index.tsx b/src/pages/applicants/index.tsx
new file mode 100644
index 0000000..226e1e7
--- /dev/null
+++ b/src/pages/applicants/index.tsx
@@ -0,0 +1,31 @@
+import { Flex, InnerContainer } from '@/components/common';
+import ApplicantList from '@/features/applicants/ApplicantList';
+import RecruitmentsInfo from '@/features/recruitments/RecruitmentInfo';
+import Layout from '@/features/layout';
+import styled from '@emotion/styled';
+import { applicantList, recruitment } from './index.mock';
+
+export default function Applicants() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const MainContainer = styled.div`
+ padding: 40px 0 80px 0;
+`;
diff --git a/src/pages/apply/applyguide/ApplyGuide.stories.tsx b/src/pages/apply/applyguide/ApplyGuide.stories.tsx
index b327357..c0a470b 100644
--- a/src/pages/apply/applyguide/ApplyGuide.stories.tsx
+++ b/src/pages/apply/applyguide/ApplyGuide.stories.tsx
@@ -1,18 +1,10 @@
import { Meta, StoryObj } from '@storybook/react';
import ApplyGuide from './ApplyGuide';
-import { BrowserRouter } from 'react-router-dom';
const meta: Meta = {
title: 'PAGES/Apply/ApplyGuide',
component: ApplyGuide,
tags: ['autodocs'],
- decorators: [
- (Story) => (
-
-
-
- ),
- ],
};
export default meta;
diff --git a/src/pages/auth/Loading/index.tsx b/src/pages/auth/Loading/index.tsx
new file mode 100644
index 0000000..7502e1c
--- /dev/null
+++ b/src/pages/auth/Loading/index.tsx
@@ -0,0 +1,18 @@
+import { Spinner, Typo } from '@/components/common';
+import { useGoogleOAuth } from '@/apis/auth/mutations/useGoogleOAuth';
+
+export default function LoadingPage() {
+ const { isLoading } = useGoogleOAuth();
+
+ return (
+
+ {isLoading ? (
+
+ ) : (
+
+ 로그인 처리 중 오류가 발생했습니다.
+
+ )}
+
+ );
+}
diff --git a/src/pages/auth/SignIn/index.styles.ts b/src/pages/auth/SignIn/index.styles.ts
new file mode 100644
index 0000000..a5e045d
--- /dev/null
+++ b/src/pages/auth/SignIn/index.styles.ts
@@ -0,0 +1,20 @@
+import theme from '@/assets/theme';
+import { responsiveStyle, responsiveSectionPadding } from '@/utils/responsive';
+
+export const sectionStyle = {
+ backgroundColor: theme.palettes.backgroundGray,
+ ...responsiveStyle(responsiveSectionPadding),
+};
+
+export const innerContainerStyle = responsiveStyle({
+ mobile: {
+ flexDirection: 'column',
+ },
+});
+
+export const catchphraseContainerStyle = responsiveStyle({
+ default: {
+ marginRight: '24px',
+ },
+ mobile: { marginRight: '0', alignItems: 'center', marginBottom: '32px' },
+});
diff --git a/src/pages/auth/SignIn/index.tsx b/src/pages/auth/SignIn/index.tsx
index 0bb9029..51b1e5c 100644
--- a/src/pages/auth/SignIn/index.tsx
+++ b/src/pages/auth/SignIn/index.tsx
@@ -1,83 +1,19 @@
-import { InnerContainer, Flex, Typo, Icon, Button } from '@components/common';
+import { InnerContainer, Flex } from '@components/common';
import Layout from '@features/layout';
-import theme from '@/assets/theme';
import Illustration from '@assets/images/signin-Illustration.svg?react';
-import { responsiveSectionPadding, responsiveStyle } from '@utils/responsive';
+import { sectionStyle, innerContainerStyle, catchphraseContainerStyle } from './index.styles';
+import { SignInText } from '@/features/auth/SignIn/components/SignInText';
+import { SignInButton } from '@/features/auth/SignIn/components/SignInButton';
export default function SignIn() {
return (
-
+
-
-
-
-
- 지금 바로 시작하세요. 🚀
-
-
- 안정적이고 투명한 고용 관계의 시작, 지금 바로 경험해보세요!
-
-
-
-
-
-
- Sign up with Google
-
-
-
+
+
+
+
diff --git a/src/pages/auth/SignUp/index.tsx b/src/pages/auth/SignUp/index.tsx
index 3ae46c0..fec169a 100644
--- a/src/pages/auth/SignUp/index.tsx
+++ b/src/pages/auth/SignUp/index.tsx
@@ -1,68 +1,20 @@
-import { ReactNode, useState } from 'react';
import Layout from '@features/layout';
-import { Flex, Typo, InnerContainer } from '@components/common';
+import { InnerContainer } from '@components/common';
import { responsiveStyle, responsiveSectionPadding } from '@utils/responsive';
-import RoleSelection from '@/features/auth/RoleSelection';
-import RoleModal from '@/features/auth/RoleModal';
-import useToggle from '@hooks/useToggle';
+import RoleSelection from '@/features/auth/SignUp/components/RoleSelection';
+import SignUpText from '@/features/auth/SignUp/components/SignUpText';
-export default function SignUp() {
- const [isToggle, toggle] = useToggle();
- const [modalContent, setModalContent] = useState();
-
- const handleRoleSelect = (modalContent: ReactNode) => {
- toggle();
- setModalContent(modalContent);
- };
+const sectionStyle = responsiveStyle(responsiveSectionPadding);
+export default function SignUp() {
return (
-
+
-
-
- 가입자 정보 선택
-
-
- 대상에 해당하는 가입자 정보를 선택해주세요.
-
-
-
+
+
- {isToggle && }
);
}
diff --git a/src/pages/auth/index.ts b/src/pages/auth/index.ts
deleted file mode 100644
index 46ddd03..0000000
--- a/src/pages/auth/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as SignIn } from './SignIn';
-export { default as SignUp } from './SignUp';
diff --git a/src/pages/contract/EmployeeContract/EmployeeContract.tsx b/src/pages/contract/EmployeeContract/EmployeeContract.tsx
index 7913f30..6cdac37 100644
--- a/src/pages/contract/EmployeeContract/EmployeeContract.tsx
+++ b/src/pages/contract/EmployeeContract/EmployeeContract.tsx
@@ -4,154 +4,152 @@ import styled from '@emotion/styled';
export const EmployeeContract = () => {
return (
- <>
-
-
+
);
};
diff --git a/src/pages/contract/EmployerContract/EmployerContract.tsx b/src/pages/contract/EmployerContract/EmployerContract.tsx
index 24f0eef..ce8328e 100644
--- a/src/pages/contract/EmployerContract/EmployerContract.tsx
+++ b/src/pages/contract/EmployerContract/EmployerContract.tsx
@@ -4,157 +4,153 @@ import styled from '@emotion/styled';
export const EmployerContract = () => {
return (
- <>
-
-
+
);
};
diff --git a/src/pages/employee/myPage/EmployeeMyPage.stories.tsx b/src/pages/employee/myPage/EmployeeMyPage.stories.tsx
new file mode 100644
index 0000000..5cdaa9a
--- /dev/null
+++ b/src/pages/employee/myPage/EmployeeMyPage.stories.tsx
@@ -0,0 +1,14 @@
+import { Meta, StoryObj } from '@storybook/react';
+import EmployeeMyPage from './EmployeeMyPage';
+
+const meta: Meta = {
+ title: 'pages/EmployeeMyPage',
+ component: EmployeeMyPage,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/pages/employee/myPage/EmployeeMyPage.tsx b/src/pages/employee/myPage/EmployeeMyPage.tsx
new file mode 100644
index 0000000..de02c3f
--- /dev/null
+++ b/src/pages/employee/myPage/EmployeeMyPage.tsx
@@ -0,0 +1,55 @@
+import { Icon, InnerContainer, Typo } from '@/components/common';
+import Layout from '@/features/layout';
+import styled from '@emotion/styled';
+import MyRecruitList from '../../../features/employee/myPage/MyRecruitList';
+import CardButton from '../../../features/employee/myPage/CardButton';
+import EmployeeProfile from '../../../features/employee/myPage/EmployeeProfile';
+// import { myRecruitList } from './data/index.mock';
+import { useGetMyApplication } from '@/apis/employee/hooks/useGetMyApplication';
+
+export default function EmployeeMyPage() {
+ const { data: myRecruitList } = useGetMyApplication();
+
+ return (
+
+
+
+
+
+
+ 이력서 등록
+
+
+
+ 사인 등록
+
+
+
+ 외국인 번호 및 비자 발급 일자 등록
+
+
+
+
+
+
+ 내가 지원한 공고
+
+ {myRecruitList && }
+
+
+
+ );
+}
+
+const Section = styled.div`
+ width: 100%;
+ direction: column;
+ align-items: center;
+ margin-bottom: 52px;
+`;
+
+const ColumnSection = styled.div`
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+`;
diff --git a/src/pages/employee/myPage/data/index.mock.ts b/src/pages/employee/myPage/data/index.mock.ts
new file mode 100644
index 0000000..8a82202
--- /dev/null
+++ b/src/pages/employee/myPage/data/index.mock.ts
@@ -0,0 +1,36 @@
+import { MyRecruitListProps } from '@/types';
+
+export const myRecruitList: MyRecruitListProps[] = [
+ {
+ id: 1,
+ image:
+ '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',
+ title: '제목',
+ area: '대전광역시 유성구',
+ state: '근로계약서 서명하기',
+ },
+ {
+ id: 2,
+ image:
+ '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',
+ title: '제목2',
+ area: '대전광역시 유성구',
+ state: '채용 마감',
+ },
+ {
+ id: 1,
+ image:
+ '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',
+ title: '제목3',
+ area: '대전광역시 유성구',
+ state: '지원서 검토중',
+ },
+ {
+ id: 1,
+ image:
+ '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',
+ title: '제목4',
+ area: '대전광역시 유성구',
+ state: '채용 완료',
+ },
+];
diff --git a/src/pages/employee/visaRegistration/index.tsx b/src/pages/employee/visaRegistration/index.tsx
deleted file mode 100644
index 4258d59..0000000
--- a/src/pages/employee/visaRegistration/index.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { responsiveStyle } from '@utils/responsive';
-import { Flex, InnerContainer } from '@/components/common';
-import Layout from '@/features/layout';
-import VisaRegistrationForm from '@/features/employee/visaRegistration';
-import styled from '@emotion/styled';
-
-export default function VisaRegistration() {
- return (
-
-
-
-
-
- 외국인 번호 및 비자 발급 일자 등록
-
-
-
-
-
-
- );
-}
-
-const MainContainer = styled.div`
- padding: 40px 0;
-`;
-
-const innerContainerStyle = {
- height: '520px',
- padding: '50px 0',
- border: '1px solid #E9E9E9',
- borderRadius: '12px',
-};
-
-const Title = styled.div`
- font-size: 24px;
- font-weight: bold;
- text-align: center;
-`;
-
-const BreakableText = styled.span`
- ${responsiveStyle({
- default: {
- display: 'inline',
- },
- mobile: {
- display: 'block',
- },
- })}
-`;
diff --git a/src/pages/employer/myAccount/index.tsx b/src/pages/employer/myAccount/index.tsx
deleted file mode 100644
index 2b66591..0000000
--- a/src/pages/employer/myAccount/index.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import Layout from '@/features/layout';
-import CompanyLogo from '@/features/employer/CompanyInfo/coupang.png';
-import CompanyInfo from '@/features/employer/CompanyInfo';
-import styled from '@emotion/styled';
-import { Flex, InnerContainer } from '@/components/common';
-import MyRecruitments from '@/features/employer/MyRecruitments';
-
-const initialCompanyData = {
- company: '쿠팡 유성점',
- industryOccupation: '온라인 소매',
- brand: '쿠팡',
- revenuePerYear: '15조',
- logo: CompanyLogo,
-};
-
-const initialRecruitmentsData = [
- {
- koreanTitle: '제목1',
- area: '대전 유성구',
- koreanDetailedDescription: '쿠팡 유성점에서 아르바이트 모집합니다.',
- },
- {
- koreanTitle: '제목2',
- area: '대전 유성구',
- koreanDetailedDescription: '쿠팡 유성점에서 아르바이트 모집합니다.',
- },
- {
- koreanTitle: '제목3',
- area: '대전 유성구',
- koreanDetailedDescription: '쿠팡 유성점에서 아르바이트 모집합니다.',
- },
-];
-
-export default function EmployerMyAccount() {
- return (
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-const MainContainer = styled.div`
- padding: 60px 0;
-`;
diff --git a/src/pages/employer/postNotice/PostNotice.tsx b/src/pages/employer/postNotice/PostNotice.tsx
index 289b79e..3b2f6a2 100644
--- a/src/pages/employer/postNotice/PostNotice.tsx
+++ b/src/pages/employer/postNotice/PostNotice.tsx
@@ -1,50 +1,75 @@
+import { FetchPostNotice } from '@/apis/employer/hooks/usePostNotice';
import { Button, Flex, Input, Typo } from '@/components/common';
import Layout from '@/features/layout';
import styled from '@emotion/styled';
+import { useState } from 'react';
+
+export default function PostNotice() {
+ const mutation = FetchPostNotice();
+ const [inputs, setInputs] = useState({
+ salary: '',
+ workingDuration: '',
+ workDays: '',
+ workHours: '',
+ workType: '',
+ eligibilityCriteria: '',
+ preferredConditions: '',
+ });
+
+ const { salary, workingDuration, workDays, workHours, workType, eligibilityCriteria, preferredConditions } = inputs;
+
+ const onChange = (e: React.ChangeEvent) => {
+ const { value, name } = e.target;
+ setInputs({
+ ...inputs,
+ [name]: value,
+ });
+ };
+
+ const handlePostNotice = () => {
+ mutation.mutate(inputs);
+ };
-const PostNotice = () => {
return (
- <>
-
-
-
- >
+
+
+
);
-};
+}
const LineWrapper = styled.div`
border: 1px solid #e9e9e9;
@@ -59,4 +84,4 @@ const InputContainer = styled.div`
margin-top: 32px;
`;
-export default PostNotice;
+const InputStyle = { width: '700px', height: '48px', marginTop: '12px' };
diff --git a/src/pages/home/index.tsx b/src/pages/home/index.tsx
index 61143ca..0a162f0 100644
--- a/src/pages/home/index.tsx
+++ b/src/pages/home/index.tsx
@@ -1,21 +1,24 @@
-import { InnerContainer } from '@components/common';
+import { InnerContainer, Flex } from '@components/common';
import { responsiveStyle, responsiveSectionPadding } from '@utils/responsive';
import Layout from '@features/layout';
-import { recruitmentList } from './data/index.mock';
-// import Worker from '@features/home/Worker';
-import Employer from '@features/home/Employer';
-import RecruitmentHeader from '@features/home/components/RecruitmentHeader';
-import RecruitmentList from '@features/home/RecruitmentList';
+import RecruitmentHeader from '@features/home/components/common/RecruitmentHeader';
+import RecruitmentFilter from '@features/home/components/common/RecruitmentFilter';
+import RecruitmentList from '@features/home/components/common/RecruitmentList';
+import ConditionalRenderer from '@features/home/components/ConditionalRenderer';
+
+const sectionStyle = responsiveStyle(responsiveSectionPadding);
export default function Home() {
return (
- {/* */}
-
-
diff --git a/src/pages/myAccount/employer/index.mock.ts b/src/pages/myAccount/employer/index.mock.ts
new file mode 100644
index 0000000..85a5905
--- /dev/null
+++ b/src/pages/myAccount/employer/index.mock.ts
@@ -0,0 +1,20 @@
+import LogoImage from '@assets/images/coupang.png';
+
+export const companyList = [
+ {
+ id: 1,
+ name: '쿠팡 유성점 1',
+ industryOccupation: '온라인 소매',
+ brand: '쿠팡',
+ revenuePerYear: 1000000,
+ logoImage: LogoImage,
+ },
+ {
+ id: 2,
+ name: '쿠팡 유성점 2',
+ industryOccupation: '온라인 소매',
+ brand: '쿠팡',
+ revenuePerYear: 1000000,
+ logoImage: LogoImage,
+ },
+];
diff --git a/src/pages/employer/myAccount/index.stories.tsx b/src/pages/myAccount/employer/index.stories.tsx
similarity index 73%
rename from src/pages/employer/myAccount/index.stories.tsx
rename to src/pages/myAccount/employer/index.stories.tsx
index a89a309..3f5ba5d 100644
--- a/src/pages/employer/myAccount/index.stories.tsx
+++ b/src/pages/myAccount/employer/index.stories.tsx
@@ -1,8 +1,8 @@
import { Meta, StoryObj } from '@storybook/react';
-import EmployerMyAccount from '../../employer/myAccount';
+import EmployerMyAccount from '.';
const meta: Meta = {
- title: 'pages/Employer/MyAccount',
+ title: 'pages/MyAccount/Employer',
component: EmployerMyAccount,
tags: ['autodocs'],
};
diff --git a/src/pages/myAccount/employer/index.styles.ts b/src/pages/myAccount/employer/index.styles.ts
new file mode 100644
index 0000000..beb492d
--- /dev/null
+++ b/src/pages/myAccount/employer/index.styles.ts
@@ -0,0 +1,30 @@
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+
+export const innerContainerStyle = css`
+ ${responsiveStyle({
+ default: {
+ padding: '60px 0 80px 0',
+ },
+ tablet: {
+ padding: '60px 0 80px 0',
+ },
+ mobile: {
+ padding: '40px 0 80px 0',
+ },
+ })}
+`;
+
+export const typoStyle = {
+ ...responsiveStyle({
+ default: {
+ fontWeight: 'bold',
+ },
+ tablet: {
+ fontSize: '33px',
+ },
+ mobile: {
+ fontSize: '30px',
+ },
+ }),
+};
diff --git a/src/pages/myAccount/employer/index.tsx b/src/pages/myAccount/employer/index.tsx
new file mode 100644
index 0000000..84af857
--- /dev/null
+++ b/src/pages/myAccount/employer/index.tsx
@@ -0,0 +1,22 @@
+import { Flex, InnerContainer, Typo } from '@/components/common';
+import Layout from '@/features/layout';
+import CompanyList from '@/features/companies/CompanyList';
+import { companyList } from './index.mock';
+import { innerContainerStyle, typoStyle } from './index.styles';
+
+export default function EmployerMyAccount() {
+ return (
+
+
+
+
+
+ 사장님, 안녕하세요!
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/myCompany/index.mock.ts b/src/pages/myCompany/index.mock.ts
new file mode 100644
index 0000000..6bae7d6
--- /dev/null
+++ b/src/pages/myCompany/index.mock.ts
@@ -0,0 +1,42 @@
+import CompanyLogo from '@assets/images/coupang.png';
+
+export const company = {
+ name: '쿠팡 유성점',
+ industryOccupation: '온라인 소매',
+ brand: '쿠팡',
+ revenuePerYear: 10000000,
+ logoImage: CompanyLogo,
+};
+
+export const recruitmentList = [
+ {
+ recruitmentId: 1,
+ image: CompanyLogo,
+ koreanTitle: '쿠팡 유성점에서 아르바이트 모집합니다.',
+ vietnameseTitle: '',
+ companyName: '쿠팡 유성점',
+ salary: 100000,
+ area: '대전 유성구',
+ workHours: '',
+ },
+ {
+ recruitmentId: 2,
+ image: CompanyLogo,
+ koreanTitle: '쿠팡 유성점에서 아르바이트 모집합니다.',
+ vietnameseTitle: '',
+ companyName: '쿠팡 유성점',
+ salary: 100000,
+ area: '대전 유성구',
+ workHours: '',
+ },
+ {
+ recruitmentId: 3,
+ image: CompanyLogo,
+ koreanTitle: '쿠팡 유성점에서 아르바이트 모집합니다.',
+ vietnameseTitle: '',
+ companyName: '쿠팡 유성점',
+ salary: 100000,
+ area: '대전 유성구',
+ workHours: '',
+ },
+];
diff --git a/src/pages/myCompany/index.stories.tsx b/src/pages/myCompany/index.stories.tsx
new file mode 100644
index 0000000..96fdee8
--- /dev/null
+++ b/src/pages/myCompany/index.stories.tsx
@@ -0,0 +1,14 @@
+import { Meta, StoryObj } from '@storybook/react';
+import MyCompany from '.';
+
+const meta: Meta = {
+ title: 'pages/MyCompany',
+ component: MyCompany,
+ tags: ['autodocs'],
+};
+
+export default meta;
+
+type Story = StoryObj;
+
+export const Default: Story = {};
diff --git a/src/pages/myCompany/index.styles.ts b/src/pages/myCompany/index.styles.ts
new file mode 100644
index 0000000..597708d
--- /dev/null
+++ b/src/pages/myCompany/index.styles.ts
@@ -0,0 +1,55 @@
+import { palettes } from '@/assets/styles/global/palettes';
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+
+export const innerContainerStyle = css`
+ ${responsiveStyle({
+ default: {
+ padding: '60px 0 80px 0',
+ },
+ tablet: {
+ padding: '60px 0 80px 0',
+ },
+ mobile: {
+ padding: '40px 0 80px 0',
+ },
+ })}
+`;
+
+export const companyWrapperStyle = css`
+ ${responsiveStyle({
+ tablet: {
+ flexDirection: 'column',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: '15px',
+ width: '100%',
+ },
+ mobile: {
+ flexDirection: 'column',
+ },
+ })}
+`;
+
+export const imageStyle = css`
+ ${responsiveStyle({
+ default: {
+ width: '280px',
+ height: 'auto',
+ },
+ tablet: {
+ margin: '0 auto',
+ width: '50%',
+ },
+ mobile: {
+ width: '70%',
+ },
+ })}
+`;
+
+export const buttonStyle = css`
+ background-color: ${palettes.blue};
+ color: ${palettes.white};
+ border-radius: 4px;
+ white-space: nowrap;
+`;
diff --git a/src/pages/myCompany/index.tsx b/src/pages/myCompany/index.tsx
new file mode 100644
index 0000000..6baacd0
--- /dev/null
+++ b/src/pages/myCompany/index.tsx
@@ -0,0 +1,39 @@
+import Layout from '@/features/layout';
+import { Flex, InnerContainer, Image, Typo, Button, Icon } from '@/components/common';
+import CompanyLogo from '@assets/images/coupang.png';
+import CompanyInfo from '@/features/companies/CompanyInfo';
+import { company, recruitmentList } from '@/pages/myCompany/index.mock';
+import { palettes } from '@/assets/styles/global/palettes';
+import { buttonStyle, imageStyle, companyWrapperStyle, innerContainerStyle } from './index.styles';
+import RecruitmentList from '@/features/recruitments/RecruitmentList';
+
+export default function MyCompany() {
+ return (
+
+
+
+
+
+
+
+
+
+
+ 회사 정보 수정하기
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/pages/resume/Resume.tsx b/src/pages/resume/Resume.tsx
new file mode 100644
index 0000000..5d165f9
--- /dev/null
+++ b/src/pages/resume/Resume.tsx
@@ -0,0 +1,164 @@
+import Layout from '@/features/layout';
+import { Flex, Typo, Modal, Button } from '@/components/common';
+import styled from '@emotion/styled';
+import { SelectKoreanLevel, InputResumeInfo, TextareaResumeInfo } from './ResumeInput';
+import { useForm, SubmitHandler } from 'react-hook-form';
+import { type ResumeInfo } from './ResumeType';
+import useToggle from '@/hooks/useToggle';
+import { useState } from 'react';
+
+export default function Resume() {
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm();
+ const [isToggle, toggle] = useToggle();
+ const [formData, setFormData] = useState(null);
+
+ const onSubmit: SubmitHandler = (data) => {
+ setFormData(data);
+ toggle();
+ };
+
+ const handleResumeSubmit = () => {
+ if (formData) {
+ console.log(formData);
+ alert('제출 완료!');
+ toggle();
+ }
+ };
+ return (
+
+
+
+
+ 이력서 작성
+
+
+
+ {/* 인풋 */}
+
+
+
+
+
+
+
+
+ {/* 라디오 */}
+
+
+
+ 한국어 실력
+
+
+
+
+ {errors.koreanLanguageLevel && (
+ 한국어 실력을 선택해주세요!
+ )}
+
+
+
+ 제출하기
+
+
+ {isToggle && (
+ 정말 제출하시겠습니까?
}
+ buttonChildren={제출하기}
+ onClose={toggle}
+ />
+ )}
+
+
+ );
+}
+
+const ResumeCard = styled.div`
+ width: 60%;
+ box-shadow: 0px 12px 32px 0px rgba(24, 25, 28, 0.08);
+ border-radius: 12px;
+ border: 1px solid #e9e9e9;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 60px;
+ padding: 40px 0;
+ margin: 40px 0;
+ @media (max-width: 768px) {
+ width: 80%;
+ }
+ @media (max-width: 480px) {
+ width: 95%;
+ }
+`;
+const CustomForm = styled.form`
+ width: 90%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 30px;
+`;
+const CustomBtn = styled(Button)`
+ background: #0a65cc;
+ color: white;
+ border: 1px solid #e4e5e8;
+ &:hover {
+ border: '2px solid #0A65CC';
+ background: white;
+ color: #0a65cc;
+ }
+`;
diff --git a/src/pages/resume/ResumeInput.tsx b/src/pages/resume/ResumeInput.tsx
new file mode 100644
index 0000000..5db7ce3
--- /dev/null
+++ b/src/pages/resume/ResumeInput.tsx
@@ -0,0 +1,111 @@
+import { Flex } from '@/components/common';
+import styled from '@emotion/styled';
+import { UseFormRegister, FieldErrors } from 'react-hook-form';
+import { type ResumeInfo } from './ResumeType';
+
+interface InputType {
+ id: keyof ResumeInfo;
+ label?: string;
+ register: UseFormRegister;
+ errors: FieldErrors;
+ pattern?: RegExp;
+ patternMessage?: string;
+ placeholder: string;
+}
+
+export function SelectKoreanLevel({ level, register }: { level: string; register: UseFormRegister }) {
+ return (
+
+
+
+
+ );
+}
+
+export function InputResumeInfo({ id, label, register, pattern, errors, patternMessage, placeholder }: InputType) {
+ return (
+
+
+
+
+
+ {errors[id]?.type === 'required' && {label}을(를) 입력해주세요!}
+ {errors[id]?.type === 'pattern' && {patternMessage}}
+
+ );
+}
+
+export function TextareaResumeInfo({ id, register, errors, placeholder }: InputType) {
+ return (
+
+
+
+
+
+ {errors[id]?.type === 'required' && 자기소개를 입력해주세요!}
+
+ );
+}
+
+const RadioButton = styled.input`
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ border-radius: 3px;
+ position: relative;
+ cursor: pointer;
+
+ &:before {
+ content: '';
+ position: absolute;
+ width: 16px;
+ height: 16px;
+ background-color: white;
+ top: 1px;
+ left: 1px;
+ border-radius: 3px;
+ border: 1px solid #e4e5e8;
+ }
+
+ &:checked:before {
+ background-color: #0a65cc;
+ border: none;
+ }
+
+ &:checked:after {
+ content: '✔';
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-55%, -55%);
+ font-size: 14px;
+ color: #fff;
+ }
+`;
+const CustomInput = styled.input`
+ display: flex;
+ flex: 1;
+ margin: 0 10px;
+ border-radius: 5px;
+ padding: 14px;
+ font-size: 16px;
+ border: 1px solid #e4e5e8;
+`;
+
+const CustomTextarea = styled.textarea`
+ width: 100%;
+ border-radius: 5px;
+ padding: 10px;
+ fontsize: 16px;
+ height: 100px;
+ resize: none;
+ border: 1px solid #e4e5e8;
+`;
+
+const Warring = styled.p`
+ color: red;
+`;
diff --git a/src/pages/resume/ResumeType.ts b/src/pages/resume/ResumeType.ts
new file mode 100644
index 0000000..d64e0b5
--- /dev/null
+++ b/src/pages/resume/ResumeType.ts
@@ -0,0 +1,8 @@
+export interface ResumeInfo {
+ applicantName: string; // 이름
+ address: string; // 주소
+ phoneNumber: string; // 번호
+ career: string; // 경력
+ koreanLanguageLevel: '초급' | '중급' | '고급'; // 한국어 실력
+ introduction: string; // 소개
+}
diff --git a/src/pages/employee/visaRegistration/index.stories.tsx b/src/pages/visaRegistration/index.stories.tsx
similarity index 100%
rename from src/pages/employee/visaRegistration/index.stories.tsx
rename to src/pages/visaRegistration/index.stories.tsx
diff --git a/src/pages/visaRegistration/index.styles.ts b/src/pages/visaRegistration/index.styles.ts
new file mode 100644
index 0000000..318aed3
--- /dev/null
+++ b/src/pages/visaRegistration/index.styles.ts
@@ -0,0 +1,37 @@
+import { palettes } from '@/assets/styles/global/palettes';
+import { responsiveStyle } from '@/utils/responsive';
+import { css } from '@emotion/react';
+import styled from '@emotion/styled';
+
+export const innerContainerStyle = css`
+ ${responsiveStyle({
+ default: {
+ border: `1px solid ${palettes.borderGray}`,
+ padding: '80px 0',
+ borderRadius: '12px',
+ marginTop: '60px',
+ marginBottom: '80px',
+ },
+ tablet: {
+ padding: '80px 0',
+ },
+ mobile: {
+ padding: '60px 0',
+ },
+ })}
+`;
+
+export const titleStyle = {
+ fontWeight: 'bold',
+};
+
+export const BreakableText = styled.span`
+ ${responsiveStyle({
+ default: {
+ display: 'inline',
+ },
+ mobile: {
+ display: 'block',
+ },
+ })}
+`;
diff --git a/src/pages/visaRegistration/index.tsx b/src/pages/visaRegistration/index.tsx
new file mode 100644
index 0000000..91967e4
--- /dev/null
+++ b/src/pages/visaRegistration/index.tsx
@@ -0,0 +1,27 @@
+import { Flex, InnerContainer, Typo } from '@/components/common';
+import Layout from '@/features/layout';
+import VisaRegistrationForm from '@/features/visaRegistration';
+import { BreakableText, innerContainerStyle, titleStyle } from './index.styles';
+
+export default function VisaRegistration() {
+ return (
+
+
+
+
+
+ 외국인 번호 및 비자 발급 일자 등록
+
+
+
+
+
+
+ );
+}
diff --git a/src/routes/path.ts b/src/routes/path.ts
index 2333738..743b2c3 100644
--- a/src/routes/path.ts
+++ b/src/routes/path.ts
@@ -1,6 +1,7 @@
export const AUTH = {
SIGN_IN: '/sign-in',
SIGN_UP: '/sign-up',
+ LOADING: '/loading',
} as const;
export const APPLY = {
@@ -8,11 +9,12 @@ export const APPLY = {
APPLYPAGE: '/apply',
};
-export const EMPLOYER = {
- MY_ACCOUNT: '/employer-my-account',
-} as const;
+export const MY_ACCOUNT = {
+ EMPLOYER: '/employer-my-account',
+};
export const EMPLOYEE = {
+ EMPLOYEE_PAGE: '/employee-my-page',
VISA_REGISTRATION: '/visa-registration',
} as const;
@@ -22,8 +24,12 @@ const ROUTE_PATH = {
POST_NOTICE: '/post-notice',
APPLY,
AUTH,
- EMPLOYER,
+ APPLICANTS: '/applicants',
EMPLOYEE,
+ RESUME: '/resume',
+ MY_COMPANY: '/my-company',
+ VISA_REGISTRATION: '/visa-registration',
+ MY_ACCOUNT,
} as const;
export default ROUTE_PATH;
diff --git a/src/routes/router.tsx b/src/routes/router.tsx
index a38be75..10e3b63 100644
--- a/src/routes/router.tsx
+++ b/src/routes/router.tsx
@@ -1,14 +1,20 @@
import { createBrowserRouter } from 'react-router-dom';
import ROUTE_PATH from './path';
-import { SignIn, SignUp } from '@pages/auth';
+import SignIn from '@pages/auth/SignIn';
+import SignUp from '@pages/auth/SignUp';
+import LoadingPage from '@/pages/auth/Loading';
import App from '@/App';
import Recruit from '@/pages/recruit';
-import VisaRegistration from '@/pages/employee/visaRegistration';
+import VisaRegistration from '@/pages/visaRegistration';
import PostNotice from '@/pages/employer/postNotice/PostNotice';
import Home from '@/pages/home';
import ApplyGuide from '@/pages/apply/applyguide/ApplyGuide';
import ApplyPage from '@/pages/apply/applypage/ApplyPage';
-import EmployerMyAccount from '@/pages/employer/myAccount';
+import MyCompany from '@/pages/myCompany';
+import Applicants from '@/pages/applicants';
+import Resume from '@/pages/resume/Resume';
+import EmployerMyAccount from '@/pages/myAccount/employer';
+import EmployeeMyPage from '@/pages/employee/myPage/EmployeeMyPage';
export const router = createBrowserRouter([
{
@@ -21,14 +27,23 @@ export const router = createBrowserRouter([
path: ROUTE_PATH.AUTH.SIGN_UP,
element: ,
},
+ {
+ path: ROUTE_PATH.AUTH.LOADING,
+ element: ,
+ },
{ path: ROUTE_PATH.RECRUIT, element: },
{ path: ROUTE_PATH.POST_NOTICE, element: },
{ path: ROUTE_PATH.APPLY.GUIDE, element: },
{ path: ROUTE_PATH.APPLY.APPLYPAGE, element: },
{ path: ROUTE_PATH.RECRUIT, element: },
+ { path: ROUTE_PATH.VISA_REGISTRATION, element: },
{ path: ROUTE_PATH.EMPLOYEE.VISA_REGISTRATION, element: },
+ { path: ROUTE_PATH.EMPLOYEE.EMPLOYEE_PAGE, element: },
{ path: ROUTE_PATH.POST_NOTICE, element: },
- { path: ROUTE_PATH.EMPLOYER.MY_ACCOUNT, element: },
+ { path: ROUTE_PATH.MY_COMPANY, element: },
+ { path: ROUTE_PATH.APPLICANTS, element: },
+ { path: ROUTE_PATH.RESUME, element: },
+ { path: ROUTE_PATH.MY_ACCOUNT.EMPLOYER, element: },
],
},
]);
diff --git a/src/store/index.ts b/src/store/index.ts
deleted file mode 100644
index e69de29..0000000
diff --git a/src/store/selectStore.ts b/src/store/selectStore.ts
new file mode 100644
index 0000000..afd42d6
--- /dev/null
+++ b/src/store/selectStore.ts
@@ -0,0 +1,22 @@
+import { create } from 'zustand';
+import { persist } from 'zustand/middleware';
+import { SelectOptionType } from '@/types/select';
+
+type SelectStore = {
+ selectedOption: SelectOptionType | null;
+ setSelectedOption: (option: SelectOptionType) => void;
+ initializeOption: (option: SelectOptionType) => void;
+};
+
+export const useSelectStore = create(
+ persist(
+ (set) => ({
+ selectedOption: null,
+ setSelectedOption: (option) => set({ selectedOption: option }),
+ initializeOption: (option) => set({ selectedOption: option }),
+ }),
+ {
+ name: 'language',
+ },
+ ),
+);
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index af7a4dd..d5f2b41 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -1,6 +1,40 @@
-export type BannerItem = {
+export type CompanyData = {
id: number;
- imageUrl;
+ name: string;
+ industryOccupation: string;
+ brand: string;
+ revenuePerYear: number;
+ logoImage: string;
+};
+
+export type ApplicantData = {
+ userId: number;
+ name: string;
+ resumeId: number;
+ applyId: number;
+ applicantNation: string;
+ korean: string;
+};
+
+export type ForeignerData = {
+ foreignerIdNumber: string;
+ visaGenerateDate: string;
+ visaExpiryDate: string;
+};
+
+export type UserData = {
+ type: string;
+ profileImage: string;
+};
+
+export type StateProps = '근로계약서 서명하기' | '채용 마감' | '지원서 검토중' | '채용 완료';
+
+export type MyRecruitListProps = {
+ id: number;
+ title: string;
+ area: string;
+ image: string;
+ state: StateProps;
};
export type RecruitmentItem = {
diff --git a/src/types/select.ts b/src/types/select.ts
new file mode 100644
index 0000000..c20568a
--- /dev/null
+++ b/src/types/select.ts
@@ -0,0 +1,5 @@
+export type SelectOptionType = {
+ value: string;
+ text: string;
+ action: () => void;
+};