diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 55f6e53..128176e 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -21,23 +21,10 @@ module.exports = {
plugins: ['react', '@typescript-eslint'],
rules: {
'react/react-in-jsx-scope': 'off',
- 'react/jsx-filename-extension': [
- 1,
- { extensions: ['.js', '.jsx', '.ts', '.tsx'] },
- ], //should add ".ts" if typescript project
+ 'react/jsx-filename-extension': [1, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], //should add ".ts" if typescript project
+ 'prettier/prettier': ['error', { endOfLine: 'auto' }],
quotes: ['error', 'single', { avoidEscape: true }],
- indent: ['error', 2],
- 'no-trailing-spaces': 0,
- 'keyword-spacing': 0,
- 'no-unused-vars': 1,
- 'no-multiple-empty-lines': 0,
- 'space-before-function-paren': 0,
- 'eol-last': 0,
- 'prettier/prettier': [
- 'error',
- {
- endOfLine: 'auto',
- },
- ],
+ indent: 'off',
+ settings: 'off',
},
}
diff --git a/.github/issue_template.md b/.github/issue_template.md
new file mode 100644
index 0000000..cfa25b2
--- /dev/null
+++ b/.github/issue_template.md
@@ -0,0 +1,6 @@
+## 목적
+
+## 세부 내용
+
+- [ ] 세부내용 1
+- [ ] 세부내용 2
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 0000000..e2737aa
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,9 @@
+## 🔊 팀원들에게 알릴 사항
+
+- 알릴 사항 또는 확인 요청 작성
+
+## 💸 작업 내용
+
+- 작업 개요(이슈 번호)
+- 작업내용 1
+- 작업내용 2
diff --git a/.prettierrc.json b/.prettierrc.json
index b3792d8..23b8710 100644
--- a/.prettierrc.json
+++ b/.prettierrc.json
@@ -1,6 +1,7 @@
{
- "trailingComma":"es5",
- "tabWidth":2,
- "semi":false,
- "singleQuote":true
-}
\ No newline at end of file
+ "trailingComma": "es5",
+ "tabWidth": 2,
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 120
+}
diff --git a/Readme.md b/Readme.md
new file mode 100644
index 0000000..c4554a1
--- /dev/null
+++ b/Readme.md
@@ -0,0 +1,103 @@
+# Field-Passer
+
+커뮤니티형 체육시설 양도 시스템의 불편함을 해소하기 위한
+구장 양도 서비스 **_Field-Passer_**
+
+## 프로젝트 설명
+
+-
+-
+
+## 배포 링크
+
+> [배포 링크](https://fieldpasser.netlify.app/)
+
+## 👥 팀원
+
+
+
+
+## 로컬 실행 방법
+
+1. 로컬 환경에 프로젝트 복사본 생성
+
+```bash
+git clone
+```
+
+2. 프로젝트 폴더로 이동
+
+```bash
+cd newFieldPasser-FE
+```
+
+3. 프로젝트 종속성 설치
+
+```bash
+npm install
+```
+
+4. 프로젝트 실행
+
+```bash
+npm run dev
+```
+
+## 기술 스택
+
+
+
+
+
+
+
+
+
+## 프로젝트 구조
+
+```bash
+.
+└── src/
+ ├── api/
+ ├── components/
+ ├── constants/
+ ├── hooks/
+ ├── pages/
+ ├── routes/
+ ├── storage/
+ ├── store/
+ ├── utils/
+ ├── App.tsx
+ ├── globalStyles.tsx
+ ├── main.tsx
+ └── vite-env.d.ts
+```
diff --git a/index.html b/index.html
index e0d1c84..c25f8d8 100644
--- a/index.html
+++ b/index.html
@@ -2,10 +2,11 @@
-
+
- Vite + React + TS
+ Field Passer
+
diff --git a/package-lock.json b/package-lock.json
index 3425fc8..4ba7445 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,16 +9,26 @@
"version": "0.0.0",
"dependencies": {
"@reduxjs/toolkit": "^1.9.5",
+ "@types/react-responsive": "^8.0.5",
"axios": "^1.4.0",
+ "date-fns": "^2.30.0",
+ "dotenv": "^16.3.1",
"react": "^18.2.0",
+ "react-cookie": "^4.1.1",
+ "react-datepicker": "^4.15.0",
"react-dom": "^18.2.0",
+ "react-intersection-observer": "^9.5.2",
+ "react-paginate": "^8.2.0",
"react-redux": "^8.0.7",
+ "react-responsive": "^9.0.2",
"react-router": "^6.11.2",
"react-router-dom": "^6.11.2",
+ "redux-persist": "^6.0.0",
"styled-components": "^6.0.0-rc.3"
},
"devDependencies": {
"@types/react": "^18.0.37",
+ "@types/react-datepicker": "^4.11.2",
"@types/react-dom": "^18.0.11",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^5.59.9",
@@ -32,7 +42,8 @@
"eslint-plugin-react-refresh": "^0.3.4",
"prettier": "2.8.8",
"typescript": "^5.0.2",
- "vite": "^4.3.9"
+ "vite": "^4.3.9",
+ "vite-tsconfig-paths": "^4.2.0"
}
},
"node_modules/@ampproject/remapping": {
@@ -132,9 +143,9 @@
}
},
"node_modules/@babel/core/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -194,9 +205,9 @@
}
},
"node_modules/@babel/helper-compilation-targets/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -224,9 +235,9 @@
}
},
"node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -248,9 +259,9 @@
}
},
"node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -288,9 +299,9 @@
}
},
"node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -1806,9 +1817,9 @@
}
},
"node_modules/@babel/preset-env/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -2477,6 +2488,15 @@
"node": ">= 8"
}
},
+ "node_modules/@popperjs/core": {
+ "version": "2.11.8",
+ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
+ "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/popperjs"
+ }
+ },
"node_modules/@reduxjs/toolkit": {
"version": "1.9.5",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-1.9.5.tgz",
@@ -2508,6 +2528,11 @@
"node": ">=14"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.3.3",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz",
+ "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow=="
+ },
"node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@@ -2538,6 +2563,18 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/@types/react-datepicker": {
+ "version": "4.11.2",
+ "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-4.11.2.tgz",
+ "integrity": "sha512-ELYyX3lb3K1WltqdlF1hbnaDGgzlF6PIR5T4W38cSEcfrQDIrPE+Ioq5pwRe/KEJ+ihHMjvTVZQkwJx0pWMNHQ==",
+ "dev": true,
+ "dependencies": {
+ "@popperjs/core": "^2.9.2",
+ "@types/react": "*",
+ "date-fns": "^2.0.1",
+ "react-popper": "^2.2.5"
+ }
+ },
"node_modules/@types/react-dom": {
"version": "18.2.4",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.2.4.tgz",
@@ -2547,6 +2584,14 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-responsive": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/@types/react-responsive/-/react-responsive-8.0.5.tgz",
+ "integrity": "sha512-k3gQJgI87oP5IrVZe//3LKJFnAeFaqqWmmtl5eoYL2H3HqFcIhUaE30kRK1CsW3DHdojZxcVj4ZNc2ClsEu2PA==",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/scheduler": {
"version": "0.16.3",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.3.tgz",
@@ -2969,9 +3014,9 @@
}
},
"node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"bin": {
"semver": "bin/semver.js"
}
@@ -3166,6 +3211,11 @@
"node": ">= 6"
}
},
+ "node_modules/classnames": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.2.tgz",
+ "integrity": "sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw=="
+ },
"node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -3208,6 +3258,14 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz",
"integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A=="
},
+ "node_modules/cookie": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz",
+ "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.30.2",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz",
@@ -3242,6 +3300,11 @@
"node": ">=4"
}
},
+ "node_modules/css-mediaquery": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz",
+ "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q=="
+ },
"node_modules/css-to-react-native": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
@@ -3257,6 +3320,21 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
},
+ "node_modules/date-fns": {
+ "version": "2.30.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
+ "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
+ "dependencies": {
+ "@babel/runtime": "^7.21.0"
+ },
+ "engines": {
+ "node": ">=0.11"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/date-fns"
+ }
+ },
"node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
@@ -3327,6 +3405,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dotenv": {
+ "version": "16.3.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz",
+ "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/motdotla/dotenv?sponsor=1"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.4.421",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.421.tgz",
@@ -3634,9 +3723,9 @@
}
},
"node_modules/eslint-plugin-react/node_modules/semver": {
- "version": "6.3.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
- "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"bin": {
"semver": "bin/semver.js"
@@ -4194,6 +4283,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/globrex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
+ "dev": true
+ },
"node_modules/gopd": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz",
@@ -4305,6 +4400,11 @@
"react-is": "^16.7.0"
}
},
+ "node_modules/hyphenate-style-name": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
+ "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
+ },
"node_modules/ignore": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz",
@@ -4764,13 +4864,21 @@
}
},
"node_modules/make-dir/node_modules/semver": {
- "version": "5.7.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
- "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
+ "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"bin": {
"semver": "bin/semver"
}
},
+ "node_modules/matchmediaquery": {
+ "version": "0.3.1",
+ "resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.3.1.tgz",
+ "integrity": "sha512-Hlk20WQHRIm9EE9luN1kjRjYXAQToHOIAHPJn9buxBwuhfTHoKUcX+lXBbxc85DVQfXYbEQ4HcwQdd128E3qHQ==",
+ "dependencies": {
+ "css-mediaquery": "^0.1.2"
+ }
+ },
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -4875,7 +4983,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
- "dev": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5181,7 +5288,6 @@
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
- "dev": true,
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
@@ -5233,6 +5339,36 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-cookie": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-4.1.1.tgz",
+ "integrity": "sha512-ffn7Y7G4bXiFbnE+dKhHhbP+b8I34mH9jqnm8Llhj89zF4nPxPutxHT1suUqMeCEhLDBI7InYwf1tpaSoK5w8A==",
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.0.1",
+ "hoist-non-react-statics": "^3.0.0",
+ "universal-cookie": "^4.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0"
+ }
+ },
+ "node_modules/react-datepicker": {
+ "version": "4.15.0",
+ "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-4.15.0.tgz",
+ "integrity": "sha512-kysEqVv6wRQkmAyn0wJi4Xx+JjBPBtXWfQSfh6sR3wdzZX1/LjYTPmaurnVI6ao177ecompg8ze7NCgtEGW78A==",
+ "dependencies": {
+ "@popperjs/core": "^2.9.2",
+ "classnames": "^2.2.6",
+ "date-fns": "^2.24.0",
+ "prop-types": "^15.7.2",
+ "react-onclickoutside": "^6.12.2",
+ "react-popper": "^2.3.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17 || ^18",
+ "react-dom": "^16.9.0 || ^17 || ^18"
+ }
+ },
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -5245,11 +5381,62 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-fast-compare": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
+ "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
+ },
+ "node_modules/react-intersection-observer": {
+ "version": "9.5.2",
+ "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.5.2.tgz",
+ "integrity": "sha512-EmoV66/yvksJcGa1rdW0nDNc4I1RifDWkT50gXSFnPLYQ4xUptuDD4V7k+Rj1OgVAlww628KLGcxPXFlOkkU/Q==",
+ "peerDependencies": {
+ "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
+ "node_modules/react-onclickoutside": {
+ "version": "6.13.0",
+ "resolved": "https://registry.npmjs.org/react-onclickoutside/-/react-onclickoutside-6.13.0.tgz",
+ "integrity": "sha512-ty8So6tcUpIb+ZE+1HAhbLROvAIJYyJe/1vRrrcmW+jLsaM+/powDRqxzo6hSh9CuRZGSL1Q8mvcF5WRD93a0A==",
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/Pomax/react-onclickoutside/blob/master/FUNDING.md"
+ },
+ "peerDependencies": {
+ "react": "^15.5.x || ^16.x || ^17.x || ^18.x",
+ "react-dom": "^15.5.x || ^16.x || ^17.x || ^18.x"
+ }
+ },
+ "node_modules/react-paginate": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/react-paginate/-/react-paginate-8.2.0.tgz",
+ "integrity": "sha512-sJCz1PW+9PNIjUSn919nlcRVuleN2YPoFBOvL+6TPgrH/3lwphqiSOgdrLafLdyLDxsgK+oSgviqacF4hxsDIw==",
+ "dependencies": {
+ "prop-types": "^15"
+ },
+ "peerDependencies": {
+ "react": "^16 || ^17 || ^18"
+ }
+ },
+ "node_modules/react-popper": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz",
+ "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==",
+ "dependencies": {
+ "react-fast-compare": "^3.0.1",
+ "warning": "^4.0.2"
+ },
+ "peerDependencies": {
+ "@popperjs/core": "^2.0.0",
+ "react": "^16.8.0 || ^17 || ^18",
+ "react-dom": "^16.8.0 || ^17 || ^18"
+ }
+ },
"node_modules/react-redux": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-8.0.7.tgz",
@@ -5306,6 +5493,23 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-responsive": {
+ "version": "9.0.2",
+ "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-9.0.2.tgz",
+ "integrity": "sha512-+4CCab7z8G8glgJoRjAwocsgsv6VA2w7JPxFWHRc7kvz8mec1/K5LutNC2MG28Mn8mu6+bu04XZxHv5gyfT7xQ==",
+ "dependencies": {
+ "hyphenate-style-name": "^1.0.0",
+ "matchmediaquery": "^0.3.0",
+ "prop-types": "^15.6.1",
+ "shallow-equal": "^1.2.1"
+ },
+ "engines": {
+ "node": ">=0.10"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
"node_modules/react-router": {
"version": "6.11.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.11.2.tgz",
@@ -5356,6 +5560,14 @@
"@babel/runtime": "^7.9.2"
}
},
+ "node_modules/redux-persist": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz",
+ "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==",
+ "peerDependencies": {
+ "redux": ">4.0.0"
+ }
+ },
"node_modules/redux-thunk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz",
@@ -5563,9 +5775,9 @@
}
},
"node_modules/semver": {
- "version": "7.5.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.1.tgz",
- "integrity": "sha512-Wvss5ivl8TMRZXXESstBA4uR5iXgEN/VC5/sOcuXdVLzcdkz4HWetIoRfG5gb5X+ij/G9rw9YoGn3QoQ8OCSpw==",
+ "version": "7.5.4",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
+ "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
@@ -5595,6 +5807,11 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
+ "node_modules/shallow-equal": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-1.2.1.tgz",
+ "integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA=="
+ },
"node_modules/shallowequal": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
@@ -5838,6 +6055,26 @@
"node": ">=8.0"
}
},
+ "node_modules/tsconfck": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-2.1.1.tgz",
+ "integrity": "sha512-ZPCkJBKASZBmBUNqGHmRhdhM8pJYDdOXp4nRgj/O0JwUwsMq50lCDRQP/M5GBNAA0elPrq4gAeu4dkaVCuKWww==",
+ "dev": true,
+ "bin": {
+ "tsconfck": "bin/tsconfck.js"
+ },
+ "engines": {
+ "node": "^14.13.1 || ^16 || >=18"
+ },
+ "peerDependencies": {
+ "typescript": "^4.3.5 || ^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
@@ -5961,6 +6198,15 @@
"node": ">=4"
}
},
+ "node_modules/universal-cookie": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-4.0.4.tgz",
+ "integrity": "sha512-lbRVHoOMtItjWbM7TwDLdl8wug7izB0tq3/YVKhT/ahB4VDvWMyvnADfnJI8y6fSvsjh51Ix7lTGC6Tn4rMPhw==",
+ "dependencies": {
+ "@types/cookie": "^0.3.3",
+ "cookie": "^0.4.0"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.11.tgz",
@@ -6055,6 +6301,33 @@
}
}
},
+ "node_modules/vite-tsconfig-paths": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-4.2.0.tgz",
+ "integrity": "sha512-jGpus0eUy5qbbMVGiTxCL1iB9ZGN6Bd37VGLJU39kTDD6ZfULTTb1bcc5IeTWqWJKiWV5YihCaibeASPiGi8kw==",
+ "dev": true,
+ "dependencies": {
+ "debug": "^4.1.1",
+ "globrex": "^0.1.2",
+ "tsconfck": "^2.1.0"
+ },
+ "peerDependencies": {
+ "vite": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/warning": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz",
+ "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
+ "dependencies": {
+ "loose-envify": "^1.0.0"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6107,9 +6380,9 @@
}
},
"node_modules/word-wrap": {
- "version": "1.2.3",
- "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
- "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
"dev": true,
"engines": {
"node": ">=0.10.0"
diff --git a/package.json b/package.json
index 0534cba..8cf5ec3 100644
--- a/package.json
+++ b/package.json
@@ -11,16 +11,26 @@
},
"dependencies": {
"@reduxjs/toolkit": "^1.9.5",
+ "@types/react-responsive": "^8.0.5",
"axios": "^1.4.0",
+ "date-fns": "^2.30.0",
+ "dotenv": "^16.3.1",
"react": "^18.2.0",
+ "react-cookie": "^4.1.1",
+ "react-datepicker": "^4.15.0",
"react-dom": "^18.2.0",
+ "react-intersection-observer": "^9.5.2",
+ "react-paginate": "^8.2.0",
"react-redux": "^8.0.7",
+ "react-responsive": "^9.0.2",
"react-router": "^6.11.2",
"react-router-dom": "^6.11.2",
+ "redux-persist": "^6.0.0",
"styled-components": "^6.0.0-rc.3"
},
"devDependencies": {
"@types/react": "^18.0.37",
+ "@types/react-datepicker": "^4.11.2",
"@types/react-dom": "^18.0.11",
"@types/styled-components": "^5.1.26",
"@typescript-eslint/eslint-plugin": "^5.59.9",
@@ -34,6 +44,7 @@
"eslint-plugin-react-refresh": "^0.3.4",
"prettier": "2.8.8",
"typescript": "^5.0.2",
- "vite": "^4.3.9"
+ "vite": "^4.3.9",
+ "vite-tsconfig-paths": "^4.2.0"
}
}
diff --git a/public/_redirects b/public/_redirects
new file mode 100644
index 0000000..f824337
--- /dev/null
+++ b/public/_redirects
@@ -0,0 +1 @@
+/* /index.html 200
\ No newline at end of file
diff --git a/public/badminton0.png b/public/badminton0.png
new file mode 100644
index 0000000..10cd1d3
Binary files /dev/null and b/public/badminton0.png differ
diff --git a/public/badminton1.png b/public/badminton1.png
new file mode 100644
index 0000000..956a042
Binary files /dev/null and b/public/badminton1.png differ
diff --git a/public/badminton2.png b/public/badminton2.png
new file mode 100644
index 0000000..1af9b59
Binary files /dev/null and b/public/badminton2.png differ
diff --git a/public/banner0.png b/public/banner0.png
new file mode 100644
index 0000000..ea3888b
Binary files /dev/null and b/public/banner0.png differ
diff --git a/public/banner1.png b/public/banner1.png
new file mode 100644
index 0000000..062b23d
Binary files /dev/null and b/public/banner1.png differ
diff --git a/public/banner2.png b/public/banner2.png
new file mode 100644
index 0000000..f1244f3
Binary files /dev/null and b/public/banner2.png differ
diff --git a/public/banner3.png b/public/banner3.png
new file mode 100644
index 0000000..fa8070e
Binary files /dev/null and b/public/banner3.png differ
diff --git a/public/banner4.png b/public/banner4.png
new file mode 100644
index 0000000..fd90ff6
Binary files /dev/null and b/public/banner4.png differ
diff --git a/public/basketball0.png b/public/basketball0.png
new file mode 100644
index 0000000..55880e4
Binary files /dev/null and b/public/basketball0.png differ
diff --git a/public/basketball1.png b/public/basketball1.png
new file mode 100644
index 0000000..53e8d68
Binary files /dev/null and b/public/basketball1.png differ
diff --git a/public/basketball2.png b/public/basketball2.png
new file mode 100644
index 0000000..c3032fb
Binary files /dev/null and b/public/basketball2.png differ
diff --git a/public/calendar-dark.png b/public/calendar-dark.png
new file mode 100644
index 0000000..0e19526
Binary files /dev/null and b/public/calendar-dark.png differ
diff --git a/public/calendar-light.png b/public/calendar-light.png
new file mode 100644
index 0000000..9fce9ee
Binary files /dev/null and b/public/calendar-light.png differ
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..f9072d5
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/futsal0.png b/public/futsal0.png
new file mode 100644
index 0000000..24d8158
Binary files /dev/null and b/public/futsal0.png differ
diff --git a/public/futsal1.png b/public/futsal1.png
new file mode 100644
index 0000000..dc034d0
Binary files /dev/null and b/public/futsal1.png differ
diff --git a/public/futsal2.png b/public/futsal2.png
new file mode 100644
index 0000000..2fff932
Binary files /dev/null and b/public/futsal2.png differ
diff --git a/public/logo.png b/public/logo.png
new file mode 100644
index 0000000..830b47b
Binary files /dev/null and b/public/logo.png differ
diff --git a/public/my_page_banner/mobile_0.jpg b/public/my_page_banner/mobile_0.jpg
new file mode 100644
index 0000000..e446b13
Binary files /dev/null and b/public/my_page_banner/mobile_0.jpg differ
diff --git a/public/my_page_banner/mobile_1.jpg b/public/my_page_banner/mobile_1.jpg
new file mode 100644
index 0000000..64d784c
Binary files /dev/null and b/public/my_page_banner/mobile_1.jpg differ
diff --git a/public/my_page_banner/mobile_2.jpg b/public/my_page_banner/mobile_2.jpg
new file mode 100644
index 0000000..1358cb1
Binary files /dev/null and b/public/my_page_banner/mobile_2.jpg differ
diff --git a/public/my_page_banner/mobile_3.jpg b/public/my_page_banner/mobile_3.jpg
new file mode 100644
index 0000000..e9e0d3b
Binary files /dev/null and b/public/my_page_banner/mobile_3.jpg differ
diff --git a/public/my_page_banner/pc_0.jpg b/public/my_page_banner/pc_0.jpg
new file mode 100644
index 0000000..c6a9cac
Binary files /dev/null and b/public/my_page_banner/pc_0.jpg differ
diff --git a/public/my_page_banner/pc_1.jpg b/public/my_page_banner/pc_1.jpg
new file mode 100644
index 0000000..72d957c
Binary files /dev/null and b/public/my_page_banner/pc_1.jpg differ
diff --git a/public/my_page_banner/pc_2.jpg b/public/my_page_banner/pc_2.jpg
new file mode 100644
index 0000000..70ce901
Binary files /dev/null and b/public/my_page_banner/pc_2.jpg differ
diff --git a/public/my_page_banner/pc_3.jpg b/public/my_page_banner/pc_3.jpg
new file mode 100644
index 0000000..a392f3e
Binary files /dev/null and b/public/my_page_banner/pc_3.jpg differ
diff --git a/public/select-arrow.png b/public/select-arrow.png
new file mode 100644
index 0000000..b0c32c0
Binary files /dev/null and b/public/select-arrow.png differ
diff --git a/public/soccer0.png b/public/soccer0.png
new file mode 100644
index 0000000..3a917b2
Binary files /dev/null and b/public/soccer0.png differ
diff --git a/public/soccer1.png b/public/soccer1.png
new file mode 100644
index 0000000..b37acb7
Binary files /dev/null and b/public/soccer1.png differ
diff --git a/public/soccer2.png b/public/soccer2.png
new file mode 100644
index 0000000..23fbd5b
Binary files /dev/null and b/public/soccer2.png differ
diff --git a/public/tennis0.png b/public/tennis0.png
new file mode 100644
index 0000000..484f9c9
Binary files /dev/null and b/public/tennis0.png differ
diff --git a/public/tennis1.png b/public/tennis1.png
new file mode 100644
index 0000000..4bd9734
Binary files /dev/null and b/public/tennis1.png differ
diff --git a/public/tennis2.png b/public/tennis2.png
new file mode 100644
index 0000000..8ab840e
Binary files /dev/null and b/public/tennis2.png differ
diff --git a/public/vite.svg b/public/vite.svg
deleted file mode 100644
index e7b8dfb..0000000
--- a/public/vite.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/src/App.tsx b/src/App.tsx
index 1af9c19..23c076b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,14 +1,35 @@
-import { Outlet } from 'react-router'
+import { Outlet, useLocation } from 'react-router'
import Header from './components/Header'
import Footer from './components/Footer'
+import { ThemeProvider } from 'styled-components'
+import theme from './constants/theme'
+import Sidebar from './components/Sidebar'
+import { useEffect } from 'react'
+import { cheakOpenBox } from './store/slices/searchChkSlice'
+import { useDispatch } from 'react-redux'
+import ModalWithHook from './components/ModalWithHook'
+import Overlay from './components/Overlay'
+// import useAxiosInterceptor from './hooks/useAxiosInterceptor'
const App = () => {
+ const dispatch = useDispatch()
+ const location = useLocation()
+ useEffect(() => {
+ if (window.document.body.classList.contains('stop-scrolling')) {
+ window.document.body.classList.remove('stop-scrolling')
+ dispatch(cheakOpenBox({ openBox: false }))
+ }
+ }, [location])
+
return (
- <>
+
+
+
+
- >
+
)
}
diff --git a/src/api/Instance.tsx b/src/api/Instance.tsx
index f7e640b..9d9e8fd 100644
--- a/src/api/Instance.tsx
+++ b/src/api/Instance.tsx
@@ -1,21 +1,58 @@
-import axios from 'axios'
-const BASE_URL = import.meta.env.BASE_URL
+import axios, { InternalAxiosRequestConfig } from 'axios'
+import CheckAuthorization from '@src/components/CheckAuthorization'
+import { removeCookieToken } from '@src/storage/Cookie'
+import { DELETE_TOKEN } from '@src/store/slices/authSlice'
+import store from '@src/store/config'
+import { DELETE_INFO } from '@src/store/slices/infoSlice'
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const axiosApi = (url: string, options?: any) => {
- const instance = axios.create({ baseURL: url, ...options })
- return instance
-}
+const { dispatch } = store
+const BASE_URL = import.meta.env.VITE_BASE_URL
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-const axiosAuthApi = (url: string, options?: any) => {
- const token = '토큰 값'
- const instance = axios.create({
- baseURL: url,
- headers: { Authorization: 'Bearer ' + token },
- ...options,
- })
- return instance
-}
-export const defaultInstance = axiosApi(BASE_URL)
-export const authInstance = axiosAuthApi(BASE_URL)
+// 전역에 쿠키 전송 허용 설정
+axios.defaults.withCredentials = true
+
+// 토큰이 필요 없는 api 요청을 보내는 axios 인스턴스
+export const publicApi = axios.create({
+ baseURL: BASE_URL,
+ timeout: 10000,
+})
+
+// 토큰이 필요한 api 요청을 보내는 axios 인스턴스
+export const privateApi = axios.create({
+ baseURL: BASE_URL,
+})
+
+// 토큰이 필요한 api 요청의 request 인터셉터
+privateApi.interceptors.request.use(
+ async function (config): Promise {
+ const atExpire = store.getState().accessToken.expireTime
+ const curTime = new Date().getTime()
+
+ if (atExpire < curTime) {
+ console.log(new Date(atExpire) + '/' + new Date(curTime))
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ window.location.replace('/login')
+ console.log('at시간 만료로 스토리지 리셋')
+ return Promise.resolve()
+ }
+
+ const newConfig = await CheckAuthorization(config)
+ if (newConfig === 'NoToken') {
+ window.location.replace('/login')
+ console.log('CheckAuthorization === NoToken.')
+ alert('토큰이 존재하지 않습니다. 로그인 페이지로 이동합니다.')
+ return Promise.resolve()
+ } else if (newConfig === 'ExpiredToken') {
+ window.location.replace('/login')
+ console.log('CheckAuthorization === ExpiredToken.')
+ alert('토큰이 만료되어 자동으로 로그아웃 되었습니다. 다시 로그인 해주세요.')
+ return Promise.resolve()
+ }
+ return newConfig
+ },
+ function (error) {
+ return Promise.reject(error)
+ }
+)
diff --git a/src/api/authApi.tsx b/src/api/authApi.tsx
index e69de29..17a2d84 100644
--- a/src/api/authApi.tsx
+++ b/src/api/authApi.tsx
@@ -0,0 +1,370 @@
+import axios, { isAxiosError } from 'axios'
+import { privateApi, publicApi } from './Instance'
+
+// 로그인
+export const userLogin = async ({ userEmail, userPw }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/auth/login', {
+ method: 'POST',
+ data: {
+ memberId: userEmail,
+ password: userPw,
+ },
+ })
+ // if(!response) return
+ if (response.status && response.data.data) {
+ return {
+ status: response.status,
+ tokens: response.data.data,
+ }
+ }
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ return {
+ status: error.response?.status,
+ }
+ }
+ }
+}
+
+// 로그아웃
+export const userLogout = async () => {
+ // const access_token = store.getState().accessToken.accessToken
+ const access_token = window.localStorage.getItem('accessToken')
+ if (access_token == null) return
+ try {
+ const response = await privateApi('/auth/logout', {
+ method: 'POST',
+ })
+ if (response.status === 200) {
+ // window.location.replace('/login')
+ return {
+ status: response.status,
+ result: response.data.result,
+ message: response.data.message,
+ }
+ }
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ return {
+ status: error.response?.status,
+ }
+ }
+ }
+}
+
+// accessToken 검사 (AT 검사)
+export async function checkTokenExpire() {
+ const access_token = window.localStorage.getItem('accessToken')
+ if (access_token == null) {
+ return
+ }
+ try {
+ const response = await publicApi('/auth/validate', {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${access_token}`,
+ },
+ })
+ return response.status
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ return {
+ status: error.response?.status,
+ }
+ }
+ }
+}
+
+// refreshToken 재발급
+export async function postRefereshToken() {
+ // const access_token = store.getState().accessToken.accessToken
+ const access_token = window.localStorage.getItem('accessToken')
+ try {
+ const response = await publicApi('/auth/reissue', {
+ withCredentials: true,
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${access_token}`,
+ },
+ })
+ return {
+ status: response.status,
+ tokens: response.data.data,
+ }
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ return {
+ status: error.response?.status,
+ }
+ }
+ }
+}
+
+// 회원가입
+export const join = async ({ userEmail, userPw, userName, userNickName, userPhone }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/signup', {
+ method: 'POST',
+ data: {
+ memberId: userEmail,
+ password: userPw,
+ memberName: userName,
+ memberNickName: userNickName,
+ memberPhone: userPhone,
+ },
+ })
+ return response.status
+ } catch (error) {
+ if (axios.isAxiosError(error)) {
+ return {
+ status: error.response?.status,
+ }
+ }
+ }
+}
+
+// (회원가입시) 이메일 중복 검사
+export const checkDuplicateEmail = async ({ userEmail }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/duplicate-email', {
+ method: 'POST',
+ data: {
+ memberId: userEmail,
+ },
+ })
+ return response.status
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 비밀번호 찾기(find-password) - 인증번호 메일 요청(PIN번호 메일 전송)
+export const verifyUserEmail = async ({ userEmail }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/check-email', {
+ method: 'POST',
+ params: {
+ memberId: userEmail,
+ },
+ })
+ return {
+ status: response.status,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 비밀번호 찾기 - 인증번호 확인(PIN번호 확인)
+export const verifyUserNum = async ({ userEmail, userVerifyNum }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/check-pin', {
+ method: 'GET',
+ params: {
+ memberId: userEmail,
+ pin: userVerifyNum,
+ },
+ })
+ return {
+ status: response.status,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 비밀번호 찾기 - 임시 비밀번호 발급(임시 비밀번호 생성, 저장, 메일 전송)
+export const temporaryPassword = async ({ userEmail }: IUserInfoType) => {
+ try {
+ const response = await publicApi('/member-temporary', {
+ method: 'POST',
+ params: {
+ email: userEmail,
+ },
+ })
+ return {
+ status: response.status,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 회원 정보 조회
+export const getUserInfo = async () => {
+ try {
+ const response = await privateApi('/my-page/member-inquiry', {
+ method: 'GET',
+ })
+ return {
+ status: response.status,
+ memberId: response.data.data.memberId,
+ memberName: response.data.data.memberName,
+ memberNickName: response.data.data.memberNickName,
+ memberPhone: response.data.data.memberPhone,
+ data: response.data.data,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 회원 정보 수정
+export const editUserInfo = async ({ userName, userNickName, userPhone }: IUserInfoType) => {
+ try {
+ const response = await privateApi('/my-page/edit-info', {
+ method: 'PATCH',
+ data: {
+ memberName: userName,
+ memberNickName: userNickName,
+ memberPhone: userPhone,
+ },
+ })
+ return {
+ status: response.status,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 비밀번호 변경(마이페이지에서)
+export const editUserPw = async ({ newPw }: IUserInfoType) => {
+ try {
+ const response = await privateApi('/my-page/edit-password', {
+ method: 'POST',
+ data: {
+ password: newPw,
+ },
+ })
+ return {
+ status: response.status,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 내가 작성한 글 조회
+export const getMyPost = async (page: number) => {
+ try {
+ const response = await privateApi(`/my-page/post-inquiry/${page}`, {
+ method: 'GET',
+ })
+ return {
+ status: response.status,
+ message: response.data.message,
+ data: response.data.data.content,
+ totalPages: response.data.data.totalPages,
+ totalElements: response.data.data.totalElements,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 관심글 조회
+export const getWishlist = async (page: number) => {
+ try {
+ const response = await privateApi(`/my-page/wish-list/${page}`, {
+ method: 'GET',
+ })
+ return {
+ status: response.status,
+ message: response.data.message,
+ data: response.data.data.content,
+ totalPages: response.data.data.totalPages,
+ totalElements: response.data.data.totalElements,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
+
+// 내가 작성한 댓글 조회
+// export const getMyReply = async (page: number) => {
+// try {
+// const response = await privateApi(`/comment/my-inquiry/${page}`, {
+// method: 'GET',
+// })
+// return {
+// status: response.status,
+// message: response.data.message,
+// data: response.data.data.content,
+// totalPages: response.data.data.totalPages,
+// totalElements: response.data.data.totalElements,
+// }
+// } catch (error) {
+// if (isAxiosError(error)) {
+// return {
+// status: error.response?.data.state,
+// }
+// }
+// }
+// }
+
+export const getMyReply = async (page: number) => {
+ const response = await privateApi.get(`/comment/my-inquiry/${page}`)
+ return {
+ status: response.status,
+ message: response.data.message,
+ data: response.data.data.content,
+ totalPages: response.data.data.totalPages,
+ totalElements: response.data.data.totalElements,
+ }
+}
+
+// 회원 게시글 조회
+export const getUserPost = async (page: number, memberId: string) => {
+ try {
+ const response = await publicApi(`member-inquiry/${memberId}/${page}`, { method: 'GET' })
+ return {
+ status: response.status,
+ data: response.data.data.content,
+ lastPage: response.data.data.last,
+ }
+ } catch (error) {
+ if (isAxiosError(error)) {
+ return {
+ status: error.response?.data.state,
+ }
+ }
+ }
+}
diff --git a/src/api/boardApi.tsx b/src/api/boardApi.tsx
new file mode 100644
index 0000000..81b13c6
--- /dev/null
+++ b/src/api/boardApi.tsx
@@ -0,0 +1,107 @@
+import { publicApi, privateApi } from './Instance'
+
+export const getSearchPostList = async (values: SearchValueTypes, page = 1) => {
+ if (!values.chkDate) {
+ values.startTime = ''
+ values.endTime = ''
+ }
+
+ return await publicApi
+ .get(
+ `/search/${page}?title=${values.title}&categoryName=${values.category}&startTime=${values.startTime}&endTime=${
+ values.endTime
+ }&districtNames=${values.district.join()}`
+ )
+ .then((res) => {
+ return res.data.data
+ })
+}
+
+export const getMainPostList = async (params: IMainListPayload, page = 1) => {
+ return await publicApi.get(`/search/${page}`, { params }).then((res) => {
+ return res.data.data
+ })
+}
+
+export const getPostDetail = async (userId: number, loginVal: boolean) => {
+ const Instance = loginVal ? privateApi : publicApi
+
+ return await Instance.get(`/detail/${userId}`).then((res) => {
+ return res.data.data
+ })
+}
+
+export const delPost = async (boardId: number | undefined) => {
+ return await privateApi.delete(`/board/delete/${boardId}`).then(() => {
+ alert('삭제 되었습니다.')
+ })
+}
+
+export const getComment = async (boardId: number, page: number, loginVal: boolean) => {
+ const Instance = loginVal ? privateApi : publicApi
+ return await Instance.get(`comment-lookup/${boardId}/${page}`).then((res) => {
+ return res.data.data
+ })
+}
+
+export const postComment = async (boardId: number, comment: string, parentId?: number) => {
+ return await privateApi
+ .post('comment/write', {
+ commentContent: comment,
+ boardId: boardId,
+ parentId: parentId,
+ })
+ .then((res) => {
+ return res
+ })
+}
+
+export const postLikeBoard = async (boardId: number, loginVal: boolean) => {
+ if (!loginVal) return alert('관심글 저장을 위해 로그인이 필요합니다.')
+
+ return await privateApi
+ .post('/board/register/wish-list', {
+ boardId: boardId,
+ })
+ .then(() => {
+ alert('관심글에 저장되었습니다.')
+ })
+}
+
+export const delLikeBoard = async (boardId: number) => {
+ return await privateApi
+ .delete('board/delete/wish-list', {
+ data: {
+ boardId: boardId,
+ },
+ })
+ .then(() => {
+ alert('관심글에서 삭제되었습니다.')
+ })
+}
+
+export const delComment = async (commentId: number) => {
+ return await privateApi.delete(`/comment/delete/${commentId}`).then(() => {
+ alert('삭제되었습니다.')
+ })
+}
+export const addComment = async (commentId: number, content: string) => {
+ return await privateApi
+ .put(`/comment/edit/${commentId}`, {
+ commentContent: content,
+ })
+ .then(() => {
+ alert('수정되었습니다.')
+ })
+}
+
+export const addTransactionStatus = async (commentId: number) => {
+ return await privateApi.put(`/board/sold-out/${commentId}`).then(() => {
+ alert('변경 되었습니다.')
+ })
+}
+
+// 게시글 블라인드 처리
+export const blindBoard = async (boardId: number) => {
+ return await privateApi.put(`/admin/board/blind/${boardId}`)
+}
diff --git a/src/api/getApi.tsx b/src/api/getApi.tsx
index e69de29..ace1e6b 100644
--- a/src/api/getApi.tsx
+++ b/src/api/getApi.tsx
@@ -0,0 +1,34 @@
+import { privateApi } from './Instance'
+
+// 관리자 문의글 전체 조회
+export const getAdminQuestion = async (page: number) => {
+ const response = await privateApi.get(`/admin/question-list/${page}`)
+ return response.data.data
+}
+
+// 내 문의글 조회
+export const getQuestion = async (page: number) => {
+ const response = await privateApi.get(`/question/inquiry/${page}`)
+ return response.data.data
+}
+
+// 문의글 상세 조회
+export const getQuestionDetail = async (questionId: number) => {
+ const response = await privateApi.get(`/question/${questionId}`)
+ return response.data.data
+}
+
+// 문의글 답변 조회
+export const getQuestionAnswer = async (questionId: number) => {
+ const response = await privateApi.get(`/question/answer/${questionId}`)
+ return response.data.data
+}
+
+// 관리자 블라인드 게시글 조회
+export const getAdminBlind = async (page: number) => {
+ const response = await privateApi.get(`admin/board/blind/lookup/${page}`)
+ return {
+ data: response.data.data.content,
+ last: response.data.data.last,
+ }
+}
diff --git a/src/api/postApi.tsx b/src/api/postApi.tsx
new file mode 100644
index 0000000..fe45d4b
--- /dev/null
+++ b/src/api/postApi.tsx
@@ -0,0 +1,50 @@
+import { privateApi } from './Instance'
+
+// 게시글 작성
+export const requestWrite = async (formData: FormData) => {
+ const response = await privateApi('/board/register', {
+ method: 'POST',
+ headers: {
+ 'content-type': 'multipart/form-data',
+ },
+ data: formData,
+ })
+ if (response.data.state === 200) {
+ return 200
+ }
+}
+
+// 게시글 수정
+export const requestEdit = async (formData: FormData, postId: number) => {
+ const response = await privateApi(`/board/edit/${postId}`, {
+ method: 'PUT',
+ headers: {
+ 'content-type': 'multipart/form-data',
+ },
+ data: formData,
+ })
+ if (response.data.state === 200) {
+ return 200
+ }
+}
+
+// 관리자 문의글 답변 등록
+export const postAdmintQuestion = async (questionId: number, data: QuestionTypes) => {
+ const response = await privateApi(`/admin/answer/register?questionId=${questionId}`, {
+ method: 'POST',
+ data,
+ })
+ return {
+ status: response.status,
+ }
+}
+
+// 문의글 등록
+export const postQuestion = async (data: QuestionPostType) => {
+ const response = await privateApi.post('/question/register', {
+ data,
+ })
+ return {
+ status: response.status,
+ }
+}
diff --git a/src/api/userApi.tsx b/src/api/userApi.tsx
new file mode 100644
index 0000000..60f5f29
--- /dev/null
+++ b/src/api/userApi.tsx
@@ -0,0 +1,17 @@
+import { privateApi } from './Instance'
+
+// 승급
+export const promoteUser = async (memberId: string) => {
+ const response = await privateApi.put(`/admin/promote?memberId=${memberId}`)
+ return {
+ status: response.status,
+ }
+}
+
+// 강등
+export const demoteUser = async (memberId: string) => {
+ const response = await privateApi.put(`/admin/demote?memberId=${memberId}`)
+ return {
+ status: response.status,
+ }
+}
diff --git a/src/components/Ask.tsx b/src/components/Ask.tsx
new file mode 100644
index 0000000..25444ae
--- /dev/null
+++ b/src/components/Ask.tsx
@@ -0,0 +1,42 @@
+import { styled } from 'styled-components'
+
+interface IAsk {
+ title: string
+ comment: string
+ screen: string
+}
+
+const Ask = ({ title, comment, screen }: IAsk) => {
+ return (
+
+
+
Q. {title}
+
+
+ A. {comment}
+
+ )
+}
+
+export default Ask
+
+const AskStyle = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 1024px;
+ .question {
+ display: flex;
+ gap: 10px;
+ h3 {
+ font-weight: ${({ screen }) => (screen === 'pc' ? 700 : 500)};
+ }
+ }
+
+ .answer {
+ padding: 16px 8px;
+ background: #fafafa;
+ border-radius: 8px;
+ font-size: ${({ screen }) => screen === 'mobile' && '12px'};
+ }
+`
diff --git a/src/components/Board.tsx b/src/components/Board.tsx
new file mode 100644
index 0000000..0aff531
--- /dev/null
+++ b/src/components/Board.tsx
@@ -0,0 +1,208 @@
+import { Harticon } from '@src/constants/icons'
+import theme from '@src/constants/theme'
+import { COLORS, FONT } from '@src/globalStyles'
+import { dateFormat, handleImgError, randomImages } from '@src/utils/utils'
+import { useNavigate } from 'react-router'
+import { ThemeProvider, styled } from 'styled-components'
+
+interface Props {
+ data: POST_TYPE[] | IWishlistType[]
+ message: string
+}
+
+const Board = ({ data, message }: Props) => {
+ const navigate = useNavigate()
+
+ return (
+
+
+ {data && data.length > 0 ? (
+
+ {data.map((list, idx) => (
+ navigate(`/board-details/${list.boardId}`)}
+ >
+
+

handleImgError(e, list.categoryName, list.boardId)}
+ alt="이미지"
+ />
+
+
+
{list.title}
+
{list.price.toLocaleString()} 원
+
+ {list.districtName} {dateFormat(list.startTime)}
+
+
+ 조회수
+ {list.viewCount}
+
+
+
+ {list.wishCount}
+
+
+ {list.transactionStatus === '판매 완료' && 판매 완료
}
+
+ ))}
+
+ ) : (
+ {message}
+ )}
+
+
+ )
+}
+
+export default Board
+
+const BoardContainer = styled.div`
+ padding: 20px;
+ display: flex;
+ justify-content: center;
+
+ @media ${({ theme }) => theme.device.laptop} {
+ padding-left: 4px;
+ padding-right: 4px;
+ }
+
+ ul {
+ width: 100%;
+ max-width: calc(var(--screen-pc) + 16px);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 16px;
+ padding-left: 16px;
+
+ .imgae_wrap {
+ width: 100%;
+ background: #fff;
+ border-radius: 20px;
+ position: relative;
+
+ img {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ border-radius: 20px;
+ object-fit: cover;
+ background-color: #d9d9d935;
+ }
+
+ &:after {
+ content: '';
+ display: block;
+ padding-bottom: 100%;
+ }
+ }
+
+ .info_wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+
+ .title {
+ font-size: ${FONT.pc};
+ font-weight: 400;
+ }
+
+ .price {
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ }
+
+ .date {
+ font-size: ${FONT.m};
+ font-weight: 400;
+ }
+
+ .view_like {
+ display: flex;
+ gap: 4px;
+ color: ${COLORS.gray40};
+ font-size: ${FONT.m};
+ font-weight: 400;
+
+ .stadium {
+ color: ${COLORS.green};
+ font-size: ${FONT.m};
+ font-weight: 700;
+ }
+ .title {
+ font-size: ${FONT.pc};
+ font-weight: 400;
+ }
+
+ .price {
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ }
+
+ .date {
+ display: flex;
+ justify-content: space-between;
+ font-size: ${FONT.m};
+ font-weight: 400;
+ }
+
+ .view_like {
+ display: flex;
+ gap: 4px;
+ color: ${COLORS.gray40};
+ font-size: ${FONT.m};
+ font-weight: 400;
+ }
+ }
+ }
+
+ .sold_out {
+ width: 95px;
+ height: 35px;
+ line-height: 35px;
+ border-radius: 16px;
+ background: ${COLORS.green};
+ position: absolute;
+ right: 15px;
+ top: 15px;
+ font-size: ${FONT.pc};
+ font-weight: 900;
+ color: #fff;
+ text-align: center;
+ z-index: 1;
+ }
+ }
+`
+
+const PostListBox = styled.li<{ blind: string | null }>`
+ width: calc(100% / 4 - 16px);
+ @media ${({ theme }) => theme.device.laptop} {
+ width: calc(100% / 3 - 16px);
+ }
+ @media ${({ theme }) => theme.device.tablet} {
+ width: calc(100% / 2 - 16px);
+ }
+ @media ${({ theme }) => theme.device.mobile} {
+ width: calc(100% - 16px);
+ }
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ margin-bottom: 36px;
+ position: relative;
+ cursor: pointer;
+
+ &::after {
+ content: '';
+ display: ${(props) => (props.blind === '판매 완료' ? 'block' : 'none')};
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ left: 0;
+ top: 0;
+ background: rgba(255, 255, 255, 0.5);
+ }
+`
diff --git a/src/components/CheckAuthorization.tsx b/src/components/CheckAuthorization.tsx
new file mode 100644
index 0000000..541cfcf
--- /dev/null
+++ b/src/components/CheckAuthorization.tsx
@@ -0,0 +1,55 @@
+import { checkTokenExpire, postRefereshToken } from '@src/api/authApi'
+import { getCookieToken, removeCookieToken, setRefreshToken } from '@src/storage/Cookie'
+import store from '@src/store/config'
+import { DELETE_TOKEN, SET_TOKEN } from '@src/store/slices/authSlice'
+import { DELETE_INFO } from '@src/store/slices/infoSlice'
+import { InternalAxiosRequestConfig } from 'axios'
+
+const { dispatch } = store
+
+const CheckAuthorization = async (config: InternalAxiosRequestConfig) => {
+ const refresh_token = getCookieToken()
+ const access_token = localStorage.getItem('accessToken')
+
+ if (!access_token || !refresh_token) {
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ console.log('at, rf 없음')
+ // return 'Failed'
+ return 'NoToken'
+ }
+ const status = await checkTokenExpire()
+
+ if (status === 200) {
+ config.headers['Authorization'] = `Bearer ${access_token}`
+ return config
+ } else {
+ if (refresh_token) {
+ const { status, tokens } = (await postRefereshToken()) as IResponseType
+ if (status === 200) {
+ removeCookieToken()
+ dispatch(SET_TOKEN(tokens.accessToken))
+ setRefreshToken(tokens.refreshToken)
+ config.headers['Authorization'] = `Bearer ${access_token}`
+ console.log('rf로 at 재발급')
+ return config
+ } else {
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ console.log('만료된 rf로 at재발급 실패')
+ return 'ExpiredToken'
+ }
+ } else {
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ console.log('rf 아예 없음')
+ // return 'Failed'
+ return 'NoToken'
+ }
+ }
+}
+
+export default CheckAuthorization
diff --git a/src/components/Comment.tsx b/src/components/Comment.tsx
new file mode 100644
index 0000000..93a6104
--- /dev/null
+++ b/src/components/Comment.tsx
@@ -0,0 +1,371 @@
+import { getComment } from '@src/api/boardApi'
+import React, { useState, useEffect, useCallback, useRef } from 'react'
+import { styled } from 'styled-components'
+import BoardCommentInput from './CommentInput'
+import { useDispatch, useSelector } from 'react-redux'
+import { RootState } from '../store/config'
+import { BalloonIcon, ClackIcon, MoreIcon } from '@src/constants/icons'
+import { dateFormat } from '@src/utils/utils'
+import CommentOptions from '../components/CommentOptions'
+import { setCommentAdd, setCommentInput, setCommentOptions } from '@src/store/slices/commentSlice'
+
+type PropsType = {
+ boardId: number
+ loginVal: boolean
+}
+
+const BoardComment = (props: PropsType) => {
+ const [page, setPage] = useState(1)
+ const [comments, setComments] = useState([])
+ const dispatch = useDispatch()
+ const [totalPage, setTotalPage] = useState([])
+
+ //element
+ const commentMoreBtn = useRef(null)
+
+ const commentData = useSelector((state: RootState) => {
+ return {
+ data: state.commentData.comment,
+ commentNum: state.commentData.commentNum,
+ commentBox: state.commentData.commentBox,
+ commentAdd: state.commentData.commentAdd,
+ }
+ })
+
+ const getCommnetData = useCallback(
+ async (boardId: number, page: number, loginVal: boolean) => {
+ try {
+ const CommentData = await getComment(boardId, page, loginVal)
+
+ const total = []
+ for (let i = 1; i <= CommentData.totalPages; i++) {
+ total.push(i)
+ }
+
+ setTotalPage(total)
+ setComments(CommentData.content)
+ } catch (err) {
+ console.log(err)
+ }
+ },
+ [commentData.data, commentData.commentAdd, page]
+ )
+
+ useEffect(() => {
+ getCommnetData(props.boardId, page, props.loginVal)
+ }, [getCommnetData, commentData.data, commentData.commentAdd])
+
+ useEffect(() => {
+ dispatch(setCommentInput({ commentNum: -1 }))
+ dispatch(setCommentOptions({ commentBox: -1 }))
+ dispatch(setCommentAdd({ commentAdd: -1 }))
+ }, [])
+
+ useEffect(() => {
+ const outClickFn = (e: MouseEvent) => {
+ const moreBtn = document.querySelector('.comment-more-btn')
+ if (e.currentTarget !== moreBtn) return dispatch(setCommentOptions({ commentBox: -1 }))
+ return
+ }
+
+ document.body.addEventListener('click', outClickFn)
+
+ return () => {
+ document.body.removeEventListener('click', outClickFn)
+ }
+ }, [])
+
+ return (
+
+
+
+
+
+ {comments.map((item: CommentTypes, key: number) => (
+
+
+ {item.memberNickname} {item.memberId}{' '}
+ {item.commentRegisterDate.substring(0, 20) !== item.commentUpDate.substring(0, 20) ? '(수정됨)' : null}
+
+ {commentData.commentAdd !== item.commentId ? (
+
+ {item.deleteCheck ? '삭제된 댓글입니다.' : item.commentContent}
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+ {dateFormat(item.commentUpDate, 'comment')}
+
+
+
+ {item.children.length}
+
+
+ {item.deleteCheck ? null : (
+
+ )}
+
+ {commentData.commentBox === item.commentId && (
+
+
+
+ )}
+ {commentData.commentNum === item.commentId && (
+
+
+
+ )}
+ {item.children && (
+
+ )}
+
+ ))}
+
+
+ {totalPage.map((num: number) => (
+
+
+
+ ))}
+
+
+ )
+}
+
+const Container = styled.div`
+ max-width: var(--screen-pc);
+ margin: 0 auto;
+
+ * {
+ box-sizing: border-box;
+ }
+`
+
+const InputBox = styled.div<{ type: string }>`
+ padding: 16px;
+ padding-right: 0;
+ padding-left: ${(props) => (props.type === 'add' ? '0' : '16px')};
+ border: ${(props) => (props.type === 'parent' ? '1px solid #d9d9d9' : 'none')};
+ border-right: 0;
+ border-left: 0;
+ position: relative;
+
+ @media ${({ theme }) => theme.device.tablet} {
+ padding-right: 16px;
+ }
+`
+
+const CommentList = styled.ul`
+ li {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ padding: 16px 0 0 16px;
+ position: relative;
+
+ & > p:nth-child(1) {
+ font-size: 12px;
+ color: #aaa;
+ }
+
+ & > p:nth-child(2) {
+ font-size: 16px;
+ color: #000;
+ margin: 12px 0 8px;
+
+ @media ${({ theme }) => theme.device.tablet} {
+ font-size: 14px !important;
+ }
+ }
+
+ .comment-info-box {
+ display: flex;
+ justify-content: space-between;
+ padding-bottom: 16px;
+ position: relative;
+
+ &::after {
+ content: '';
+ width: calc(100% + 16px);
+ height: 1px;
+ background: #ecececf4;
+ position: absolute;
+ bottom: 0;
+ left: -16px;
+ }
+
+ p {
+ display: flex;
+ gap: 8px;
+ font-size: 12px;
+ color: #aaa;
+
+ span {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ }
+ }
+
+ button {
+ margin-right: 16px;
+ }
+ }
+ }
+`
+
+const ChildComment = styled.li`
+ background: #fafafa;
+ padding-left: 32px !important;
+ margin-left: -16px;
+
+ .comment-info-box {
+ &::after {
+ width: calc(100% + 32px) !important;
+ background: #d9d9d9 !important;
+ left: -32px !important;
+ }
+ }
+
+ &:last-child .comment-info-box {
+ &::after {
+ background: none !important;
+ }
+ }
+`
+
+const CommentOptionBox = styled.ul`
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ top: 100px;
+ right: 10px;
+ background: #fff;
+ border: 1px solid #d9d9d9;
+ padding: 16px;
+ width: 120px;
+ z-index: 100;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2);
+
+ &::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ border: 1px solid #ddd;
+ border-bottom: none;
+ border-right: none;
+ position: absolute;
+ top: -11px;
+ right: 5px;
+ background: #fff;
+ transform: rotate(50deg) skew(20deg, 15deg);
+ }
+
+ li {
+ padding: 0;
+
+ button {
+ text-align: left;
+ }
+ }
+`
+
+const PagenationBtn = styled.ul`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ margin: 20px auto;
+ gap: 5px;
+
+ button {
+ color: #777;
+ font-size: 16px;
+ }
+
+ .selected {
+ color: #000;
+ pointer-events: none;
+ }
+`
+
+export default BoardComment
diff --git a/src/components/CommentInput.tsx b/src/components/CommentInput.tsx
new file mode 100644
index 0000000..c7084a3
--- /dev/null
+++ b/src/components/CommentInput.tsx
@@ -0,0 +1,162 @@
+import { addComment, getComment, postComment } from '@src/api/boardApi'
+import { useRef } from 'react'
+import { UpLoadIcon } from '@src/constants/icons'
+import { styled } from 'styled-components'
+import { useDispatch } from 'react-redux'
+import { setCommentAdd, setCommentData, setCommentInput } from '@src/store/slices/commentSlice'
+import { useNavigate } from 'react-router'
+import { COLORS } from '@src/globalStyles'
+import PATH from '@src/constants/pathConst'
+
+type PropsType = {
+ boardId: number
+ loginVal: boolean
+ commentId?: number
+ type?: string
+ commentContent?: string
+}
+
+const BoardCommentInput = (props: PropsType) => {
+ const dispatch = useDispatch()
+ const navigate = useNavigate()
+ const commentValue = useRef(null)
+
+ const getCommnetData = async (boardId: number, page: number, loginVal: boolean) => {
+ try {
+ const CommentData = await getComment(boardId, page, loginVal)
+ dispatch(setCommentData({ comment: CommentData }))
+ } catch (err) {
+ console.log(err)
+ }
+ }
+
+ const addCommentFn = async () => {
+ try {
+ await addComment(props.commentId as number, commentValue.current?.value as string)
+ } catch (err) {
+ alert('오류가 발생했습니다. 다시 시도해주세요.')
+ } finally {
+ dispatch(setCommentAdd({ commentAdd: -1 }))
+ }
+ }
+
+ return (
+
+
+ )
+}
+
+const InputEl = styled.div`
+ width: 100%;
+ height: 40px;
+
+ textarea {
+ width: 100%;
+ height: 40px;
+ border: 1px solid #d9d9d9;
+ border-radius: 10px;
+ padding-top: 10px;
+ padding-bottom: 10px;
+ padding-left: 15px;
+ padding-right: 50px;
+ resize: none;
+ }
+
+ & > button,
+ & > div {
+ position: absolute;
+ top: 50%;
+ transform: translateY(-50%);
+ right: 10px;
+ height: 25px;
+
+ @media ${({ theme }) => theme.device.tablet} {
+ right: 26px;
+ }
+ }
+
+ & > div {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: 10px;
+
+ button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ padding: 10px;
+ border-radius: 8px;
+ }
+
+ .add_comment_btn {
+ background: ${COLORS.green};
+ color: #fff;
+ }
+ }
+`
+
+export default BoardCommentInput
diff --git a/src/components/CommentOptions.tsx b/src/components/CommentOptions.tsx
new file mode 100644
index 0000000..ddcaf6b
--- /dev/null
+++ b/src/components/CommentOptions.tsx
@@ -0,0 +1,70 @@
+import { delComment, getComment } from '@src/api/boardApi'
+import { setCommentAdd, setCommentData, setCommentInput } from '@src/store/slices/commentSlice'
+import { useDispatch } from 'react-redux'
+import { useNavigate } from 'react-router'
+
+type Props = {
+ item: CommentTypes
+ login: boolean
+ boardId: number
+ child: boolean
+}
+
+const CommentOptions = ({ item, login, boardId, child }: Props) => {
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+
+ const getCommnetData = async (Id: number, login: boolean) => {
+ try {
+ const CommentData = await getComment(Id, 1, login)
+ dispatch(setCommentData({ comment: CommentData }))
+ } catch (err) {
+ console.log(err)
+ }
+ }
+
+ return (
+ <>
+ {!child && (
+
+
+
+ )}
+
+ {item.myComment && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+ >
+ )
+}
+
+export default CommentOptions
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index c63ed90..2bc55fb 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,5 +1,108 @@
+import { GithubIcon } from '@src/constants/icons'
+import PATH from '@src/constants/pathConst'
+import { COLORS } from '@src/globalStyles'
+import { useNavigate } from 'react-router'
+import { styled } from 'styled-components'
+
const Footer = () => {
- return <>Footer>
+ const currentYear = new Date().getFullYear()
+ const navigate = useNavigate()
+ return (
+
+
+
+

+
+ navigate(PATH.HELP)}>이용약관
+ navigate(PATH.HELP)}>운영정책
+
+
+
+
+ Backend Repository
+
+
+
+ Frontend Repository
+
+
+ 필드패서는 통신판매중개자이며 통신판매의 당사자가 아닙니다. 따라서 필드패서는 공간 거래정보 및 거래에 대해
+ 책임지지 않습니다.
+
+
© {currentYear} FIELD-PASSER. All Rights Reserved.
+
+
+ )
}
+const FooterContainer = styled.footer`
+ background-color: ${COLORS.gray10};
+ color: ${COLORS.font};
+ margin-top: 50px;
+ /* bottom: 0; */
+ width: 100%;
+ position: relative;
+ padding: 30px 0;
+
+ .inner {
+ position: relative;
+ max-width: var(--screen-pc);
+ margin: auto;
+ height: 160px;
+ font-size: 13px;
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 12px;
+ box-sizing: border-box;
+
+ .top {
+ display: flex;
+ justify-content: space-between;
+ padding-bottom: 10px;
+ border-bottom: 1px solid ${COLORS.font};
+
+ img {
+ width: 144px;
+ right: 0;
+ }
+
+ .policy {
+ display: flex;
+ gap: 16px;
+ line-height: 20px;
+ cursor: pointer;
+ }
+ }
+
+ .link {
+ padding: 0 4px;
+ display: flex;
+ gap: 4px;
+ }
+
+ .alert {
+ font-size: 11px;
+ margin: 10px 0;
+ }
+
+ .copyright {
+ font-weight: 700;
+ text-align: right;
+ font-size: 11px;
+ }
+ }
+`
+
export default Footer
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 27c5d16..e91994e 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,11 +1,159 @@
import styled from 'styled-components'
+import { Link, useNavigate } from 'react-router-dom'
+import { COLORS } from '@src/globalStyles'
+import { Mobile, PC } from '@src/hooks/useScreenHook'
+import type { RootState } from '@src/store/config'
+import { useDispatch, useSelector } from 'react-redux'
+import { getUserInfo } from '@src/api/authApi'
+import { getCookieToken } from '@src/storage/Cookie'
+import { useEffect } from 'react'
+import { SET_INFO, DELETE_INFO } from '@src/store/slices/infoSlice'
+import PATH from '@src/constants/pathConst'
+import { HamburgerIcon } from '@src/constants/icons'
+import { OPEN_SIDEBAR } from '@src/store/slices/sidebarSlice'
+import useLoginState from '@src/hooks/useLoginState'
const Header = () => {
- return header
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+
+ const openSidebar = () => {
+ dispatch(OPEN_SIDEBAR())
+ }
+ const authenticated = useSelector((state: RootState) => state.accessToken.authenticated)
+ const userRole = useSelector((state: RootState) => state.userInfo.role)
+ const refreshToken = getCookieToken()
+ const { accessAfterLoginAlert, logoutHandler } = useLoginState()
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (refreshToken) {
+ const response = await getUserInfo()
+ dispatch(SET_INFO(response?.data))
+ } else if (!refreshToken) {
+ dispatch(DELETE_INFO())
+ }
+ }
+ fetchData()
+ }, [refreshToken])
+
+ return (
+ <>
+
+
+ {
+ openSidebar()
+ }}
+ >
+
+
+
navigate(PATH.HOME)} />
+
+
+
+
+
+
+
+
+
+ {authenticated && userRole === '관리자' &&
게시글 관리}
+
고객센터
+ {authenticated &&
마이페이지}
+ {!authenticated &&
회원가입}
+ {authenticated &&
1:1 문의}
+ {authenticated ?
로그아웃 :
로그인}
+
+
+
+
+
+ >
+ )
}
-const Container = styled.p`
- color: #ddd;
+const MContainer = styled.header`
+ height: 48px;
+ display: flex;
+ justify-content: center;
+ position: relative;
+ background-color: white;
+ border-bottom: 1px solid ${COLORS.gray20};
+
+ .logo {
+ width: 160px;
+ height: 24px;
+ }
+
+ .sidebar {
+ svg {
+ width: 24px;
+ height: 24px;
+ position: absolute;
+ top: 12px;
+ left: 16px;
+ cursor: pointer;
+ }
+ }
+
+ img {
+ width: 160px;
+ margin: auto auto;
+ cursor: pointer;
+ }
+`
+
+const Container = styled.header`
+ padding: 12px 20px;
+ height: 60px;
+ box-sizing: border-box;
+ border-bottom: 1px solid ${COLORS.gray20};
+`
+const Inner = styled.div`
+ max-width: var(--screen-pc);
+ display: flex;
+ justify-content: space-between;
+ margin: 0 auto;
+
+ .logo {
+ margin: auto 0;
+ cursor: pointer;
+ img {
+ width: 160px;
+ height: 24px;
+ }
+ }
+
+ .menu {
+ display: flex;
+ gap: 20px;
+ height: 32px;
+ align-items: center;
+ font-size: 15px;
+ gap: 10px;
+
+ button {
+ width: 100px;
+ height: 32px;
+ font-size: 15px;
+ background-color: ${COLORS.green};
+ color: white;
+ }
+
+ a {
+ cursor: pointer;
+ }
+ }
`
export default Header
diff --git a/src/components/HelpNAskForm.tsx b/src/components/HelpNAskForm.tsx
new file mode 100644
index 0000000..cce0039
--- /dev/null
+++ b/src/components/HelpNAskForm.tsx
@@ -0,0 +1,173 @@
+import Inner from '@src/components/Inner'
+import Title from '@src/components/Title'
+import { COLORS, FONT } from '@src/globalStyles'
+import { useMediaQuery } from 'react-responsive'
+import styled from 'styled-components'
+import { useState } from 'react'
+import { postQuestion, postAdmintQuestion } from '@src/api/postApi'
+import PATH from '@src/constants/pathConst'
+import Modal from '@components/Modal'
+
+interface Props {
+ type: string
+ questionId?: number
+}
+const HelpNAskForm = ({ type, questionId }: Props) => {
+ const [title, setTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [modalOpen, setModalOpen] = useState(false)
+ const [modalIsConfirm, setModalIsConfirm] = useState(false)
+ const [modalText, setModalText] = useState([])
+ const [modalNavigate, setModalNavigate] = useState('')
+ const isPC = useMediaQuery({
+ query: '(min-width: 450px)',
+ })
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault()
+ if (type === 'help') {
+ try {
+ setModalOpen(true)
+ setModalIsConfirm(true)
+ setModalText(['문의 작성이 완료되었습니다. 메인으로 이동합니다.'])
+ setModalNavigate(PATH.HOME)
+ } catch (error) {
+ setModalOpen(true)
+ setModalIsConfirm(true)
+ setModalText(['문의 작성 오류가 있습니다.'])
+ }
+ } else if (type === 'ask') {
+ try {
+ setModalOpen(true)
+ setModalIsConfirm(true)
+ setModalText(['문의 답변을 작성하였습니다. 문의 목록으로 돌아갑니다.'])
+ setModalNavigate(PATH.ASK)
+ } catch (error) {
+ setModalOpen(true)
+ setModalIsConfirm(true)
+ setModalText(['문의 작성 오류가 있습니다.'])
+ }
+ }
+ }
+ return (
+ <>
+ {isPC ? (
+
+
+
+
+ 제목
+ setTitle(event.target.value)} />
+
+
+ 내용
+
+
+
+ 문의 등록
+
+
+
+ ) : (
+
+
+
+
+ 제목
+ setTitle(event.target.value)} />
+
+
+ 내용
+
+
+
+ 문의 등록
+
+
+
+ )}
+ {modalOpen && (
+ {
+ if (type === 'help') {
+ const data = {
+ questionTitle: title,
+ questionContent: content,
+ questionCategory: 'TRANSACTION',
+ }
+ postQuestion(data)
+ } else if (type === 'ask') {
+ const data = {
+ answerTitle: title,
+ answerContent: content,
+ }
+ postAdmintQuestion(Number(questionId), data)
+ }
+ }}
+ >
+ )}
+ >
+ )
+}
+
+export default HelpNAskForm
+
+const FormStyle = styled.form`
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ gap: 32px;
+`
+
+const FormDetailStyle = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ h2 {
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT['pc-lg'] : FONT.m)};
+ }
+ input {
+ padding: 16px 8px;
+ border-radius: 8px;
+ border: 1px solid ${COLORS.gray30};
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT['m-sm'])};
+ }
+ textarea {
+ padding: 16px 8px;
+ border-radius: 8px;
+ border: 1px solid ${COLORS.gray30};
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT['m-sm'])};
+ &:focus {
+ border: 1px solid ${COLORS.gray30};
+ outline: none;
+ }
+ }
+`
+
+const ButtonStyle = styled.button`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: ${COLORS.green};
+ color: #fff;
+ width: ${({ screen }) => (screen === 'pc' ? '486px' : '100%')};
+ height: 48px;
+ font-size: ${FONT['pc-lg']};
+ margin: 46px auto;
+ border-radius: 8px;
+`
diff --git a/src/components/Inner.tsx b/src/components/Inner.tsx
new file mode 100644
index 0000000..c9e444b
--- /dev/null
+++ b/src/components/Inner.tsx
@@ -0,0 +1,32 @@
+import React from 'react'
+import styled from 'styled-components'
+
+interface IInner {
+ width?: string
+ height?: string
+ display?: string
+ padding?: string
+ children: React.ReactNode
+}
+const Inner = ({ width = '834px', height = '', display = '', padding = '', children }: IInner) => {
+ return (
+
+ {children}
+
+ )
+}
+
+export default Inner
+
+const InnerStyle = styled.div<{
+ width: string
+ height: string
+ display: string
+ padding: string
+}>`
+ width: ${({ width }) => width};
+ height: ${({ height }) => height};
+ margin: auto;
+ display: ${({ display }) => display};
+ padding: ${({ padding }) => padding};
+`
diff --git a/src/components/MBoardList.tsx b/src/components/MBoardList.tsx
new file mode 100644
index 0000000..9258174
--- /dev/null
+++ b/src/components/MBoardList.tsx
@@ -0,0 +1,88 @@
+import { Harticon } from '@src/constants/icons'
+import { randomImages, handleImgError } from '@src/utils/utils'
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import { useNavigate } from 'react-router'
+
+interface Props {
+ post: POST_TYPE | IWishlistType
+}
+
+const MBoardList = ({ post }: Props) => {
+ const navigate = useNavigate()
+ return (
+ navigate(`/board-details/${post.boardId}`)}>
+
+
handleImgError(e, post.categoryName, post.boardId)}
+ alt="이미지"
+ />
+
+
+ {post.title}
+ {post.price.toLocaleString()} 원
+
+ {post.startTime.slice(0, 10)} {post.startTime.slice(11, 16)}
+
+
+ 조회수 {post.viewCount}
+
+ {post.wishCount}
+
+
+ {post.transactionStatus === '판매 완료' && 판매 완료}
+
+
+ )
+}
+
+export default MBoardList
+
+const ListContainer = styled.li`
+ width: 100%;
+ height: 86px;
+ display: flex;
+ gap: 16px;
+ cursor: pointer;
+`
+
+const ImgWrap = styled.div`
+ img {
+ width: 84px;
+ height: 84px;
+ object-fit: cover;
+ border-radius: 8px;
+ }
+`
+
+const DesContainer = styled.div`
+ position: relative;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ font-size: ${FONT.pc};
+ justify-content: space-between;
+ .price {
+ font-weight: 700;
+ }
+ .count-box {
+ display: flex;
+ align-items: center;
+ gap: 13px;
+ .wish {
+ display: flex;
+ align-items: center;
+ }
+ }
+`
+
+const StatusBadge = styled.div`
+ padding: 2px 8px;
+ border-radius: 4px;
+ background-color: ${COLORS.green};
+ position: absolute;
+ color: #fff;
+ right: 0px;
+ bottom: 0px;
+`
diff --git a/src/components/MobileMenu.tsx b/src/components/MobileMenu.tsx
new file mode 100644
index 0000000..62dde8e
--- /dev/null
+++ b/src/components/MobileMenu.tsx
@@ -0,0 +1,50 @@
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+
+interface IProps {
+ menuLists: string[]
+ activeMenu: number
+ setActiveMenu: React.Dispatch>
+}
+
+const MobileMenu = ({ menuLists, activeMenu, setActiveMenu }: IProps) => {
+ return (
+
+ {menuLists.map((list, i) => (
+ setActiveMenu(i)}
+ style={{
+ color: `${activeMenu === i ? COLORS.green : '#d9d9d9'}`,
+ fontWeight: `${activeMenu === i ? 700 : 400}`,
+ borderBottom: `${activeMenu === i ? `solid 2px ${COLORS.green}` : 'none'}`,
+ }}
+ >
+ {list}
+
+ ))}
+
+ )
+}
+
+export default MobileMenu
+
+const MenuStyle = styled.menu`
+ display: flex;
+ gap: 2px;
+ padding: 0px 16px;
+ align-items: center;
+ li {
+ cursor: pointer;
+ font-size: ${FONT.m};
+ display: flex;
+ padding: 10px 0px;
+ justify-content: center;
+ align-items: center;
+ flex: 1 0 0;
+ color: var(--unnamed, #aaa);
+ font-weight: 400;
+ letter-spacing: -1.4px;
+ }
+ border-bottom: 1px solid var(--unnamed, #d9d9d9);
+`
diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx
new file mode 100644
index 0000000..9f71f84
--- /dev/null
+++ b/src/components/Modal.tsx
@@ -0,0 +1,184 @@
+import { COLORS } from '@src/globalStyles'
+import { styled } from 'styled-components'
+import { CloseButton, InfoIcon } from '@src/constants/icons'
+import { useMediaQuery } from 'react-responsive'
+import { useNavigate } from 'react-router'
+
+const Modal = ({ modalOpen, setModalOpen, content, isConfirm, navigateOption, confirmFn }: IModalProps) => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+
+ const navigate = useNavigate()
+
+ const closeModal = () => {
+ setModalOpen(false)
+ }
+
+ return (
+ <>
+ {modalOpen && (
+
+ {!isMobile && (
+ {
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+
+
+ )}
+
+
+ {content.map((text) => {
+ return {text}
+ })}
+
+ {!isMobile && isConfirm && (
+
+ {
+ confirmFn && confirmFn()
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ {
+ closeModal()
+ }}
+ >
+ 취소
+
+
+ )}
+ {isMobile && !isConfirm && (
+ {
+ confirmFn && confirmFn()
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ )}
+ {isMobile && isConfirm && (
+
+ {
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ {
+ closeModal()
+ }}
+ >
+ 취소
+
+
+ )}
+
+ )}
+ >
+ )
+}
+
+const Container = styled.div`
+ position: fixed;
+ z-index: 1000;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: white;
+ width: 80%;
+ max-width: 500px;
+ height: 190px;
+ margin: auto;
+ border: none;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ font-weight: 700;
+ border: 1px solid ${COLORS.gray20};
+ line-height: 23px;
+
+ @media (min-width: 834px) {
+ min-width: 500px;
+ width: 50%;
+ max-width: 620px;
+ height: 240px;
+ }
+
+ .info {
+ width: 64px;
+ height: 64px;
+ color: ${COLORS.gray20};
+ margin: 0 auto;
+
+ @media (max-width: 833px) {
+ display: none;
+ }
+ }
+`
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 21px;
+ width: 90%;
+ margin: 0 auto;
+ text-align: center;
+
+ span {
+ color: ${COLORS.font};
+ font-size: 16px;
+ }
+`
+
+const MobileButton = styled.button`
+ width: 90%;
+ height: 45px;
+ background-color: ${COLORS.gray20};
+ border-radius: 8px;
+ margin: 0 auto;
+ font-size: 16px;
+ color: ${COLORS.gray40};
+ font-weight: 700;
+
+ &:hover {
+ color: ${COLORS.font};
+ }
+`
+
+const PcButton = styled.button`
+ position: absolute;
+ top: 26px;
+ right: 26px;
+`
+
+const ConfirmContainer = styled.div`
+ position: relative;
+ width: 90%;
+ height: 45px;
+ display: flex;
+ justify-content: space-around;
+ margin: 0 auto;
+`
+
+const ConfirmButton = styled.button`
+ font-size: 16px;
+ background-color: ${COLORS.gray20};
+ width: 40%;
+ height: 40px;
+`
+
+export default Modal
diff --git a/src/components/ModalWithHook.tsx b/src/components/ModalWithHook.tsx
new file mode 100644
index 0000000..d26f4dc
--- /dev/null
+++ b/src/components/ModalWithHook.tsx
@@ -0,0 +1,189 @@
+import { COLORS } from '@src/globalStyles'
+import { styled } from 'styled-components'
+import { CloseButton, InfoIcon } from '@src/constants/icons'
+import { useNavigate } from 'react-router'
+import useModal from '@src/hooks/useModal'
+import { useSelector } from 'react-redux'
+import { RootState } from '@src/store/config'
+import { Mobile, PC } from '@src/hooks/useScreenHook'
+
+const ModalWithHook = () => {
+ const { isModalOpen, isConfirm, content, navigateOption, confirmAction } = useSelector(
+ (state: RootState) => state.modal
+ )
+ const { closeModal } = useModal()
+ const navigate = useNavigate()
+
+ if (isModalOpen)
+ return (
+ <>
+
+
+ {
+ navigateOption && navigate(navigateOption)
+ confirmAction && confirmAction()
+ closeModal()
+ }}
+ >
+
+
+
+
+
+
+ {content.map((text) => {
+ return {text}
+ })}
+
+
+
+ {isConfirm && (
+
+ {
+ confirmAction && confirmAction()
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ {
+ closeModal()
+ }}
+ >
+ 취소
+
+
+ )}
+
+
+
+ {!isConfirm ? (
+ {
+ confirmAction && confirmAction()
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ ) : (
+
+ {
+ navigateOption && navigate(navigateOption)
+ closeModal()
+ }}
+ >
+ 확인
+
+ {
+ closeModal()
+ }}
+ >
+ 취소
+
+
+ )}
+
+
+ >
+ )
+}
+
+const Container = styled.div`
+ position: fixed;
+ z-index: 1000;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ background-color: white;
+ width: 80%;
+ max-width: 500px;
+ height: 190px;
+ margin: auto;
+ border: none;
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-evenly;
+ font-weight: 700;
+ border: 1px solid ${COLORS.gray20};
+ line-height: 23px;
+
+ @media (min-width: 834px) {
+ min-width: 500px;
+ width: 50%;
+ max-width: 620px;
+ height: 240px;
+ }
+
+ .info {
+ width: 64px;
+ height: 64px;
+ color: ${COLORS.gray20};
+ margin: 0 auto;
+
+ @media (max-width: 833px) {
+ display: none;
+ }
+ }
+`
+
+const Content = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 21px;
+ width: 90%;
+ margin: 0 auto;
+ text-align: center;
+
+ span {
+ color: ${COLORS.font};
+ font-size: 16px;
+ }
+`
+
+const MobileButton = styled.button`
+ width: 90%;
+ height: 45px;
+ background-color: ${COLORS.gray20};
+ border-radius: 8px;
+ margin: 0 auto;
+ font-size: 16px;
+ color: ${COLORS.gray40};
+ font-weight: 700;
+
+ &:hover {
+ color: ${COLORS.font};
+ }
+`
+
+const PcButton = styled.button`
+ position: absolute;
+ top: 26px;
+ right: 26px;
+`
+
+const ConfirmContainer = styled.div`
+ position: relative;
+ width: 90%;
+ height: 45px;
+ display: flex;
+ justify-content: space-around;
+ margin: 0 auto;
+`
+
+const ConfirmButton = styled.button`
+ font-size: 16px;
+ background-color: ${COLORS.gray20};
+ width: 40%;
+ height: 40px;
+`
+
+export default ModalWithHook
diff --git a/src/components/MyPage/CommentLists.tsx b/src/components/MyPage/CommentLists.tsx
new file mode 100644
index 0000000..3a6fc2b
--- /dev/null
+++ b/src/components/MyPage/CommentLists.tsx
@@ -0,0 +1,134 @@
+import { COLORS, FONT } from '@src/globalStyles'
+import styled from 'styled-components'
+import { useState, useEffect } from 'react'
+import { getMyReply } from '@src/api/authApi'
+import ReactPaginate from 'react-paginate'
+import { ChatBubbleIcon, MypageCalendarIcon } from '@src/constants/icons'
+
+interface IProps {
+ screen: string
+}
+
+const CommentLists = ({ screen }: IProps) => {
+ const [comments, setComments] = useState([])
+ const [totalPage, setTotalPage] = useState(1)
+
+ const fetchData = async (page = 1) => {
+ try {
+ const response = await getMyReply(page)
+ setComments(response?.data)
+ setTotalPage(response?.totalPages)
+ } catch (error) {
+ console.log(error)
+ }
+ }
+ useEffect(() => {
+ fetchData(1)
+ }, [])
+
+ const handlePage = (event: { selected: number }) => {
+ fetchData(event.selected + 1)
+ }
+ return (
+
+ {comments?.length ? (
+ <>
+ {comments?.map((comment) => (
+
+ {comment.title}
+ {comment.commentContent}
+
+
+
+ {comment.commentUpDate.slice(0, 10)}
+
+
+
+ {comment.children.length}
+
+
+
+ ))}
+
+
+
+ >
+ ) : (
+ 댓글이 없습니다.
+ )}
+
+ )
+}
+
+export default CommentLists
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT.m)};
+`
+
+const CommentBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border-bottom: 1px solid ${COLORS.gray10};
+ padding: 16px;
+`
+const BoardTitle = styled.span`
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT.m)};
+ color: ${COLORS.gray40};
+`
+
+const Content = styled.span`
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT.m)};
+`
+
+const Des = styled.div`
+ display: flex;
+ gap: 11px;
+ div {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ span {
+ color: ${COLORS.gray40};
+ font-weight: 400;
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.m : '12px ')};
+ }
+ }
+`
+
+const NoComment = styled.div`
+ display: flex;
+ justify-content: center;
+ margin: 20px;
+`
+
+const Paginate = styled.div`
+ .paginate {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ gap: 10px;
+ color: ${COLORS.gray30};
+ font-size: ${({ screen }) => (screen === 'pc' ? '20px' : '14px')};
+ .previous {
+ color: ${COLORS.green};
+ }
+ .next {
+ color: ${COLORS.green};
+ }
+ .selected {
+ color: ${COLORS.green};
+ }
+ }
+`
diff --git a/src/components/MyPage/PCBoardCard.tsx b/src/components/MyPage/PCBoardCard.tsx
new file mode 100644
index 0000000..94b73d2
--- /dev/null
+++ b/src/components/MyPage/PCBoardCard.tsx
@@ -0,0 +1,59 @@
+import { PlusIcon } from '@src/constants/icons'
+import { COLORS, FONT } from '@src/globalStyles'
+import styled from 'styled-components'
+
+interface Props {
+ title: string
+ posts: POST_TYPE[]
+ onClick: React.MouseEventHandler
+}
+
+const PCBoardCard = ({ title, posts, onClick }: Props) => {
+ return (
+
+
+ {title}
+
+
+
+ {posts?.length ? (
+ posts.slice(0, 3).map((post, idx) => {post.title})
+ ) : (
+ 게시글이 없습니다.
+ )}
+
+
+ )
+}
+
+export default PCBoardCard
+
+const Container = styled.div`
+ background-color: ${COLORS.gray10};
+ width: 257px;
+ padding: 16px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`
+
+const Title = styled.div`
+ display: flex;
+ justify-content: space-between;
+ cursor: pointer;
+ span {
+ font-weight: 700;
+ }
+`
+
+const Text = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ color: ${COLORS.gray40};
+ span {
+ font-size: ${FONT.pc};
+ font-weight: 400;
+ }
+`
diff --git a/src/components/OneOnOne.tsx b/src/components/OneOnOne.tsx
new file mode 100644
index 0000000..cdc624b
--- /dev/null
+++ b/src/components/OneOnOne.tsx
@@ -0,0 +1,52 @@
+import { styled } from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import { useNavigate } from 'react-router'
+import PATH from '@src/constants/pathConst'
+
+interface IAsk {
+ title: string
+ comment: string
+ screen: string
+ info: QuestionGetTypes
+}
+
+const OneOnOne = ({ title, comment, screen, info }: IAsk) => {
+ const navigate = useNavigate()
+ return (
+
+
+
navigate(`${PATH.ASK}/${info.questionId}`)}>Q. {title}
+ {info.questionProcess}
+
+ {comment}
+
+ )
+}
+
+export default OneOnOne
+
+const AskStyle = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 1024px;
+ .question {
+ display: flex;
+ gap: 10px;
+ h3 {
+ font-weight: ${({ screen }) => (screen === 'pc' ? 700 : 500)};
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT['pc-lg'] : FONT.pc)};
+ cursor: pointer;
+ }
+ span {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .answer {
+ padding: 16px 8px;
+ background: #fafafa;
+ border-radius: 8px;
+ font-size: ${({ screen }) => screen === 'mobile' && '12px'};
+ }
+`
diff --git a/src/components/Overlay.tsx b/src/components/Overlay.tsx
new file mode 100644
index 0000000..c8e3719
--- /dev/null
+++ b/src/components/Overlay.tsx
@@ -0,0 +1,36 @@
+import useSidebar from '@src/hooks/useSidebar'
+import { RootState } from '@src/store/config'
+import { useSelector } from 'react-redux'
+import { useMediaQuery } from 'react-responsive'
+import { styled } from 'styled-components'
+
+const Overlay = () => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ const { isModalOpen } = useSelector((state: RootState) => state.modal)
+ const { isSidebarOpen } = useSelector((state: RootState) => state.sidebar)
+ const { closeSidebar } = useSidebar()
+
+ return (
+ <>
+ {isSidebarOpen && isMobile && (
+ {
+ closeSidebar()
+ }}
+ >
+ )}
+ {isModalOpen && }
+ >
+ )
+}
+
+const Container = styled.div`
+ position: fixed;
+ background-color: rgba(79, 77, 77, 0.2);
+ inset: 0;
+ z-index: 80;
+`
+
+export default Overlay
diff --git a/src/components/ResetPassword/ResetPw.tsx b/src/components/ResetPassword/ResetPw.tsx
new file mode 100644
index 0000000..fcb6d6d
--- /dev/null
+++ b/src/components/ResetPassword/ResetPw.tsx
@@ -0,0 +1,179 @@
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import React from 'react'
+import { useNavigate } from 'react-router'
+import useInput from '@src/hooks/useInputHook'
+import { editUserPw } from '@src/api/authApi'
+import { removeCookieToken } from '@src/storage/Cookie'
+import { useDispatch } from 'react-redux'
+import { DELETE_TOKEN } from '@src/store/slices/authSlice'
+import { DELETE_INFO } from '@src/store/slices/infoSlice'
+import PATH from '@src/constants/pathConst'
+
+const ResetPw = () => {
+ const dispatch = useDispatch()
+ const navigate = useNavigate()
+ const newPwValidator = (newPw: string) => {
+ const rNewPw = /^(?=.*\d)(?=.*[a-zA-Z])[0-9a-zA-Z]{8,10}$/
+ if (newPw === '' || !rNewPw.test(newPw)) return true
+ }
+ const [newPw, onChangeNewPw, newPwError] = useInput(newPwValidator, '')
+ const [newConfirmPw, onChangeNewConfirmPw] = useInput(newPwValidator, '')
+
+ const changePwHandler = async (e: React.MouseEvent) => {
+ e.preventDefault()
+ if (newPwError) return alert('비밀번호 양식을 다시 확인해주세요.')
+ if (newPw !== newConfirmPw) return alert('입력한 비밀번호가 같지 않습니다.')
+ const { status } = (await editUserPw({ newPw })) as IResponseType
+ if (status === 200) {
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ alert('비밀번호 변경에 성공했습니다. 다시 로그인 해주세요.')
+ navigate(PATH.LOGIN)
+ }
+ }
+
+ return (
+
+
+
비밀번호 새로 설정
+
+
+
+
+ )
+}
+
+const Container = styled.div`
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ form {
+ margin-top: 44px;
+ }
+
+ .text_wrap {
+ margin-bottom: 60px;
+ width: 170px;
+ }
+
+ h3 {
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ .input_wrap {
+ &_inner {
+ position: relative;
+ margin: 8px 0;
+ height: 88px;
+ button {
+ position: absolute;
+ top: 24px;
+ right: 0;
+ margin: 8px;
+ padding: 8px;
+ border: 1px solid;
+ border-radius: 8px;
+ border-color: ${COLORS.green};
+ font-size: ${FONT['m-sm']};
+ font-weight: 700;
+ color: ${COLORS.green};
+ }
+ .btn_verifyNum {
+ background-color: ${COLORS.green};
+ color: white;
+ }
+ }
+ }
+
+ input {
+ margin: 8px 0 6px;
+ padding: 16px 8px;
+ width: 100%;
+ height: 47px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 12px;
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .error_message {
+ font-size: 12px;
+ color: ${COLORS.error};
+ }
+
+ .help_message {
+ font-size: 12px;
+ color: ${COLORS.green};
+ }
+
+ .btn_verify {
+ margin-top: 48px;
+ width: 100%;
+ height: 47px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ color: white;
+ &:disabled {
+ background-color: ${COLORS.gray20};
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .find_wrap {
+ padding: 16px 0;
+ display: flex;
+ justify-content: center;
+ gap: 40px;
+ width: 100%;
+ font-size: 12px;
+ }
+`
+
+export default ResetPw
diff --git a/src/components/ResetPassword/TemporaryPw.tsx b/src/components/ResetPassword/TemporaryPw.tsx
new file mode 100644
index 0000000..23a0bd0
--- /dev/null
+++ b/src/components/ResetPassword/TemporaryPw.tsx
@@ -0,0 +1,89 @@
+import styled from 'styled-components'
+// import { useNavigate } from 'react-router-dom'
+import { COLORS, FONT } from '@src/globalStyles'
+// import React, { useState } from 'react'
+// import { useNavigate } from 'react-router'
+// import useInput from '@src/hooks/useInputHook'
+import { Link } from 'react-router-dom'
+
+const TemporaryPw = () => {
+ // const navigate = useNavigate()
+
+ return (
+
+
+
임시 비밀번호 발급 완료
+
+
+
인증한 메일로 임시 비밀번호가 발급되었습니다.
+
해당 임시 비밀번호로 로그인 후 비밀번호를 변경해주세요!
+
+
+ 로그인하러 가기
+
+
+ )
+}
+
+const Container = styled.div`
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ form {
+ margin-top: 44px;
+ }
+
+ .text_wrap {
+ margin-bottom: 60px;
+ width: 170px;
+ }
+
+ h3 {
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ .error_message {
+ font-size: 12px;
+ color: ${COLORS.error};
+ }
+
+ .help_message {
+ font-size: 12px;
+ color: ${COLORS.green};
+ }
+
+ .btn_login {
+ display: inline-block;
+ margin-top: 48px;
+ width: 100%;
+ height: 47px;
+ line-height: 47px;
+ border-radius: 10px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ text-align: center;
+ color: white;
+ }
+
+ .find_wrap {
+ padding: 16px 0;
+ display: flex;
+ justify-content: center;
+ gap: 40px;
+ width: 100%;
+ font-size: 12px;
+ }
+`
+
+export default TemporaryPw
diff --git a/src/components/ResetPassword/Verification.tsx b/src/components/ResetPassword/Verification.tsx
new file mode 100644
index 0000000..0ff135a
--- /dev/null
+++ b/src/components/ResetPassword/Verification.tsx
@@ -0,0 +1,170 @@
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import React, { useState } from 'react'
+import { temporaryPassword, verifyUserEmail, verifyUserNum } from '@src/api/authApi'
+import useInput from '@src/hooks/useInputHook'
+
+interface propsType {
+ setStep: React.Dispatch>
+}
+
+const Verification = ({ setStep }: propsType) => {
+ // 인풋 유효성 검사
+ const emailValidator = (userEmail: string) => {
+ setPersonalVerify(false)
+ const rUserEmail = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i
+ if (userEmail === '' || !rUserEmail.test(userEmail)) return true
+ }
+ const verifyNumValidator = (userVerifyNum: string) => {
+ const rUserVerifyNum = /^[0-9]{1,6}$/
+ if (userVerifyNum === null || !rUserVerifyNum.test(userVerifyNum)) return true
+ }
+
+ const [userEmail, onChangeUserEmail, userEmailError] = useInput(emailValidator, '')
+ const [userVerifyNum, onChangeUserVerifyNum, userVerifyNumError] = useInput(verifyNumValidator, '')
+
+ const [mailLoading, setMailLoading] = useState(false)
+ const [verifyLoading, setVerifyLoading] = useState(false)
+ const [personalVerify, setPersonalVerify] = useState(false)
+
+ const onSubmitHandler = (e: React.FormEvent) => {
+ e.preventDefault()
+ }
+
+ const verifyMailHandler = async (e: React.MouseEvent) => {
+ e.preventDefault()
+ setMailLoading(true)
+ const { status } = (await verifyUserEmail({
+ userEmail,
+ })) as IResponseType
+ if (status === 200) {
+ setPersonalVerify(true)
+ alert('인증 메일 발송이 완료되었습니다!')
+ } else alert('가입된 메일이 아닙니다! 다시 확인해주세요.')
+ setMailLoading(false)
+ }
+
+ const verifyNumHandler = async (e: React.MouseEvent) => {
+ e.preventDefault()
+ setVerifyLoading(true)
+ const { status } = (await verifyUserNum({
+ userEmail,
+ userVerifyNum,
+ })) as IResponseType
+ if (status === 200) {
+ const { status } = (await temporaryPassword({
+ userEmail,
+ })) as IResponseType
+ if (status === 200) {
+ setVerifyLoading(false)
+ alert('인증에 성공했습니다!')
+ setStep(2)
+ }
+ } else alert('인증에 실패하였습니다. 입력한 정보를 다시 확인해주세요.')
+ setVerifyLoading(false)
+ }
+ return (
+ <>
+
+
비밀번호 찾기
+
+
+
+
+ >
+ )
+}
+
+const Form = styled.form`
+ margin-top: 44px;
+
+ .input_wrap {
+ &_inner {
+ position: relative;
+ margin: 8px 0;
+ height: 88px;
+ button {
+ position: absolute;
+ top: 24px;
+ right: 0;
+ margin: 8px;
+ padding: 8px;
+ border-radius: 8px;
+ border-color: ${COLORS.green};
+ font-size: ${FONT['m-sm']};
+ font-weight: 700;
+ color: ${COLORS.green};
+ }
+ .btn_verifyNum {
+ background-color: ${COLORS.green};
+ color: white;
+ &:disabled {
+ background-color: ${COLORS.gray20};
+ color: ${COLORS.gray40};
+ :hover {
+ cursor: default;
+ }
+ }
+ }
+ }
+ }
+
+ .btn_verify {
+ margin-top: 48px;
+ width: 100%;
+ height: 47px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ color: white;
+ &:disabled {
+ background-color: ${COLORS.gray20};
+ color: ${COLORS.gray40};
+ }
+ }
+`
+
+export default Verification
diff --git a/src/components/SearchForm.tsx b/src/components/SearchForm.tsx
new file mode 100644
index 0000000..cbf8233
--- /dev/null
+++ b/src/components/SearchForm.tsx
@@ -0,0 +1,1099 @@
+import { SearchIcon, SearchToggleIcon, CalendarIcon, CloseIcon } from '@src/constants/icons'
+import { categoryOptions, districtOptions } from '@src/constants/options'
+import { useDispatch, useSelector } from 'react-redux'
+import theme from '@src/constants/theme'
+import { FONT, COLORS } from '@src/globalStyles'
+import { useRef, useState } from 'react'
+import { ThemeProvider, styled } from 'styled-components'
+import DatePicker from 'react-datepicker'
+import 'react-datepicker/dist/react-datepicker.css'
+import { ko } from 'date-fns/esm/locale'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { createSearchValue } from '@src/store/slices/searchVlaueSlice'
+import { RootState } from '../store/config'
+import { cheakOpenBox } from '@src/store/slices/searchChkSlice'
+import PATH from '@src/constants/pathConst'
+
+const SearchForm = () => {
+ const dispatch = useDispatch()
+ const location = useLocation()
+ const urlPathname = location.pathname
+ const navigate = useNavigate()
+
+ // text Input element
+ const textInputEl = useRef(null)
+ const textInputTitle = useRef(null)
+
+ // current slice value
+ const selectVal = useSelector((state: RootState) => {
+ return {
+ title: state.searchVlaue.title,
+ startDate: state.searchVlaue.startDate,
+ endDate: state.searchVlaue.endDate,
+ district: state.searchVlaue.district,
+ category: state.searchVlaue.category,
+ startTime: state.searchVlaue.startTime,
+ endTime: state.searchVlaue.endTime,
+ }
+ })
+
+ // search box open value
+ const searchBoxOpen = useSelector((state: RootState) => {
+ return state.searchBox.openBox
+ })
+
+ // current state value
+ const [valueState, setValueState] = useState({
+ categoryValue: selectVal.category && selectVal.category !== '전체' ? selectVal.category : '전체',
+ districtValue: selectVal.district ? selectVal.district : [],
+ startTimeValue: selectVal.startTime ? selectVal.startTime : '00:00',
+ endTimeValue: selectVal.endTime ? selectVal.endTime : '23:59',
+ startDate: selectVal.startDate ? new Date(selectVal.startDate).toISOString() : new Date().toISOString(),
+ endDate: selectVal.endDate ? new Date(selectVal.endDate).toISOString() : new Date().toISOString(),
+ searchTextValue: selectVal.title ? selectVal.title : '',
+ })
+
+ // cheack State
+ const [checkState, setCheckState] = useState({
+ categoryOpen: false,
+ districtOpen: valueState.districtValue ? true : false,
+ districtSelect: false,
+ timeChange: selectVal.startTime !== '00:00' ? true : false,
+ startDateChange: false,
+ endDateChange: false,
+ })
+
+ // current state value change fn
+ type Value = string | string[] | Date
+ const valueStateChangeFn = (key: string, value: Value) => {
+ return setValueState((state) => {
+ const newState = { ...state }
+ newState[key] = value
+ return newState
+ })
+ }
+
+ // check value change fn
+ const checkValueStateChangeFn = (key: string, value: boolean) => {
+ return setCheckState((state) => {
+ const newState = { ...state }
+ newState[key] = value
+ return newState
+ })
+ }
+
+ // dispatch value
+ const dispatchValue: SearchValueTypes = {
+ title: valueState.searchTextValue,
+ startDate: valueState.startDate,
+ endDate: valueState.endDate,
+ startTime: valueState.startTimeValue,
+ endTime: valueState.endTimeValue,
+ district: valueState.districtValue,
+ category: valueState.categoryValue,
+ chkDate: checkState.startDateChange,
+ }
+
+ // searchbox click function *
+ const searchBoxOpenFn = () => {
+ if (searchBoxOpen) {
+ dispatch(cheakOpenBox({ openBox: false }))
+ window.document.body.classList.remove('stop-scrolling')
+ } else {
+ dispatch(cheakOpenBox({ openBox: true }))
+ window.document.body.classList.add('stop-scrolling')
+ }
+
+ window.scrollTo({ top: 0 })
+ }
+
+ // district select function
+ const districtValueFn = (value: string) => {
+ if (value === '전체') return valueStateChangeFn('districtValue', [])
+
+ const idx = valueState.districtValue.indexOf(value)
+ // 선택 최대 갯수 처리
+ if (valueState.districtValue.length === 5 && idx === -1) {
+ return alert('최대 5개 선택 가능합니다.')
+ }
+
+ // 선택시 state 배열 변경
+ if (idx === -1) {
+ checkValueStateChangeFn('districtSelect', true)
+ return valueStateChangeFn('districtValue', [...valueState.districtValue, value])
+ } else {
+ const districtArray = [...valueState.districtValue]
+ districtArray.splice(idx, 1)
+
+ if (districtArray.length === 0) checkValueStateChangeFn('districtSelect', false)
+ return valueStateChangeFn('districtValue', [...districtArray])
+ }
+ }
+
+ // time input change function
+ const timeChangeFn = (element: React.ChangeEvent, type: string) => {
+ if (valueState.startDate.slice(0, 10) !== valueState.endDate.slice(0, 10))
+ return alert('동일 날짜에만 시간을 지정할 수 있습니다.')
+ if (!checkState.timeChange) checkValueStateChangeFn('timeChange', true)
+
+ if (type === 'start') {
+ return valueStateChangeFn('startTimeValue', element.target.value)
+ } else {
+ return valueStateChangeFn('endTimeValue', element.target.value)
+ }
+ }
+
+ // form reset function
+ const formResetFn = () => {
+ setValueState({
+ categoryValue: '전체',
+ districtValue: [],
+ startTimeValue: '00:00',
+ endTimeValue: '23:59',
+ startDate: new Date().toISOString(),
+ endDate: new Date().toISOString(),
+ searchTextValue: '',
+ })
+ checkValueStateChangeFn('timeChange', false)
+ checkValueStateChangeFn('startDateChange', false)
+ checkValueStateChangeFn('endDateChange', false)
+ checkValueStateChangeFn('districtOpen', false)
+
+ const input = textInputEl.current as HTMLInputElement
+ input.value = ''
+ }
+
+ // search text input function
+ let timeoutId: number | null = null
+ const searchTextFn = (element: React.ChangeEvent) => {
+ const { value } = element.target
+
+ if (timeoutId) clearTimeout(timeoutId)
+
+ timeoutId = setTimeout(() => {
+ valueStateChangeFn('searchTextValue', value)
+ }, 500)
+ }
+
+ // search value dispatch function
+ const dispatchSearchValue = () => {
+ if (dispatchValue.startDate > dispatchValue.endDate) return alert('날짜를 확인해주세요')
+ if (dispatchValue.startTime > dispatchValue.endTime) return alert('시간을 확인해주세요')
+ return dispatch(createSearchValue(dispatchValue)), navigate(PATH.BOARD_LIST), searchBoxOpenFn()
+ }
+
+ // search keyWord btn function
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const dispatchSearchKewordValue = (type: string, key: any, value: any) => {
+ valueStateChangeFn(key, value)
+ dispatchValue[type] = value
+
+ return dispatch(createSearchValue(dispatchValue))
+ }
+
+ return (
+
+
+ {searchBoxOpen ? (
+
+ searchBoxOpenFn()}>
+
+ 닫기
+
+
+
+ 찾는 구장을 검색해주세요
+
+ textInputTitle.current?.classList.add('focused')}
+ onBlur={() => {
+ if (!valueState.searchTextValue) textInputTitle.current?.classList.remove('focused')
+ }}
+ onChange={(e) => searchTextFn(e)}
+ ref={textInputEl}
+ />
+
+
+
+
+ 날짜
+
+
+ {
+ checkValueStateChangeFn('startDateChange', true)
+ valueStateChangeFn('startDate', date.toISOString())
+ }}
+ />
+ -
+ {
+ checkValueStateChangeFn('endDateChange', true)
+ valueStateChangeFn('endDate', date.toISOString())
+ }}
+ />
+
+
+
+ 시간
+
+
+
+
+
+ {checkState.districtOpen && (
+
+
+ -
+
+
+ {districtOptions.map((v, i) => (
+ -
+
+
+ ))}
+
+
+ )}
+
+
+ 종목을 선택해주세요
+
+
+
+
+
+
+
+
+
+ ) : (
+ <>
+ searchBoxOpenFn()} path={urlPathname}>
+
+
+
{selectVal.title ? selectVal.title : '어떤 구장을 찾으세요?'}
+ {urlPathname === '/' && (
+
+ 어디든지
+ 원하는 날짜
+
+ )}
+
+
+
+ {urlPathname !== '/' && (
+
+ {selectVal.title && (
+
+
+
+ )}
+ {checkState.startDateChange && (
+
+
+
+ )}
+ {selectVal.category !== '전체' && (
+
+
+
+ )}
+ {selectVal.startTime !== '00:00' && (
+
+
+
+ )}
+ {selectVal.district.map((item: string, idx: number) => (
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+ )
+}
+
+export default SearchForm
+
+const boxline = {
+ border: '1px solid #d9d9d9',
+ 'border-radius': '16px',
+ 'box-shadow': '0px 4px 4px rgba(0, 0, 0, 0.10)',
+}
+
+const Container = styled.div<{ searchboxopen: string; path: string }>`
+ padding: 20px 20px;
+ width: 100%;
+ min-height: 60px;
+ display: flex;
+ justify-content: center;
+ flex-direction: ${(props) => (props.searchboxopen === 'false' && props.path !== '/' ? 'column' : 'row')};
+ align-items: center;
+ margin: 0 auto;
+ background: #fff;
+ height: ${(props) => (props.searchboxopen === 'false' ? '' : '100vh')};
+ box-shadow: ${(props) => (props.path !== '/' ? '0px 4px 4px rgba(0, 0, 0, 0.10)' : 'none')};
+ box-sizing: border-box;
+
+ * {
+ box-sizing: border-box;
+ color: ${COLORS.font};
+ }
+
+ .focused {
+ color: ${COLORS.font};
+ }
+
+ button {
+ cursor: pointer;
+ }
+`
+
+const SearchCorver = styled.div<{ path: string }>`
+ max-width: var(--screen-pc);
+ width: 100%;
+ height: 60px;
+ padding: 4px 16px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ cursor: pointer;
+ box-shadow: ${(props) => props.path !== '/' && 'none'};
+ ${boxline}
+
+ div {
+ width: ${(props) => (props.path === '/' ? 'auto' : '100%')};
+ padding-left: ${(props) => (props.path === '/' ? '0' : '20px')};
+
+ p:first-child {
+ font-size: ${FONT.pc};
+ font-weight: 900;
+ height: 30px;
+ line-height: 30px;
+ }
+
+ p:last-child {
+ display: flex;
+ gap: 10px;
+ font-size: ${FONT.pc};
+
+ height: 22px;
+ line-height: 22px;
+ color: #777;
+ }
+ }
+
+ @media ${({ theme }) => theme.device.mobile} {
+ height: ${(props) => (props.path !== '/' ? '40px' : '60px')};
+ div {
+ p:last-child {
+ font-size: 12px;
+ }
+ }
+ }
+ @media (min-width: 834px) {
+ border-radius: 40px;
+ }
+`
+
+const SearchInform = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ max-width: var(--screen-pc);
+ padding: 20px 0 142px;
+ gap: 16px;
+ position: fixed;
+ top: 60px;
+ z-index: 50;
+ background: #fff;
+ overflow: auto;
+ height: 100%;
+
+ & > div {
+ padding: 16px 18px;
+
+ & > p {
+ height: 26px;
+ line-height: 26px;
+ }
+ }
+
+ p {
+ font-size: ${FONT.pc};
+ font-weight: 900;
+ color: ${COLORS.gray40};
+ }
+
+ @media ${({ theme }) => theme.device.laptop} {
+ padding: 20px;
+ padding-bottom: 130px;
+ }
+
+ @media ${({ theme }) => theme.device.mobile} {
+ top: 48px;
+
+ & > div {
+ padding: 16px 18px;
+
+ & > p {
+ height: 37px;
+ line-height: 37px;
+ }
+ }
+
+ p {
+ font-size: ${FONT.m};
+ }
+ }
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`
+
+const StadiumForm = styled.div`
+ ${boxline}
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 120px;
+
+ svg {
+ position: absolute;
+ bottom: 28px;
+ left: 26px;
+ }
+
+ input {
+ width: 100%;
+ height: 42px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ padding-left: 32px;
+ font-size: 12px;
+ color: ${COLORS.font};
+ }
+`
+
+const DateForm = styled.div<{ startdatechange: string; enddatechange: string }>`
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+
+ svg {
+ position: absolute;
+ top: 23px;
+ right: 165px;
+ }
+
+ p {
+ height: 33px;
+ line-height: 33px;
+ }
+
+ & > div {
+ width: 100%;
+ position: relative;
+
+ & > .react-datepicker-wrapper:first-child {
+ position: absolute;
+ top: -31px;
+ right: 75px;
+ }
+
+ & > span {
+ position: absolute;
+ height: 31px;
+ line-height: 31px;
+ top: -31px;
+ right: 65px;
+ color: #aaa;
+ }
+
+ & > .react-datepicker-wrapper {
+ position: absolute;
+ top: -31px;
+ right: 0;
+ }
+ }
+
+ // 라이브러리 css 커스텀
+ .react-datepicker__triangle::before,
+ .react-datepicker__triangle::after {
+ display: none;
+ }
+
+ .react-datepicker-popper {
+ width: 100% !important;
+ position: relative !important;
+ inset: 0 !important;
+ transform: none !important;
+ }
+
+ .react-datepicker-ignore-onclickoutside,
+ input[type='text'] {
+ padding: 8px 12px;
+ border-radius: 10px;
+ font-size: 13px;
+ width: 61px;
+ cursor: pointer;
+ border: 1px solid ${(props) => (props.enddatechange === 'true' ? '#fff' : COLORS.gray20)};
+ color: ${(props) => (props.enddatechange === 'true' ? '#fff' : COLORS.gray40)};
+ background: ${(props) => (props.enddatechange === 'true' ? COLORS.green : '#fff')};
+ }
+
+ .react-datepicker-wrapper:first-child {
+ input[type='text'] {
+ border: 1px solid ${(props) => (props.startdatechange === 'true' ? '#fff' : COLORS.gray20)};
+ color: ${(props) => (props.startdatechange === 'true' ? '#fff' : COLORS.gray40)};
+ background: ${(props) => (props.startdatechange === 'true' ? COLORS.green : '#fff')};
+ }
+ }
+
+ input:focus {
+ cursor: pointer;
+ }
+
+ .react-datepicker__tab-loop {
+ width: 100% !important;
+ display: block !important;
+ }
+
+ .react-datepicker {
+ width: 100%;
+ border: 0;
+ }
+
+ .react-datepicker__header {
+ background: none;
+ }
+
+ .react-datepicker__month-container {
+ width: 100%;
+ }
+
+ .react-datepicker__day-name {
+ margin-top: 50px;
+ font-size: 12px;
+ color: #979797;
+ }
+
+ .react-datepicker__day {
+ color: #434343;
+
+ &:hover {
+ background: none;
+ }
+ }
+
+ .react-datepicker__day--disabled,
+ .react-datepicker__day--outside-month {
+ color: #aaaaaa;
+ }
+
+ .react-datepicker__day-name,
+ .react-datepicker__day,
+ .react-datepicker__time-name {
+ width: calc((100% / 7) - (0.166rem * 2));
+ line-height: 3;
+ }
+
+ .react-datepicker__day-name::after,
+ .react-datepicker__day::after,
+ .react-datepicker__day-name::after {
+ content: '';
+ display: block;
+ }
+
+ .react-datepicker__day--selected {
+ position: relative;
+ height: 100%;
+ border-radius: 50%;
+ background: rgba(0, 0, 0, 0);
+ color: #fff;
+
+ &::before {
+ content: '';
+ width: calc(38.39px - 0.166rem);
+ height: calc(38.39px - 0.166rem);
+ display: block;
+ position: absolute;
+ transform: translate(-50%, -50%);
+ left: 50%;
+ top: 50%;
+ background-color: ${COLORS.green};
+ z-index: -1;
+ border-radius: 50%;
+ }
+ }
+
+ .react-datepicker__navigation-icon--previous::before,
+ .react-datepicker__navigation-icon--next::before {
+ border-color: ${COLORS.green};
+ }
+
+ .react-datepicker__month-container,
+ .react-datepicker {
+ background-color: rgba(0, 0, 0, 0);
+ }
+
+ .react-datepicker-popper[data-placement^='bottom'] {
+ padding-top: 20px;
+ }
+`
+
+const DistrictForm = styled.div`
+ ${boxline}
+
+ ul {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 16px;
+ margin-top: 10px;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 36px 0;
+
+ li {
+ button {
+ padding: 6px 12px;
+ background: #e9e9e9;
+ box-shadow: 0px 1px 3px rgba(0, 0, 0, 0.25);
+ border-radius: 11px;
+ font-size: 16px;
+ color: ${COLORS.font};
+
+ &.selected {
+ background: ${COLORS.green};
+ color: #fff;
+ }
+ }
+ }
+ }
+
+ @media ${({ theme }) => theme.device.mobile} {
+ ul {
+ display: flex;
+ gap: 10px;
+ margin-top: 10px;
+ max-width: 800px;
+ margin: 0 auto;
+ padding: 16px 0;
+
+ li {
+ button {
+ padding: 5px 8px;
+ border-radius: 8px;
+ font-size: 12px;
+
+ &.selected {
+ background: ${COLORS.green};
+ color: #fff;
+ }
+ }
+ }
+ }
+ }
+`
+
+const CategoryForm = styled.div`
+ ${boxline}
+
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+
+ ul {
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ cursor: pointer;
+
+ * {
+ font-size: 12px;
+ color: ${COLORS.font};
+ }
+
+ p {
+ font-weight: 300;
+ padding: 8px;
+ height: 40px;
+ line-height: 24px;
+ }
+
+ li {
+ height: 40px;
+ line-height: 24px;
+ }
+
+ button {
+ width: 100%;
+ height: 100%;
+ text-align: left;
+ padding: 8px;
+ }
+ }
+
+ ul.open {
+ color: ${COLORS.font};
+
+ p {
+ color: ${COLORS.font};
+ }
+ }
+`
+
+const TimeForm = styled.div<{ timeopen: string }>`
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .react-datepicker__input-container :focus {
+ outline: none;
+ }
+
+ div {
+ position: relative;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ span {
+ padding: 0 3px;
+ font-size: 10px;
+ color: ${(props) => (props.timeopen === 'true' ? COLORS.green : COLORS.gray40)};
+ }
+
+ .medium {
+ font-size: 16px;
+ }
+
+ label {
+ display: inline-block;
+ height: 33px;
+ width: 63px;
+ line-height: 33px;
+ background: ${(props) => (props.timeopen === 'true' ? COLORS.green : 'rgba(0,0,0,0)')};
+ border-radius: 10px;
+ border: 1px solid ${(props) => (props.timeopen === 'true' ? '#fff' : '#d9d9d9')};
+ color: ${(props) => (props.timeopen === 'true' ? '#fff' : COLORS.gray40)};
+ text-align: center;
+ font-size: 12px;
+ letter-spacing: 1.5px;
+ }
+
+ input {
+ position: absolute;
+ background: rgba(0, 0, 0, 0);
+ width: 63px;
+ height: 100%;
+ border: none;
+ left: 0;
+ opacity: 0;
+ }
+
+ input:first-child {
+ left: 0;
+ }
+
+ input[type='time']::-webkit-inner-spin-button {
+ display: none;
+ }
+ input[type='time']::-webkit-calendar-picker-indicator {
+ opacity: 0;
+ width: 100%;
+ }
+ }
+
+ @media ${({ theme }) => theme.device.tablet} {
+ label {
+ height: 27px;
+ line-height: 27px;
+ }
+ }
+`
+
+const SearchBtnContainer = styled.div`
+ position: relative;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ padding: 0 18px !important;
+ border: 0px !important;
+ border-top: 0 !important;
+ border-radius: 0 !important;
+ box-shadow: none !important;
+ background: #fff;
+ z-index: 1;
+ margin-top: 24px;
+
+ button:first-child {
+ position: absolute;
+ top: 50%;
+ left: calc(50% - 214px);
+ transform: translate(-50%, -50%);
+ font-size: ${FONT.pc};
+ }
+
+ button:last-child {
+ position: relative;
+ width: 328px;
+ height: 48px;
+ text-align: center;
+ font-size: 20px;
+ font-weight: 600;
+ color: #fff;
+ border-radius: 8px;
+ background-color: ${COLORS.green};
+ }
+
+ @media ${({ theme }) => theme.device.tablet} {
+ display: block;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ border-top: 1px solid #d9d9d9 !important;
+ margin-top: 0;
+
+ button:first-child {
+ position: relative;
+ left: 0;
+ top: 0;
+ transform: none;
+ font-size: 14px;
+ }
+
+ button:last-child {
+ width: 88px;
+ height: 38px;
+ padding: 8px 16px;
+ background-image: url('data:image/svg+xml;utf8,');
+ background-color: ${COLORS.green};
+ background-repeat: no-repeat;
+ background-size: 14px;
+ background-position: 15px center;
+ text-align: right;
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+ border-radius: 16px;
+ }
+
+ & > div {
+ position: relative;
+ max-width: 1440px;
+ height: 62px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 auto;
+ line-height: 62px;
+ }
+ }
+`
+
+const FlexContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 0 !important;
+
+ & > div {
+ ${boxline}
+ padding: 16px 18px;
+ width: 50%;
+ }
+
+ & > div:last-child {
+ flex-grow: 0;
+ max-height: 67px;
+ }
+
+ @media ${({ theme }) => theme.device.tablet} {
+ flex-wrap: wrap;
+ padding: 0 !important;
+ border: none !important;
+ border-radius: none !important;
+ box-shadow: none !important;
+
+ & > div {
+ width: 100%;
+ }
+ }
+`
+
+const SearchKeyWordBtn = styled.ul`
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ max-width: var(--screen-pc);
+ width: 100%;
+ margin: 12px auto 0;
+ align-items: center;
+
+ button {
+ display: flex;
+ align-items: center;
+ height: 34px;
+ line-height: 30px;
+ padding: 2px 8px;
+ background-color: ${COLORS.green};
+ border-radius: 8px;
+ color: #fff;
+ font-size: ${FONT.pc};
+
+ svg {
+ margin: 0 5px 0 10px;
+ }
+ }
+
+ @media ${({ theme }) => theme.device.mobile} {
+ button {
+ display: flex;
+ align-items: center;
+ height: 26px;
+ line-height: 22px;
+ padding: 2px 8px;
+ font-size: 12px;
+
+ svg {
+ margin: 0 5px 0 10px;
+ width: 8px;
+ height: 8px;
+ margin: 0 2px 0 5px;
+ }
+ }
+ }
+`
+
+const CloseBtn = styled.button`
+ align-self: flex-start;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin: 0 0 10px 5px;
+`
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx
new file mode 100644
index 0000000..8ec83e2
--- /dev/null
+++ b/src/components/Sidebar.tsx
@@ -0,0 +1,294 @@
+import { COLORS, FONT } from '@src/globalStyles'
+import { getCookieToken } from '@src/storage/Cookie'
+import { RootState } from '@src/store/config'
+import { useEffect } from 'react'
+import { useSelector } from 'react-redux'
+import { useMediaQuery } from 'react-responsive'
+import { Link, useNavigate } from 'react-router-dom'
+import { styled } from 'styled-components'
+import PATH from '@src/constants/pathConst'
+import useSidebar from '@src/hooks/useSidebar'
+import useLoginState from '@src/hooks/useLoginState'
+import { BoldCloseIcon, MyCommentIcon, MyHeartIcon, MyPageIcon, MyPostIcon } from '@src/constants/icons'
+
+const Sidebar = () => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ const navigate = useNavigate()
+ const { isSidebarOpen } = useSelector((state: RootState) => state.sidebar)
+ const { closeSidebar } = useSidebar()
+ const { accessAfterLoginAlert, logoutHandler } = useLoginState()
+
+ useEffect(() => {
+ if (!isMobile) {
+ closeSidebar()
+ }
+ }, [isMobile])
+
+ const userName = useSelector((state: RootState) => state.userInfo.memberName)
+ const userRole = useSelector((state: RootState) => state.userInfo.role)
+ const refreshToken = getCookieToken()
+
+ return (
+
+ )
+}
+
+const SideContainer = styled.div`
+ position: fixed;
+ width: 240px;
+ height: 100%;
+ padding: 32px 0;
+ border-right: 1px solid ${COLORS.gray20};
+ font-size: ${FONT.m};
+ z-index: 100;
+ background-color: white;
+ left: -150%;
+ transition: 0.3s ease;
+
+ &.open {
+ left: 0;
+ }
+`
+const FirstSection = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ border-bottom: 1px solid ${COLORS.gray20};
+ padding: 0 16px 32px;
+
+ .name {
+ display: flex;
+ gap: 8px;
+ font-size: ${FONT['m-lg']};
+ }
+
+ .logo {
+ width: 116px;
+ }
+
+ .close {
+ width: 24px;
+ cursor: pointer;
+ }
+
+ button {
+ width: 208px;
+ height: 32px;
+ background-color: ${COLORS.green};
+ color: #fff;
+ border-radius: 8px;
+ font-size: ${FONT['m-lg']};
+
+ :hover {
+ background-color: black;
+ }
+ }
+
+ .not-member {
+ display: flex;
+ gap: 8px;
+
+ button {
+ width: 100px;
+ }
+
+ .login {
+ background-color: white;
+ color: ${COLORS.green};
+ border: 1px solid ${COLORS.green};
+ }
+ }
+`
+const MiddleSection = styled.section`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 32px 16px;
+ border-bottom: 1px solid ${COLORS.gray20};
+
+ .block {
+ display: flex;
+ gap: 10px;
+ line-height: 20px;
+ cursor: pointer;
+
+ :hover {
+ font-weight: 900;
+ }
+ }
+`
+const LastSection = styled.section`
+ position: fixed;
+ width: 240px;
+ box-sizing: border-box;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding: 32px 16px;
+ border-top: 1px solid ${COLORS.gray20};
+
+ div {
+ cursor: pointer;
+ }
+
+ .blur {
+ color: ${COLORS.gray40};
+ }
+
+ :hover {
+ font-weight: 900;
+ }
+`
+
+export default Sidebar
diff --git a/src/components/SocialLogin.tsx b/src/components/SocialLogin.tsx
new file mode 100644
index 0000000..41f0d6d
--- /dev/null
+++ b/src/components/SocialLogin.tsx
@@ -0,0 +1,66 @@
+// import React from 'react'
+// import { useNavigate } from 'react-router-dom'
+import styled from 'styled-components'
+import { COLORS } from '@src/globalStyles'
+// import { Link } from 'react-router-dom'
+import { SocialLoginGoogleIcon, SocialLoginNaverIcon } from '@src/constants/icons'
+
+const SocialLogin = () => {
+ const socialLoginHandler = (e: React.MouseEvent) => {
+ e.preventDefault()
+ const LINK = `https://field-passer.store/oauth2/authorization/${e.currentTarget.dataset.name}`
+ window.location.replace(LINK)
+ }
+
+ return (
+
+
+
+
+
+ )
+}
+
+const SocialLoginWrap = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ text-align: center;
+ > button {
+ /* button { */
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 47px;
+ border-radius: 10px;
+ font-size: 15px;
+ svg {
+ flex-shrink: 0;
+ margin-left: 14px;
+ }
+ span {
+ flex-grow: 1;
+ margin-left: -14px;
+ pointer-events: none;
+ }
+ }
+ .btn_naverLogin {
+ /* border: none; */
+ background-color: #03c75a;
+ span {
+ color: #ffffff;
+ }
+ }
+ .btn_googleLogin {
+ border: 1px solid ${COLORS.gray30};
+ }
+ /* } */
+`
+export default SocialLogin
diff --git a/src/components/SocialLoginRedirect.tsx b/src/components/SocialLoginRedirect.tsx
new file mode 100644
index 0000000..7ae665c
--- /dev/null
+++ b/src/components/SocialLoginRedirect.tsx
@@ -0,0 +1,43 @@
+// import React from 'react'
+
+import { getUserInfo } from '@src/api/authApi'
+import PATH from '@src/constants/pathConst'
+import { setRefreshToken } from '@src/storage/Cookie'
+import { SET_TOKEN } from '@src/store/slices/authSlice'
+import { SET_INFO } from '@src/store/slices/infoSlice'
+import { useEffect } from 'react'
+import { useDispatch } from 'react-redux'
+import { useNavigate } from 'react-router-dom'
+
+const SocialLoginRedirect = () => {
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+
+ const accessToken = new URL(window.location.href).searchParams.get('access_token') as string
+ const refreshToken = new URL(window.location.href).searchParams
+ .get('refresh_token')
+ ?.replace('refresh-token=', '') as string
+
+ useEffect(() => {
+ const socialLogin = async () => {
+ dispatch(SET_TOKEN(accessToken))
+ setRefreshToken(refreshToken)
+ try {
+ const response = await getUserInfo()
+ if (response) {
+ dispatch(SET_INFO(response?.data))
+ }
+ } catch (err) {
+ console.log(err)
+ }
+ console.log('소셜 로그인 성공', new Date())
+ return navigate(PATH.HOME, { replace: true })
+ }
+ socialLogin()
+ //navigate('/', { replace: true })
+ })
+
+ return <>>
+}
+
+export default SocialLoginRedirect
diff --git a/src/components/Title.tsx b/src/components/Title.tsx
new file mode 100644
index 0000000..7337d6c
--- /dev/null
+++ b/src/components/Title.tsx
@@ -0,0 +1,22 @@
+import styled from 'styled-components'
+import { FONT } from '@src/globalStyles'
+
+interface IProps {
+ screen: string
+ name: string
+}
+
+const Title = ({ screen, name }: IProps) => {
+ return {name}
+}
+
+export default Title
+
+const TitleStyle = styled.h1`
+ font-size: ${({ screen }) => (screen === 'pc' ? '20px' : FONT['m-lg'])};
+ text-align: center;
+ font-family: 'NanumSquareNeo-Variable';
+ font-weight: ${({ screen }) => (screen === 'pc' ? 700 : 500)};
+ margin: ${({ screen }) => screen === 'mobile' && '16px 0'};
+ padding-bottom: ${({ screen }) => screen === 'pc' && '48px'};
+`
diff --git a/src/components/Write/CategorySelect.tsx b/src/components/Write/CategorySelect.tsx
new file mode 100644
index 0000000..bdaff73
--- /dev/null
+++ b/src/components/Write/CategorySelect.tsx
@@ -0,0 +1,27 @@
+import { ChangeEvent } from 'react'
+import { categoryOptions } from '@src/constants/options'
+
+const CategorySelect = ({ postData, setPostData }: IWriteInputsProps) => {
+ const setSelectedCategory = (e: ChangeEvent) => {
+ setPostData((prev) => {
+ return { ...prev, categoryName: e.target.value }
+ })
+ }
+ return (
+ <>
+ 종목
+
+ >
+ )
+}
+
+export default CategorySelect
diff --git a/src/components/Write/ContentInput.tsx b/src/components/Write/ContentInput.tsx
new file mode 100644
index 0000000..693ca7d
--- /dev/null
+++ b/src/components/Write/ContentInput.tsx
@@ -0,0 +1,51 @@
+import { COLORS, FONT } from '@src/globalStyles'
+import styled from 'styled-components'
+
+const ContentInput = ({ postData, setPostData }: IWriteInputsProps) => {
+ const setContent = (e: React.ChangeEvent) => {
+ setPostData((prev) => {
+ return { ...prev, content: e.target.value }
+ })
+ }
+ return (
+ <>
+ 본문내용
+
+ setContent(e)}
+ />
+
+ >
+ )
+}
+
+const ContentBox = styled.textarea`
+ color: ${COLORS.font};
+ width: 328px;
+ height: 140px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ padding: 0 10px;
+ box-sizing: border-box;
+ resize: none;
+ overflow-y: auto;
+ padding: 10px;
+
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+
+ @media (min-width: 834px) {
+ width: 100%;
+ height: 180px;
+ padding: 16px;
+ font-size: ${FONT.pc};
+ }
+`
+
+export default ContentInput
diff --git a/src/components/Write/DateInput.tsx b/src/components/Write/DateInput.tsx
new file mode 100644
index 0000000..8d69ef1
--- /dev/null
+++ b/src/components/Write/DateInput.tsx
@@ -0,0 +1,144 @@
+import DatePicker from 'react-datepicker'
+import 'react-datepicker/dist/react-datepicker.css'
+import { ko } from 'date-fns/esm/locale'
+import { styled } from 'styled-components'
+import { forwardRef } from 'react'
+import { useMediaQuery } from 'react-responsive'
+import { COLORS } from '@src/globalStyles'
+
+type PropsType = {
+ isDateChange: boolean
+ setIsDateChange: React.Dispatch>
+ selectedDate: Date | null
+ setSelectedDate: React.Dispatch>
+}
+
+const DateInput = ({ isDateChange, setIsDateChange, setSelectedDate, selectedDate }: PropsType) => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+
+ const dateFormat = isMobile ? 'yyyy년 MM월 dd일' : 'MMdd'
+
+ const CustomDateInput = forwardRef(({ value, onClick }, ref) => (
+
+ {isMobile ? (
+ <>
+
+
{value}
+ >
+ ) : (
+ <>
+
{value.slice(0, 2)}
+
월
+
{value.slice(2, 4)}
+
일
+ >
+ )}
+
+ ))
+ CustomDateInput.displayName = 'CustomDateInput'
+
+ return (
+
+ {
+ date && setSelectedDate(date)
+ setIsDateChange(true)
+ }}
+ className={isDateChange ? 'selected' : ''}
+ customInput={ null} />}
+ minDate={new Date()}
+ required
+ />
+
+ )
+}
+
+const DatePickerContainer = styled.div`
+ .react-datepicker__triangle::before,
+ .react-datepicker__triangle::after {
+ display: none;
+ }
+
+ .react-datepicker-popper {
+ width: 100% !important;
+ inset: -10px 0 0 -12px !important;
+
+ @media (min-width: 834px) {
+ inset: -10px 0 0 -50px !important;
+ }
+ }
+
+ .react-datepicker {
+ width: 100%;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 10px;
+ }
+
+ .react-datepicker__header {
+ background: none;
+ border-bottom: 1px solid ${COLORS.gray20};
+ }
+
+ .react-datepicker__month-container {
+ width: 100%;
+ padding-top: 10px;
+ }
+
+ .react-datepicker__day-name {
+ color: ${COLORS.gray40};
+ }
+
+ .react-datepicker__day {
+ color: ${COLORS.font};
+
+ &:hover {
+ background: none;
+ }
+ }
+
+ .react-datepicker__day--disabled,
+ .react-datepicker__day--outside-month {
+ color: ${COLORS.gray40};
+ }
+
+ .react-datepicker__day-name,
+ .react-datepicker__day,
+ .react-datepicker__time-name {
+ width: calc((100% / 7) - (0.166rem * 2));
+ line-height: 3;
+ }
+
+ .react-datepicker__day--selected {
+ position: relative;
+ height: 100%;
+ border-radius: 50%;
+ background-color: ${COLORS.green};
+ color: #fff;
+
+ &:hover {
+ color: black;
+ }
+ }
+
+ .react-datepicker__navigation-icon {
+ margin-top: 12px;
+ }
+
+ .react-datepicker__navigation-icon--previous::before,
+ .react-datepicker__navigation-icon--next::before {
+ border-color: ${COLORS.green};
+ }
+
+ .react-datepicker__day--keyboard-selected {
+ background: none;
+ }
+`
+
+export default DateInput
diff --git a/src/components/Write/DistrictSelect.tsx b/src/components/Write/DistrictSelect.tsx
new file mode 100644
index 0000000..5c835cf
--- /dev/null
+++ b/src/components/Write/DistrictSelect.tsx
@@ -0,0 +1,27 @@
+import { ChangeEvent } from 'react'
+import { districtOptions } from '@src/constants/options'
+
+const DistrictSelect = ({ postData, setPostData }: IWriteInputsProps) => {
+ const setSelectedDistrict = (e: ChangeEvent) => {
+ setPostData((prev) => {
+ return { ...prev, districtName: e.target.value }
+ })
+ }
+
+ return (
+ <>
+ 지역
+
+ >
+ )
+}
+
+export default DistrictSelect
diff --git a/src/components/Write/FileUpload.tsx b/src/components/Write/FileUpload.tsx
new file mode 100644
index 0000000..3bf47a0
--- /dev/null
+++ b/src/components/Write/FileUpload.tsx
@@ -0,0 +1,166 @@
+import { ImageUploadIcon } from '@src/constants/icons'
+import { COLORS } from '@src/globalStyles'
+import useModal from '@src/hooks/useModal'
+import { useMediaQuery } from 'react-responsive'
+import styled from 'styled-components'
+
+type PropsType = {
+ imgRef: React.RefObject
+ previewImgSrc: string
+ setPreviewImgSrc: React.Dispatch>
+ isFileChanged: boolean
+ setIsFileChanged: React.Dispatch>
+}
+
+const FileUpload = ({ imgRef, previewImgSrc, setPreviewImgSrc, isFileChanged, setIsFileChanged }: PropsType) => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ const { openModal } = useModal()
+ const iconSize = isMobile ? '48px' : '54px'
+ const previewImg = (event: React.ChangeEvent) => {
+ const thisFile = event.target.files && event.target.files[0]
+ const fileReader = new FileReader()
+
+ if (thisFile && thisFile.size > 10485760) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['첨부파일 사이즈는 10MB 이내로만 등록 가능합니다.'],
+ })
+ event.target.files = null
+ return false
+ }
+
+ thisFile && fileReader.readAsDataURL(thisFile)
+ return new Promise((resolve) => {
+ fileReader.onload = () => {
+ setPreviewImgSrc(fileReader.result + '')
+ resolve()
+ }
+ })
+ }
+ const removeImg = () => {
+ if (imgRef.current) {
+ imgRef.current.value = ''
+ }
+ setPreviewImgSrc('')
+ setIsFileChanged(true)
+ }
+ const changeImg = (event: React.ChangeEvent) => {
+ previewImg(event)
+ setIsFileChanged(true)
+ }
+
+ return (
+ <>
+ 사진 추가
+
+ {
+ changeImg(event)
+ }}
+ />
+
+
+ 예약 인증 사진을 올려주세요
+ (첨부 불가능할 경우, 거래 시 개인에게 확인 필수)
+
+ {previewImgSrc &&
}
+ {location.pathname.includes('edit') && !isFileChanged ? (
+
+
+
+ 예약 인증 사진을 올려주세요
+ (첨부 불가능할 경우, 거래 시 개인에게 확인 필수)
+
+
+ ) : null}
+
+ {previewImgSrc && (
+ {
+ removeImg()
+ }}
+ >
+ 삭제
+
+ )}
+ >
+ )
+}
+
+const FileLabel = styled.label`
+ position: relative;
+ width: 328px;
+ height: 160px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ justify-content: center;
+ align-items: center;
+ color: ${COLORS.gray40};
+ cursor: pointer;
+
+ .preview {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background-color: white;
+ object-fit: contain;
+ border-radius: 8px;
+ }
+
+ input {
+ display: none;
+ }
+
+ .img-text {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .img-overlay {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ border-radius: 8px;
+ background-color: rgba(0, 0, 0, 0.6);
+ gap: 16px;
+ justify-content: center;
+ align-items: center;
+ color: ${COLORS.gray40};
+ }
+
+ .delete {
+ position: absolute;
+ background-color: ${COLORS.gray40};
+ right: 10px;
+ bottom: 10px;
+ padding: 8px;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ color: white;
+ }
+
+ @media (min-width: 834px) {
+ width: 100%;
+ height: 100%;
+ }
+`
+
+export default FileUpload
diff --git a/src/components/Write/PriceInput.tsx b/src/components/Write/PriceInput.tsx
new file mode 100644
index 0000000..6c4535d
--- /dev/null
+++ b/src/components/Write/PriceInput.tsx
@@ -0,0 +1,46 @@
+import { ChangeEvent } from 'react'
+import styled from 'styled-components'
+
+type PropsType = {
+ formattedPrice: string
+ setFormattedPrice: React.Dispatch>
+}
+
+const PriceInput = ({ formattedPrice, setFormattedPrice }: PropsType) => {
+ const priceFormatting = (event: ChangeEvent) => {
+ let price = event.target.value
+ price = Number(price.replace(/[^0-9]/g, '')).toLocaleString('ko-KR')
+ setFormattedPrice(price)
+ }
+
+ return (
+ <>
+ 가격
+
+
priceFormatting(event)}
+ />
+ 원
+
+ >
+ )
+}
+
+const PriceBox = styled.input`
+ width: 100%;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+ }
+`
+
+export default PriceInput
diff --git a/src/components/Write/TitleInput.tsx b/src/components/Write/TitleInput.tsx
new file mode 100644
index 0000000..5079714
--- /dev/null
+++ b/src/components/Write/TitleInput.tsx
@@ -0,0 +1,32 @@
+import styled from 'styled-components'
+
+const TitleInput = ({ postData, setPostData }: IWriteInputsProps) => {
+ const setTitle = (e: React.ChangeEvent) => {
+ setPostData((prev) => {
+ return { ...prev, title: e.target.value }
+ })
+ }
+
+ return (
+ <>
+ 구장명
+ setTitle(e)}
+ />
+ >
+ )
+}
+
+const TitleBox = styled.input`
+ width: 100%;
+`
+
+export default TitleInput
diff --git a/src/components/Write/Write.tsx b/src/components/Write/Write.tsx
new file mode 100644
index 0000000..a3ba743
--- /dev/null
+++ b/src/components/Write/Write.tsx
@@ -0,0 +1,640 @@
+import { COLORS, FONT } from '@src/globalStyles'
+import { styled } from 'styled-components'
+import { useRef, useState, useEffect } from 'react'
+import { useLocation } from 'react-router'
+import TimeSelector from '@src/components/common/TimeSelector'
+import useModal from '@src/hooks/useModal'
+import { useMediaQuery } from 'react-responsive'
+import FileUpload from '@src/components/Write/FileUpload'
+import TitleInput from '@src/components/Write/TitleInput'
+import PriceInput from '@src/components/Write/PriceInput'
+import ContentInput from '@src/components/Write/ContentInput'
+import DistrictSelect from '@src/components/Write/DistrictSelect'
+import CategorySelect from '@src/components/Write/CategorySelect'
+import DateInput from '@src/components/Write/DateInput'
+
+const Write = ({ postData, setPostData, pageName, submitData }: IWriteProps) => {
+ const location = useLocation()
+ const { openModal } = useModal()
+ const imgRef = useRef(null)
+ const [previewImgSrc, setPreviewImgSrc] = useState('')
+ const [isStartChange, setIsStartChange] = useState(false)
+ const [isEndChange, setIsEndChange] = useState(false)
+ const [isDateChange, setIsDateChange] = useState(false)
+ const [isFileChanged, setIsFileChanged] = useState(false)
+ const [selectedDate, setSelectedDate] = useState(new Date())
+ const [startTimeTemp, setStartTimeTemp] = useState('')
+ const [endTimeTemp, setEndTimeTemp] = useState('')
+ const [formattedPrice, setFormattedPrice] = useState('')
+ const [selectedStartTime, setSelectedStartTime] = useState('')
+ const [selectedEndTime, setSelectedEndTime] = useState('')
+ const [startTimeSelectorOpen, setStartTimeSelectorOpen] = useState(false)
+ const [endTimeSelectorOpen, setEndTimeSelectorOpen] = useState(false)
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+
+ useEffect(() => {
+ if (pageName === 'edit') {
+ setIsDateChange(true)
+ setIsStartChange(true)
+ setIsEndChange(true)
+ setPreviewImgSrc(postData.imageUrl)
+ setFormattedPrice(postData.price.toLocaleString('ko-KR'))
+ setStartTimeTemp(postData.startTime.slice(11, 16))
+ setEndTimeTemp(postData.endTime.slice(11, 16))
+ setSelectedDate(new Date(postData.startTime))
+ }
+ }, [])
+
+ const checkInputsValidity = () => {
+ if (selectedStartTime === selectedEndTime) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['시작 시간과 끝나는 시간이 동일합니다.', '예약 일시를 정확히 선택해주세요.'],
+ })
+ return false
+ }
+
+ if (!selectedStartTime || !selectedEndTime) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['시작 시간과 끝나는 시간을 모두 선택해주세요.'],
+ })
+ return false
+ }
+
+ if (postData.content.length < 5) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['내용을 5자 이상 입력해주세요.'],
+ })
+ return false
+ }
+ }
+
+ const handleSubmit = async (event: React.FormEvent) => {
+ const formData = new FormData()
+ const target = event.target as HTMLFormElement
+
+ checkInputsValidity()
+
+ for (let i = 0; i < target.length; i += 1) {
+ const item = target[i] as HTMLInputElement
+ if (item.name === 'file') {
+ if (item.files && !item.files.length) {
+ formData.append('file', '')
+ } else if (item.files) {
+ formData.append('file', item.files[0])
+ }
+ } else if (item.name === 'price') {
+ formData.append('price', item.value.replace(/,/g, ''))
+ } else if (item.name) {
+ formData.append(item.name, item.value)
+ }
+ }
+
+ const date = new Date(selectedDate?.getTime() - selectedDate.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
+ const start = date + 'T' + selectedStartTime + ':00'
+ let end = date + 'T' + selectedEndTime + ':00'
+
+ if (+start.slice(11, 13) > +end.slice(11, 13)) {
+ const newDate = (+end.slice(8, 10) + 1 + '').padStart(2, '0') + 'T'
+ end = end.replace(/[0-9]+[0-9]+T/, newDate)
+ }
+
+ formData.append('startTime', start)
+ formData.append('endTime', end)
+ formData.append('transactionStatus', '판매중')
+
+ if (pageName === 'edit') {
+ if (postData.imageUrl && !formData.get('file') && !previewImgSrc) {
+ formData.append('imageUrlDel', 'true')
+ } else {
+ formData.append('imageUrlDel', 'false')
+ }
+ }
+
+ const entries = formData.entries()
+ for (const pair of entries) {
+ console.log(pair[0] + ', ' + pair[1])
+ }
+
+ submitData(formData)
+ }
+
+ return (
+
+ {isMobile ? (
+ {
+ event.preventDefault()
+ handleSubmit(event)
+ }}
+ >
+
+
+
+
+
+
+
+
+ ) : (
+ {
+ event.preventDefault()
+ handleSubmit(event)
+ }}
+ >
+
+ {location.pathname === '/write' ?
게시물 등록
: 게시물 수정
}
+
+
+
+
+ 세부사항
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 예약일시
+
+
+
+
시간
+
+
+
+
부터
+
+
+
+
까지
+
+
+
+
+
+
+ )}
+
+ )
+}
+const Container = styled.main`
+ position: relative;
+ margin: auto;
+
+ select {
+ color: ${COLORS.font};
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ appearance: none;
+ background: url('/select-arrow.png') no-repeat 97% 50%;
+ cursor: pointer;
+
+ &:focus {
+ outline: none;
+ }
+ }
+
+ input,
+ textarea {
+ &:focus {
+ outline: none;
+ background-color: none;
+ }
+ }
+ input:-webkit-autofill,
+ input:-webkit-autofill:hover,
+ input:-webkit-autofill:focus,
+ input:-webkit-autofill:active {
+ -webkit-text-fill-color: ${COLORS.font};
+ -webkit-box-shadow: 0 0 0px 1000px #fff inset;
+ box-shadow: 0 0 0px 1000px #fff inset;
+ transition: background-color 5000s ease-in-out 0s;
+ }
+
+ input:autofill,
+ input:autofill:hover,
+ input:autofill:focus,
+ input:autofill:active {
+ -webkit-text-fill-color: ${COLORS.font};
+ -webkit-box-shadow: 0 0 0px 1000px #fff inset;
+ box-shadow: 0 0 0px 1000px #fff inset;
+ transition: background-color 5000s ease-in-out 0s;
+ }
+`
+
+const PcForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 32px;
+ font-size: ${FONT.pc};
+ padding: 64px 32px;
+ width: 770px;
+ position: relative;
+ margin: auto;
+
+ h2 {
+ font-weight: 700;
+ }
+
+ input {
+ color: ${COLORS.font};
+
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ input[type='text'],
+ select {
+ width: 300px;
+ height: 40px;
+ border: none;
+ border-bottom: 1px solid ${COLORS.gray30};
+ padding: 8px;
+ box-sizing: border-box;
+ font-size: 16px;
+ }
+
+ .submit-button {
+ width: 328px;
+ height: 48px;
+ background-color: ${COLORS.green};
+ color: white;
+ font-size: ${FONT['m-lg']};
+ margin: auto;
+ }
+
+ .page-title {
+ width: 100%;
+ h1 {
+ font-size: ${FONT['pc-lg']};
+ font-weight: 700;
+ }
+ }
+
+ .full-section {
+ width: 100%;
+
+ h2 {
+ margin-bottom: 16px;
+ }
+ }
+`
+
+const PcDetail = styled.div`
+ display: flex;
+ justify-content: space-between;
+ width: 100%;
+
+ .half-section {
+ position: relative;
+ width: 360px;
+ height: 270px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ .delete {
+ position: absolute;
+ background-color: ${COLORS.gray40};
+ right: 10px;
+ bottom: 10px;
+ padding: 8px;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ color: white;
+ }
+
+ .row-box {
+ display: flex;
+ flex-direction: row;
+ line-height: 40px;
+ justify-content: space-between;
+
+ .won {
+ position: absolute;
+ left: 150px;
+ }
+ }
+ }
+`
+
+const MobileForm = styled.form`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ font-size: ${FONT.m};
+ padding-top: 32px;
+
+ input {
+ color: ${COLORS.font};
+ }
+
+ .submit-button {
+ width: 328px;
+ height: 44px;
+ background-color: ${COLORS.green};
+ color: white;
+ font-size: ${FONT['m-lg']};
+ margin: auto;
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin: auto;
+ position: relative;
+
+ :focus {
+ outline: none;
+ border-color: ${COLORS.gray20};
+ }
+
+ ::placeholder {
+ color: ${COLORS.gray40};
+ }
+
+ input,
+ .date {
+ width: 328px;
+ height: 40px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ padding: 0 10px;
+ box-sizing: border-box;
+ }
+
+ select {
+ width: 328px;
+ height: 40px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ padding: 0 10px;
+ box-sizing: border-box;
+ }
+
+ .won {
+ position: absolute;
+ top: 35px;
+ right: 30px;
+ color: ${COLORS.gray40};
+ }
+
+ .delete {
+ position: absolute;
+ background-color: ${COLORS.gray40};
+ right: 10px;
+ top: 30px;
+ padding: 8px;
+ border: none;
+ border-radius: 10px;
+ cursor: pointer;
+ color: white;
+ }
+ }
+`
+
+const MobileReservation = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ color: ${COLORS.gray40};
+
+ .date {
+ position: relative;
+ line-height: 30px;
+
+ .date-input {
+ position: relative;
+ width: 100%;
+ line-height: 40px;
+ padding: 0 70px 0 30px;
+ cursor: pointer;
+ display: flex;
+ flex-direction: row;
+
+ .icon {
+ position: absolute;
+ left: -20px;
+ width: 14px;
+ height: 14px;
+ padding: 13px;
+ background: url('/calendar-light.png') no-repeat 90% 50%;
+ }
+ }
+ .selected {
+ color: ${COLORS.font};
+
+ .icon {
+ background: url('/calendar-dark.png') no-repeat 90% 50%;
+ }
+ }
+ }
+
+ .time {
+ display: flex;
+ justify-content: space-between;
+ height: 40px;
+ line-height: 40px;
+
+ .time-inner {
+ position: relative;
+ }
+
+ .time-selector-view {
+ position: relative;
+ width: 128px;
+ cursor: pointer;
+ display: flex;
+ gap: 8px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ padding: 0 10px;
+ box-sizing: border-box;
+
+ svg {
+ position: absolute;
+ right: 10px;
+ top: 13px;
+ }
+ }
+
+ .time-selector-selected {
+ background-color: ${COLORS.gray30};
+ color: white;
+ }
+ }
+`
+const PcReservation = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+
+ .date {
+ display: flex;
+ position: relative;
+ gap: 16px;
+ height: 40px;
+ line-height: 40px;
+
+ .date-input {
+ display: flex;
+ gap: 16px;
+ cursor: pointer;
+ color: ${COLORS.gray40};
+
+ .month,
+ .day {
+ display: block;
+ width: 100px;
+ text-align: center;
+ border-bottom: 1px solid ${COLORS.gray20};
+ }
+ }
+
+ .selected {
+ color: ${COLORS.font};
+ }
+ }
+
+ .time {
+ display: flex;
+ gap: 16px;
+ line-height: 40px;
+
+ .text-gray {
+ color: ${COLORS.gray40};
+ }
+
+ .time-inner {
+ position: relative;
+ color: ${COLORS.gray40};
+ }
+
+ .time-selector-view {
+ width: 110px;
+ cursor: pointer;
+ display: flex;
+ gap: 8px;
+ border-bottom: 1px solid ${COLORS.gray20};
+ padding: 0 10px;
+ box-sizing: border-box;
+ }
+
+ .time-selector-selected {
+ color: ${COLORS.font};
+ }
+ }
+`
+
+export default Write
diff --git a/src/components/common/TimeSelector.tsx b/src/components/common/TimeSelector.tsx
new file mode 100644
index 0000000..71c64d4
--- /dev/null
+++ b/src/components/common/TimeSelector.tsx
@@ -0,0 +1,171 @@
+import { minutes, times } from '@src/constants/options'
+import { COLORS } from '@src/globalStyles'
+import { useEffect, useState } from 'react'
+import { styled } from 'styled-components'
+import { ClockIcon } from '@src/constants/icons'
+import { useMediaQuery } from 'react-responsive'
+
+const TimeSelector = ({
+ isTimeChange,
+ setIsTimeChange,
+ setSelectedTime,
+ timeSelectorOpen,
+ setTimeSelectorOpen,
+ timeTemp,
+}: ITimeSelectorProps) => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ const [timeZone, setTimeZone] = useState('오전')
+ const [hour, setHour] = useState('--')
+ const [minute, setMinute] = useState('--')
+
+ useEffect(() => {
+ if (timeTemp) {
+ const selectedHour: string = timeTemp.slice(0, 2)
+ const selectedMinute: string = timeTemp.slice(-2)
+
+ if (selectedHour === '00') {
+ setTimeZone('오전')
+ setHour('12')
+ }
+ Number(selectedHour) >= 12 ? setTimeZone('오후') : setTimeZone('오전')
+ Number(selectedHour) > 12 ? setHour((+selectedHour - 12 + '').padStart(2, '0')) : setHour(selectedHour + '')
+ setMinute(selectedMinute)
+ }
+ }, [timeTemp])
+
+ useEffect(() => {
+ if (hour !== '--' && minute !== '--') {
+ setTimeSelectorOpen(false)
+ setIsTimeChange(true)
+
+ timeZone === '오후' && hour !== '12' && +hour + 12 <= 23
+ ? setSelectedTime(+hour + 12 + ':' + minute)
+ : setSelectedTime(hour + ':' + minute)
+
+ timeZone === '오전' && hour === '12' && setSelectedTime('00:' + minute)
+ }
+ }, [timeZone, hour, minute])
+
+ return (
+ <>
+
+ {
+ setTimeSelectorOpen(!timeSelectorOpen)
+ }}
+ >
+
{timeZone}
+
+ {hour} : {minute}
+
+ {isMobile ?
: null}
+
+
+
+ {timeSelectorOpen ? (
+
+
+
+
+
+
+
+ {times.map((item) => {
+ return (
+
+ )
+ })}
+
+
+ {minutes.map((item) => {
+ return (
+
+ )
+ })}
+
+
+
+ ) : null}
+ >
+ )
+}
+
+const ViewTimeContainer = styled.div`
+ position: relative;
+
+ .time-selector-view {
+ position: relative;
+
+ svg {
+ position: absolute;
+ right: 10px;
+ top: 13px;
+ }
+ }
+`
+
+const SelectorContainer = styled.div`
+ width: 160px;
+ height: 250px;
+ z-index: 10;
+ position: absolute;
+ left: 0;
+ background-color: white;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 10px;
+ line-height: 10px;
+ box-sizing: border-box;
+
+ .selector-inner {
+ height: 100%;
+ box-sizing: border-box;
+ position: relative;
+ display: flex;
+ justify-content: center;
+ padding: 10px;
+ }
+
+ .selector-timezone,
+ .selector-hour,
+ .selector-minute {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ .selector-hour,
+ .selector-minute {
+ overflow-y: scroll;
+
+ &::-webkit-scrollbar {
+ display: none; /* 크롬, 사파리, 오페라, 엣지 */
+ }
+ scrollbar-width: none; /* 파이어폭스 */
+ }
+`
+const Option = styled.div`
+ font-size: 15px;
+ width: 35px;
+ padding: 10px 5px;
+ text-align: center;
+ cursor: pointer;
+
+ &:hover,
+ &.chosen {
+ background-color: ${COLORS.green};
+ color: white;
+ }
+`
+
+export default TimeSelector
diff --git a/src/components/common/loading.tsx b/src/components/common/loading.tsx
new file mode 100644
index 0000000..58fca37
--- /dev/null
+++ b/src/components/common/loading.tsx
@@ -0,0 +1,53 @@
+import { styled } from 'styled-components'
+import { LoadingIcon } from '../../constants/icons'
+import { COLORS } from '@src/globalStyles'
+
+const Loading = () => {
+ return (
+
+
+
+
+
+ )
+}
+
+const LoadingContainer = styled.div`
+ width: 50px;
+ height: 50px;
+ margin: 10px auto;
+ color: ${COLORS.green};
+
+ svg {
+ animation: progress-circular-rotate 1.4s linear infinite;
+ }
+ circle {
+ animation: progress-circular-dash 1.4s ease-in-out infinite;
+ fill: transparent;
+ stroke-linecap: round;
+ stroke-dasharray: 80, 200;
+ stroke-dashoffset: 0px;
+ stroke: currentColor;
+ }
+ @keyframes progress-circular-rotate {
+ to {
+ transform: rotate(1turn);
+ }
+ }
+ @keyframes progress-circular-dash {
+ 0% {
+ stroke-dasharray: 1, 200;
+ stroke-dashoffset: 0px;
+ }
+ 50% {
+ stroke-dasharray: 100, 200;
+ stroke-dashoffset: -15px;
+ }
+ to {
+ stroke-dasharray: 100, 200;
+ stroke-dashoffset: -124px;
+ }
+ }
+`
+
+export default Loading
diff --git a/src/constants/helpList.ts b/src/constants/helpList.ts
new file mode 100644
index 0000000..1038e54
--- /dev/null
+++ b/src/constants/helpList.ts
@@ -0,0 +1,44 @@
+const memberAccountList = [
+ {
+ listId: 0,
+ title: '아이디를 찾고 싶어요.',
+ comment: '현재 아이디 찾기는 지원하지 않고 있습니다. 죄송합니다.',
+ },
+ {
+ listId: 1,
+ title: '비밀번호를 찾고 싶어요.',
+ comment:
+ '비밀번호 찾기를 누른 후 사용 중인 이메일을 입력하시면 이메일을 통해 임시 비밀번호를 발급 받으실 수 있습니다. 임시 비밀번호로 로그인 후 비밀번호를 변경해 주세요.',
+ },
+]
+
+const dealManagementList = [
+ {
+ listId: 0,
+ title: '거래할 때 구매 인증을 올리지 않으면 어떻게 되나요?',
+ comment: '운영자가 확인 후 무통보 삭제 혹은 블라인드 처리될 수 있습니다.',
+ },
+ {
+ listId: 1,
+ title: '거래자가 연락을 받지 않는데 어떻게 신고하면 될까요?',
+ comment:
+ '금전적 문제는 운영자가 책임지지 않으며 회원 제재를 해 드릴 수 있습니다. 운영자 이메일(abc@test.com)으로 연락 주시면 확인해 드리겠습니다.',
+ },
+ {
+ listId: 2,
+ title: '필드패서는 어떤 사이트인가요?',
+ comment:
+ '수도권에 있는 체육 시설을 양도하고 양도 받을 수 있는 서비스입니다. 간단한 회원가입 후 양도 받을 구장을 찾아보세요.',
+ },
+]
+
+const useEctlist = [
+ {
+ listId: 0,
+ title: '필드패서를 기획, 제작한 사람들은 누구인가요?',
+ comment:
+ '필드패서는 UXUI 2명, 프론트엔드 4명, 백엔드 2명이 기획하고 제작한 프로젝트입니다. 필드패서 프로젝트 깃허브에 방문해 보시고 저희의 진행 상황을 확인해 보세요. https://github.com/Field-Passer',
+ },
+]
+
+export { memberAccountList, dealManagementList, useEctlist }
diff --git a/src/constants/icons.tsx b/src/constants/icons.tsx
new file mode 100644
index 0000000..aeddd14
--- /dev/null
+++ b/src/constants/icons.tsx
@@ -0,0 +1,487 @@
+interface propsTypes {
+ size?: string
+ color?: string
+}
+
+export const SearchIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const SearchToggleIcon = () => {
+ return (
+
+ )
+}
+
+export const CalendarIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const BadmintonIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const BasketballIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const FutsalIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const SoccerIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const TennisIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const Harticon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const PlusIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const CloseIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const DownwardArrowIcon = () => {
+ return (
+
+ )
+}
+
+export const BigHart = (prop: propsTypes) => {
+ return (
+
+ )
+}
+
+export const MoreIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const UpLoadIcon = () => {
+ return (
+
+ )
+}
+
+export const ClackIcon = () => {
+ return (
+
+ )
+}
+
+export const BalloonIcon = () => {
+ return (
+
+ )
+}
+
+export const CloseButton = () => {
+ return (
+
+ )
+}
+
+export const ClockIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const ImageUploadIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const SocialLoginNaverIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const SocialLoginGoogleIcon = (props: propsTypes) => {
+ return (
+
+ )
+}
+
+export const NoticeIcon = () => {
+ return (
+
+ )
+}
+
+export const GithubIcon = () => {
+ return (
+
+ )
+}
+
+export const HamburgerIcon = () => {
+ return (
+
+ )
+}
+
+export const InfoIcon = () => {
+ return (
+
+ )
+}
+
+export const LoadingIcon = () => {
+ return (
+
+ )
+}
+
+export const BoldCloseIcon = () => {
+ return (
+
+ )
+}
+
+export const MyCommentIcon = () => {
+ return (
+
+ )
+}
+
+export const MyHeartIcon = () => {
+ return (
+
+ )
+}
+
+export const MyPageIcon = () => {
+ return (
+
+ )
+}
+
+export const MyPostIcon = () => {
+ return (
+
+ )
+}
+
+export const ChatBubbleIcon = () => {
+ return (
+
+ )
+}
+
+export const MypageCalendarIcon = () => {
+ return (
+
+ )
+}
diff --git a/src/constants/options.ts b/src/constants/options.ts
new file mode 100644
index 0000000..0b99779
--- /dev/null
+++ b/src/constants/options.ts
@@ -0,0 +1,36 @@
+export const districtOptions: string[] = [
+ '강남구',
+ '강동구',
+ '강북구',
+ '강서구',
+ '관악구',
+ '광진구',
+ '구로구',
+ '금천구',
+ '노원구',
+ '도봉구',
+ '동대문구',
+ '동작구',
+ '마포구',
+ '서대문구',
+ '서초구',
+ '성동구',
+ '성북구',
+ '송파구',
+ '양천구',
+ '영등포구',
+ '용산구',
+ '은평구',
+ '종로구',
+ '중구',
+ '중랑구',
+]
+
+export const categoryOptions: string[] = ['전체', '축구장', '풋살장', '농구장', '테니스장', '배드민턴장']
+
+export const sortOptions = ['가장 최신 순', '인기순', '낮은 가격 순', '높은 가격 순']
+
+export const times: string[] = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']
+export const minutes: string[] = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']
+
+export const categoryNamesList = 'futsal' || 'soccer' || 'basketball' || 'badminton' || 'tennis'
diff --git a/src/constants/pathConst.ts b/src/constants/pathConst.ts
new file mode 100644
index 0000000..bb5bc7a
--- /dev/null
+++ b/src/constants/pathConst.ts
@@ -0,0 +1,24 @@
+const PATH = {
+ HOME: '/',
+ BOARD_DETAILS: '/board-details/:boardId',
+ WRITE_POST: '/write',
+ EDIT_POST: '/edit/:postId',
+ BOARD_LIST: '/board-list',
+ HELP: '/help',
+ HELP_FORM: '/help-form',
+ JOIN: '/join',
+ LOGIN: '/login',
+ MYPAGE: '/mypage',
+ MYPAGE_DETAIL: '/mypage-detail',
+ MYPAGE_EDIT: '/mypage/edit-info',
+ MYPAGE_PW: '/mypage/edit-pw',
+ FIND_PASSWORD: '/find-password',
+ ASK: '/ask',
+ ASK_DETAIL: '/ask/:questionId',
+ ASK_ANSWER_FORM: '/ask/answer-form',
+ PROFILE: '/profile/:user',
+ BOARD_BLIND: '/board-list/blind',
+ SOCIAL_REDIRECT: '/social-redirect/*',
+}
+
+export default PATH
diff --git a/src/constants/theme.ts b/src/constants/theme.ts
new file mode 100644
index 0000000..710e2f7
--- /dev/null
+++ b/src/constants/theme.ts
@@ -0,0 +1,17 @@
+const deviceSizes = {
+ mobile: 450,
+ tablet: 834,
+ laptop: 1440,
+}
+
+const device = {
+ mobile: `screen and (max-width: ${deviceSizes.mobile}px)`,
+ tablet: `screen and (max-width: ${deviceSizes.tablet}px)`,
+ laptop: `screen and (max-width: ${deviceSizes.laptop}px)`,
+}
+
+const theme = {
+ device,
+}
+
+export default theme
diff --git a/src/globalStyles.ts b/src/globalStyles.ts
new file mode 100644
index 0000000..01a4f1d
--- /dev/null
+++ b/src/globalStyles.ts
@@ -0,0 +1,154 @@
+import { createGlobalStyle } from 'styled-components'
+
+const GlobalStyles = createGlobalStyle`
+ @font-face {
+ font-family: 'NanumSquareNeo-Variable';
+ src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_11-01@1.0/NanumSquareNeo-Variable.woff2')
+ format('woff2');
+ font-weight: normal;
+ font-style: normal;
+ }
+ @font-face {
+ font-family: 'Pretendard-Regular';
+ src: url('https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff') format('woff');
+ font-weight: 400;
+ font-style: normal;
+ }
+
+ html, body, div, span, applet, object, iframe,
+ h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+ a, abbr, acronym, address, big, cite, code,
+ del, dfn, em, img, ins, kbd, q, s, samp,
+ small, strike, strong, sub, sup, tt, var,
+ b, u, i, center,
+ dl, dt, dd, ol, ul, li,
+ fieldset, form, label, legend,
+ table, caption, tbody, tfoot, thead, tr, th, td,
+ article, aside, canvas, details, embed,
+ figure, figcaption, footer, header, hgroup,
+ menu, nav, output, ruby, section, summary,
+ time, mark, audio, video {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ font-size: 100%;
+ font: inherit;
+ vertical-align: baseline;
+ }
+
+ /* HTML5 display-role reset for older browsers */
+ article, aside, details, figcaption, figure,
+ footer, header, hgroup, menu, nav, section {
+ display: block;
+ }
+
+ body {
+ line-height: 1;
+ color: #3a3a3a;
+ font-family: 'Pretendard-Regular', 'Noto Sans KR', sans-serif;
+ }
+
+ ol, ul, menu {
+ list-style: none;
+ }
+
+ blockquote, q {
+ quotes: none;
+ }
+
+ blockquote:before, blockquote:after,
+ q:before, q:after {
+ content: '';
+ content: none;
+ }
+
+ table {
+ border-collapse: collapse;
+ border-spacing: 0;
+ }
+
+ main, section {
+ max-width: 1024px;
+ }
+
+ a {
+ text-decoration: none;
+ color: inherit;
+ }
+
+ input, textarea {
+ -moz-user-select: auto;
+ -webkit-user-select: auto;
+ -ms-user-select: auto;
+ user-select: auto;
+ font-family: 'Pretendard-Regular', 'Noto Sans KR', sans-serif;
+ }
+
+ input:focus {
+ outline: none;
+ }
+
+ button {
+ border: none;
+ background: none;
+ padding: 0;
+ border-radius: 10px;
+ cursor: pointer;
+ font-family: 'Pretendard-Regular', 'Noto Sans KR', sans-serif;
+ }
+
+ #root {
+ font-family: 'Pretendard-Regular', 'Noto Sans KR', sans-serif;
+ position: relative;
+ min-height: 100%;
+ color: #3a3a3a;
+
+ /* Screen max-width */
+ --screen-m: 833px;
+ --screen-pc: 1024px;
+
+ }
+
+ .stop-scrolling {
+ height: 100%;
+ overflow: hidden
+ }
+
+ input[type='text'],
+ input[type='password'],
+ input[type='submit'],
+ input[type='search'],
+ input[type='tel'],
+ input[type='email'],
+ input[type='button'],
+ input[type='reset'],
+ input[type='time'] {
+ appearance: none;
+ -moz-appearance: none;
+ -webkit-appearance: none;
+ border-radius:0;
+ -moz-border-radius:0;
+ -webkit-border-radius:0;
+ outline:0;
+ }
+
+`
+export const COLORS = {
+ green: '#5FCA7B',
+ font: '#3A3A3A',
+ gray40: '#AAAAAA',
+ gray30: '#CCCCCC',
+ gray20: '#D9D9D9',
+ gray10: '#FAFAFA',
+ error: '#EF7C7C',
+}
+
+export const FONT = {
+ pc: '16px',
+ 'pc-lg': '20px',
+ m: '14px',
+ 'm-sm': '12px',
+ 'm-lg': '16px',
+}
+
+export default GlobalStyles
diff --git a/src/hooks/useAxiosInterceptor.tsx b/src/hooks/useAxiosInterceptor.tsx
new file mode 100644
index 0000000..91d44d9
--- /dev/null
+++ b/src/hooks/useAxiosInterceptor.tsx
@@ -0,0 +1,76 @@
+// import { useEffect } from 'react'
+// import { useNavigate } from 'react-router'
+// import { privateApi } from '@src/api/Instance'
+// import { removeCookieToken } from '@src/storage/Cookie'
+// import store from '@src/store/config'
+// import { DELETE_TOKEN } from '@src/store/slices/authSlice'
+// import { DELETE_INFO } from '@src/store/slices/infoSlice'
+// import CheckAuthorization from '@src/components/CheckAuthorization'
+// import { InternalAxiosRequestConfig } from 'axios'
+// import { useDispatch } from 'react-redux'
+
+// // type Props = {}
+
+// const useAxiosInterceptor = () => {
+// // const { dispatch } = store
+// const dispatch = useDispatch()
+// const navigate = useNavigate()
+
+// const requestHandler = async (config: Promise) => {
+// const atExpire = store.getState().accessToken.expireTime
+// const curTime = new Date().getTime()
+
+// if (atExpire < curTime) {
+// console.log(new Date(atExpire) + '/' + new Date(curTime))
+// removeCookieToken()
+// dispatch(DELETE_TOKEN())
+// dispatch(DELETE_INFO())
+// navigate('/login', { replace: true })
+// return console.log('at시간 만료로 스토리지 리셋')
+// // return Promise.resolve()
+// }
+
+// const newConfig = await CheckAuthorization(config)
+// if (newConfig === 'NoToken') {
+// // console.log(newConfig)
+// console.log('CheckAuthorization === NoToken.')
+// navigate('/login', { replace: true })
+// return alert('토큰이 존재하지 않습니다. 로그인 페이지로 이동합니다.')
+// // return Promise.resolve()
+// // return
+// } else if (newConfig === 'ExpiredToken') {
+// console.log(newConfig)
+// console.log('CheckAuthorization === ExpiredToken.')
+// navigate('/login', { replace: true })
+// return alert('토큰이 만료되어 자동으로 로그아웃 되었습니다. 다시 로그인 해주세요.')
+// // return Promise.resolve()
+// // return
+// }
+// return newConfig
+// }
+
+// const requestInterceptor = privateApi.interceptors.request.use(requestHandler)
+
+// useEffect(() => {
+// return () => {
+// privateApi.interceptors.request.eject(requestInterceptor)
+// // customAxios.interceptors.response.eject(responseInterceptor);
+// }
+// }, [requestInterceptor])
+
+// // return (
+// // <>
+// // {modalOpen && (
+// //
+// // )}
+// // >
+// // )
+// }
+
+// export default useAxiosInterceptor
diff --git a/src/hooks/useInfinityScroll.tsx b/src/hooks/useInfinityScroll.tsx
new file mode 100644
index 0000000..6dabce5
--- /dev/null
+++ b/src/hooks/useInfinityScroll.tsx
@@ -0,0 +1,46 @@
+import { getMainPostList } from '@src/api/boardApi'
+import { useCallback, useEffect, useState } from 'react'
+import { useInView } from 'react-intersection-observer'
+
+const useInfinityScroll = ({ payload, page, setPostList, setPage }: IInfinityScrollProps) => {
+ const [ref, inView] = useInView()
+ const [isLoading, setIsLoading] = useState(false)
+ const [lastPage, setLastPage] = useState(false)
+
+ const getPostList = useCallback(
+ async (payload: IMainListPayload, page: number) => {
+ try {
+ setIsLoading(true)
+ const postData = await getMainPostList(payload, page)
+ if (!postData) {
+ setLastPage(true)
+ setPostList([])
+ return setIsLoading(false)
+ }
+ setPostList((prevList) => [...prevList, ...postData.content])
+ postData.last ? setLastPage(true) : setLastPage(false)
+ console.log('post', page)
+ } catch (error) {
+ alert(error)
+ } finally {
+ setIsLoading(false)
+ }
+ },
+ [page, payload]
+ )
+
+ useEffect(() => {
+ setPage(1)
+ setPostList([])
+ }, [payload])
+
+ useEffect(() => {
+ if (inView && !lastPage) {
+ setPage((prev: number) => prev + 1)
+ }
+ }, [inView, lastPage])
+
+ return { lastPage, isLoading, setPostList, getPostList, ref }
+}
+
+export default useInfinityScroll
diff --git a/src/hooks/useInputHook.tsx b/src/hooks/useInputHook.tsx
new file mode 100644
index 0000000..687509b
--- /dev/null
+++ b/src/hooks/useInputHook.tsx
@@ -0,0 +1,31 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import { ChangeEvent, ChangeEventHandler, useCallback, useState } from 'react'
+
+type InputValidator = () => string
+
+const useInput = (
+ validator?: InputValidator | any,
+ defaultValue?: string
+): [string, ChangeEventHandler, boolean, React.Dispatch>] => {
+ const [value, setValue] = useState(defaultValue || '')
+ const [error, setError] = useState(false)
+
+ const onChange = useCallback(
+ (e: ChangeEvent) => {
+ setValue(e.target.value)
+
+ if (validator) {
+ const error = validator(e.target.value)
+ if (error) {
+ setError(error)
+ return
+ }
+ }
+ setError(false)
+ },
+ [validator]
+ )
+ return [value, onChange, error, setValue]
+}
+
+export default useInput
diff --git a/src/hooks/useLoginState.tsx b/src/hooks/useLoginState.tsx
new file mode 100644
index 0000000..53a5c67
--- /dev/null
+++ b/src/hooks/useLoginState.tsx
@@ -0,0 +1,45 @@
+import { userLogout } from '@src/api/authApi'
+import useModal from './useModal'
+import PATH from '@src/constants/pathConst'
+import { removeCookieToken } from '@src/storage/Cookie'
+import { DELETE_TOKEN } from '@src/store/slices/authSlice'
+import { DELETE_INFO } from '@src/store/slices/infoSlice'
+import { useDispatch } from 'react-redux'
+
+const useLoginState = () => {
+ const { openModal } = useModal()
+ const dispatch = useDispatch()
+
+ const accessAfterLoginAlert = () => {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['로그인 후 이용 가능합니다.'],
+ navigateOption: PATH.LOGIN,
+ })
+ }
+
+ const logOutAlert = () => {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['로그아웃 되었습니다.'],
+ navigateOption: PATH.HOME,
+ })
+ }
+
+ const logoutHandler = async () => {
+ const { status } = (await userLogout()) as IResponseType
+ if (status === 200) {
+ removeCookieToken()
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ logOutAlert()
+ return
+ }
+ }
+
+ return { accessAfterLoginAlert, logOutAlert, logoutHandler }
+}
+
+export default useLoginState
diff --git a/src/hooks/useModal.tsx b/src/hooks/useModal.tsx
new file mode 100644
index 0000000..685e29d
--- /dev/null
+++ b/src/hooks/useModal.tsx
@@ -0,0 +1,19 @@
+import { useCallback } from 'react'
+import { useDispatch } from 'react-redux'
+import { DELETE_MODAL, SET_MODAL } from '@src/store/slices/modalSlice'
+
+const useModal = () => {
+ const dispatch = useDispatch()
+
+ const openModal = useCallback((payload: IModalPayload) => {
+ dispatch(SET_MODAL(payload))
+ }, [])
+
+ const closeModal = useCallback(() => {
+ dispatch(DELETE_MODAL())
+ }, [])
+
+ return { openModal, closeModal }
+}
+
+export default useModal
diff --git a/src/hooks/useScreenHook.tsx b/src/hooks/useScreenHook.tsx
new file mode 100644
index 0000000..be3654d
--- /dev/null
+++ b/src/hooks/useScreenHook.tsx
@@ -0,0 +1,22 @@
+import React, { ReactNode } from 'react'
+import { useMediaQuery } from 'react-responsive'
+
+interface IChildren {
+ children: ReactNode
+}
+
+const Mobile = ({ children }: IChildren) => {
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ return {isMobile && children}
+}
+
+const PC = ({ children }: IChildren) => {
+ const isPC = useMediaQuery({
+ query: '(min-width: 834px)',
+ })
+ return {isPC && children}
+}
+
+export { Mobile, PC }
diff --git a/src/hooks/useSidebar.tsx b/src/hooks/useSidebar.tsx
new file mode 100644
index 0000000..9c0df4e
--- /dev/null
+++ b/src/hooks/useSidebar.tsx
@@ -0,0 +1,19 @@
+import { useCallback } from 'react'
+import { useDispatch } from 'react-redux'
+import { OPEN_SIDEBAR, CLOSE_SIDEBAR } from '@src/store/slices/sidebarSlice'
+
+const useSidebar = () => {
+ const dispatch = useDispatch()
+
+ const openSidebar = useCallback(() => {
+ dispatch(OPEN_SIDEBAR())
+ }, [])
+
+ const closeSidebar = useCallback(() => {
+ dispatch(CLOSE_SIDEBAR())
+ }, [])
+
+ return { openSidebar, closeSidebar }
+}
+
+export default useSidebar
diff --git a/src/hooks/useTimeSelector.tsx b/src/hooks/useTimeSelector.tsx
new file mode 100644
index 0000000..7fb0032
--- /dev/null
+++ b/src/hooks/useTimeSelector.tsx
@@ -0,0 +1,25 @@
+// import { useEffect, useState } from 'react'
+
+// const useTimeSelector = ({ isTimeChange, setIsTimeChange, setSelectedTime }: ITimeSelectorProps) => {
+// const [timeSelectorOpen, setTimeSelectorOpen] = useState()
+// const [timeZone, setTimeZone] = useState('오전')
+// const [hour, setHour] = useState('--')
+// const [minute, setMinute] = useState('--')
+
+// useEffect(() => {
+// if (hour !== '--' && minute !== '--') {
+// setTimeSelectorOpen(false)
+// setIsTimeChange(true)
+
+// timeZone === '오후' && hour !== '12' && +hour + 12 <= 23
+// ? setSelectedTime(+hour + 12 + ':' + minute)
+// : setSelectedTime(hour + ':' + minute)
+
+// timeZone === '오전' && hour === '12' && setSelectedTime('00:' + minute)
+// }
+// }, [timeZone, hour, minute])
+
+// return { timeSelectorOpen, timeZone, setTimeZone, hour, setHour, minute, setMinute }
+// }
+
+// export default useTimeSelector
diff --git a/src/main.tsx b/src/main.tsx
index dfe1c98..7a5bcfa 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,26 +1,19 @@
import { Provider } from 'react-redux'
-import store from './store/config'
+import store, { persistor } from './store/config'
import ReactDOM from 'react-dom/client'
-import { createBrowserRouter, RouterProvider } from 'react-router-dom'
-import App from './App.tsx'
-import Error from './pages/Error.tsx'
-import Main from './pages/Main.tsx'
-import Test from './pages/Test.tsx'
-
-const router = createBrowserRouter([
- {
- path: '/',
- element: ,
- errorElement: ,
- children: [
- { index: true, element: },
- { path: '/test', element: },
- ],
- },
-])
+import { RouterProvider } from 'react-router-dom'
+import router from '@src/routes/router'
+import GlobalStyles from './globalStyles'
+import { CookiesProvider } from 'react-cookie'
+import { PersistGate } from 'redux-persist/integration/react'
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
-
-
-
+
+
+
+
+
+
+
+
)
diff --git a/src/pages/Ask.tsx b/src/pages/Ask.tsx
new file mode 100644
index 0000000..a7aabf8
--- /dev/null
+++ b/src/pages/Ask.tsx
@@ -0,0 +1,192 @@
+import Inner from '@src/components/Inner'
+import Title from '@src/components/Title'
+import { useMediaQuery } from 'react-responsive'
+import { useNavigate } from 'react-router'
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import { useState, useEffect } from 'react'
+import { getQuestion, getAdminQuestion } from '@src/api/getApi'
+import { useSelector } from 'react-redux'
+import { RootState } from '@src/store/config'
+import OneOneOne from '@components/OneOnOne'
+import PATH from '@src/constants/pathConst'
+import { useInView } from 'react-intersection-observer'
+
+const Ask = () => {
+ const [questions, setQuestions] = useState([])
+ const userInfo = useSelector((state: RootState) => state.userInfo)
+ const [ref, inView] = useInView()
+ const [isLoading, setIsLoading] = useState(false)
+ const [lastPage, setLastPage] = useState(false)
+ const [page, setPage] = useState(1)
+
+ const myQuestion = async () => {
+ try {
+ setIsLoading(true)
+ const response = await getQuestion(page)
+ if (page === 1) setQuestions(response.content)
+ else setQuestions((prev) => [...prev, ...response.content])
+
+ if (response.last) setLastPage(true)
+ else setLastPage(false)
+ } catch (error) {
+ alert(error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const adminQuestion = async () => {
+ try {
+ setIsLoading(true)
+ const response = await getAdminQuestion(page)
+ if (page === 1) setQuestions(response.content)
+ else setQuestions((prev) => [...prev, ...response.content])
+
+ if (response.last) setLastPage(true)
+ else setLastPage(false)
+ } catch (error) {
+ alert(error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ if (userInfo.role === '관리자') {
+ adminQuestion()
+ } else {
+ myQuestion()
+ }
+ }, [page])
+
+ useEffect(() => {
+ if (inView && !lastPage) {
+ setPage((prev) => prev + 1)
+ }
+ }, [inView, lastPage])
+
+ const isPC = useMediaQuery({
+ query: '(min-width: 450px)',
+ })
+
+ const navigate = useNavigate()
+
+ return (
+ <>
+ {isPC ? (
+
+
+
+ {questions?.length ? (
+ questions.map((list) => (
+
+ ))
+ ) : (
+ 문의 내용이 없습니다.
+ )}
+
+ {!isLoading && }
+
+ 원하는 답변이 없다면?
+
+
+
+ ) : (
+
+
+
+ {questions?.length ? (
+ questions.map((list) => (
+
+ ))
+ ) : (
+ 문의 내용이 없습니다.
+ )}
+
+ {!isLoading && }
+
+ 원하는 답변이 없다면?
+
+
+
+ )}
+ >
+ )
+}
+
+export default Ask
+
+const Container = styled.div`
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto;
+ max-width: 1024px;
+`
+
+const QuestionContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ screen }) => (screen === 'pc' ? '24px' : '16px')};
+`
+
+const OtherAskStyle = styled.div`
+ margin-top: 64px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ button {
+ display: flex;
+ width: 328px;
+ padding: 16px 56px;
+ justify-content: center;
+ background-color: ${COLORS.green};
+ color: white;
+ font-size: ${FONT['pc-lg']};
+ font-weight: 600;
+ font-family: 'Pretendard-Regular';
+ }
+`
+
+const NoQuestionStyle = styled.div`
+ display: flex;
+ justify-content: center;
+ margin: 64px;
+`
+
+const MobileOtherAsk = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ padding: 16px;
+ justify-content: center;
+ align-items: center;
+ border-top: 1px solid ${COLORS.gray30};
+ button {
+ display: flex;
+ width: 328px;
+ padding: 16px 56px;
+ justify-content: center;
+ background-color: ${COLORS.green};
+ color: white;
+ font-size: ${FONT['m-lg']};
+ font-weight: 600;
+ font-family: 'Pretendard-Regular';
+ }
+`
diff --git a/src/pages/AskAnswerForm.tsx b/src/pages/AskAnswerForm.tsx
new file mode 100644
index 0000000..3f01a35
--- /dev/null
+++ b/src/pages/AskAnswerForm.tsx
@@ -0,0 +1,9 @@
+import HelpNAskForm from '@src/components/HelpNAskForm'
+import { useLocation } from 'react-router'
+
+const AskAnswerForm = () => {
+ const location = useLocation()
+ return
+}
+
+export default AskAnswerForm
diff --git a/src/pages/AskDetail.tsx b/src/pages/AskDetail.tsx
new file mode 100644
index 0000000..58491e8
--- /dev/null
+++ b/src/pages/AskDetail.tsx
@@ -0,0 +1,165 @@
+import { getQuestionDetail, getQuestionAnswer } from '@src/api/getApi'
+import Title from '@src/components/Title'
+import { useEffect, useState } from 'react'
+import { useMediaQuery } from 'react-responsive'
+import { useLocation, useNavigate } from 'react-router'
+import styled from 'styled-components'
+import { COLORS } from '@src/globalStyles'
+import { useSelector } from 'react-redux'
+import { RootState } from '@src/store/config'
+import Inner from '@src/components/Inner'
+import PATH from '@src/constants/pathConst'
+
+const AskDetail = () => {
+ const [question, setQuestion] = useState()
+ const [answer, setAnswer] = useState()
+ const { pathname } = useLocation()
+ const questionId = Number(pathname.slice(5))
+
+ const isPC = useMediaQuery({
+ query: '(min-width: 450px)',
+ })
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const response = await getQuestionDetail(questionId)
+ setQuestion(response)
+ const answerResponse = await getQuestionAnswer(questionId)
+ setAnswer(answerResponse)
+ } catch (error) {
+ console.log(error)
+ }
+ }
+ fetchData()
+ }, [])
+
+ const navigate = useNavigate()
+
+ const userInfo = useSelector((state: RootState) => state.userInfo)
+ return (
+ <>
+ {isPC ? (
+
+
+
+ Q. {question?.questionTitle}
+ {question?.questionContent}
+
+ {answer ? (
+
+
+ A. {answer.answerTitle}
+ {answer.answerRegisterDate.slice(0, 10)}
+
+ {answer.answerContent}
+
+ ) : (
+
+
+ A. 답변 전입니다.
+ {''}
+ {userInfo.role === '관리자' && (
+
+ )}
+
+ 답변 전입니다.
+
+ )}
+
+ ) : (
+
+
+
+
+ Q. {question?.questionTitle}
+ {question?.questionContent}
+
+ {answer ? (
+
+
+ A. {answer.answerTitle}
+ {answer.answerRegisterDate.slice(0, 10)}
+
+ {answer.answerContent}
+
+ ) : (
+
+
+ A. 답변 전입니다.
+ {''}
+ {userInfo.role === '관리자' && }
+
+ 답변 전입니다.
+
+ )}
+
+
+ )}
+ >
+ )
+}
+
+export default AskDetail
+
+const Container = styled.div`
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto;
+ max-width: 1024px;
+ display: flex;
+ flex-direction: column;
+ gap: 24px;
+`
+
+const MobileContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`
+
+const QuestionContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 1024px;
+ span {
+ font-weight: ${({ screen }) => (screen === 'pc' ? 700 : 500)};
+ }
+ div {
+ padding: 16px 8px;
+ background: #fafafa;
+ border-radius: 8px;
+ font-size: ${({ screen }) => screen === 'mobile' && '12px'};
+ }
+`
+
+const AnswerContent = styled.div`
+ .answerTitle {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ span:first-child {
+ font-weight: ${({ screen }) => (screen === 'pc' ? 700 : 500)};
+ }
+ span:last-child {
+ color: ${COLORS.gray40};
+ }
+ button {
+ border-radius: 8px;
+ background-color: ${COLORS.green};
+ color: #fff;
+ width: 60px;
+ padding: 4px 16px;
+ }
+ }
+ .answerContent {
+ padding: 16px 8px;
+ background: #fafafa;
+ border-radius: 8px;
+ font-size: ${({ screen }) => screen === 'mobile' && '12px'};
+ }
+`
diff --git a/src/pages/BoardDetails.tsx b/src/pages/BoardDetails.tsx
new file mode 100644
index 0000000..149f278
--- /dev/null
+++ b/src/pages/BoardDetails.tsx
@@ -0,0 +1,480 @@
+import { addTransactionStatus, blindBoard, delLikeBoard, getPostDetail, postLikeBoard } from '@src/api/boardApi'
+import { BigHart, Harticon, MoreIcon } from '@src/constants/icons'
+import theme from '@src/constants/theme'
+import { dateFormat, handleImgError, randomImages } from '@src/utils/utils'
+import { useEffect, useState } from 'react'
+import { useNavigate, useParams } from 'react-router'
+import { ThemeProvider, styled } from 'styled-components'
+import { delPost } from './../api/boardApi'
+import { useSelector } from 'react-redux'
+import { RootState } from '@src/store/config'
+import BoardComment from '@src/components/Comment'
+import { PC, Mobile } from '@src/hooks/useScreenHook'
+import PATH from '@src/constants/pathConst'
+import useModal from '@src/hooks/useModal'
+
+const BoardDetails = () => {
+ const { boardId } = useParams()
+ const navigate = useNavigate()
+ const [detailData, setDetailData] = useState()
+ const [moreBtnChk, setMoreBtnChk] = useState(false)
+ const [likeState, setLikeState] = useState(detailData?.likeBoard)
+ const authenticated = useSelector((state: RootState) => state.accessToken.authenticated)
+ const userInfo = useSelector((state: RootState) => state.userInfo)
+ const { openModal } = useModal()
+
+ const blindFn = async () => {
+ try {
+ await blindBoard(Number(boardId))
+ } catch (error) {
+ console.log(error)
+ }
+ }
+
+ const getDetailData = async () => {
+ try {
+ const postDetailData = await getPostDetail(Number(boardId), authenticated)
+ setDetailData(postDetailData)
+ setLikeState(postDetailData.likeBoard)
+ } catch (err) {
+ console.log(err)
+ if (userInfo.role === '관리자') {
+ openModal({
+ isModalOpen: true,
+ isConfirm: true,
+ content: ['블라인드 처리된 게시글입니다. 블라인드 해제하시겠습니까?'],
+ confirmAction: () => {
+ console.log('블라인드 해제 넣기')
+ },
+ })
+ }
+ }
+ }
+
+ const delPostFn = async (id: number | undefined) => {
+ try {
+ await delPost(id)
+ navigate(PATH.HOME)
+ } catch (err) {
+ console.log(err)
+ }
+ }
+
+ const likePostFn = async (boardId: number, likeVal: boolean) => {
+ try {
+ likeVal ? await delLikeBoard(boardId) : await postLikeBoard(boardId, authenticated)
+ } catch (err) {
+ console.log(err)
+ } finally {
+ getDetailData()
+ }
+ }
+
+ useEffect(() => {
+ getDetailData()
+ }, [boardId])
+
+ useEffect(() => {
+ const outClickFn = (e: MouseEvent) => {
+ const moreBtn = document.querySelector('.more_btn')
+ if (e.currentTarget !== moreBtn) return setMoreBtnChk(false)
+ return
+ }
+
+ document.body.addEventListener('click', outClickFn)
+
+ return () => {
+ document.body.removeEventListener('click', outClickFn)
+ }
+ })
+
+ const moreBtnHandler = (role: string, myBoard: boolean) => {
+ if (role === '관리자' && myBoard) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ } else if (role === '관리자' && !myBoard) {
+ return (
+
+
+
+ )
+ } else if (role !== '관리자' && myBoard) {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
+ }
+
+ return (
+
+
+ {detailData && (
+ <>
+
+
+
+

handleImgError(e, detailData.categoryName || '', detailData.boardId || 0)}
+ alt=""
+ />
+
+
+
+
+
+
{detailData.title}
+
+
+
+
+
+
+ 예약일 {dateFormat(detailData.startTime || '')}
+
+
+ {detailData.price.toLocaleString()}
+
+
+
+
+
{detailData.price.toLocaleString()}
+
+
+
+
+
+
+ navigate(`/profile/${detailData.memberId}`, {
+ state: { memberName: detailData.memberName, memberId: detailData.memberId },
+ })
+ }
+ >
+ {detailData.memberName} {detailData.memberId}
+
+ 조회수 {detailData.viewCount}
+
+ {detailData.wishCount}
+
+
+ {(detailData.myBoard || userInfo.role === '관리자') && (
+
+ )}
+ {moreBtnChk &&
{moreBtnHandler(userInfo.role, detailData.myBoard)}
}
+
+
+
+
+
+

handleImgError(e, detailData.categoryName || '', detailData.boardId || 0)}
+ alt=""
+ />
+
+
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+const Container = styled.div`
+ max-width: 834px;
+ margin: 32px auto 0;
+ * {
+ box-sizing: border-box;
+ }
+
+ @media ${({ theme }) => theme.device.tablet} {
+ margin: 0 auto;
+ }
+`
+
+const TitleBox = styled.div`
+ padding: 0 20px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin-bottom: 30px;
+
+ div {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .title {
+ font-size: 24px;
+ font-weight: bold;
+ }
+
+ .date {
+ font-size: 16px;
+ }
+
+ .price {
+ font-size: 20px;
+ font-weight: bold;
+ }
+
+ .like {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+ }
+
+ div:last-child {
+ position: relative;
+
+ p {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ svg {
+ cursor: pointer;
+ }
+ }
+
+ @media ${({ theme }) => theme.device.tablet} {
+ div:last-child {
+ align-items: start;
+ margin-top: 8px;
+
+ p {
+ flex-wrap: wrap;
+ padding: 8px 0;
+ position: relative;
+ }
+
+ button {
+ margin-top: 8px;
+ }
+ }
+
+ div:last-child::after,
+ div:last-child::before {
+ content: '';
+ position: absolute;
+ width: calc(100% + 40px);
+ height: 1px;
+ background: #d9d9d9;
+ left: -20px;
+ }
+
+ div:last-child::before {
+ bottom: 0;
+ }
+
+ div:last-child::after {
+ top: 0;
+ }
+
+ .user_name {
+ width: 100%;
+ display: block;
+ font-size: 14px;
+ cursor: pointer;
+ }
+
+ .view,
+ .like {
+ color: #aaaaaa;
+ font-size: 12px;
+ }
+
+ .title {
+ font-size: 14px !important;
+ font-weight: 400 !important;
+ }
+
+ .date {
+ font-size: 14px;
+ }
+
+ .price {
+ font-size: 16px;
+ font-weight: bold;
+ }
+ }
+
+ .more_menu {
+ position: absolute;
+ top: 35px;
+ right: 0;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: #fff;
+ border: 1px solid #ddd;
+ width: 120px;
+ /* height: 120px; */
+ padding: 20px;
+ box-sizing: border-box;
+ font-size: 14px;
+ box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.2);
+
+ button {
+ width: 100%;
+ text-align: left;
+ }
+
+ li:first-child button {
+ font-weight: bold;
+ }
+ }
+
+ .more_menu::after {
+ content: '';
+ width: 20px;
+ height: 20px;
+ border: 1px solid #ddd;
+ border-bottom: none;
+ border-right: none;
+ position: absolute;
+ top: -11px;
+ right: 5px;
+ background: #fff;
+ transform: rotate(50deg) skew(20deg, 15deg);
+ }
+`
+
+const ContentBox = styled.div`
+ display: flex;
+ flex-direction: column;
+ padding: 0 20px;
+ gap: 20px;
+
+ @media ${({ theme }) => theme.device.tablet} {
+ padding: 0;
+
+ .content_text {
+ padding: 0 20px;
+ font-size: 14px !important;
+ }
+
+ .image_box {
+ margin-bottom: 16px !important;
+
+ img {
+ border-radius: 0 !important;
+ }
+ }
+ }
+
+ .image_box {
+ width: 100%;
+ margin-bottom: 48px;
+
+ img {
+ width: 100%;
+ border-radius: 15px;
+ }
+ }
+
+ .content_text {
+ width: 100%;
+ min-height: 300px;
+ font-size: 16px;
+ line-height: 1.6;
+ word-wrap: break-word;
+
+ @media ${({ theme }) => theme.device.tablet} {
+ min-height: 150px;
+ }
+ }
+`
+
+export default BoardDetails
diff --git a/src/pages/BoardList.tsx b/src/pages/BoardList.tsx
new file mode 100644
index 0000000..3f5505b
--- /dev/null
+++ b/src/pages/BoardList.tsx
@@ -0,0 +1,92 @@
+import Board from '@src/components/Board'
+import SearchForm from '@src/components/SearchForm'
+import { useEffect, useState, useCallback } from 'react'
+import { RootState } from '../store/config'
+import { useSelector } from 'react-redux'
+import { getSearchPostList } from '@src/api/boardApi'
+import { useInView } from 'react-intersection-observer'
+
+const BoardList = () => {
+ const [postList, setPostList] = useState([])
+ const [ref, inView] = useInView()
+ const [page, setPage] = useState(1)
+ const [lastPage, setLastPage] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const searchValue = useSelector((state: RootState) => {
+ return {
+ title: state.searchVlaue.title,
+ startTime: state.searchVlaue.startDate.substring(0, 10) + 'T' + state.searchVlaue.startTime + ':00',
+ endTime: state.searchVlaue.endDate.substring(0, 10) + 'T' + state.searchVlaue.endTime + ':59',
+ district: state.searchVlaue.district,
+ category: state.searchVlaue.category === '전체' ? '' : state.searchVlaue.category,
+ startDate: state.searchVlaue.startDate,
+ endDate: state.searchVlaue.endDate,
+ chkDate: state.searchVlaue.chkDate,
+ }
+ })
+
+ const [title, startDate, endDate, district, category, startTime, endTime, chkDate] = useSelector(
+ (state: RootState) => {
+ return [
+ state.searchVlaue.title,
+ state.searchVlaue.startDate,
+ state.searchVlaue.endDate,
+ state.searchVlaue.district,
+ state.searchVlaue.category,
+ state.searchVlaue.startTime,
+ state.searchVlaue.endTime,
+ state.searchVlaue.chkDate,
+ ]
+ }
+ )
+
+ const getPostList = useCallback(async () => {
+ setIsLoading(true)
+ const postData = await getSearchPostList(searchValue, page)
+ setPostList((prevState) => [...prevState, ...postData.content])
+
+ if (postData.last) setLastPage(true)
+ else if (!postData.last) setLastPage(false)
+ setIsLoading(false)
+ }, [page])
+
+ const changePostList = async () => {
+ const postData = await getSearchPostList(searchValue, 1)
+ setPostList(postData.content)
+
+ if (postData.last) setLastPage(true)
+ else if (!postData.last) setLastPage(false)
+ }
+
+ useEffect(() => {
+ setPage(1)
+ setLastPage(false)
+ changePostList()
+ }, [title, startDate, endDate, district, category, startTime, endTime, chkDate])
+
+ useEffect(() => {
+ if (page !== 1) {
+ getPostList()
+ }
+ }, [getPostList])
+
+ useEffect(() => {
+ setPage(1)
+ }, [])
+
+ useEffect(() => {
+ if (inView && !isLoading && !lastPage) {
+ setPage((prev) => prev + 1)
+ }
+ }, [inView, isLoading, lastPage])
+
+ return (
+ <>
+
+
+
+ >
+ )
+}
+
+export default BoardList
diff --git a/src/pages/BoradBlind.tsx b/src/pages/BoradBlind.tsx
new file mode 100644
index 0000000..c602160
--- /dev/null
+++ b/src/pages/BoradBlind.tsx
@@ -0,0 +1,52 @@
+import Board from '@src/components/Board'
+import { useState, useEffect, useCallback } from 'react'
+import { getAdminBlind } from '@src/api/getApi'
+import { useInView } from 'react-intersection-observer'
+
+const BoradBlind = () => {
+ const [blind, setBlind] = useState([])
+ const [page, setPage] = useState(1)
+ const [lastPage, setLastPage] = useState(false)
+ const [ref, inView] = useInView()
+ const [isLoading, setIsLoading] = useState(false)
+
+ const fetchData = useCallback(async () => {
+ try {
+ setIsLoading(true)
+ const response = await getAdminBlind(page)
+ if (page === 1) setBlind(response?.data)
+ // eslint-disable-next-line no-unsafe-optional-chaining
+ else setBlind((prev) => [...prev, ...response?.data])
+
+ if (response?.last) setLastPage(true)
+ else setLastPage(false)
+ } catch (error) {
+ alert(error)
+ } finally {
+ setIsLoading(false)
+ }
+ }, [page])
+
+ useEffect(() => {
+ fetchData()
+ }, [fetchData])
+
+ useEffect(() => {
+ fetchData()
+ setPage(1)
+ }, [])
+
+ useEffect(() => {
+ if (inView && !isLoading && !lastPage) {
+ setPage((prev) => prev + 1)
+ }
+ }, [inView, isLoading])
+ return (
+ <>
+
+ {!isLoading && }
+ >
+ )
+}
+
+export default BoradBlind
diff --git a/src/pages/EditPost.tsx b/src/pages/EditPost.tsx
new file mode 100644
index 0000000..80d5750
--- /dev/null
+++ b/src/pages/EditPost.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from 'react'
+import Write from '../components/Write/Write'
+import { getUserInfo } from '@src/api/authApi'
+import { useLocation } from 'react-router'
+import useModal from '@src/hooks/useModal'
+import { requestEdit } from '@src/api/postApi'
+
+const EditPost = () => {
+ const { state } = useLocation()
+ const [isWriter, setIsWriter] = useState(false)
+ const { openModal } = useModal()
+ const [postData, setPostData] = useState({
+ categoryName: state.data.categoryName,
+ content: state.data.content,
+ districtName: state.data.districtName,
+ endTime: state.data.endTime,
+ imageUrl: state.data.imageUrl,
+ price: state.data.price,
+ startTime: state.data.startTime,
+ title: state.data.title,
+ })
+
+ const goToBack = () => {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['본인이 작성한 게시물만 수정 가능합니다.'],
+ navigateOption: -1,
+ })
+ }
+
+ useEffect(() => {
+ const checkId = async () => {
+ try {
+ const idRes = await getUserInfo()
+ if (idRes?.memberId === state.data.memberId) {
+ setIsWriter(true)
+ } else {
+ goToBack()
+ }
+ } catch (err) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['유저 정보를 확인할 수 없습니다. 재로그인 후 다시 시도해주세요.'],
+ navigateOption: -1,
+ })
+ }
+ }
+ checkId()
+ }, [])
+
+ const submitData = async (formData: FormData) => {
+ try {
+ const editRes = await requestEdit(formData, state.data.boardId)
+ if (editRes === 200) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['게시글 수정이 완료되었습니다.'],
+ navigateOption: `/board-details/${state.data.boardId}`,
+ })
+ }
+ } catch (err) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['정상적으로 등록되지 않았습니다. 다시 시도해주세요.'],
+ })
+ }
+ }
+
+ return (
+ <>{isWriter && }>
+ )
+}
+
+export default EditPost
diff --git a/src/pages/Error.tsx b/src/pages/Error.tsx
index d30e6a0..dd08aea 100644
--- a/src/pages/Error.tsx
+++ b/src/pages/Error.tsx
@@ -1,5 +1,60 @@
+import { useNavigate } from 'react-router-dom'
+import { styled } from 'styled-components'
+import { COLORS } from '@src/globalStyles'
+import { NoticeIcon } from '@src/constants/icons'
+import PATH from '@src/constants/pathConst'
+
const Error = () => {
- return <>>
+ const navigate = useNavigate()
+ return (
+
+ navigate(PATH.HOME, { replace: true })}>
+

+
+
+
+ 존재하지 않는 페이지입니다.
+
+
+
+
+
+ )
}
+const Main = styled.main`
+ text-align: center;
+ font-size: 26px;
+ margin: 20px auto 0;
+
+ img {
+ width: 200px;
+ margin-bottom: 50px;
+ cursor: pointer;
+ }
+
+ .notice {
+ display: flex;
+ flex-direction: column;
+ margin: auto;
+
+ svg {
+ margin: 20px auto;
+ }
+ }
+
+ button {
+ background-color: ${COLORS.green};
+ padding: 10px 20px;
+ margin-top: 70px;
+ color: white;
+ font-size: 18px;
+ box-shadow: 5px 5px 5px ${COLORS.gray20};
+
+ &:hover {
+ background-color: ${COLORS.gray40};
+ }
+ }
+`
+
export default Error
diff --git a/src/pages/FindPassword.tsx b/src/pages/FindPassword.tsx
new file mode 100644
index 0000000..f95d4b0
--- /dev/null
+++ b/src/pages/FindPassword.tsx
@@ -0,0 +1,73 @@
+import styled from 'styled-components'
+import { COLORS } from '@src/globalStyles'
+import { useState } from 'react'
+import Verification from '@src/components/ResetPassword/Verification'
+import TemporaryPw from '@src/components/ResetPassword/TemporaryPw'
+
+const FindPassword = () => {
+ const [step, setStep] = useState(1)
+ const activeStep = (step: number) => {
+ if (step === 1) return
+ if (step === 2) return
+ }
+
+ return (
+
+ {/*
+
비밀번호 찾기
+ */}
+
+ {activeStep(step)}
+
+ )
+}
+
+const Container = styled.div`
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ .text_wrap {
+ margin-bottom: 60px;
+ width: 170px;
+ }
+
+ h3 {
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ input {
+ margin: 8px 0 6px;
+ padding: 16px 8px;
+ width: 100%;
+ height: 47px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 12px;
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .error_message {
+ font-size: 12px;
+ color: ${COLORS.error};
+ }
+
+ .help_message {
+ font-size: 12px;
+ color: ${COLORS.green};
+ }
+`
+
+export default FindPassword
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx
new file mode 100644
index 0000000..a299477
--- /dev/null
+++ b/src/pages/Help.tsx
@@ -0,0 +1,126 @@
+import styled from 'styled-components'
+import { useState } from 'react'
+import { COLORS } from '@src/globalStyles'
+import { memberAccountList, dealManagementList, useEctlist } from '@src/constants/helpList'
+import Ask from '@src/components/Ask'
+import { Mobile } from '@src/hooks/useScreenHook'
+import MobileMenu from '@src/components/MobileMenu'
+import Title from '@src/components/Title'
+import { useMediaQuery } from 'react-responsive'
+import { useNavigate } from 'react-router'
+import PATH from '@src/constants/pathConst'
+
+const Help = () => {
+ const menuLists = ['회원 / 계정', '거래 분쟁 / 운영 정책', '이용 방법 / 기타']
+ const [activeMenu, setActiveMenu] = useState(0)
+ const askList = (activeMenu: number) => {
+ if (activeMenu === 0) {
+ return memberAccountList
+ } else if (activeMenu === 1) {
+ return dealManagementList
+ } else if (activeMenu === 2) {
+ return useEctlist
+ }
+ }
+ const isPC = useMediaQuery({
+ query: '(min-width: 450px)',
+ })
+
+ const navigate = useNavigate()
+
+ return (
+ <>
+ {isPC ? (
+
+
+
+ {menuLists.map((list, i) => (
+ setActiveMenu(i)}
+ style={{
+ color: `${activeMenu === i ? COLORS.green : COLORS.font}`,
+ fontWeight: `${activeMenu === i ? 700 : 400}`,
+ }}
+ >
+ {list}
+
+ ))}
+
+
+ {askList(activeMenu)?.map((list) => (
+
+ ))}
+
+
+ 원하는 답변이 없다면?
+
+
+
+ ) : (
+
+
+
+
+ {askList(activeMenu)?.map((list) => (
+
+ ))}
+
+
+ 원하는 답변이 없다면?
+
+
+
+ )}
+ >
+ )
+}
+
+export default Help
+
+const Container = styled.div`
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto;
+ max-width: 1024px;
+`
+
+const MenuStyle = styled.menu`
+ display: flex;
+ justify-content: center;
+ gap: 3em;
+ margin: 64px 0;
+ li {
+ cursor: pointer;
+ font-size: 20px;
+ }
+`
+
+const AskListStyle = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: ${({ screen }) => (screen === 'pc' ? '24px' : '16px')};
+ padding: ${({ screen }) => screen === 'mobile' && '16px'};
+`
+
+const OtherAskStyle = styled.div`
+ margin-top: 64px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ button {
+ display: flex;
+ width: 328px;
+ padding: 16px 56px;
+ justify-content: center;
+ background-color: ${COLORS.green};
+ color: white;
+ font-size: 20px;
+ font-weight: 600;
+ font-family: 'Pretendard-Regular';
+ }
+`
diff --git a/src/pages/HelpForm.tsx b/src/pages/HelpForm.tsx
new file mode 100644
index 0000000..2d91c50
--- /dev/null
+++ b/src/pages/HelpForm.tsx
@@ -0,0 +1,7 @@
+import HelpNAskForm from '@src/components/HelpNAskForm'
+
+const HelpForm = () => {
+ return
+}
+
+export default HelpForm
diff --git a/src/pages/Join.tsx b/src/pages/Join.tsx
new file mode 100644
index 0000000..2804a5b
--- /dev/null
+++ b/src/pages/Join.tsx
@@ -0,0 +1,267 @@
+import styled from 'styled-components'
+import { useNavigate } from 'react-router-dom'
+import { COLORS, FONT } from '@src/globalStyles'
+import React, { useEffect, useState } from 'react'
+import { checkDuplicateEmail, join } from '@src/api/authApi'
+import useInput from '@src/hooks/useInputHook'
+import PATH from '@src/constants/pathConst'
+
+const Join = () => {
+ // 인풋 유효성 검사
+ const emailValidator = (userEmail: string) => {
+ setCheckEmail(false)
+ const rUserEmail = /^[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*@[0-9a-zA-Z]([-_.]?[0-9a-zA-Z])*\.[a-zA-Z]{2,3}$/i
+ if (userEmail === '' || !rUserEmail.test(userEmail)) return true
+ }
+ const pwValidator = (userPw: string) => {
+ const rUserPw = /^(?=.*[a-zA-Z])(?=.*[!@#$%^*+=-])(?=.*[0-9]).{8,16}$/
+ if (userPw === '' || !rUserPw.test(userPw)) return true
+ }
+ const nameValidator = (userName: string) => {
+ if (userName === '' || userName.length < 0 || userName.length > 5) return true
+ }
+ const nickNameValidator = (userNickName: string) => {
+ if (userNickName === null || userNickName.length > 12) return true
+ }
+ const userPhoneValidator = () => {
+ setUserPhone((userPhone) =>
+ userPhone
+ .replace(/[^0-9]/g, '')
+ .replace(/^(\d{2,3})(\d{3,4})(\d{4})$/g, '$1-$2-$3')
+ .replace(/(-{1,2})$/g, '')
+ )
+ }
+
+ useEffect(() => {
+ if (userPhone.length >= 1 && userPhone.length <= 8) setPhoneError(true)
+ if (userPhone.length >= 11) setPhoneError(false)
+ })
+
+ const navigate = useNavigate()
+
+ const [userEmail, onChangeUserEmail, userEmailError] = useInput(emailValidator, '')
+ const [userPw, onChangeUserPw, userPwError] = useInput(pwValidator, '')
+ const [userConfirmPw, onChangeUserConfirmPw, userConfirmPwError] = useInput(pwValidator, '')
+ const [userName, onChangeUserName, userNameError] = useInput(nameValidator, '')
+ const [userNickName, onChangeUserNickName, userNickNameError] = useInput(nickNameValidator, '')
+ const [userPhone, onChangeUserPhone, userPhoneError, setUserPhone] = useInput(userPhoneValidator, '')
+
+ const [checkEmail, setCheckEmail] = useState(false)
+ const [phoneError, setPhoneError] = useState(false)
+
+ const checkEmailHandler = async (e: React.MouseEvent) => {
+ e.preventDefault()
+ if (!userEmail) return alert('이메일을 입력해주세요.')
+ if (userEmailError) return alert('올바른 이메일 형식이 아닙니다.')
+ const status = await checkDuplicateEmail({ userEmail })
+ if (status === 200) {
+ setCheckEmail(true)
+ } else return alert('사용할 수 없는 이메일 입니다.')
+ }
+
+ const joinHandler = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!checkEmail) return alert('이메일 중복확인을 해주세요.')
+ if (userEmailError || userPwError || userConfirmPwError || userNameError || userNickNameError || userPhoneError)
+ return alert('양식을 다시 확인해주세요.')
+ const status = await join({
+ userEmail,
+ userPw,
+ userName,
+ userNickName,
+ userPhone,
+ })
+ if (status === 200) {
+ alert('회원가입에 성공했습니다!')
+ navigate(PATH.LOGIN)
+ }
+ }
+
+ return (
+
+
+
이메일과 비밀번호를 입력해주세요
+
+
+
+
+ )
+}
+
+const Container = styled.div`
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ .text_wrap {
+ margin-bottom: 60px;
+ width: 170px;
+ }
+
+ h3 {
+ font-size: 20px;
+ font-weight: 700;
+ }
+
+ .input_wrap {
+ &_inner {
+ position: relative;
+ margin: 8px 0;
+ height: 88px;
+ button {
+ position: absolute;
+ top: 24px;
+ right: 0;
+ margin: 8px;
+ padding: 8px;
+ border: 1px solid;
+ border-radius: 8px;
+ border-color: ${COLORS.green};
+ font-size: ${FONT['m-sm']};
+ font-weight: 700;
+ color: ${COLORS.green};
+ }
+ }
+ }
+
+ input {
+ margin: 8px 0 6px;
+ padding: 16px 8px;
+ width: 100%;
+ height: 47px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 12px;
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .error_message {
+ font-size: 12px;
+ color: ${COLORS.error};
+ }
+
+ .help_message {
+ font-size: 12px;
+ color: ${COLORS.green};
+ }
+
+ .btn_join {
+ margin-top: 48px;
+ width: 100%;
+ height: 47px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ color: white;
+ }
+
+ .find_wrap {
+ padding: 16px 0;
+ display: flex;
+ justify-content: center;
+ gap: 40px;
+ width: 100%;
+ font-size: 12px;
+ }
+`
+
+export default Join
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
new file mode 100644
index 0000000..d66db25
--- /dev/null
+++ b/src/pages/Login.tsx
@@ -0,0 +1,199 @@
+// import { Mobile, Tablet, PC } from '@src/hooks/useScreenHook'
+import React from 'react'
+import styled from 'styled-components'
+import { Link, useNavigate } from 'react-router-dom'
+import { COLORS, FONT } from '@src/globalStyles'
+import { useMediaQuery } from 'react-responsive'
+import { useState } from 'react'
+import { SET_TOKEN } from '@src/store/slices/authSlice'
+import { useDispatch } from 'react-redux'
+import { removeCookieToken, setRefreshToken } from '@src/storage/Cookie'
+import { userLogin } from '@src/api/authApi'
+import SocialLogin from '@src/components/SocialLogin'
+import Modal from '@src/components/Modal'
+import PATH from '@src/constants/pathConst'
+
+const Login = () => {
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+ const isPC = useMediaQuery({
+ query: '(min-width: 834px)',
+ })
+
+ const [modalOpen, setModalOpen] = useState(false)
+ const [modalIsConfirm, setModalIsConfirm] = useState(false)
+ const [modalText, setModalText] = useState([])
+ // const [modalNavigateOption, setModalNavigateOption] = useState('')
+
+ const [inputs, setInputs] = useState({
+ userEmail: '',
+ userPw: '',
+ })
+ const { userEmail, userPw } = inputs
+
+ const onChangeHandler = (e: React.ChangeEvent) => {
+ const { name, value } = e.target
+ setInputs({
+ ...inputs,
+ [name]: value,
+ })
+ }
+
+ const onSubmitHandler = async (e: React.FormEvent) => {
+ e.preventDefault()
+ const { status, tokens } = (await userLogin({
+ userEmail,
+ userPw,
+ })) as IResponseType
+ if (status === 200) {
+ removeCookieToken()
+ dispatch(SET_TOKEN(tokens.accessToken))
+ setRefreshToken(tokens.refreshToken)
+ console.log('로그인함', new Date())
+ // window.onpopstate = () => {
+ // console.log('뒤로가기 클릭')
+ // return navigate('/')
+ // }
+ return navigate(PATH.HOME, { replace: true })
+ } else {
+ setModalOpen(true)
+ setModalIsConfirm(false)
+ setModalText(['잘못된 로그인 정보입니다. 아이디와 비밀번호를 다시 확인해주세요.'])
+ }
+ }
+
+ return (
+
+
+ <>
+ {modalOpen && (
+
+ )}
+ >
+
+ )
+}
+
+const Container = styled.div`
+ // reset-css에 border-box 추가?
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ .logo {
+ margin-bottom: 60px;
+ text-align: center;
+ img {
+ width: 160px;
+ height: 24px;
+ }
+ }
+
+ input {
+ margin: 8px 0 16px;
+ padding: 16px 8px;
+ width: 100%;
+ height: 47px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 12px;
+ }
+
+ .btn_login {
+ width: 100%;
+ height: 47px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ color: white;
+ }
+
+ .find_wrap {
+ margin-bottom: 50px;
+ padding: 16px 0;
+ display: flex;
+ justify-content: center;
+ gap: 40px;
+ width: 100%;
+ font-size: 12px;
+ }
+
+ /* .socialLogin_wrap {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ button {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 47px;
+ font-size: 15px;
+ border: 1px solid ${COLORS.gray30};
+ svg {
+ flex-shrink: 0;
+ margin-left: 14px;
+ }
+ span {
+ flex-grow: 1;
+ margin-left: -14px;
+ }
+ }
+ .btn_naverLogin {
+ border: none;
+ background-color: #03c75a;
+ span {
+ color: #ffffff;
+ }
+ }
+ } */
+`
+
+export default Login
diff --git a/src/pages/Main.tsx b/src/pages/Main.tsx
index eef7be8..492e846 100644
--- a/src/pages/Main.tsx
+++ b/src/pages/Main.tsx
@@ -1,5 +1,469 @@
+import { districtOptions, sortOptions } from '@src/constants/options'
+import { COLORS, FONT } from '@src/globalStyles'
+import { styled } from 'styled-components'
+import { useEffect, useState } from 'react'
+import {
+ BadmintonIcon,
+ BasketballIcon,
+ DownwardArrowIcon,
+ FutsalIcon,
+ SoccerIcon,
+ TennisIcon,
+} from '@src/constants/icons'
+import SearchForm from '@src/components/SearchForm'
+import { useMediaQuery } from 'react-responsive'
+import Board from '@src/components/Board'
+import useInfinityScroll from '@src/hooks/useInfinityScroll'
+import { categoryNamesList } from '@src/constants/options'
+import Loading from '@src/components/common/loading'
+
const Main = () => {
- return <>main>
+ const isMobile = useMediaQuery({
+ query: '(max-width: 833px)',
+ })
+ const [isDistrictOpen, setIsDistrictOpen] = useState(false)
+ const [isSortOpen, setIsSortOpen] = useState(false)
+ const [selectedSortOption, setSelectedSortOption] = useState('정렬')
+ const [isActive, setIsActive] = useState({
+ futsal: true,
+ soccer: false,
+ basketball: false,
+ badminton: false,
+ tennis: false,
+ })
+ const [payload, setPayload] = useState({
+ districtName: '',
+ categoryName: '풋살장',
+ })
+ const [page, setPage] = useState(1)
+ const [postList, setPostList] = useState([])
+ const { isLoading, getPostList, ref } = useInfinityScroll({ payload, page, setPostList, setPage })
+
+ useEffect(() => {
+ getPostList(payload, page)
+ }, [page, payload])
+
+ useEffect(() => {
+ setSelectedSortOption('정렬')
+ }, [payload])
+
+ useEffect(() => {
+ switch (selectedSortOption) {
+ case '인기순':
+ setPostList([...postList.sort((a, b) => b.viewCount - a.viewCount)])
+ break
+ case '가장 최신 순':
+ setPostList([...postList.sort((a, b) => +b.boardId - +a.boardId)])
+ break
+ case '낮은 가격 순':
+ setPostList([...postList.sort((a, b) => a.price - b.price)])
+ break
+ case '높은 가격 순':
+ setPostList([...postList.sort((a, b) => b.price - a.price)])
+ break
+ default:
+ setPostList([...postList.sort((a, b) => +b.boardId - +a.boardId)])
+ break
+ }
+ }, [selectedSortOption])
+
+ const categories: ICategories[] = [
+ {
+ category: 'futsal',
+ name: '풋살',
+ icon: ,
+ },
+ {
+ category: 'soccer',
+ name: '축구',
+ icon: ,
+ },
+ {
+ category: 'basketball',
+ name: '농구',
+ icon: ,
+ },
+ {
+ category: 'badminton',
+ name: '배드민턴',
+ icon: ,
+ },
+ {
+ category: 'tennis',
+ name: '테니스',
+ icon: ,
+ },
+ ]
+
+ const handleClickCategory = (category: string) => {
+ setPage(1)
+ switch (category) {
+ case 'futsal':
+ setIsActive({
+ futsal: true,
+ soccer: false,
+ basketball: false,
+ badminton: false,
+ tennis: false,
+ })
+ setPayload({ categoryName: '풋살장', districtName: payload.districtName })
+ break
+ case 'soccer':
+ setIsActive({
+ futsal: false,
+ soccer: true,
+ basketball: false,
+ badminton: false,
+ tennis: false,
+ })
+ setPayload({ categoryName: '축구장', districtName: payload.districtName })
+ break
+ case 'basketball':
+ setIsActive({
+ futsal: false,
+ soccer: false,
+ basketball: true,
+ badminton: false,
+ tennis: false,
+ })
+ setPayload({ categoryName: '농구장', districtName: payload.districtName })
+ break
+ case 'badminton':
+ setIsActive({
+ futsal: false,
+ soccer: false,
+ basketball: false,
+ badminton: true,
+ tennis: false,
+ })
+ setPayload({ categoryName: '배드민턴장', districtName: payload.districtName })
+ break
+ case 'tennis':
+ setIsActive({
+ futsal: false,
+ soccer: false,
+ basketball: false,
+ badminton: false,
+ tennis: true,
+ })
+ setPayload({ categoryName: '테니스장', districtName: payload.districtName })
+ break
+ }
+ }
+ return (
+
+ {!isMobile && (
+
+
+
+
+ 아까운
+ 양도수수료
+
+
이제 버리지 말고 양도 하세요!
+
+ 간단한 가입절차와 상세한 게시글 필터 기능으로
+ 간편하게 체육시설을 양도/양수해보세요!
+
+
+
+ )}
+
+
+
+ {categories.map((item) => {
+ return (
+ {
+ handleClickCategory(item.category)
+ }}
+ >
+ {item.icon}
+ {item.name}
+
+ )
+ })}
+
+
+
+ {isDistrictOpen ? (
+
{
+ setIsDistrictOpen(false)
+ }}
+ >
+
setPayload({ districtName: '', categoryName: payload.categoryName })}
+ >
+ 지역
+
+ {districtOptions.map((item) => {
+ return (
+
{
+ setPayload({ districtName: item, categoryName: payload.categoryName })
+ }}
+ >
+ {item}
+
+ )
+ })}
+
+ ) : (
+
+ )}
+
+
+ {isSortOpen ? (
+
{
+ setIsSortOpen(false)
+ }}
+ >
+
{
+ setSelectedSortOption('정렬')
+ }}
+ >
+ 정렬
+
+ {sortOptions.map((item) => {
+ return (
+
{
+ setSelectedSortOption(item)
+ }}
+ >
+ {item}
+
+ )
+ })}
+
+ ) : (
+
+ )}
+
+
+
+ {!isLoading && }
+ {isLoading && }
+
+
+ )
}
+const Container = styled.main`
+ display: block;
+ font-size: ${FONT.m};
+ margin: auto;
+ max-width: 1024px;
+
+ .banner-section {
+ position: relative;
+ width: 100%;
+ height: 312px;
+ max-width: 1024px;
+ background-position: center;
+ background-size: cover;
+ background-repeat: no-repeat;
+ color: white;
+ font-size: 30px;
+ font-weight: 900;
+ line-height: 40px;
+
+ .background {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(90deg, #000000e9, #52525213);
+ }
+
+ .text {
+ position: absolute;
+ padding: 96px 0 0 15px;
+ }
+
+ .big {
+ display: flex;
+ gap: 10px;
+
+ .highlight {
+ color: black;
+ text-shadow: -1px 0 white, 0 1px white, 1px 0 white, 0 -1px white;
+ }
+ }
+ .small {
+ display: flex;
+ flex-direction: column;
+ line-height: 20px;
+ font-size: 15px;
+ font-weight: 400;
+ margin-top: 20px;
+ }
+ }
+
+ .search-section {
+ margin: auto;
+ max-width: 560px;
+ }
+`
+
+const ListSection = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+`
+const Category = styled.div`
+ height: 70px;
+ display: flex;
+ border-bottom: 1px solid ${COLORS.gray20};
+ margin: auto;
+ gap: 16px;
+
+ @media (max-width: 599px) {
+ width: 100%;
+ }
+
+ @media (min-width: 600px) {
+ gap: 28px;
+ height: 102px;
+ font-size: ${FONT.pc};
+ border: none;
+ }
+
+ @media (min-width: 834px) {
+ gap: 40px;
+ }
+
+ .icon {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ gap: 8px;
+ width: 52px;
+ height: 70px;
+ align-items: center;
+ margin: auto;
+ cursor: pointer;
+
+ img {
+ width: 24px;
+ height: 24px;
+ }
+
+ @media (min-width: 600px) {
+ width: 80px;
+
+ img {
+ width: 32px;
+ height: 32px;
+ }
+ }
+
+ .green {
+ color: ${COLORS.green};
+ }
+ }
+`
+
+const Options = styled.div`
+ position: relative;
+ display: flex;
+ gap: 14px;
+ padding: 0 16px;
+ height: 32px;
+
+ .select-cover {
+ position: relative;
+ width: 100px;
+
+ .default {
+ color: ${COLORS.green};
+ }
+
+ .option {
+ width: 100px;
+ height: 32px;
+ line-height: 22px;
+ cursor: pointer;
+ padding: 4px 8px;
+ box-sizing: border-box;
+ }
+
+ .select-close {
+ width: 100px;
+ height: 32px;
+ font-size: 14px;
+ border: 1px solid ${COLORS.green};
+ border-radius: 8px;
+ color: ${COLORS.green};
+ text-align: start;
+ padding: 4px 8px;
+
+ svg {
+ position: absolute;
+ right: 10%;
+ }
+ }
+
+ .selected {
+ background-color: ${COLORS.green};
+ color: white;
+ }
+
+ .select-open {
+ position: absolute;
+ cursor: pointer;
+ border: 1px solid ${COLORS.green};
+ border-radius: 8px;
+ width: 100px;
+ overflow: scroll;
+ overflow-x: hidden;
+ box-sizing: border-box;
+ z-index: 10;
+ background-color: white;
+
+ &::-webkit-scrollbar {
+ display: none; /* 크롬, 사파리, 오페라, 엣지 */
+ }
+ scrollbar-width: none; /* 파이어폭스 */
+
+ &.district {
+ height: 228px;
+ }
+ }
+ }
+`
+
export default Main
diff --git a/src/pages/MyPage.tsx b/src/pages/MyPage.tsx
new file mode 100644
index 0000000..3359878
--- /dev/null
+++ b/src/pages/MyPage.tsx
@@ -0,0 +1,290 @@
+import { COLORS, FONT } from '@src/globalStyles'
+import { Mobile } from '@src/hooks/useScreenHook'
+import styled from 'styled-components'
+import { useEffect, useState } from 'react'
+import { useMediaQuery } from 'react-responsive'
+import Inner from '@src/components/Inner'
+import PCBoardCard from '@src/components/MyPage/PCBoardCard'
+import { useNavigate } from 'react-router'
+import { Link } from 'react-router-dom'
+import { getMyPost, getWishlist, getMyReply } from '@src/api/authApi'
+import { useDispatch, useSelector } from 'react-redux'
+import { SET_WISHLIST } from '@src/store/slices/wishlistSlice'
+import { RootState } from '@src/store/config'
+import PATH from '@src/constants/pathConst'
+import useLoginState from '@src/hooks/useLoginState'
+
+const MyPage = () => {
+ const [random, setRandom] = useState(0)
+ const [myPost, setMyPost] = useState([])
+ const [wishlist, setWishlist] = useState([])
+ const [myReply, setMyReply] = useState([])
+ const [postNum, setPostNum] = useState(0)
+ const [wishNum, setWishNum] = useState(0)
+ const [replyNum, setReplyNum] = useState(0)
+
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+ const { logoutHandler } = useLoginState()
+
+ useEffect(() => {
+ const randomNumFn = (total: number) => {
+ const num = Math.floor(Math.random() * total + 1)
+ setRandom(num)
+ }
+ randomNumFn(3)
+ const fetchData = async () => {
+ const postResponse = await getMyPost(1)
+ setMyPost(postResponse?.data)
+ const wishlistResponse = await getWishlist(1)
+ setWishlist(wishlistResponse?.data)
+ dispatch(SET_WISHLIST(postResponse?.data))
+ const replyResponse = await getMyReply(1)
+ setMyReply(replyResponse?.data)
+ setPostNum(postResponse?.totalElements)
+ setWishNum(wishlistResponse?.totalElements)
+ setReplyNum(replyResponse?.totalElements)
+ }
+ fetchData()
+ }, [])
+ const userInfo = useSelector((state: RootState) => state.userInfo)
+ const isPC = useMediaQuery({
+ query: '(min-width: 834px)',
+ })
+
+ return (
+
+ {isPC && (
+ <>
+
+
+
+
+ {userInfo.memberName} 님
+
+ 안녕하세요
+
+
+
+
+
+ 작성 글 목록
+
+ -
+ navigate(PATH.MYPAGE_DETAIL, { state: 0 })} />
+
+ -
+ navigate(PATH.MYPAGE_DETAIL, { state: 1 })}
+ />
+
+ -
+ navigate(PATH.MYPAGE_DETAIL, { state: 2 })}
+ />
+
+
+
+
+
+ 회원 정보 변경
+
+
+ 고객센터
+
+
+ 비밀번호 변경
+
+
+
+ 로그아웃
+
+
+ 현재 버전 1.02
+
+
+ >
+ )}
+
+
+
+ {userInfo.memberName} 님
+
+ 안녕하세요
+
+
+ navigate(PATH.MYPAGE_DETAIL, { state: 0 })}>
+ {postNum || 0}
+ 양도
+
+ navigate(PATH.MYPAGE_DETAIL, { state: 1 })}>
+ {wishNum || 0}
+ 좋아요
+
+ navigate(PATH.MYPAGE_DETAIL, { state: 2 })}>
+ {replyNum || 0}
+ 댓글
+
+
+
+
+
+
+ 회원 정보 변경
+
+
+
+
+
+ 고객센터
+
+
+
+ 비밀번호 변경
+
+
+ 로그아웃
+
+ 현재 버전 1.01
+
+
+
+ )
+}
+
+export default MyPage
+
+interface IStyleProps {
+ random: number
+}
+
+const NameStyle = styled.div`
+ background-image: ${(props) => `url(/my_page_banner/mobile_${props.random}.jpg)`};
+ background-repeat: no-repeat;
+ background-size: cover;
+ display: flex;
+ flex-direction: column;
+ padding: 16px;
+ font-size: 24px;
+ height: 120px;
+ justify-content: center;
+ border-bottom: 1px solid ${COLORS.gray20};
+ color: white;
+ span {
+ font-weight: 700;
+ }
+`
+
+const MyMenuStyle = styled.ul`
+ display: flex;
+ justify-content: space-between;
+ border-bottom: 16px solid #fafafa;
+
+ li {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ padding: 16px 45px;
+ cursor: pointer;
+ span:first-child {
+ font-weight: 700;
+ }
+ }
+`
+
+const ListStlye = styled.ul`
+ display: flex;
+ flex-direction: column;
+ li {
+ height: 24px;
+ padding: 16px;
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid ${COLORS.gray20};
+ a {
+ display: flex;
+ align-items: center;
+ width: fit-content;
+ cursor: pointer;
+ }
+ div {
+ width: 20px;
+ height: 20px;
+ display: flex;
+ padding: 2px;
+ justify-content: center;
+ align-items: center;
+ border-radius: 30px;
+ background: ${COLORS.green};
+ margin-right: 8px;
+ }
+ }
+`
+
+const PCNameContainer = styled.div`
+ background-image: ${(props) => `url(/my_page_banner/pc_${props.random}.jpg)`};
+ background-repeat: no-repeat;
+ background-size: cover;
+ height: 300px;
+ position: relative;
+`
+
+const PCName = styled.div`
+ position: absolute;
+ padding: 16px;
+ bottom: 30px;
+ font-size: 32px;
+ color: white;
+ span {
+ font-weight: 700;
+ }
+`
+
+const PCBoardContainer = styled.div`
+ padding: 32px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ ul {
+ display: flex;
+ gap: 15px;
+ }
+`
+
+const Title = styled.h2`
+ font-size: ${FONT['pc-lg']};
+ font-weight: 500;
+`
+
+const PCList = styled.ul`
+ display: flex;
+ flex-direction: column;
+ font-size: ${FONT['pc-lg']};
+ li {
+ padding: 16px 0;
+ height: 60px;
+ display: flex;
+ align-items: center;
+ /* cursor: pointer; */
+ }
+`
diff --git a/src/pages/MyPageDetail.tsx b/src/pages/MyPageDetail.tsx
new file mode 100644
index 0000000..e97db77
--- /dev/null
+++ b/src/pages/MyPageDetail.tsx
@@ -0,0 +1,210 @@
+import MobileMenu from '@src/components/MobileMenu'
+import CommentLists from '@src/components/MyPage/CommentLists'
+import Title from '@src/components/Title'
+import { Mobile } from '@src/hooks/useScreenHook'
+import { useState, useEffect } from 'react'
+import { useLocation } from 'react-router'
+import { useMediaQuery } from 'react-responsive'
+import Inner from '@src/components/Inner'
+import Board from '@src/components/Board'
+import { getMyPost, getWishlist } from '@src/api/authApi'
+import styled from 'styled-components'
+import MBoardList from '@src/components/MBoardList'
+import ReactPaginate from 'react-paginate'
+import { COLORS } from '@src/globalStyles'
+
+const MyPageDetail = () => {
+ const { state }: { state: number } = useLocation()
+ const [activeMenu, setActiveMenu] = useState(state)
+ const [posts, setPosts] = useState([])
+ const [postTotalPage, setPostTotalPage] = useState(1)
+ const [wishTotalPage, setWishTotalPage] = useState(1)
+ const [wishlists, setWishlists] = useState([])
+ const menuLists = ['양도', '좋아요', '댓글']
+
+ const activeList = (activeMenu: number, screen: string) => {
+ if (activeMenu === 0 && screen === 'mobile') {
+ return (
+ <>
+
+ {posts?.length ? (
+ posts.map((post) => )
+ ) : (
+ 작성한 게시물이 없습니다.
+ )}
+
+ {posts?.length > 0 && (
+
+
+
+ )}
+ >
+ )
+ } else if (activeMenu === 1 && screen === 'mobile') {
+ return (
+ <>
+
+ {wishlists?.length ? (
+ wishlists.map((post) => )
+ ) : (
+ 게시물이 없습니다.
+ )}
+
+ {wishlists?.length > 0 && (
+
+
+
+ )}
+ >
+ )
+ } else if (activeMenu === 2 && screen === 'mobile') {
+ return (
+ <>
+
+ >
+ )
+ } else if (activeMenu === 0 && screen === 'pc') {
+ return (
+ <>
+
+ {posts?.length > 0 && (
+
+
+
+ )}
+ >
+ )
+ } else if (activeMenu === 1 && screen === 'pc') {
+ return (
+ <>
+ {' '}
+ {wishlists?.length > 0 && (
+
+
+
+ )}
+ >
+ )
+ } else if (activeMenu === 2 && screen === 'pc') {
+ return (
+ <>
+
+ >
+ )
+ }
+ }
+
+ const postData = async (page = 1) => {
+ try {
+ const postsResponse = await getMyPost(page)
+ setPosts(postsResponse?.data)
+ setPostTotalPage(postsResponse?.totalPages)
+ } catch (error) {
+ console.log(error)
+ }
+ }
+ const wishlistData = async (page = 1) => {
+ try {
+ const wishlistResponse = await getWishlist(page)
+ setWishlists(wishlistResponse?.data)
+ setWishTotalPage(wishlistResponse?.totalPages)
+ } catch (error) {
+ console.log(error)
+ }
+ }
+
+ useEffect(() => {
+ ;(state === 0 || state === 1 || state === 2) && setActiveMenu(state)
+ postData()
+ wishlistData()
+ }, [state])
+
+ const isPC = useMediaQuery({
+ query: '(min-width: 834px)',
+ })
+
+ const handlePage = (event: { selected: number }) => {
+ postData(event.selected + 1)
+ wishlistData(event.selected + 1)
+ }
+
+ return (
+ <>
+ {isPC && (
+
+
+
+ {activeList(activeMenu, 'pc')}
+
+ )}
+
+
+
+ {activeList(activeMenu, 'mobile')}
+
+ >
+ )
+}
+
+export default MyPageDetail
+
+const PostContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ padding: 16px;
+`
+
+const NoData = styled.div`
+ display: flex;
+ justify-content: center;
+`
+
+const Paginate = styled.div`
+ .paginate {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ cursor: pointer;
+ gap: 10px;
+ color: ${COLORS.gray30};
+ font-size: ${({ screen }) => (screen === 'pc' ? '20px' : '14px')};
+ .previous {
+ color: ${COLORS.green};
+ }
+ .next {
+ color: ${COLORS.green};
+ }
+ .selected {
+ color: ${COLORS.green};
+ }
+ }
+`
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx
new file mode 100644
index 0000000..8d02552
--- /dev/null
+++ b/src/pages/Profile.tsx
@@ -0,0 +1,188 @@
+import Board from '@src/components/Board'
+import Inner from '@src/components/Inner'
+import { useMediaQuery } from 'react-responsive'
+import styled from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import MBoardList from '@src/components/MBoardList'
+import { useState, useEffect } from 'react'
+import { getUserPost } from '@src/api/authApi'
+import { useLocation } from 'react-router'
+import { useInView } from 'react-intersection-observer'
+import { useSelector } from 'react-redux'
+import { RootState } from '@src/store/config'
+import { promoteUser, demoteUser } from '@src/api/userApi'
+import Modal from '@src/components/Modal'
+import PATH from '@src/constants/pathConst'
+
+const Profile = () => {
+ const { state } = useLocation()
+ const { pathname } = useLocation()
+ const memberName = state.memberName
+ const memberId = pathname.slice(9)
+
+ const [posts, setPosts] = useState([])
+
+ const [ref, inView] = useInView()
+ const [page, setPage] = useState(1)
+ const [isLoading, setIsLoading] = useState(false)
+ const [lastPage, setLastPage] = useState(false)
+ const [modalOpen, setModalOpen] = useState(false)
+ const [modalIsConfirm, setModalIsConfirm] = useState(false)
+ const [modalText, setModalText] = useState([])
+ const [modalNavigate, setModalNavigate] = useState('')
+
+ const getPost = async () => {
+ try {
+ setIsLoading(true)
+ const response = await getUserPost(page, memberId)
+ if (page === 1) setPosts(response?.data)
+ // eslint-disable-next-line no-unsafe-optional-chaining
+ else setPosts((prev) => [...prev, ...response?.data])
+
+ if (response?.lastPage) setLastPage(true)
+ else setLastPage(false)
+ } catch (error) {
+ alert(error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ getPost()
+ }, [page])
+
+ useEffect(() => {
+ if (inView && !lastPage) {
+ setPage((prev) => prev + 1)
+ }
+ }, [inView, lastPage])
+
+ const isPC = useMediaQuery({ query: '(min-width: 834px' })
+
+ const userInfo = useSelector((state: RootState) => state.userInfo)
+
+ // 회원 상태 확인
+ const checkRole = (role: string) => {
+ if (role === 'USER') {
+ return (
+ promoteFn(posts[0].memberRole)}>
+ 관리자 등록
+
+ )
+ } else {
+ return (
+ promoteFn(posts[0].memberRole)}>
+ 관리자 해제
+
+ )
+ }
+ }
+
+ // 등급 조정 함수
+ const promoteFn = (role: string) => {
+ setModalOpen(true)
+ setModalIsConfirm(true)
+ if (role === 'USER') {
+ try {
+ setModalText(['관리자로 승급하시겠습니까?'])
+ setModalNavigate(PATH.HOME)
+ } catch (error) {
+ setModalText(['오류가 발생하였습니다.'])
+ }
+ } else {
+ try {
+ setModalText(['일반회원으로 강등하시겠습니까?'])
+ setModalNavigate(PATH.HOME)
+ } catch (error) {
+ setModalText(['오류가 발생하였습니다.'])
+ }
+ }
+ }
+
+ return (
+ <>
+ {isPC ? (
+
+
+
+ {memberName} 님의 게시물
+
+ {userInfo.role === '관리자' && checkRole(posts[0]?.memberRole)}
+
+
+ {!isLoading && }
+
+ ) : (
+
+
+
+ {memberName} 님의 게시물
+
+ {userInfo.role === '관리자' && checkRole(posts[0]?.memberRole)}
+
+
+ {posts?.length ? (
+ posts.map((post) => )
+ ) : (
+ 작성한 게시물이 없습니다.
+ )}
+ {!isLoading && }
+
+
+ )}
+ {modalOpen && (
+ {
+ if (posts[0].memberRole === 'USER') {
+ promoteUser(memberId)
+ } else {
+ demoteUser(memberId)
+ }
+ }}
+ >
+ )}
+ >
+ )
+}
+
+export default Profile
+
+const TopStyle = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: ${({ screen }) => (screen === 'pc' ? '64px 0 32px 0' : '16px')};
+ .title {
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT['pc-lg'] : FONT.pc)};
+ span {
+ font-weight: 700;
+ }
+ }
+`
+
+const RoleButton = styled.button`
+ color: ${COLORS.error};
+ font-size: ${({ screen }) => (screen === 'pc' ? FONT.pc : FONT.m)};
+ width: ${({ screen }) => (screen === 'pc' ? '120px' : '100px')};
+ height: ${({ screen }) => (screen === 'pc' ? '40px' : '32px')};
+ padding: 2px 8px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ border-radius: 8px;
+ border: 1px solid ${COLORS.error};
+ background-color: #fff;
+`
+
+const PostContainer = styled.ul`
+ display: flex;
+ flex-direction: column;
+ gap: 32px;
+ padding: 16px;
+`
diff --git a/src/pages/UserEdit.tsx b/src/pages/UserEdit.tsx
new file mode 100644
index 0000000..4b12054
--- /dev/null
+++ b/src/pages/UserEdit.tsx
@@ -0,0 +1,205 @@
+import React, { useEffect } from 'react'
+import { useNavigate } from 'react-router'
+import { styled } from 'styled-components'
+import { COLORS, FONT } from '@src/globalStyles'
+import { editUserInfo, getUserInfo } from '@src/api/authApi'
+import useInput from '@src/hooks/useInputHook'
+import { useDispatch } from 'react-redux'
+import { SET_INFO } from '@src/store/slices/infoSlice'
+import PATH from '@src/constants/pathConst'
+
+const UserEdit = () => {
+ const navigate = useNavigate()
+ const dispatch = useDispatch()
+
+ // 인풋 유효성 검사
+ const nameValidator = (userName: string) => {
+ if (userName === '' || userName.length < 0 || userName.length > 5) return true
+ }
+ const nickNameValidator = (userNickName: string) => {
+ if (userNickName === null || userNickName.length > 12) return true
+ }
+ const userPhoneValidator = (uPhone: string) => {
+ setUserPhone(
+ uPhone
+ .replace(/[^0-9]/g, '')
+ .replace(/^(\d{2,3})(\d{3,4})(\d{4})$/g, '$1-$2-$3')
+ .replace(/(-{1,2})$/g, '')
+ )
+ if (uPhone.length >= 0 && uPhone.length <= 8) return true
+ if (uPhone.length >= 11) return false
+ }
+
+ const [userEmail, onChangeUserEmail, userEmailError, setUserEmail] = useInput('')
+ const [userName, onChangeUserName, userNameError, setUserName] = useInput(nameValidator, '')
+ const [userNickName, onChangeUserNickName, userNickNameError, setUserNickName] = useInput(nickNameValidator, '')
+ const [userPhone, onChangeUserPhone, userPhoneError, setUserPhone] = useInput(userPhoneValidator, '')
+
+ console.log(userEmailError)
+
+ // 회원 정보 불러오기
+ useEffect(() => {
+ const getUserValue = async () => {
+ const { status, memberId, memberName, memberNickName, memberPhone } = (await getUserInfo()) as IUserInfoEditType
+ if (status === 200) {
+ setUserEmail(memberId)
+ setUserName(memberName)
+ setUserNickName(memberNickName)
+ setUserPhone(memberPhone)
+ }
+ }
+ getUserValue()
+ }, [])
+
+ // 회원 정보 변경 요청
+ const onSubmitHandler = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (userName === '' || userNickName === '' || userPhone === '') return alert('양식은 비어있을 수 없습니다.')
+ if (userPhoneError) return alert('양식을 다시 확인해주세요.')
+ const { status } = (await editUserInfo({
+ userName,
+ userNickName,
+ userPhone,
+ })) as IResponseType
+ if (status === 200) {
+ dispatch(
+ SET_INFO({
+ memberId: userEmail,
+ memberName: userName,
+ memberNickName: userNickName,
+ memberPhone: userPhone,
+ })
+ )
+ navigate(PATH.MYPAGE)
+ alert('회원 정보가 변경되었습니다.')
+ }
+ }
+
+ return (
+
+
+
+ )
+}
+
+const Container = styled.div`
+ * {
+ box-sizing: border-box;
+ }
+
+ @media screen and (max-width: 360px) {
+ padding: 0 16px;
+ width: 100%;
+ }
+
+ margin: 64px auto 0;
+ width: 328px;
+
+ form {
+ margin-top: 44px;
+ }
+
+ .text_wrap {
+ margin-bottom: 60px;
+ width: 170px;
+ }
+
+ .input_wrap {
+ &_inner {
+ position: relative;
+ margin: 8px 0;
+ height: 88px;
+ }
+ }
+
+ input {
+ margin: 8px 0 6px;
+ padding: 16px 8px;
+ width: 100%;
+ height: 47px;
+ border: 1px solid ${COLORS.gray20};
+ border-radius: 8px;
+ font-weight: 500;
+ font-size: 12px;
+ &::placeholder {
+ color: ${COLORS.gray40};
+ }
+ }
+
+ .error_message {
+ font-size: 12px;
+ color: ${COLORS.error};
+ }
+
+ .help_message {
+ font-size: 12px;
+ color: ${COLORS.green};
+ }
+
+ .btn_edit {
+ margin-top: 48px;
+ width: 100%;
+ height: 47px;
+ background-color: ${COLORS.green};
+ font-size: ${FONT.pc};
+ font-weight: 700;
+ color: white;
+ }
+`
+
+export default UserEdit
diff --git a/src/pages/WritePost.tsx b/src/pages/WritePost.tsx
new file mode 100644
index 0000000..065dcd9
--- /dev/null
+++ b/src/pages/WritePost.tsx
@@ -0,0 +1,45 @@
+import { useState } from 'react'
+import Write from '../components/Write/Write'
+import { requestWrite } from '@src/api/postApi'
+import useModal from '@src/hooks/useModal'
+import PATH from '@src/constants/pathConst'
+
+const WritePost = () => {
+ const { openModal } = useModal()
+ const [postData, setPostData] = useState({
+ categoryName: '',
+ content: '',
+ districtName: '',
+ endTime: '',
+ imageUrl: '',
+ price: 0,
+ startTime: '',
+ title: '',
+ })
+
+ const submitData = async (formData: FormData) => {
+ try {
+ const writeRes = await requestWrite(formData)
+ if (writeRes === 200) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['게시글 작성이 완료되었습니다.'],
+ navigateOption: PATH.HOME,
+ })
+ } else {
+ throw new Error()
+ }
+ } catch (err) {
+ openModal({
+ isModalOpen: true,
+ isConfirm: false,
+ content: ['정상적으로 등록되지 않았습니다. 다시 시도해주세요.'],
+ })
+ }
+ }
+
+ return
+}
+
+export default WritePost
diff --git a/src/routes/PrivateRoute.tsx b/src/routes/PrivateRoute.tsx
new file mode 100644
index 0000000..0df8e79
--- /dev/null
+++ b/src/routes/PrivateRoute.tsx
@@ -0,0 +1,31 @@
+import { useEffect } from 'react'
+import { useDispatch } from 'react-redux'
+import { getCookieToken } from '@src/storage/Cookie'
+import { DELETE_TOKEN } from '@src/store/slices/authSlice'
+import { DELETE_INFO } from '@src/store/slices/infoSlice'
+import { useLocation } from 'react-router'
+import useLoginState from '@src/hooks/useLoginState'
+
+interface IPrivateRoute {
+ children: React.ReactNode
+}
+
+const PrivateRoute = ({ children }: IPrivateRoute) => {
+ const dispatch = useDispatch()
+ const location = useLocation()
+ const { accessAfterLoginAlert } = useLoginState()
+
+ const access_token = window.localStorage.getItem('accessToken')
+ const refresh_token = getCookieToken()
+ useEffect(() => {
+ if (!access_token || !refresh_token) {
+ dispatch(DELETE_TOKEN())
+ dispatch(DELETE_INFO())
+ accessAfterLoginAlert()
+ // console.log('privateRouter-----------')
+ }
+ }, [location])
+ return <>{access_token && refresh_token && children}>
+}
+
+export default PrivateRoute
diff --git a/src/routes/router.tsx b/src/routes/router.tsx
new file mode 100644
index 0000000..45a2990
--- /dev/null
+++ b/src/routes/router.tsx
@@ -0,0 +1,99 @@
+import { createBrowserRouter } from 'react-router-dom'
+import App from '@src/App'
+import Error from '@pages/Error'
+import PATH from '@src/constants/pathConst'
+import Home from '@pages/Main'
+import BoardDetails from '@pages/BoardDetails'
+import BoardList from '@pages/BoardList'
+import Help from '@pages/Help'
+import HelpForm from '@pages/HelpForm'
+import Join from '@pages/Join'
+import Login from '@pages/Login'
+import MyPage from '@pages/MyPage'
+import MyPageDetail from '@src/pages/MyPageDetail'
+import UserEdit from '@src/pages/UserEdit'
+import FindPassword from '@src/pages/FindPassword'
+import EditPost from '@src/pages/EditPost'
+import ResetPw from '@src/components/ResetPassword/ResetPw'
+import Ask from '@src/pages/Ask'
+import AskDetail from '@src/pages/AskDetail'
+import AskAnswerForm from '@src/pages/AskAnswerForm'
+import PrivateRoute from './PrivateRoute'
+import BoradBlind from '@src/pages/BoradBlind'
+import Profile from '@src/pages/Profile'
+import SocialLoginRedirect from '@src/components/SocialLoginRedirect'
+import WritePost from '@src/pages/WritePost'
+
+const router = createBrowserRouter([
+ {
+ path: '/',
+ element: ,
+ errorElement: ,
+ children: [
+ { index: true, path: PATH.HOME, element: },
+ { path: PATH.BOARD_DETAILS, element: },
+ {
+ path: PATH.WRITE_POST,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: PATH.EDIT_POST,
+ element: (
+
+
+
+ ),
+ },
+ { path: PATH.BOARD_LIST, element: },
+ { path: PATH.HELP, element: },
+ { path: PATH.HELP_FORM, element: },
+ { path: PATH.JOIN, element: },
+ { path: PATH.LOGIN, element: },
+ { path: PATH.FIND_PASSWORD, element: },
+ {
+ path: PATH.MYPAGE,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: PATH.MYPAGE_DETAIL,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: PATH.MYPAGE_EDIT,
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: PATH.MYPAGE_PW,
+ element: (
+
+
+
+ ),
+ },
+ { path: PATH.ASK, element: },
+ { path: PATH.ASK_DETAIL, element: },
+ { path: PATH.ASK_ANSWER_FORM, element: },
+ { path: PATH.BOARD_BLIND, element: },
+ { path: PATH.PROFILE, element: },
+ { path: PATH.SOCIAL_REDIRECT, element: },
+ ],
+ },
+])
+
+export default router
diff --git a/src/storage/Cookie.tsx b/src/storage/Cookie.tsx
new file mode 100644
index 0000000..f6e4d38
--- /dev/null
+++ b/src/storage/Cookie.tsx
@@ -0,0 +1,23 @@
+import { Cookies } from 'react-cookie'
+
+const cookies = new Cookies()
+
+export const setRefreshToken = (refreshToken: string) => {
+ const today = new Date()
+ const expireDate = today.setDate(today.getTime() + 90 * 2 * 10000) // 밀리초 // 15분
+
+ return cookies.set('refresh-token', refreshToken, {
+ sameSite: 'none',
+ secure: true,
+ expires: new Date(expireDate),
+ maxAge: 900, // 초 // 15분
+ })
+}
+
+export const getCookieToken = () => {
+ return cookies.get('refresh-token')
+}
+
+export const removeCookieToken = () => {
+ return cookies.remove('refresh-token', { sameSite: 'strict', path: '/' })
+}
diff --git a/src/store/config.tsx b/src/store/config.tsx
index e49d462..a4be90b 100644
--- a/src/store/config.tsx
+++ b/src/store/config.tsx
@@ -1,11 +1,50 @@
-import { configureStore } from '@reduxjs/toolkit'
+import { combineReducers, configureStore } from '@reduxjs/toolkit'
+import { persistStore, persistReducer } from 'redux-persist'
+import storage from 'redux-persist/lib/storage'
import testSlice from './slices/testSlice'
+import authReducer from './slices/authSlice'
+import searchVlaue from './slices/searchVlaueSlice'
+import postData from './slices/postDataSlice'
+import infoSlice from './slices/infoSlice'
+import wishlistSlice from './slices/wishlistSlice'
+import { setupListeners } from '@reduxjs/toolkit/dist/query'
+import searchBox from './slices/searchChkSlice'
+import commentData from './slices/commentSlice'
+import modal from './slices/modalSlice'
+import sidebar from './slices/sidebarSlice'
+
+const rootReducer = combineReducers({
+ createTest: testSlice.reducer,
+ accessToken: authReducer.reducer,
+ searchVlaue: searchVlaue.reducer,
+ postData: postData.reducer,
+ userInfo: infoSlice.reducer,
+ wishlist: wishlistSlice.reducer,
+ searchBox: searchBox.reducer,
+ commentData: commentData.reducer,
+ modal: modal.reducer,
+ sidebar: sidebar.reducer,
+})
+
+const persistConfig = {
+ key: 'root',
+ storage,
+}
+
+const persistedReducer = persistReducer(persistConfig, rootReducer)
const store = configureStore({
- reducer: {
- createTest: testSlice.reducer,
- },
+ reducer: persistedReducer,
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ serializableCheck: false,
+ }),
})
+setupListeners(store.dispatch)
+
export default store
+export type ReducerType = ReturnType
+export type AppDispath = typeof store.dispatch
export type RootState = ReturnType // useSelector 타입 지정.
+export const persistor = persistStore(store)
diff --git a/src/store/slices/authSlice.tsx b/src/store/slices/authSlice.tsx
new file mode 100644
index 0000000..c3bca48
--- /dev/null
+++ b/src/store/slices/authSlice.tsx
@@ -0,0 +1,31 @@
+import { createSlice } from '@reduxjs/toolkit'
+
+// export const TOKEN_TIME_OUT = 10 * 1000 // 10초 // 테스트 할 때만 사용
+export const TOKEN_TIME_OUT = 15 * 60 * 1000 // 15분
+
+export const authSlice = createSlice({
+ name: 'authToken',
+ initialState: {
+ authenticated: false,
+ accessToken: null,
+ expireTime: 0,
+ },
+ reducers: {
+ SET_TOKEN: (state, action) => {
+ state.authenticated = true
+ state.accessToken = action.payload
+ state.expireTime = new Date().getTime() + TOKEN_TIME_OUT
+ window.localStorage.setItem('accessToken', action.payload)
+ },
+ DELETE_TOKEN: (state) => {
+ state.authenticated = false
+ state.accessToken = null
+ state.expireTime = 0
+ window.localStorage.removeItem('accessToken')
+ },
+ },
+})
+
+export const { SET_TOKEN, DELETE_TOKEN } = authSlice.actions
+
+export default authSlice
diff --git a/src/store/slices/commentSlice.tsx b/src/store/slices/commentSlice.tsx
new file mode 100644
index 0000000..a171108
--- /dev/null
+++ b/src/store/slices/commentSlice.tsx
@@ -0,0 +1,37 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+
+interface MyState {
+ comment?: CommentTypes[]
+ commentNum?: number
+ commentBox?: number
+ commentAdd?: number
+}
+
+const initialState: MyState = {
+ comment: [],
+ commentNum: -1,
+ commentBox: -1,
+ commentAdd: -1,
+}
+
+export const commentSlice = createSlice({
+ name: 'commentData',
+ initialState,
+ reducers: {
+ setCommentData: (state, action: PayloadAction) => {
+ state.comment = action.payload.comment
+ },
+ setCommentInput: (state, action: PayloadAction) => {
+ state.commentNum = action.payload.commentNum
+ },
+ setCommentOptions: (state, action: PayloadAction) => {
+ state.commentBox = action.payload.commentBox
+ },
+ setCommentAdd: (state, action: PayloadAction) => {
+ state.commentAdd = action.payload.commentAdd
+ },
+ },
+})
+
+export const { setCommentData, setCommentInput, setCommentOptions, setCommentAdd } = commentSlice.actions
+export default commentSlice
diff --git a/src/store/slices/infoSlice.tsx b/src/store/slices/infoSlice.tsx
new file mode 100644
index 0000000..5d2c69a
--- /dev/null
+++ b/src/store/slices/infoSlice.tsx
@@ -0,0 +1,34 @@
+import { createSlice } from '@reduxjs/toolkit'
+
+const initialState = {
+ memberId: '',
+ memberName: '',
+ memberNickName: '',
+ role: '',
+ memberPhone: '',
+}
+
+export const infoSlice = createSlice({
+ name: 'userInfo',
+ initialState,
+ reducers: {
+ SET_INFO: (state, action) => {
+ state.memberId = action.payload.memberId
+ state.memberName = action.payload.memberName
+ state.memberNickName = action.payload.memberNickName
+ state.role = action.payload.role
+ state.memberPhone = action.payload.memberPhone
+ },
+ DELETE_INFO: (state) => {
+ state.memberId = ''
+ state.memberName = ''
+ state.memberNickName = ''
+ state.role = ''
+ state.memberPhone = ''
+ },
+ },
+})
+
+export const { SET_INFO, DELETE_INFO } = infoSlice.actions
+
+export default infoSlice
diff --git a/src/store/slices/modalSlice.tsx b/src/store/slices/modalSlice.tsx
new file mode 100644
index 0000000..4be578b
--- /dev/null
+++ b/src/store/slices/modalSlice.tsx
@@ -0,0 +1,31 @@
+import { createSlice } from '@reduxjs/toolkit'
+
+export const modalSlice = createSlice({
+ name: 'modal',
+ initialState: {
+ isModalOpen: false,
+ isConfirm: false,
+ content: [],
+ navigateOption: '',
+ confirmAction: () => null,
+ },
+ reducers: {
+ SET_MODAL: (state, action) => {
+ state.isModalOpen = true
+ state.isConfirm = action.payload.isConfirm
+ state.content = action.payload.content
+ state.navigateOption = action.payload.navigateOption
+ state.confirmAction = action.payload.confirmAction
+ },
+ DELETE_MODAL: (state) => {
+ state.isModalOpen = false
+ state.isConfirm = false
+ state.content = []
+ state.navigateOption = ''
+ state.confirmAction = () => null
+ },
+ },
+})
+
+export const { SET_MODAL, DELETE_MODAL } = modalSlice.actions
+export default modalSlice
diff --git a/src/store/slices/postDataSlice.tsx b/src/store/slices/postDataSlice.tsx
new file mode 100644
index 0000000..3726b5d
--- /dev/null
+++ b/src/store/slices/postDataSlice.tsx
@@ -0,0 +1,17 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+
+const initialState: POST_TYPE[] = []
+
+const postDataSlice = createSlice({
+ name: 'postData',
+ initialState,
+ reducers: {
+ createPostData: (state, action: PayloadAction) => {
+ console.log(state)
+ state = action.payload
+ },
+ },
+})
+
+export default postDataSlice
+export const { createPostData } = postDataSlice.actions
diff --git a/src/store/slices/searchChkSlice.tsx b/src/store/slices/searchChkSlice.tsx
new file mode 100644
index 0000000..e54ae3c
--- /dev/null
+++ b/src/store/slices/searchChkSlice.tsx
@@ -0,0 +1,22 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+
+interface MyState {
+ openBox: boolean
+}
+
+const initialState: MyState = {
+ openBox: false,
+}
+
+const searchChkSlice = createSlice({
+ name: 'searchBox',
+ initialState,
+ reducers: {
+ cheakOpenBox: (state, action: PayloadAction) => {
+ state.openBox = action.payload.openBox
+ },
+ },
+})
+
+export default searchChkSlice
+export const { cheakOpenBox } = searchChkSlice.actions
diff --git a/src/store/slices/searchVlaueSlice.tsx b/src/store/slices/searchVlaueSlice.tsx
new file mode 100644
index 0000000..8b002a3
--- /dev/null
+++ b/src/store/slices/searchVlaueSlice.tsx
@@ -0,0 +1,43 @@
+import { createSlice, PayloadAction } from '@reduxjs/toolkit'
+
+interface MyState {
+ title: string
+ startDate: string
+ endDate: string
+ startTime: string
+ endTime: string
+ district: string[]
+ category: string
+ chkDate: boolean
+}
+
+const initialState: MyState = {
+ title: '',
+ startDate: new Date().toISOString(),
+ endDate: new Date().toISOString(),
+ startTime: '',
+ endTime: '',
+ district: [],
+ category: '',
+ chkDate: false,
+}
+
+const searchValueSlice = createSlice({
+ name: 'searchValue',
+ initialState,
+ reducers: {
+ createSearchValue: (state, action: PayloadAction) => {
+ state.title = action.payload.title
+ state.startDate = action.payload.startDate
+ state.endDate = action.payload.endDate
+ state.startTime = action.payload.startTime
+ state.endTime = action.payload.endTime
+ state.district = action.payload.district
+ state.category = action.payload.category
+ state.chkDate = action.payload.chkDate
+ },
+ },
+})
+
+export default searchValueSlice
+export const { createSearchValue } = searchValueSlice.actions
diff --git a/src/store/slices/sidebarSlice.tsx b/src/store/slices/sidebarSlice.tsx
new file mode 100644
index 0000000..2c67e3c
--- /dev/null
+++ b/src/store/slices/sidebarSlice.tsx
@@ -0,0 +1,19 @@
+import { createSlice } from '@reduxjs/toolkit'
+
+export const sidebarSlice = createSlice({
+ name: 'sidebar',
+ initialState: {
+ isSidebarOpen: false,
+ },
+ reducers: {
+ OPEN_SIDEBAR: (state) => {
+ state.isSidebarOpen = true
+ },
+ CLOSE_SIDEBAR: (state) => {
+ state.isSidebarOpen = false
+ },
+ },
+})
+
+export const { OPEN_SIDEBAR, CLOSE_SIDEBAR } = sidebarSlice.actions
+export default sidebarSlice
diff --git a/src/store/slices/wishlistSlice.tsx b/src/store/slices/wishlistSlice.tsx
new file mode 100644
index 0000000..0537677
--- /dev/null
+++ b/src/store/slices/wishlistSlice.tsx
@@ -0,0 +1,16 @@
+import { createSlice } from '@reduxjs/toolkit'
+
+export const wishlistSlice = createSlice({
+ name: 'wishlist',
+ initialState: {
+ wishlist: [],
+ },
+ reducers: {
+ SET_WISHLIST: (state, action) => {
+ state.wishlist = action.payload
+ },
+ },
+})
+
+export const { SET_WISHLIST } = wishlistSlice.actions
+export default wishlistSlice
diff --git a/src/utils/utils.tsx b/src/utils/utils.tsx
new file mode 100644
index 0000000..332c5bb
--- /dev/null
+++ b/src/utils/utils.tsx
@@ -0,0 +1,46 @@
+export const dateFormat = (dateVal: string, type?: string) => {
+ const nowDate = new Date(dateVal)
+ const month = nowDate.getMonth()
+ const date = nowDate.getDate()
+ const hours = nowDate.getHours() < 10 ? `0${nowDate.getHours()}` : nowDate.getHours()
+ const minutes = nowDate.getMinutes() < 10 ? `0${nowDate.getMinutes()}` : nowDate.getMinutes()
+
+ if (type === 'comment' && nowDate.getDate() === new Date().getDate()) return `${hours}:${minutes}`
+ else if (type === 'comment') return `${month + 1}.${date} ${hours}:${minutes}`
+
+ return `${month + 1}월 ${date}일 ${hours}:${minutes}`
+}
+
+export const randomImages = (category: string, imgNum: number) => {
+ const randomNum = imgNum % 3
+ const publicPath = import.meta.env.BASE_URL
+ const badminton = ['badminton0.png', 'badminton1.png', 'badminton2.png']
+ const basketball = ['basketball0.png', 'basketball1.png', 'basketball2.png']
+ const soccer = ['soccer0.png', 'soccer1.png', 'soccer2.png']
+ const tennis = ['tennis0.png', 'tennis1.png', 'tennis2.png']
+ const futsal = ['futsal0.png', 'futsal1.png', 'futsal2.png']
+
+ type Category = {
+ 배드민턴장: string
+ 농구장: string
+ 축구장: string
+ 테니스장: string
+ 풋살장: string
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [prop: string]: any
+ }
+
+ const categoryId: Category = {
+ 배드민턴장: badminton[randomNum],
+ 농구장: basketball[randomNum],
+ 축구장: soccer[randomNum],
+ 테니스장: tennis[randomNum],
+ 풋살장: futsal[randomNum],
+ }
+
+ return publicPath + categoryId[category]
+}
+
+export const handleImgError = (e: React.SyntheticEvent, category: string, imgNum: number) => {
+ e.currentTarget.src = randomImages(category, imgNum)
+}
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 11f02fe..26762e2 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -1 +1,237 @@
///
+interface IUserInfoType {
+ userEmail?: string
+ userPw?: string
+ userName?: string
+ userNickName?: string
+ userPhone?: string
+ userVerifyNum?: string
+ newPw?: string
+}
+
+interface IUserInfoEditType {
+ status: number
+ memberId: string
+ memberName: string
+ memberNickName: string
+ memberPhone: string
+}
+
+interface IResponseType {
+ status: number
+ tokens: {
+ accessToken: string
+ refreshToken: string
+ }
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ data?: any
+}
+
+interface IResponseErrorType {
+ status?: number
+ state?: number
+}
+
+interface ISaveImgFile {
+ imgFile: File | null
+ imgSrc: string | undefined
+}
+
+interface POST_TYPE {
+ blind: boolean
+ boardId: number
+ categoryName: string
+ content: string
+ deleteCheck: boolean
+ districtName: string
+ endTime: string
+ imageUrl: string
+ memberId: string
+ memberName: string
+ memberNickName: string
+ myBoard: boolean
+ phone: string
+ price: number
+ registerDate: string
+ startTime: string
+ title: string
+ transactionStatus: string | null
+ viewCount: number
+ wishCount: number
+ likeBoard: boolean
+}
+
+interface POST_TYPE_INFO extends POST_TYPE {
+ memberRole: string
+}
+
+interface SearchValueTypes {
+ title: string
+ startDate: string
+ endDate: string
+ startTime: string
+ endTime: string
+ district: string[]
+ category: string
+ chkDate: boolean
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [prop: string]: any
+}
+
+interface IMainListPayload {
+ districtName: string
+ categoryName: string
+}
+
+interface ValueStateType {
+ categoryValue: string
+ districtValue: string[]
+ startTimeValue: string
+ endTimeValue: string
+ startDate: string
+ endDate: string
+ searchTextValue: string
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [prop: string]: any
+}
+
+interface CheckValueStateType {
+ categoryOpen: boolean
+ districtOpen: boolean
+ districtSelect: boolean
+ timeChange: boolean
+ startDateChange: boolean
+ endDateChange: boolean
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ [prop: string]: any
+}
+
+interface ICategories {
+ category: string
+ name: string
+ icon: ReactElement
+}
+
+interface CommentTypes {
+ children: CommentTypes[]
+ commentContent: string
+ commentId: number
+ commentRegisterDate: string
+ commentUpDate: string
+ deleteCheck: boolean
+ memberId: string
+ memberNickname: string
+ myComment: boolean
+ title: string
+}
+
+interface QuestionTypes {
+ answerTitle: string
+ answerContent: string
+}
+
+interface StyleProps {
+ screen: string
+}
+
+interface QuestionPostType {
+ questionTitle: string
+ questionContent: string
+ questionCategory: string
+}
+
+interface QuestionGetTypes {
+ answerId: number
+ questionCategory: string
+ questionContent: string
+ questionId: number
+ questionProcess: string
+ questionRegisterDate: string
+ questionTitle: string
+ questionUpdateDate: string
+}
+
+interface QuestionAnswerTypes {
+ answerContent: string
+ answerRegisterDate: string
+ answerTitle: string
+ memberName: string
+}
+
+//삭제예정
+interface IModalProps {
+ modalOpen: boolean
+ setModalOpen: React.Dispatch>
+ content: string[]
+ isConfirm: boolean
+ navigateOption?: string
+ confirmFn?: () => void
+}
+
+interface ITimeSelectorProps {
+ isTimeChange: boolean
+ setIsTimeChange: React.Dispatch>
+ setSelectedTime: React.Dispatch>
+ timeSelectorOpen: boolean
+ setTimeSelectorOpen: React.Dispatch>
+ timeTemp: string
+}
+
+interface IWishlistType {
+ boardId: number
+ title: string
+ memberName: string
+ categoryName: string
+ districtName: string
+ registerDate: string
+ startTime: string
+ endTime: string
+ imageUrl: string
+ transactionStatus: string
+ price: number
+ viewCount: number
+ wishCount: number
+}
+
+interface CustomDateInputProps {
+ value: string
+ onClick: () => void
+}
+
+interface IInfinityScrollProps {
+ payload: SearchValueTypes | IMainListPayload
+ page: number
+ setPostList: React.Dispatch>
+ setPage: React.Dispatch>
+}
+
+interface IModalPayload {
+ isModalOpen: boolean
+ isConfirm: boolean
+ content: string[]
+ navigateOption?: string | number
+ confirmAction?: () => void
+}
+
+interface IWritePostData {
+ categoryName: string
+ content: string
+ districtName: string
+ endTime: string
+ imageUrl: string
+ price: number
+ startTime: string
+ title: string
+}
+
+interface IWriteProps {
+ postData: IWritePostData
+ setPostData: React.Dispatch>
+ pageName: string
+ submitData: (formData: FormData) => Promise
+}
+
+interface IWriteInputsProps {
+ postData: IWritePostData
+ setPostData: React.Dispatch>
+}
diff --git a/tsconfig.json b/tsconfig.json
index a7fc6fb..f368432 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,8 +1,8 @@
{
"compilerOptions": {
- "target": "ES2020",
+ "target": "ES2021",
"useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
@@ -18,7 +18,13 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
+ "noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
+ "paths": {
+ "@src/*": ["src/*"],
+ "@components/*": ["src/components/*"],
+ "@pages/*": ["src/pages/*"]
+ }
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
diff --git a/vite.config.ts b/vite.config.ts
index 5a33944..7b6b398 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,7 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import tsconfigPaths from 'vite-tsconfig-paths'
// https://vitejs.dev/config/
export default defineConfig({
- plugins: [react()],
+ plugins: [react(), tsconfigPaths()],
+ resolve: {
+ alias: [
+ { find: '@src', replacement: '/src' },
+ { find: '@components', replacement: '/src/components' },
+ { find: '@pages', replacement: '/src/pages' },
+ ],
+ },
})