├── .github ├── ISSUE_TEMPLATE │ └── customissue.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── cd.yml │ └── dev-check.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── README.md ├── client ├── .env.template ├── .eslintrc.json ├── index.html ├── package-lock.json ├── package.json ├── src │ ├── App.tsx │ ├── Router.tsx │ ├── apis │ │ └── index.ts │ ├── assets │ │ ├── fonts │ │ │ ├── BMHANNAAir_ttf.ttf │ │ │ └── BMHANNAPro.ttf │ │ └── images │ │ │ ├── arrow-down.svg │ │ │ ├── backward-arrow-icon.svg │ │ │ ├── candidate-list.svg │ │ │ ├── chicken.svg │ │ │ ├── dumpling.svg │ │ │ ├── fake-word.svg │ │ │ ├── filled-like.svg │ │ │ ├── flag.svg │ │ │ ├── gps.svg │ │ │ ├── hamburger.svg │ │ │ ├── hotdog.svg │ │ │ ├── list-icon.svg │ │ │ ├── logo.svg │ │ │ ├── map-icon.svg │ │ │ ├── map-location-dot.svg │ │ │ ├── map-location.svg │ │ │ ├── phone-icon.svg │ │ │ ├── point-circle.svg │ │ │ ├── restaurant-default.jpg │ │ │ ├── rice.svg │ │ │ ├── search.svg │ │ │ ├── share.svg │ │ │ ├── shortcut.svg │ │ │ ├── spaghetti.svg │ │ │ ├── star-icon.svg │ │ │ ├── sushi.svg │ │ │ ├── unfilled-like.svg │ │ │ ├── user.svg │ │ │ ├── vote.svg │ │ │ └── x.svg │ ├── components │ │ ├── ActiveUserInfo │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── EmptyListPlaceholder │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── LinkShareButton │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── LoadingScreen │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── MainMap │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── MapController │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── MeetLocationSettingFooter │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── MeetLocationSettingMap │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── Modal │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantCandidateList │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantCategory │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantDetail │ │ │ ├── RestaurantDetailBody │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── RestaurantDetailCarousel │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── RestaurantDetailDrivingInfo │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── RestaurantDetailTitle │ │ │ │ ├── index.tsx │ │ │ │ └── styles.tsx │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantDetailLayer │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantFilteredList │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantListLayer │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantPreview │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantRow │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── RestaurantVoteButton │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── Toast │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── VirtualizedRestaurantList │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ ├── constants │ │ ├── category.ts │ │ ├── error.ts │ │ ├── map.ts │ │ ├── modal.ts │ │ ├── toast.ts │ │ ├── url.ts │ │ └── vote.ts │ ├── hooks │ │ ├── useCurrentLocation.tsx │ │ ├── useNaverMaps.tsx │ │ ├── useSocket.ts │ │ └── useToast.tsx │ ├── main.tsx │ ├── pages │ │ ├── ErrorPage │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── HomePage │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ ├── InitRoomPage │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ │ └── MainPage │ │ │ ├── index.tsx │ │ │ └── styles.tsx │ ├── store │ │ ├── index.tsx │ │ └── socket.tsx │ ├── styles │ │ ├── GlobalStyle.tsx │ │ ├── Variables.ts │ │ ├── font.css │ │ └── marker.module.css │ ├── types │ │ ├── location.d.ts │ │ ├── naverMapsMarkerClustering.d.ts │ │ ├── socket.d.ts │ │ └── svg.d.ts │ ├── utils │ │ ├── MarkerClustering.js │ │ ├── distance.ts │ │ └── time.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ├── script ├── client-deploy.sh └── server-deploy.sh └── server ├── .env.template ├── .eslintrc.js ├── .husky └── pre-commit ├── README.md ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.module.ts ├── cache │ ├── redis.module.ts │ └── redis.service.ts ├── common │ ├── exceptions │ │ └── custom.exception.ts │ ├── filters │ │ └── exception.filter.ts │ └── interceptors │ │ └── template.interceptor.ts ├── constants │ ├── api.ts │ ├── location.ts │ ├── nickname.ts │ ├── response │ │ ├── common.ts │ │ ├── index.ts │ │ └── location.ts │ ├── restaurant.ts │ └── time.ts ├── main.ts ├── map │ ├── dto │ │ └── get-driving-query.dto.ts │ ├── map.controller.ts │ ├── map.d.ts │ ├── map.module.ts │ ├── map.response.ts │ └── map.service.ts ├── restaurant │ ├── dto │ │ └── get-restaurant-detail-query.dto.ts │ ├── restaurant.controller.ts │ ├── restaurant.d.ts │ ├── restaurant.module.ts │ ├── restaurant.schema.ts │ └── restaurant.service.ts ├── room │ ├── dto │ │ ├── connect-room.dto.ts │ │ └── create-room.dto.ts │ ├── room.controller.ts │ ├── room.module.ts │ ├── room.response.ts │ ├── room.schema.ts │ └── room.service.ts ├── socket │ ├── dto │ │ ├── connect-room.dto.ts │ │ ├── user-location.dto.ts │ │ └── vote-restaurant.dto.ts │ ├── socket.d.ts │ ├── socket.gateway.ts │ ├── socket.module.ts │ └── socket.response.ts ├── task │ ├── task.module.ts │ └── task.service.ts └── utils │ ├── location.ts │ ├── nickname.ts │ ├── random.ts │ └── session.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.github/ISSUE_TEMPLATE/customissue.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: CustomIssue 3 | about: Describe this issue template's purpose here. 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 개요 11 | { 업무에 대한 요약 및 설명 } 12 | 13 |
14 | 15 | ## 체크리스트 16 | { 작업 체크리스트 } 17 | ### FE 18 | - [ ] 19 | 20 | ### BE 21 | - [ ] 22 | 23 |
24 | 25 | ## 비고 26 | { 기타 내용, 의존성있는 작업 } 27 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## 링크(관련 이슈) 2 | 3 |
4 | 5 | ## 개요 6 | { 업무에 대한 제목 혹은 요약 } 7 | 8 |
9 | 10 | ## 내용 11 | { 변경사항(작업내용) } 12 | 13 |
14 | 15 | ## 비고 16 | { 기타 내용, 의존성있는 작업, 스크린샷 } 17 | 18 |
19 | 20 | ## 리뷰어한테 할 말 21 | { 집중적으로 리뷰해줬으면 하는 부분 } -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Auto Deployment 2 | 3 | on: 4 | push: 5 | # prod, release 브랜치에 push 시 작동 6 | branches: 7 | - prod 8 | - release 9 | 10 | jobs: 11 | # push 의 타겟 브랜치가 release 일 때 작동하는 job 12 | deploy_release: 13 | if: contains(github.ref, 'release') 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: sshAction 18 | # with 의 정보와 함께 원격 접속 수행 19 | uses: appleboy/ssh-action@master 20 | with: 21 | host: ${{ secrets.RELEASE_HOST }} 22 | username: ${{ secrets.RELEASE_USERNAME }} 23 | password: ${{ secrets.RELEASE_PASSWORD }} 24 | script: | 25 | cd chobab_release 26 | git clean -fd 27 | git checkout release 28 | git pull origin release 29 | sh script/client-deploy.sh 30 | sh script/server-deploy.sh 31 | 32 | # push 의 타겟 브랜치가 main 일 때 작동하는 job 33 | deploy_prod: 34 | if: contains(github.ref, 'prod') 35 | runs-on: ubuntu-latest 36 | 37 | steps: 38 | - name: sshAction 39 | uses: appleboy/ssh-action@master 40 | with: 41 | host: ${{ secrets.PROD_HOST }} 42 | username: ${{ secrets.PROD_USERNAME }} 43 | password: ${{ secrets.PROD_PASSWORD }} 44 | script: | 45 | cd chobab 46 | git clean -fd 47 | git checkout prod 48 | git pull origin prod 49 | sh script/client-deploy.sh 50 | sh script/server-deploy.sh 51 | -------------------------------------------------------------------------------- /.github/workflows/dev-check.yml: -------------------------------------------------------------------------------- 1 | name: Lint + Test + Build Check 2 | 3 | on: 4 | pull_request: 5 | branches: [ dev ] 6 | 7 | jobs: 8 | changes: 9 | # client, server 폴더에 변화가 있는지 체크 10 | name: Check for changes 11 | runs-on: ubuntu-latest 12 | outputs: 13 | client: ${{steps.filter.outputs.client}} 14 | server: ${{steps.filter.outputs.server}} 15 | steps: 16 | - uses: dorny/paths-filter@v2 17 | id: filter 18 | with: 19 | filters: | 20 | client: 21 | - 'client/**' 22 | server: 23 | - 'server/**' 24 | 25 | client: 26 | # client 폴더에 변화가 있는 경우 Job이 실행됨 27 | needs: changes 28 | if : ${{ needs.changes.outputs.client == 'true' }} 29 | runs-on: ubuntu-latest 30 | defaults: 31 | run: 32 | working-directory: client 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | - name: Setup Node.js(v16.18.1) 37 | uses: actions/setup-node@v3 38 | with: 39 | node-version: 16.18.1 40 | 41 | - name: Cache Client dependencies 42 | id: client-cache 43 | uses: actions/cache@v3 44 | with: 45 | path: client/node_modules 46 | key: npm-packages-client-${{hashFiles('**/package-lock.json')}} 47 | restore-keys: | 48 | npm-packages-client- 49 | 50 | - name: Install Client dependencies 51 | if: ${{steps.client-cache.outputs.cache-hit != 'true'}} 52 | run: npm ci 53 | 54 | - name: Run Client Lint 55 | run: npm run lint --if-present 56 | 57 | - name: Run Client Test 58 | run: npm run test --if-present 59 | 60 | - name: Run Client Build 61 | run: npm run build --if-present 62 | 63 | server: 64 | # server 폴더에 변화가 있는 경우 Job이 실행됨 65 | needs: changes 66 | if : ${{ needs.changes.outputs.server == 'true' }} 67 | runs-on: ubuntu-latest 68 | defaults: 69 | run: 70 | working-directory: server 71 | 72 | steps: 73 | - uses: actions/checkout@v3 74 | - name: Setup Node.js(v16.18.1) 75 | uses: actions/setup-node@v3 76 | with: 77 | node-version: 16.18.1 78 | 79 | - name: Cache Server dependencies 80 | id: server-cache 81 | uses: actions/cache@v3 82 | with: 83 | path: server/node_modules 84 | key: npm-packages-server-${{hashFiles('**/package-lock.json')}} 85 | restore-keys: | 86 | npm-packages-server- 87 | 88 | - name: Install Server dependencies 89 | if: ${{steps.server-cache.outputs.cache-hit != 'true'}} 90 | run: npm ci 91 | 92 | - name: Run Server Lint 93 | run: npm run lint --if-present 94 | 95 | - name: Run Server Test 96 | run: npm run test --if-present 97 | 98 | - name: Run Server Build 99 | run: npm run build --if-present 100 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | dist 3 | node_modules 4 | dist-ssr 5 | *.local 6 | 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | vite.config.ts.timestamp-*.mjs 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDE 21 | .idea/ 22 | .vscode/ 23 | 24 | # env 25 | .env* 26 | !.env.template 27 | 28 | # session file store 29 | sessions/ 30 | 31 | # etc 32 | .gitkeep -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": true, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "BracketSameLine": false, 7 | "jsxSingleQuote": false, 8 | "printWidth": 100, 9 | "quoteProps": "as-needed", 10 | "semi": true, 11 | "singleQuote": true, 12 | "tabWidth": 2, 13 | "trailingComma": "es5", 14 | "useTabs": false, 15 | "jsxBracketSameLine": false, 16 | "overrides": [ 17 | { 18 | "files": "*.json", 19 | "options": { 20 | "printWidth": 200 21 | } 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍣 Cho Bab 2 | **Cho**ice **Bab** : 지도를 보며, 함께 갈 음식점을 선택하세요! 3 | 4 |
5 | 6 | ## :rice: Intro: 서비스 소개 7 | 여러명과 음식점을 선택할 때, 어렵지 않으셨나요?
8 | 함께 **지도**를 보며 음식점을 찾고, **투표**를 통해 음식점을 선택해보세요! 9 | 10 |
11 | 12 | ## :star: Key Features: 주요 기능 소개 13 | #### 1. 사용자 위치 기반 모임 장소 지정 기능 14 | 사용자의 위치를 기반으로 모임 장소를 선택하고, 모임방을 만들 수 있습니다. 15 | 16 | #### 2. 모임 장소 근처 음식점 조회 17 | 정해진 모임 장소 근처의 음식점을 지도, 리스트 원하는 형태로 편리하게 탐색할 수 있습니다. 18 | 19 | #### 3. 카테고리를 통한 음식점 필터링 20 | 음식 종류에 따라 음식점을 필터링할 수 있습니다. 21 | 22 | #### 4. 음식점 후보 등록 및 투표 기능 23 | 마음에 드는 음식점을 후보로 등록하고, 모임원들과 함께 투표할 수 있습니다. 24 | 25 |
26 | 27 | ## :iphone: Pages: 주요 화면 소개 28 | 29 | 스크린샷 2022-12-15 오후 6 03 17 30 | 31 | 스크린샷 2022-12-15 오후 6 04 45 32 | 33 | 34 | ## :hammer_and_wrench: Skills: 기술스택 35 | 스크린샷 2022-12-15 오후 5 10 26 36 | 37 | ## :gear: System Architecture: 시스템 아키텍처 38 | 39 | 스크린샷 2022-12-15 오후 6 11 06 40 | 41 | 42 | 43 | 44 | ## :desktop_computer: Skills: 실행방법 45 | - `.env` 파일 46 | - 환경 변수 파일인 .env는 client 폴더와 server에 각각 하나씩 필요합니다. 각 폴더 하위에 환경 변수 템플릿 파일로 넣어둔 .env.template을 복사하셔서 필요한 환경 변수를 세팅하시면 됩니다. 47 | - [자세한 설명](https://delicious-guys.notion.site/env-7262953cd0994e5594c2ea1752ca5f04) 48 | - 로컬에서 실행하기 위한 명령어 49 | - client 50 | ```bash 51 | cd client 52 | npm ci # 필요한 패키지 설치 53 | npm run dev # 개발 모드로 실행 54 | ``` 55 | - server 56 | ```bash 57 | cd server 58 | npm ci # 필요한 패키지 설치 59 | npm run start:dev # 개발 모드로 실행 60 | ``` 61 | 62 | 63 | ## :busts_in_silhouette: 맛있는 녀석들 구성원 64 | | J005 강윤희 | J018 권유리 | J086 박정현 | J162 이창명 | 65 | |:---:|:---:|:---:|:---:| 66 | ||||| 67 | |[@CodeDiary18](https://github.com/CodeDiary18)|[@YuriKwon](https://github.com/YuriKwon)|[@jeong57281](https://github.com/jeong57281)|[@One-armed-boy](https://github.com/One-armed-boy)| 68 | -------------------------------------------------------------------------------- /client/.env.template: -------------------------------------------------------------------------------- 1 | VITE_APP_NAVER_MAP_CLIENT_ID= -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["@typescript-eslint", "prettier"], 4 | "extends": ["airbnb", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended", "plugin:@typescript-eslint/recommended"], 5 | "rules": { 6 | "prettier/prettier": 0, 7 | "import/no-unresolved": "off", 8 | "import/prefer-default-export": "off", 9 | "react/jsx-uses-react": "off", 10 | "react/react-in-jsx-scope": "off", 11 | "react/jsx-filename-extension": ["warn", { "extensions": [".ts", ".tsx"] }], 12 | "import/extensions": [ 13 | "error", 14 | "ignorePackages", 15 | { 16 | "js": "never", 17 | "jsx": "never", 18 | "ts": "never", 19 | "tsx": "never", 20 | "": "never" 21 | } 22 | ] 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ChoBab 8 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview", 10 | "lint": "eslint \"{src,test}/**/*.{ts,tsx}\"" 11 | }, 12 | "dependencies": { 13 | "@vitejs/plugin-react": "^2.2.0", 14 | "axios": "^1.2.0", 15 | "framer-motion": "^7.6.18", 16 | "geolib": "^3.3.3", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-loader-spinner": "^5.3.4", 20 | "react-router-dom": "^6.4.3", 21 | "react-virtualized-auto-sizer": "^1.0.7", 22 | "react-window": "^1.8.8", 23 | "react-window-infinite-loader": "^1.0.8", 24 | "socket.io-client": "^4.5.3", 25 | "string-to-color": "^2.2.2", 26 | "styled-components": "^5.3.6", 27 | "vite": "^3.2.3", 28 | "vite-plugin-html-env": "^1.2.7", 29 | "vite-plugin-svgr": "^2.2.2", 30 | "zustand": "^4.1.4" 31 | }, 32 | "devDependencies": { 33 | "@types/navermaps": "^3.6.1", 34 | "@types/node": "^18.11.9", 35 | "@types/react": "^18.0.24", 36 | "@types/react-dom": "^18.0.8", 37 | "@types/react-virtualized-auto-sizer": "^1.0.1", 38 | "@types/react-window": "^1.8.5", 39 | "@types/react-window-infinite-loader": "^1.0.6", 40 | "@types/styled-components": "^5.1.26", 41 | "@typescript-eslint/eslint-plugin": "^5.43.0", 42 | "@typescript-eslint/parser": "^5.43.0", 43 | "eslint": "^8.27.0", 44 | "eslint-config-airbnb": "^19.0.4", 45 | "eslint-config-prettier": "^8.5.0", 46 | "eslint-plugin-import": "^2.26.0", 47 | "eslint-plugin-jsx-a11y": "^6.6.1", 48 | "eslint-plugin-prettier": "^4.2.1", 49 | "eslint-plugin-react": "^7.31.10", 50 | "eslint-plugin-react-hooks": "^4.6.0", 51 | "lint-staged": "^13.0.3", 52 | "prettier": "^2.7.1", 53 | "typescript": "^4.6.4" 54 | }, 55 | "engines": { 56 | "node": ">=16.0.0 <17.0.0" 57 | }, 58 | "lint-staged": { 59 | "*.{ts,tsx}": [ 60 | "eslint --fix" 61 | ] 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import Toast from '@components/Toast'; 2 | import Router from './Router'; 3 | import '@styles/font.css'; 4 | 5 | function App() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | ); 12 | } 13 | 14 | export default App; 15 | -------------------------------------------------------------------------------- /client/src/Router.tsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 2 | import GlobalStyle, { MainLayout } from '@styles/GlobalStyle'; 3 | import HomePage from '@pages/HomePage'; 4 | import InitRoomPage from '@pages/InitRoomPage'; 5 | import MainPage from '@pages/MainPage'; 6 | import ErrorPage from '@pages/ErrorPage'; 7 | import { ERROR_REASON } from '@constants/error'; 8 | import { URL_PATH } from '@constants/url'; 9 | 10 | function Router() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | } /> 18 | } /> 19 | } /> 20 | } 23 | /> 24 | } 27 | /> 28 | } 31 | /> 32 | } /> 33 | 34 | 35 | 36 | ); 37 | } 38 | 39 | export default Router; 40 | -------------------------------------------------------------------------------- /client/src/apis/index.ts: -------------------------------------------------------------------------------- 1 | import { API_URL } from '@constants/url'; 2 | import axios from 'axios'; 3 | 4 | export const apiService = { 5 | getRoomValid: async (roomCode: string) => { 6 | const { 7 | data: { 8 | data: { isRoomValid }, 9 | }, 10 | } = await axios.get>(API_URL.GET.ROOM_VALID, { 11 | params: { roomCode }, 12 | }); 13 | return isRoomValid; 14 | }, 15 | 16 | getDrivingInfoData: async ( 17 | startLat: number, 18 | startLng: number, 19 | goalLat: number, 20 | goalLng: number 21 | ) => { 22 | const { 23 | data: { data: drivingInfoData }, 24 | } = await axios.get>(API_URL.GET.DRIVING_INFO, { 25 | params: { 26 | start: `${startLng},${startLat}`, 27 | goal: `${goalLng},${goalLat}`, 28 | }, 29 | }); 30 | return drivingInfoData; 31 | }, 32 | 33 | postRoom: async (lat: number, lng: number) => { 34 | const { 35 | data: { 36 | data: { roomCode }, 37 | }, 38 | } = await axios.post>(API_URL.POST.CREATE_ROOM, { 39 | lat, 40 | lng, 41 | }); 42 | 43 | return roomCode; 44 | }, 45 | }; 46 | -------------------------------------------------------------------------------- /client/src/assets/fonts/BMHANNAAir_ttf.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web08-ChoBab/f61c0009b7f9a618de9b81536001e4cc300bb679/client/src/assets/fonts/BMHANNAAir_ttf.ttf -------------------------------------------------------------------------------- /client/src/assets/fonts/BMHANNAPro.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web08-ChoBab/f61c0009b7f9a618de9b81536001e4cc300bb679/client/src/assets/fonts/BMHANNAPro.ttf -------------------------------------------------------------------------------- /client/src/assets/images/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/images/backward-arrow-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/candidate-list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/images/fake-word.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/images/filled-like.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/images/flag.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/assets/images/gps.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/images/hamburger.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 29 | 33 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /client/src/assets/images/hotdog.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/list-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/assets/images/map-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/images/map-location-dot.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/map-location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/images/phone-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/point-circle.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/assets/images/restaurant-default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boostcampwm-2022/web08-ChoBab/f61c0009b7f9a618de9b81536001e4cc300bb679/client/src/assets/images/restaurant-default.jpg -------------------------------------------------------------------------------- /client/src/assets/images/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/images/share.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/images/shortcut.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/images/star-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/assets/images/sushi.svg: -------------------------------------------------------------------------------- 1 | 549-sushi -------------------------------------------------------------------------------- /client/src/assets/images/unfilled-like.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/images/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/assets/images/vote.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/assets/images/x.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/components/ActiveUserInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import Modal from '@components/Modal'; 2 | import React, { MutableRefObject, useEffect, useState } from 'react'; 3 | import { Socket } from 'socket.io-client'; 4 | import { ReactComponent as User } from '@assets/images/user.svg'; 5 | import stc from 'string-to-color'; 6 | import { 7 | ListToggleButton, 8 | ActiveUserInfoList, 9 | ActiveUserInfoItem, 10 | ActiveUserInfoBox, 11 | ActiveUserIconBox, 12 | ActiveUserInfoLayout, 13 | } from './styles'; 14 | 15 | interface PropsType { 16 | myId: string; 17 | myName: string; 18 | socketRef: MutableRefObject; 19 | joinList: Map; 20 | setJoinList: React.Dispatch>>; 21 | } 22 | 23 | function ActiveUserInfo({ myId, myName, socketRef, joinList, setJoinList }: PropsType) { 24 | const [isListOpen, setListOpen] = useState(false); 25 | 26 | useEffect(() => { 27 | const socket = socketRef.current; 28 | 29 | if (!socket) { 30 | return; 31 | } 32 | 33 | socket.on('join', (response: ResTemplateType) => { 34 | if (!response.data) { 35 | return; 36 | } 37 | 38 | /** 39 | * 멘토님이 조언해주신 부분 40 | * 내 정보(myId)가 초기화되지 않았을 때 요청 튕기기 41 | */ 42 | if (!myId) { 43 | return; 44 | } 45 | 46 | const userInfo = response.data; 47 | 48 | const { userId } = userInfo; 49 | 50 | setJoinList((prev) => { 51 | const newMap = new Map(prev); 52 | newMap.set(userId, userInfo); 53 | return newMap; 54 | }); 55 | }); 56 | 57 | socket.on('leave', (response: ResTemplateType) => { 58 | if (!response.data) { 59 | return; 60 | } 61 | 62 | if (!myId) { 63 | return; 64 | } 65 | 66 | const userId = response.data; 67 | 68 | setJoinList((prev) => { 69 | const newMap = new Map(prev); 70 | const userInfo = newMap.get(userId); 71 | 72 | if (!userInfo) { 73 | return newMap; 74 | } 75 | 76 | userInfo.isOnline = false; 77 | newMap.set(userId, userInfo); 78 | 79 | return newMap; 80 | }); 81 | }); 82 | }, []); 83 | 84 | const onlineUserList = [...joinList].filter(([userId, userInfo]) => userInfo.isOnline); 85 | const offlineUserList = [...joinList].filter(([userId, userInfo]) => !userInfo.isOnline); 86 | 87 | return ( 88 | 89 | { 92 | setListOpen(!isListOpen); 93 | 94 | event.stopPropagation(); 95 | }} 96 | > 97 | 98 | 99 | 100 | 101 | 102 |
  • 103 |

    접속자 총 {onlineUserList.length}명

    104 |
  • 105 | {onlineUserList.map(([userId, userInfo]) => { 106 | return ( 107 | 108 | 109 |

    110 | {userInfo.userName} 111 | {myId === userId && ' (나)'} 112 |

    113 |
    114 | ); 115 | })} 116 |
  • 117 |

    오프라인 {offlineUserList.length}명

    118 |
  • 119 | {offlineUserList.map(([userId, userInfo]) => { 120 | return ( 121 | 122 | 123 |

    124 | {userInfo.userName} 125 | {myId === userId && ' (나)'} 126 |

    127 |
    128 | ); 129 | })} 130 |
    131 |
    132 |
    133 |
    134 | ); 135 | } 136 | 137 | export default ActiveUserInfo; 138 | -------------------------------------------------------------------------------- /client/src/components/ActiveUserInfo/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const ActiveUserInfoLayout = styled.div` 5 | height: 100%; 6 | `; 7 | 8 | export const ListToggleButton = styled.button` 9 | border: none; 10 | background: none; 11 | height: 100%; 12 | aspect-ratio: 1 / 1; 13 | cursor: pointer; 14 | `; 15 | 16 | export const ActiveUserInfoBox = styled.div` 17 | // TODO: 색상변수 정리 필요 18 | background-color: white; 19 | font-size: 11px; 20 | padding: 10px; 21 | width: 220px; 22 | max-height: 200px; 23 | box-shadow: 0px 4px 4px rgba(104, 94, 94, 0.25), inset 0px 4px 4px rgba(0, 0, 0, 0.25); 24 | 25 | overflow-y: overlay; 26 | &::-webkit-scrollbar { 27 | width: 8px; 28 | background-color: ${palette.SCROLL_BAR_COLOR}; 29 | } 30 | 31 | &::-webkit-scrollbar-thumb { 32 | background-color: ${palette.SCROLL_THUMB_COLOR}; 33 | border-radius: 4px; 34 | } 35 | `; 36 | 37 | export const ActiveUserInfoList = styled.ul` 38 | li { 39 | list-style: none; 40 | } 41 | `; 42 | 43 | export const ActiveUserInfoItem = styled.li` 44 | display: flex; 45 | align-items: center; 46 | margin: 10px 0; 47 | `; 48 | 49 | export const ActiveUserIconBox = styled.div` 50 | width: 10px; 51 | height: 10px; 52 | margin: 5px; 53 | border-radius: 100px; 54 | background-color: gray; 55 | margin-right: 5px; 56 | `; 57 | -------------------------------------------------------------------------------- /client/src/components/EmptyListPlaceholder/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | EmptyListPlaceholderGuideBox, 3 | EmptyListPlaceholderParagraph, 4 | EmptyListPlaceholderIconBox, 5 | } from './styles'; 6 | 7 | function EmptyListPlaceholder() { 8 | return ( 9 | 10 | 11 | {['컾', '핒', '짲', '잌', '칰'][Math.floor(Math.random() * 5)]} 12 | 13 | 목록이 텅~ 비었어요 14 | 15 | ); 16 | } 17 | 18 | export default EmptyListPlaceholder; 19 | -------------------------------------------------------------------------------- /client/src/components/EmptyListPlaceholder/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const EmptyListPlaceholderGuideBox = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | `; 12 | 13 | export const EmptyListPlaceholderIconBox = styled.div` 14 | font-size: 8rem; 15 | font-weight: bold; 16 | `; 17 | 18 | export const EmptyListPlaceholderParagraph = styled.p` 19 | border: 0.45rem solid black; 20 | border-radius: 5px; 21 | background-color: white; 22 | font-weight: bold; 23 | padding: 3%; 24 | `; 25 | -------------------------------------------------------------------------------- /client/src/components/LinkShareButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactComponent as ShareImage } from '@assets/images/share.svg'; 2 | import { TOAST_DURATION_TIME, SUCCCESS_COPY_MESSAGE, FAIL_COPY_MESSAGE } from '@constants/toast'; 3 | import { useToast } from '@hooks/useToast'; 4 | import { ButtonBox } from './styles'; 5 | 6 | function LinkShareButton() { 7 | const { fireToast } = useToast(); 8 | const location = window.location.href; // 현재 URL 9 | 10 | const copyClipboard = async (text: string) => { 11 | try { 12 | await navigator.clipboard.writeText(text); 13 | fireToast({ content: SUCCCESS_COPY_MESSAGE, duration: TOAST_DURATION_TIME, bottom: 80 }); 14 | } catch (error) { 15 | fireToast({ content: FAIL_COPY_MESSAGE, duration: TOAST_DURATION_TIME, bottom: 80 }); 16 | } 17 | }; 18 | 19 | const handleClick = () => { 20 | // Web share API 사용 가능 환경 -> 공유 레이어 띄우기 21 | if (navigator.share) { 22 | navigator 23 | .share({ 24 | title: '[ChoBab] 모임방 링크 : 링크를 통해 모임에 참여하세요!', 25 | url: location, 26 | }) 27 | .catch((error) => { 28 | console.log(error); 29 | }); 30 | return; 31 | } 32 | // Web share API 사용 불가능 환경 -> 클립보드 복사 기능 33 | copyClipboard(location); 34 | }; 35 | 36 | return ( 37 | 38 | 41 | 42 | ); 43 | } 44 | 45 | export default LinkShareButton; 46 | -------------------------------------------------------------------------------- /client/src/components/LinkShareButton/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ButtonBox = styled.div` 4 | height: 100%; 5 | aspect-ratio: 1 / 1; 6 | display: flex; 7 | justify-content: center; 8 | align-items: center; 9 | 10 | button { 11 | border: none; 12 | background: none; 13 | } 14 | `; 15 | -------------------------------------------------------------------------------- /client/src/components/LoadingScreen/index.tsx: -------------------------------------------------------------------------------- 1 | import { Oval } from 'react-loader-spinner'; 2 | 3 | import { LoadingContentsLayout, LoadingSpinnerBox, LoadingMessageParagraph } from './styles'; 4 | 5 | interface PropType { 6 | size: string; // 스피너 크기 - large, small 7 | // eslint-disable-next-line react/require-default-props 8 | message?: string; 9 | } 10 | 11 | function LoadingScreen({ size, message }: PropType) { 12 | return ( 13 | 14 | 15 | 26 | 27 | {message} 28 | 29 | ); 30 | } 31 | 32 | export default LoadingScreen; 33 | -------------------------------------------------------------------------------- /client/src/components/LoadingScreen/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | interface LoadingSpinnerPropsType { 4 | size: string; 5 | } 6 | 7 | export const LoadingContentsLayout = styled.div` 8 | display: flex; 9 | flex-direction: column; 10 | text-align: center; 11 | justify-content: center; 12 | align-items: center; 13 | gap: 1rem; 14 | `; 15 | 16 | export const LoadingSpinnerBox = styled.div` 17 | margin-top: ${({ size }) => (size === 'large' ? '25vh' : '5vh')}; 18 | `; 19 | 20 | export const LoadingMessageParagraph = styled.p` 21 | font-size: 1.5rem; 22 | `; 23 | -------------------------------------------------------------------------------- /client/src/components/MainMap/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const MapLayout = styled.div` 5 | width: 100%; 6 | height: 100%; 7 | `; 8 | 9 | export const MapLoadingBox = styled.div` 10 | position: absolute; 11 | top: 0; 12 | left: 0; 13 | right: 0; 14 | bottom: 0; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | z-index: 2; 19 | `; 20 | 21 | export const MapBox = styled.div` 22 | width: 100%; 23 | height: 100%; 24 | z-index: 1; 25 | 26 | position: absolute; 27 | 28 | &:focus-visible { 29 | outline: none; 30 | } 31 | `; 32 | -------------------------------------------------------------------------------- /client/src/components/MapController/index.tsx: -------------------------------------------------------------------------------- 1 | import { Socket } from 'socket.io-client'; 2 | 3 | import { ReactComponent as GpsIcon } from '@assets/images/gps.svg'; 4 | import { ReactComponent as PointCircleIcon } from '@assets/images/point-circle.svg'; 5 | 6 | import useCurrentLocation from '@hooks/useCurrentLocation'; 7 | 8 | import { useSocketStore } from '@store/socket'; 9 | import { useMeetLocationStore, useMapStore } from '@store/index'; 10 | 11 | import { DEFAULT_ZOOM } from '@constants/map'; 12 | 13 | import { MapControlBox } from './styles'; 14 | 15 | function MapController() { 16 | const { getCurrentLocation, updateUserLocation } = useCurrentLocation(); 17 | const { socket } = useSocketStore((state) => state); 18 | const { map } = useMapStore((state) => state); 19 | const { meetLocation } = useMeetLocationStore(); 20 | 21 | return ( 22 | 23 | 36 | 53 | 54 | ); 55 | } 56 | 57 | export default MapController; 58 | -------------------------------------------------------------------------------- /client/src/components/MapController/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const MapControlBox = styled.div` 5 | position: absolute; 6 | left: 0; 7 | bottom: 0; 8 | margin: 0 0 18px 8px; 9 | z-index: ${palette.MAP_CONTROLLER_Z_INDEX}; 10 | gap: 10px; 11 | display: flex; 12 | flex-direction: column; 13 | 14 | button { 15 | width: 40px; 16 | height: 40px; 17 | background-color: white; 18 | border: none; 19 | border-radius: 5px; 20 | display: flex; 21 | justify-content: center; 22 | align-items: center; 23 | box-shadow: 0px 0px 4px rgb(0 0 0 / 50%); 24 | } 25 | `; 26 | -------------------------------------------------------------------------------- /client/src/components/MeetLocationSettingFooter/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { ReactComponent as SearchImage } from '@assets/images/search.svg'; 4 | import { NAVER_ADDRESS } from '@constants/map'; 5 | import { 6 | FAIL_SEARCH_MESSAGE, 7 | FAIL_UPDATE_ADDR_MESSAGE, 8 | NO_RESULTS_MESSAGE, 9 | TOAST_DURATION_TIME, 10 | } from '@constants/toast'; 11 | import { useMeetLocationStore } from '@store/index'; 12 | import { useToast } from '@hooks/useToast'; 13 | 14 | import { apiService } from '@apis/index'; 15 | import { URL_PATH } from '@constants/url'; 16 | import { AddressBox, FooterBox, GuideTextBox, SearchBarBox, StartButton } from './styles'; 17 | 18 | function MeetLocationSettingFooter() { 19 | const [address, setAddress] = useState(NAVER_ADDRESS); 20 | const [isCreateRoomLoading, setCreateRoomLoading] = useState(false); 21 | const { meetLocation, updateMeetLocation } = useMeetLocationStore((state) => state); 22 | const inputRef = useRef(null); 23 | const navigate = useNavigate(); 24 | const { fireToast } = useToast(); 25 | 26 | // 좌표 -> 주소 변환 & setAddress 27 | const updateAddress = (lat: number, lng: number) => { 28 | naver.maps.Service.reverseGeocode( 29 | { 30 | coords: new naver.maps.LatLng(lat, lng), 31 | orders: [naver.maps.Service.OrderType.ROAD_ADDR, naver.maps.Service.OrderType.ADDR].join( 32 | ',' 33 | ), 34 | }, 35 | // eslint-disable-next-line consistent-return 36 | (status, response) => { 37 | if (status !== naver.maps.Service.Status.OK) { 38 | fireToast({ 39 | content: FAIL_UPDATE_ADDR_MESSAGE, 40 | duration: TOAST_DURATION_TIME, 41 | bottom: 280, 42 | }); 43 | return; 44 | } 45 | 46 | setAddress(response.v2.address.roadAddress || response.v2.address.jibunAddress); 47 | } 48 | ); 49 | }; 50 | 51 | // 모임 위치(전역 상태) 변경 시 주소 업데이트 52 | useEffect(() => { 53 | if (!meetLocation) { 54 | return; 55 | } 56 | updateAddress(meetLocation.lat, meetLocation.lng); 57 | }, [meetLocation]); 58 | 59 | const handleClick = () => { 60 | if (!inputRef.current) { 61 | return; 62 | } 63 | 64 | const searchWord = inputRef.current.value; 65 | 66 | naver.maps.Service.geocode( 67 | { 68 | query: searchWord, 69 | }, 70 | // eslint-disable-next-line func-names, consistent-return 71 | function (status, response) { 72 | if (status !== naver.maps.Service.Status.OK) { 73 | fireToast({ 74 | content: FAIL_SEARCH_MESSAGE, 75 | duration: TOAST_DURATION_TIME, 76 | bottom: 280, 77 | }); 78 | return; 79 | } 80 | 81 | const result = response.v2; // 검색 결과의 컨테이너 82 | const items = result.addresses; // 검색 결과의 배열 83 | 84 | if (items.length === 0) { 85 | fireToast({ content: NO_RESULTS_MESSAGE, duration: TOAST_DURATION_TIME, bottom: 280 }); 86 | return; 87 | } 88 | 89 | // 첫번째 검색 결과로 처리 90 | const firstSearchResult = items[0]; 91 | updateMeetLocation({ lat: +firstSearchResult.y, lng: +firstSearchResult.x }); 92 | } 93 | ); 94 | }; 95 | 96 | const initRoom = async () => { 97 | if (!meetLocation) { 98 | return; 99 | } 100 | const { lat, lng } = meetLocation; 101 | setCreateRoomLoading(true); 102 | try { 103 | const roomCode = await apiService.postRoom(lat, lng); 104 | 105 | navigate(`${URL_PATH.JOIN_ROOM}/${roomCode}`); 106 | } catch (error: any) { 107 | if (error.response.status === 500) { 108 | navigate(URL_PATH.INTERNAL_SERVER_ERROR); 109 | return; 110 | } 111 | navigate(URL_PATH.FAIL_CREATE_ROOM); 112 | } 113 | }; 114 | 115 | return ( 116 | 117 | 118 |

    119 | 모임 위치를 정해주세요! 120 |

    121 |
    122 | 123 | 124 | 125 | 128 | 129 | 130 | {address} 131 | 132 | { 136 | if (isCreateRoomLoading) { 137 | return; 138 | } 139 | e.preventDefault(); 140 | initRoom(); 141 | }} 142 | > 143 | {isCreateRoomLoading ? '모임 생성 중...' : '시작하기'} 144 | 145 |
    146 | ); 147 | } 148 | 149 | export default MeetLocationSettingFooter; 150 | -------------------------------------------------------------------------------- /client/src/components/MeetLocationSettingFooter/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const FooterBox = styled.div` 5 | width: 100%; 6 | height: 35%; 7 | background-color: white; 8 | position: absolute; 9 | bottom: 0px; 10 | z-index: 3; 11 | 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: center; 15 | align-items: center; 16 | gap: 1rem; 17 | 18 | border-top-left-radius: 30px 30px; 19 | border-top-right-radius: 30px 30px; 20 | border-top: 2px solid ${palette.BORDER}; 21 | `; 22 | 23 | export const GuideTextBox = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | align-items: center; 27 | `; 28 | 29 | export const SearchBarBox = styled.div` 30 | display: flex; 31 | border: 1.5px solid ${palette.BORDER}; 32 | border-radius: 8px; 33 | padding: 0.5rem; 34 | width: 40%; 35 | 36 | input { 37 | width: 80%; 38 | border: none; 39 | outline: none; 40 | padding: 0 0.5rem; 41 | } 42 | button { 43 | width: 20%; 44 | border: none; 45 | outline: none; 46 | background-color: transparent; 47 | } 48 | `; 49 | 50 | export const StartButton = styled.button` 51 | width: 8rem; 52 | height: 2.5rem; 53 | text-align: center; 54 | text-decoration: none; 55 | background-color: black; 56 | color: white; 57 | cursor: pointer; 58 | border: none; 59 | border-radius: 4px; 60 | `; 61 | 62 | export const AddressBox = styled.div` 63 | width: 55%; 64 | height: 2rem; 65 | overflow: hidden; 66 | text-overflow: ellipsis; 67 | text-align: center; 68 | display: -webkit-box; 69 | -webkit-line-clamp: 2; 70 | -webkit-box-orient: vertical; 71 | ` -------------------------------------------------------------------------------- /client/src/components/MeetLocationSettingMap/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { ReactComponent as FlagIcon } from '@assets/images/flag.svg'; 3 | import { useMeetLocationStore } from '@store/index'; 4 | import { useNaverMaps } from '@hooks/useNaverMaps'; 5 | import { MapBox, MarkerBox } from './styles'; 6 | 7 | function MeetLocationSettingMap() { 8 | const [mapRef, mapDivRef] = useNaverMaps(); 9 | const { meetLocation, updateMeetLocation } = useMeetLocationStore((state) => state); 10 | 11 | // dragEnd 이벤트 핸들러 생성 12 | const onDragEnd = (map: naver.maps.Map): naver.maps.MapEventListener => { 13 | const dragEndListener = naver.maps.Event.addListener(map, 'dragend', () => { 14 | const lat = map.getCenter().y; 15 | const lng = map.getCenter().x; 16 | 17 | updateMeetLocation({ lat, lng }); 18 | }); 19 | 20 | return dragEndListener; 21 | }; 22 | 23 | // zoom_changed 이벤트 핸들러 생성 24 | const onZoomChanged = (map: naver.maps.Map): naver.maps.MapEventListener => { 25 | const zoomChangedListener = naver.maps.Event.addListener(map, 'zoom_changed', () => { 26 | const lat = map.getCenter().y; 27 | const lng = map.getCenter().x; 28 | 29 | updateMeetLocation({ lat, lng }); 30 | }); 31 | 32 | return zoomChangedListener; 33 | }; 34 | 35 | useEffect(() => { 36 | if (!mapRef.current) { 37 | return; 38 | } 39 | 40 | const dragEndListener = onDragEnd(mapRef.current); 41 | const zoomChangedListener = onZoomChanged(mapRef.current); 42 | 43 | // eslint-disable-next-line consistent-return 44 | return () => { 45 | naver.maps.Event.removeListener(dragEndListener); 46 | naver.maps.Event.removeListener(zoomChangedListener); 47 | }; 48 | }, []); 49 | 50 | // 모임 위치(전역 상태) 변경 시 지도 화면 이동 51 | useEffect(() => { 52 | if (!mapRef.current) { 53 | return; 54 | } 55 | if (!meetLocation) { 56 | return; 57 | } 58 | 59 | mapRef.current.setCenter({ x: meetLocation.lng, y: meetLocation.lat }); 60 | }, [meetLocation]); 61 | 62 | return ( 63 | 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | export default MeetLocationSettingMap; 72 | -------------------------------------------------------------------------------- /client/src/components/MeetLocationSettingMap/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MapBox = styled.div` 4 | width: 100%; 5 | height: 70%; 6 | z-index: 1; 7 | 8 | position: absolute; 9 | 10 | &:focus-visible { 11 | outline: none; 12 | } 13 | `; 14 | 15 | export const MarkerBox = styled.div` 16 | z-index: 2; 17 | position: relative; 18 | pointer-events: none; 19 | // 마커 이미지 크기를 고려했을 때, 마커의 끝을 43% 지점에 찍어야 지도 중앙을 가리킴 20 | top: 43%; 21 | left: 50%; 22 | `; 23 | -------------------------------------------------------------------------------- /client/src/components/Modal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { ModalLayout } from './styles'; 3 | 4 | interface ModalProps { 5 | isOpen: boolean; 6 | setIsOpen: (status: boolean) => void; 7 | children: React.ReactNode; 8 | } 9 | 10 | function Modal({ isOpen, setIsOpen, children }: ModalProps) { 11 | const modalRef = useRef(null); 12 | 13 | useEffect(() => { 14 | const modalCloseWindowEvent = (e: Event) => { 15 | const { target } = e; 16 | 17 | if (modalRef.current && target instanceof HTMLElement && modalRef.current.contains(target)) { 18 | return; 19 | } 20 | 21 | setIsOpen(false); 22 | }; 23 | 24 | window.addEventListener('click', modalCloseWindowEvent); 25 | 26 | return () => { 27 | window.removeEventListener('click', modalCloseWindowEvent); 28 | }; 29 | }, []); 30 | 31 | return {isOpen && children} ; 32 | } 33 | 34 | export default Modal; 35 | -------------------------------------------------------------------------------- /client/src/components/Modal/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalLayout = styled.div` 4 | position: absolute; 5 | width: inherit; 6 | `; 7 | -------------------------------------------------------------------------------- /client/src/components/RestaurantCandidateList/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import RestaurantRow from '@components/RestaurantRow'; 4 | import { RESTAURANT_DETAIL_TYPES, RESTAURANT_LIST_TYPES } from '@constants/modal'; 5 | 6 | import { useSocketStore } from '@store/socket'; 7 | import { Socket } from 'socket.io-client'; 8 | import EmptyListPlaceholder from '@components/EmptyListPlaceholder'; 9 | import { useRestaurantDetailLayerStatusStore, useSelectedRestaurantDataStore } from '@store/index'; 10 | import { CandidateItem, CandidateListModalBox, CandidateListModalLayout } from './styles'; 11 | 12 | interface CandidateType extends RestaurantType { 13 | count?: number; 14 | } 15 | 16 | interface PropsType { 17 | restaurantData: RestaurantType[]; 18 | } 19 | 20 | interface VoteDataType { 21 | restaurantId: string; 22 | count: number; 23 | } 24 | 25 | interface VoteResultType { 26 | message: string; 27 | data?: { candidateList: VoteDataType[] }; 28 | } 29 | 30 | export function CandidateListModal({ restaurantData }: PropsType) { 31 | const { socket } = useSocketStore((state) => state); 32 | const [candidateData, setCandidateData] = useState([]); // 투표된 음식점의 정보 데이터 33 | const { updateRestaurantDetailLayerStatus } = useRestaurantDetailLayerStatusStore( 34 | (state) => state 35 | ); 36 | const { updateSelectedRestaurantData } = useSelectedRestaurantDataStore((state) => state); 37 | 38 | const compare = (a: CandidateType, b: CandidateType): number => { 39 | const aCount = a.count || 0; 40 | const bCount = b.count || 0; 41 | if (aCount > bCount) { 42 | return -1; 43 | } 44 | return 1; 45 | }; 46 | 47 | const makeCandidateData = (candidateList: VoteDataType[]): CandidateType[] => { 48 | const tempList: CandidateType[] = []; 49 | 50 | // voteList에 있는 후보 음식점들의 상세정보를 candidateData에 세팅 51 | restaurantData.forEach((restaurantItem) => { 52 | candidateList.forEach((voteItem: VoteDataType) => { 53 | if (restaurantItem.id === voteItem.restaurantId) { 54 | const tempItem: CandidateType = { ...restaurantItem }; 55 | 56 | // 좋아요 수 렌더링을 위해 음식점 상세정보에 투표 count값 추가 57 | tempItem.count = voteItem.count; 58 | tempList.push(tempItem); 59 | } 60 | }); 61 | }); 62 | 63 | return tempList; 64 | }; 65 | 66 | useEffect(() => { 67 | if (!(socket instanceof Socket)) { 68 | return; 69 | } 70 | 71 | socket.on('currentVoteResult', (result: VoteResultType) => { 72 | if (!result.data) { 73 | return; 74 | } 75 | 76 | setCandidateData(makeCandidateData(result.data.candidateList)); 77 | }); 78 | // 투표 결과 요청 79 | socket.emit('getVoteResult'); 80 | 81 | // 투표 결과 업데이트 (다른 참여자가 투표하는 경우 발생) 82 | socket.on('voteResultUpdate', (result: VoteResultType) => { 83 | if (!result.data) { 84 | return; 85 | } 86 | 87 | setCandidateData(makeCandidateData(result.data.candidateList)); 88 | }); 89 | }, []); 90 | 91 | return ( 92 | 93 | {!candidateData.length ? ( 94 | 95 | ) : ( 96 | 97 | {[...candidateData].sort(compare).map((candidate: CandidateType) => { 98 | return ( 99 | { 101 | updateRestaurantDetailLayerStatus(RESTAURANT_DETAIL_TYPES.show); 102 | updateSelectedRestaurantData(candidate); 103 | }} 104 | key={candidate.id} 105 | > 106 | 112 | 113 | ); 114 | })} 115 | 116 | )} 117 | 118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /client/src/components/RestaurantCandidateList/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const CandidateListModalLayout = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | padding: 10% 5%; 7 | background-color: white; 8 | overflow-y: scroll; 9 | `; 10 | 11 | export const CandidateListModalBox = styled.div` 12 | width: 100%; 13 | `; 14 | 15 | export const CandidateItem = styled.li` 16 | list-style: none; 17 | padding-bottom: 5%; 18 | &:last-child { 19 | padding-bottom: 0; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /client/src/components/RestaurantCategory/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import Modal from '@components/Modal'; 3 | import { CATEGORY_TYPE } from '@constants/category'; 4 | import { useSelectedCategoryStore } from '@store/index'; 5 | import { ReactComponent as ArrowDown } from '@assets/images/arrow-down.svg'; 6 | import { 7 | RestaurantCategoryGuideParagraph, 8 | RestaurantCategoryToggleButton, 9 | RestaurantCategoryControlBarBox, 10 | RestaurantCategoryBox, 11 | RestaurantCategoryLayout, 12 | RestaurantCategoryList, 13 | RestaurantCategoryItem, 14 | } from './styles'; 15 | 16 | function RestaurantCategory() { 17 | const [isModalOpen, setModalOpen] = useState(false); 18 | 19 | const { selectedCategoryData, updateSelectedCategoryData } = useSelectedCategoryStore( 20 | (state) => state 21 | ); 22 | 23 | const handleToggleCategory = (categoryName: CATEGORY_TYPE | null): (() => void) => { 24 | return (): void => { 25 | // '전체'가 선택된 경우 26 | if (!categoryName) { 27 | updateSelectedCategoryData(new Set()); 28 | return; 29 | } 30 | 31 | // '카테고리'가 선택된 경우 32 | const newSelectedCategoryData = new Set(selectedCategoryData); 33 | 34 | if (selectedCategoryData.has(categoryName)) { 35 | newSelectedCategoryData.delete(categoryName); 36 | } else { 37 | newSelectedCategoryData.add(categoryName); 38 | } 39 | 40 | // '카테고리'가 전부 선택된 경우 41 | if (newSelectedCategoryData.size === Object.keys(CATEGORY_TYPE).length) { 42 | updateSelectedCategoryData(new Set()); 43 | return; 44 | } 45 | 46 | updateSelectedCategoryData(newSelectedCategoryData); 47 | }; 48 | }; 49 | 50 | return ( 51 | 52 | 53 | 54 | {!selectedCategoryData.size 55 | ? '먹고싶은 음식을 선택해주세요!' 56 | : [...selectedCategoryData].join(', ')} 57 | 58 | { 62 | setModalOpen(!isModalOpen); 63 | 64 | event.stopPropagation(); 65 | }} 66 | > 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 78 | 전체 79 | 80 | {Object.values(CATEGORY_TYPE).map((categoryName, index) => { 81 | return ( 82 | 89 | {categoryName} 90 | 91 | ); 92 | })} 93 | 94 | 95 | 96 | 97 | ); 98 | } 99 | 100 | export default RestaurantCategory; 101 | -------------------------------------------------------------------------------- /client/src/components/RestaurantCategory/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const RestaurantCategoryLayout = styled.div` 5 | width: 100%; 6 | height: 100%; 7 | `; 8 | 9 | export const RestaurantCategoryControlBarBox = styled.div` 10 | display: flex; 11 | align-items: center; 12 | 13 | width: 100%; 14 | height: 100%; 15 | `; 16 | 17 | export const RestaurantCategoryGuideParagraph = styled.p` 18 | width: 85%; 19 | padding-left: 3%; 20 | `; 21 | 22 | interface CategoryToggleButtonStateType { 23 | isOpen: boolean; 24 | } 25 | 26 | export const RestaurantCategoryToggleButton = styled.button` 27 | width: 15%; 28 | height: 100%; 29 | background: none; 30 | 31 | border: 0; 32 | border-left: 1px solid ${palette.PRIMARY}; 33 | 34 | display: flex; 35 | align-items: center; 36 | justify-content: center; 37 | 38 | svg { 39 | transform: rotate(${({ isOpen }) => (isOpen ? '-180deg' : '0')}); 40 | } 41 | `; 42 | 43 | export const RestaurantCategoryBox = styled.div` 44 | position: absolute; 45 | 46 | left: 0; 47 | right: 0; 48 | 49 | padding: 3%; 50 | 51 | background: white; 52 | box-shadow: 0px 4px 4px rgb(104 94 94 / 25%), inset 0px 4px 4px rgb(0 0 0 / 25%); 53 | `; 54 | 55 | export const RestaurantCategoryList = styled.ul` 56 | display: flex; 57 | align-items: center; 58 | justify-content: center; 59 | flex-wrap: wrap; 60 | `; 61 | 62 | interface SelectedCategoryType { 63 | isSelect: boolean; 64 | } 65 | 66 | export const RestaurantCategoryItem = styled.li` 67 | list-style: none; 68 | 69 | width: 20%; 70 | 71 | border: 1px solid ${palette.BORDER}; 72 | border-radius: 5px; 73 | 74 | text-align: center; 75 | 76 | margin: 2%; 77 | padding: 2% 1%; 78 | 79 | background-color: ${({ isSelect }) => (isSelect ? palette.PRIMARY : 'transparent')}; 80 | color: ${({ isSelect }) => (isSelect ? 'white' : 'black')}; 81 | 82 | white-space: nowrap; 83 | overflow: hidden; 84 | //text-overflow: ellipsis; 85 | `; 86 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailBody/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from 'react'; 2 | import * as palette from '@styles/Variables'; 3 | import { ReactComponent as FlagIcon } from '@assets/images/flag.svg'; 4 | import { ReactComponent as PhoneIcon } from '@assets/images/phone-icon.svg'; 5 | import { ReactComponent as ShortcutIcon } from '@assets/images/shortcut.svg'; 6 | import RestaurantDetailDrivingInfo from '@components/RestaurantDetail/RestaurantDetailDrivingInfo'; 7 | 8 | import { 9 | MapBox, 10 | MapLayout, 11 | ModalBody, 12 | ModalBodyContent, 13 | ModalBodyNav, 14 | RestaurantDetailTable, 15 | } from './styles'; 16 | 17 | interface PropsType { 18 | address: string; 19 | lat: number; 20 | lng: number; 21 | phone: string; 22 | url: string; 23 | } 24 | 25 | export function RestaurantDetailBody({ address, lat, lng, phone, url }: PropsType) { 26 | const restaurantPos = { lat, lng }; 27 | const [isSelectLeft, setSelectLeft] = useState(true); 28 | const operationInfoButtonRef = useRef(null); 29 | const getDirectionButtonRef = useRef(null); 30 | const mapRef = useRef(null); 31 | 32 | const mapSetting = useCallback(() => { 33 | if (!isSelectLeft) { 34 | return; 35 | } 36 | 37 | if (!mapRef.current) { 38 | return; 39 | } 40 | 41 | const restaurantLocation = new naver.maps.LatLng(lat, lng); 42 | 43 | const map = new naver.maps.Map(mapRef.current, { 44 | center: restaurantLocation, 45 | scrollWheel: false, 46 | }); 47 | 48 | const marker = new naver.maps.Marker({ 49 | map, 50 | position: restaurantLocation, 51 | }); 52 | }, [isSelectLeft]); 53 | 54 | useEffect(() => { 55 | const operationInfoButton = operationInfoButtonRef.current; 56 | const getDirectionButton = getDirectionButtonRef.current; 57 | 58 | if (!operationInfoButton || !getDirectionButton) { 59 | return; 60 | } 61 | 62 | mapSetting(); 63 | 64 | if (!isSelectLeft) { 65 | getDirectionButton.style.color = palette.PRIMARY; 66 | operationInfoButton.style.color = 'black'; 67 | return; 68 | } 69 | 70 | getDirectionButton.style.color = 'black'; 71 | operationInfoButton.style.color = palette.PRIMARY; 72 | }, [isSelectLeft]); 73 | 74 | return ( 75 | 76 | { 78 | if (!(e.target instanceof HTMLDivElement)) { 79 | return; 80 | } 81 | 82 | const eventTarget = e.target as HTMLDivElement; 83 | 84 | if ( 85 | eventTarget !== operationInfoButtonRef.current && 86 | eventTarget !== getDirectionButtonRef.current 87 | ) { 88 | return; 89 | } 90 | 91 | setSelectLeft(eventTarget === operationInfoButtonRef.current); 92 | }} 93 | > 94 |
    영업 정보
    95 |
    길찾기
    96 |
    97 | {isSelectLeft ? ( 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 |

    {address}

    111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 |

    {phone}

    120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | {url} 130 | 131 | 132 | 133 | 134 |
    135 | 136 | 137 | 138 | 139 |
    140 | ) : ( 141 | 142 | 143 | 144 | )} 145 |
    146 | ); 147 | } 148 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailBody/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | // Modal 네이밍 변경 필요 4 | export const ModalBody = styled.div` 5 | width: 100%; 6 | height: 35%; 7 | padding: 30px; 8 | `; 9 | 10 | // Modal 네이밍 변경 필요 11 | export const ModalBodyNav = styled.div` 12 | width: 100%; 13 | height: 20%; 14 | display: flex; 15 | flex-direction: row; 16 | justify-content: space-around; 17 | align-content: center; 18 | border-bottom: 0.1px solid gray; 19 | `; 20 | 21 | // Modal 네이밍 변경 필요 22 | export const ModalBodyContent = styled.div` 23 | display: flex; 24 | flex-direction: column; 25 | width: 100%; 26 | padding: 20px 10px; 27 | gap: 10px; 28 | `; 29 | 30 | export const RestaurantDetailTable = styled.table` 31 | width: 100%; 32 | table-layout: fixed; 33 | tr { 34 | td:nth-child(1) { 35 | display: flex; 36 | justify-content: center; 37 | align-items: center; 38 | svg { 39 | height: 26px; 40 | aspect-ratio: 1/1; 41 | } 42 | } 43 | td:nth-child(2) { 44 | padding-left: 5px; 45 | overflow: hidden; 46 | white-space: nowrap; 47 | text-overflow: ellipsis; 48 | a { 49 | color: black; 50 | } 51 | } 52 | } 53 | `; 54 | 55 | export const MapLayout = styled.div` 56 | width: 100%; 57 | padding: 5px; 58 | `; 59 | 60 | export const MapBox = styled.div` 61 | width: 100%; 62 | height: 200px; 63 | border: 0.1px solid gray; 64 | border-radius: 5px; 65 | `; 66 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailCarousel/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | import RestaurantDefaultImgUrl from '@assets/images/restaurant-default.jpg'; 3 | import { ReactComponent as FakeWordIcon } from '@assets/images/fake-word.svg'; 4 | import { ImageCarouslLayout, ImageCarouslBox, ImageFakeBox, Image, ImageBox } from './styles'; 5 | 6 | interface PropsType { 7 | imageUrlList: string[]; 8 | } 9 | 10 | export function RestaurantDetailCarousel({ imageUrlList }: PropsType) { 11 | const imageCount = imageUrlList.length; 12 | const [visibleImageIdx, setVisibleImageIdx] = useState(0); 13 | const touchPosition = useRef<{ start: number; end: number }>({ start: 0, end: 0 }); 14 | const MIN_SLIDE_UNIT = 50; 15 | 16 | const throttlingTimerRef = useRef(null); 17 | const THROTTLING_TIME = 1500; 18 | 19 | return ( 20 | { 24 | touchPosition.current.start = e.changedTouches[0].clientX; 25 | }} 26 | onTouchEnd={(e) => { 27 | touchPosition.current.end = e.changedTouches[0].clientX; 28 | 29 | const { start: touchStart, end: touchEnd } = touchPosition.current; 30 | 31 | if (touchStart - touchEnd > MIN_SLIDE_UNIT) { 32 | setVisibleImageIdx( 33 | visibleImageIdx < imageCount - 1 ? visibleImageIdx + 1 : visibleImageIdx 34 | ); 35 | return; 36 | } 37 | 38 | if (touchEnd - touchStart > MIN_SLIDE_UNIT) { 39 | setVisibleImageIdx(visibleImageIdx > 0 ? visibleImageIdx - 1 : visibleImageIdx); 40 | } 41 | }} 42 | // 데스크탑 스크롤 이벤트를 대응하기 위함 43 | // 쓰로틀링을 추가하여 스크롤 시 여러번 이벤트가 발생하는 것을 방지 44 | onWheel={(e) => { 45 | if (throttlingTimerRef.current) { 46 | return; 47 | } 48 | 49 | throttlingTimerRef.current = setTimeout(() => { 50 | throttlingTimerRef.current = null; 51 | }, THROTTLING_TIME); 52 | 53 | const isScrollLeft = e.deltaX > 0; 54 | 55 | if (isScrollLeft) { 56 | setVisibleImageIdx( 57 | visibleImageIdx < imageCount - 1 ? visibleImageIdx + 1 : visibleImageIdx 58 | ); 59 | return; 60 | } 61 | 62 | setVisibleImageIdx(visibleImageIdx > 0 ? visibleImageIdx - 1 : visibleImageIdx); 63 | }} 64 | > 65 | 66 | {imageUrlList.map((imageUrl) => ( 67 | 68 | 69 | 70 | 71 | 72 | 73 | ))} 74 | 75 |

    {imageCount ? `${visibleImageIdx + 1}/${imageCount}` : `0/0`}

    76 |
    77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailCarousel/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ImageCarouslLayout = styled.div` 4 | position: relative; 5 | overflow: hidden; 6 | width: 100%; 7 | height: 45%; 8 | background-color: black; 9 | -ms-overflow-style: none; 10 | ::-webkit-scrollbar { 11 | display: none; 12 | } 13 | p { 14 | position: absolute; 15 | bottom: 10px; 16 | right: 10px; 17 | color: white; 18 | } 19 | `; 20 | 21 | export const ImageCarouslBox = styled.div` 22 | display: flex; 23 | flex-direction: row; 24 | width: 100%; 25 | height: 100%; 26 | `; 27 | 28 | export const ImageBox = styled.div` 29 | width: 100%; 30 | height: 100%; 31 | flex: none; 32 | position: relative; 33 | `; 34 | 35 | export const Image = styled.img` 36 | width: 100%; 37 | height: 100%; 38 | background-position: 50% 50%; 39 | object-fit: cover; 40 | background-repeat: no-repeat; 41 | `; 42 | 43 | export const ImageFakeBox = styled.div` 44 | position: absolute; 45 | top: 0; 46 | left: 0; 47 | width: 100%; 48 | height: 100%; 49 | display: flex; 50 | justify-content: center; 51 | align-items: center; 52 | 53 | svg { 54 | width: 70%; 55 | height: 70%; 56 | } 57 | `; 58 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailDrivingInfo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef, useState } from 'react'; 2 | import { useNavigate } from 'react-router-dom'; 3 | import { URL_PATH } from '@constants/url'; 4 | import { useUserLocationStore } from '@store/index'; 5 | import { distanceToDisplay } from '@utils/distance'; 6 | import { msToTimeDisplay } from '@utils/time'; 7 | import { ERROR_REASON } from '@constants/error'; 8 | import { apiService } from '@apis/index'; 9 | import { useToast } from '@hooks/useToast'; 10 | import { TOAST_DURATION_TIME } from '@constants/toast'; 11 | import { DrivingInfoBox, MapBox } from './styles'; 12 | 13 | interface PropsType { 14 | restaurantPos: LocationType; 15 | } 16 | 17 | function RestaurantDetailDrivingInfo({ restaurantPos }: PropsType) { 18 | const navigate = useNavigate(); 19 | const { userLocation } = useUserLocationStore(); 20 | const { fireToast } = useToast(); 21 | const [drivingInfo, setDrivingInfo] = useState(); 22 | 23 | const mapRef = useRef(null); 24 | 25 | const userPos: LocationType | null = userLocation; 26 | 27 | // 길찾기 API 호출 28 | const getDrivingInfo = async ( 29 | startPos: LocationType, 30 | goalPos: LocationType 31 | ): Promise => { 32 | const { lat: startLat, lng: startLng } = startPos; 33 | const { lat: goalLat, lng: goalLng } = goalPos; 34 | try { 35 | const drivingInfoData = await apiService.getDrivingInfoData( 36 | startLat, 37 | startLng, 38 | goalLat, 39 | goalLng 40 | ); 41 | setDrivingInfo(() => drivingInfoData); 42 | return drivingInfoData; 43 | } catch (error: any) { 44 | if (error.response.status === 500) { 45 | throw new Error(ERROR_REASON.INTERNAL_SERVER_ERROR); 46 | } 47 | return {} as DrivingInfoType; 48 | } 49 | }; 50 | 51 | const mapSetting = useCallback(async () => { 52 | if (!mapRef.current || !userPos) { 53 | return; 54 | } 55 | 56 | const map = new naver.maps.Map(mapRef.current, { 57 | center: new naver.maps.LatLng(userPos.lat, userPos.lng), 58 | zoom: 11, 59 | }); 60 | 61 | const { lat: startLat, lng: startLng } = userPos; 62 | const { lat: goalLat, lng: goalLng } = restaurantPos; 63 | 64 | // 출발지, 도착지 마커 생성 및 지도에 표시 65 | const startMarker = new naver.maps.Marker({ 66 | map, 67 | position: new naver.maps.LatLng(startLat, startLng), 68 | }); 69 | const goalMarker = new naver.maps.Marker({ 70 | map, 71 | position: new naver.maps.LatLng(goalLat, goalLng), 72 | }); 73 | 74 | // 길찾기 정보를 받아오는 함수 호출 75 | const { path } = await getDrivingInfo(userPos, restaurantPos); 76 | 77 | if (!path) { 78 | fireToast({ 79 | content: '길찾기 정보 요청에 실패했습니다.', 80 | duration: TOAST_DURATION_TIME, 81 | bottom: 80, 82 | }); 83 | return; 84 | } 85 | 86 | // 경로를 표시할 좌표들 배열 -> naver.maps.LatLng 객체로 변환 87 | const drivingInfoPaths = 88 | path.map((pos: number[]) => new naver.maps.LatLng(pos[1], pos[0])) || []; 89 | 90 | // 경로 그리기 91 | const polyline = new naver.maps.Polyline({ 92 | map, 93 | path: drivingInfoPaths, 94 | strokeColor: 'blue', // 선 색 95 | strokeLineCap: 'round', // 라인의 끝 모양 96 | strokeWeight: 5, // 선 두께 97 | }); 98 | 99 | // 지도의 범위를 경로의 범위로 설정 100 | map.fitBounds(polyline.getBounds()); 101 | }, []); 102 | 103 | useEffect(() => { 104 | mapSetting() 105 | .then() 106 | .catch((error) => { 107 | navigate(URL_PATH.INTERNAL_SERVER_ERROR); 108 | }); 109 | }, []); 110 | 111 | return ( 112 | <> 113 | 114 | 소요 시간 : {msToTimeDisplay(drivingInfo?.duration || 0)} 115 | 이동 거리 : {distanceToDisplay(drivingInfo?.distance || 0)} 116 | 117 | 118 | 119 | ); 120 | } 121 | 122 | export default RestaurantDetailDrivingInfo; 123 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailDrivingInfo/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const MapBox = styled.div` 4 | width: 100%; 5 | height: 350px; 6 | border: 0.1px solid gray; 7 | border-radius: 5px; 8 | `; 9 | 10 | export const DrivingInfoBox = styled.div` 11 | display: inline; 12 | svg { 13 | height: 22px; 14 | } 15 | 16 | span { 17 | display: block; 18 | margin-bottom: 5px; 19 | } 20 | `; 21 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailTitle/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactComponent as StarIcon } from '@assets/images/star-icon.svg'; 2 | import * as palette from '@styles/Variables'; 3 | import { CategoryBox, ModalTitleBox, ModalTitleLayout, NameBox, RatingBox } from './styles'; 4 | 5 | interface PropsType { 6 | name: string; 7 | category: string; 8 | rating: number; 9 | } 10 | 11 | export function RestaurantDetailTitle({ name, category, rating = 0 }: PropsType) { 12 | return ( 13 | 14 | 15 | 16 |

    {name}

    17 |
    18 | {category} 19 | 20 | 21 | {!rating ? '-' : `${rating}`} 22 | 23 |
    24 |
    25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/RestaurantDetailTitle/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const ModalTitleLayout = styled.div` 4 | width: 100%; 5 | height: 20%; 6 | padding: 10px 10px; 7 | box-shadow: 0px 4px 10px rgba(51, 51, 51, 0.1), 0px 0px 4px rgba(51, 51, 51, 0.05); 8 | box-sizing: border-box; 9 | `; 10 | 11 | export const ModalTitleBox = styled.div` 12 | width: 100%; 13 | height: 100%; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | gap: 10px; 19 | `; 20 | 21 | export const NameBox = styled.div` 22 | font-size: 20px; 23 | font-weight: 700; 24 | `; 25 | 26 | export const CategoryBox = styled.div` 27 | font-size: 16px; 28 | `; 29 | 30 | export const RatingBox = styled.div` 31 | display: flex; 32 | flex-direction: row; 33 | gap: 10px; 34 | `; 35 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { ReactComponent as BackwardIcon } from '@assets/images/backward-arrow-icon.svg'; 3 | 4 | import { RestaurantDetailTitle } from '@components/RestaurantDetail/RestaurantDetailTitle'; 5 | import { RestaurantDetailCarousel } from '@components/RestaurantDetail/RestaurantDetailCarousel'; 6 | import RestaurantVoteButton from '@components/RestaurantVoteButton'; 7 | 8 | import { RESTAURANT_DETAIL_TYPES, RESTAURANT_LIST_TYPES } from '@constants/modal'; 9 | import { useSelectedRestaurantDataStore } from '@store/index'; 10 | import { ModalBox, ModalLayout, BackwardButton, VoteButtonLayout } from './styles'; 11 | import { RestaurantDetailBody } from './RestaurantDetailBody'; 12 | 13 | interface PropsType { 14 | updateRestaurantDetailLayerStatus: (restaurantDetailType: RESTAURANT_DETAIL_TYPES) => void; 15 | } 16 | 17 | export function RestaurantDetailModal({ updateRestaurantDetailLayerStatus }: PropsType) { 18 | const { selectedRestaurantData, updateSelectedRestaurantData } = useSelectedRestaurantDataStore( 19 | (state) => state 20 | ); 21 | useEffect(() => { 22 | return () => { 23 | // 굳이 useEffect에서 이를 수행해주는 이유는 24 | // 클릭 이벤트 시 이를 수행해주면 클릭 즉시 전역 상태가 변하면서 애니메이션 와중에 데이터들이 null 값으로 바뀌기 때문 25 | // 보기 안좋음 26 | updateSelectedRestaurantData(null); 27 | }; 28 | }, []); 29 | 30 | return ( 31 | 32 | 33 | { 35 | updateRestaurantDetailLayerStatus(RESTAURANT_DETAIL_TYPES.hidden); 36 | }} 37 | > 38 | 39 | 40 | 41 | 45 | 46 | 47 | 52 | 59 | 60 | 61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetail/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const ModalLayout = styled.div` 5 | width: 100%; 6 | height: 100%; 7 | max-height: 100%; 8 | position: absolute; 9 | visibility: visible; 10 | `; 11 | 12 | export const BackwardButton = styled.div` 13 | position: absolute; 14 | top: 10px; 15 | left: 10px; 16 | z-index: ${palette.DETAIL_MODAL_HEADER_BUTTON_LAYOUT_Z_INDEX}; 17 | `; 18 | 19 | export const VoteButtonLayout = styled.div` 20 | position: absolute; 21 | top: 10px; 22 | right: 10px; 23 | z-index: ${palette.DETAIL_MODAL_HEADER_BUTTON_LAYOUT_Z_INDEX}; 24 | `; 25 | 26 | export const ModalBox = styled.div` 27 | width: 100%; 28 | height: 100%; 29 | background-color: white; 30 | display: flex; 31 | flex-direction: column; 32 | position: relative; 33 | overflow-y: auto; 34 | -ms-overflow-style: none; 35 | ::-webkit-scrollbar { 36 | display: none; 37 | } 38 | `; 39 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetailLayer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRestaurantDetailLayerStatusStore } from '@store/index'; 2 | import { RESTAURANT_DETAIL_TYPES } from '@constants/modal'; 3 | import { AnimatePresence } from 'framer-motion'; 4 | import { RestaurantDetailModal } from '@components/RestaurantDetail'; 5 | import { LayerBox } from './styles'; 6 | 7 | function RestaurantDetailLayer() { 8 | const { restaurantDetailLayerStatus, updateRestaurantDetailLayerStatus } = 9 | useRestaurantDetailLayerStatusStore((state) => state); 10 | return ( 11 | 12 | {restaurantDetailLayerStatus === RESTAURANT_DETAIL_TYPES.show && ( 13 | 식당 상세정보 경로로 레이어가 열렸을 때 20 | * 다시 돌아갔을 경우에도 식당 요약정보가 닫히지 않도록 하기 위함. 21 | * window에 등록한 이벤트 리스너를 실행시키지 않는다. 22 | */ 23 | onClick={(event) => { 24 | event.stopPropagation(); 25 | }} 26 | > 27 | 30 | 31 | )} 32 | 33 | ); 34 | } 35 | export default RestaurantDetailLayer; 36 | -------------------------------------------------------------------------------- /client/src/components/RestaurantDetailLayer/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | import { motion } from 'framer-motion'; 4 | 5 | export const LayerBox = styled(motion.div)` 6 | position: absolute; 7 | width: 100%; 8 | height: 100%; 9 | z-index: ${palette.RESTAURANT_DETAIL_LAYER_Z_INDEX}; 10 | `; 11 | -------------------------------------------------------------------------------- /client/src/components/RestaurantFilteredList/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | import EmptyListPlaceholder from '@components/EmptyListPlaceholder'; 4 | import VirtualizedRestaurantList from '@components/VirtualizedRestaurantList'; 5 | 6 | import { CATEGORY_TYPE } from '@constants/category'; 7 | import { useSelectedCategoryStore, useMapStore } from '@store/index'; 8 | import { RestaurantFilteredBox } from './styles'; 9 | 10 | interface PropsType { 11 | restaurantData: RestaurantType[]; 12 | } 13 | 14 | function RestaurantFiltered({ restaurantData }: PropsType) { 15 | const { map } = useMapStore((state) => state); 16 | 17 | // 필터된 식당 데이터 18 | const [filteredRestaurantList, setFilteredRestaurantList] = useState([]); 19 | 20 | // 카테고리로 필터링 21 | const { selectedCategoryData } = useSelectedCategoryStore((state) => state); 22 | const isNotAnyFilter = () => { 23 | return selectedCategoryData.size === 0; 24 | }; 25 | 26 | useEffect(() => { 27 | const newRestaurantData = restaurantData.filter( 28 | (restaurant) => 29 | isNotAnyFilter() || selectedCategoryData.has(restaurant.category as CATEGORY_TYPE) 30 | ); 31 | 32 | if (!map) { 33 | setFilteredRestaurantList(newRestaurantData); 34 | } else { 35 | setFilteredRestaurantList( 36 | newRestaurantData.filter((restaurant) => { 37 | const { lat, lng } = restaurant; 38 | const mapBounds = map.getBounds() as naver.maps.LatLngBounds; 39 | return mapBounds.hasLatLng(new naver.maps.LatLng(lat, lng)); 40 | }) 41 | ); 42 | } 43 | }, [selectedCategoryData]); 44 | 45 | return ( 46 | 47 | {!filteredRestaurantList.length ? ( 48 | 49 | ) : ( 50 | 51 | )} 52 | 53 | ); 54 | } 55 | 56 | export default RestaurantFiltered; 57 | -------------------------------------------------------------------------------- /client/src/components/RestaurantFilteredList/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const RestaurantFilteredBox = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | background-color: white; 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/components/RestaurantListLayer/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRestaurantListLayerStatusStore } from '@store/index'; 2 | import RestaurantFiltered from '@components/RestaurantFilteredList'; 3 | import { RESTAURANT_LIST_TYPES } from '@constants/modal'; 4 | import * as palette from '@styles/Variables'; 5 | import { AnimatePresence } from 'framer-motion'; 6 | import { CandidateListModal } from '@components/RestaurantCandidateList'; 7 | import { LayerBox } from './styles'; 8 | 9 | interface PropsType { 10 | restaurantData: RestaurantType[]; 11 | } 12 | 13 | function RestaurantListLayer({ restaurantData }: PropsType) { 14 | const { restaurantListLayerStatus } = useRestaurantListLayerStatusStore((state) => state); 15 | 16 | return ( 17 | 18 | {restaurantListLayerStatus !== RESTAURANT_LIST_TYPES.hidden && ( 19 | 37 | {restaurantListLayerStatus === RESTAURANT_LIST_TYPES.filtered ? ( 38 | 39 | ) : ( 40 | 41 | )} 42 | 43 | )} 44 | 45 | ); 46 | } 47 | 48 | export default RestaurantListLayer; 49 | -------------------------------------------------------------------------------- /client/src/components/RestaurantListLayer/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | import { motion } from 'framer-motion'; 4 | 5 | interface LayerStylePropsType { 6 | headerHeight: number; 7 | zIndex: number; 8 | } 9 | 10 | export const LayerBox = styled(motion.div)` 11 | position: absolute; 12 | bottom: 0; 13 | width: 100%; 14 | height: calc(100% - ${({ headerHeight }) => `${headerHeight}%`}); 15 | z-index: ${({ zIndex }) => zIndex}; 16 | background-color: white; 17 | 18 | /* overflow-y: overlay; */ 19 | &::-webkit-scrollbar { 20 | width: 8px; 21 | background-color: transparent; 22 | } 23 | &::-webkit-scrollbar-thumb { 24 | background-color: ${palette.SCROLL_THUMB_COLOR}; 25 | border-radius: 4px; 26 | } 27 | `; 28 | -------------------------------------------------------------------------------- /client/src/components/RestaurantPreview/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import RestaurantRow from '@components/RestaurantRow'; 3 | import { AnimatePresence } from 'framer-motion'; 4 | import { 5 | useRestaurantDetailLayerStatusStore, 6 | useSelectedRestaurantPreviewDataStore, 7 | } from '@store/index'; 8 | import { RESTAURANT_LIST_TYPES, RESTAURANT_DETAIL_TYPES } from '@constants/modal'; 9 | import { RestaurantPreviewBox, RestaurantPreviewLayout } from './styles'; 10 | 11 | function RestaurantPreview() { 12 | const modalRef = useRef(null); 13 | 14 | const { updateRestaurantDetailLayerStatus } = useRestaurantDetailLayerStatusStore( 15 | (state) => state 16 | ); 17 | const { selectedRestaurantPreviewData, updateSelectedRestaurantPreviewData } = 18 | useSelectedRestaurantPreviewDataStore((state) => state); 19 | 20 | useEffect(() => { 21 | const modalCloseWindowEvent = (e: Event) => { 22 | const { target } = e; 23 | 24 | if (modalRef.current && target instanceof HTMLElement && modalRef.current.contains(target)) { 25 | return; 26 | } 27 | 28 | updateSelectedRestaurantPreviewData(null); 29 | }; 30 | 31 | window.addEventListener('click', modalCloseWindowEvent); 32 | 33 | return () => { 34 | window.removeEventListener('click', modalCloseWindowEvent); 35 | }; 36 | }, []); 37 | 38 | return ( 39 | 40 | {selectedRestaurantPreviewData && ( 41 | 42 | { 50 | updateRestaurantDetailLayerStatus(RESTAURANT_DETAIL_TYPES.show); 51 | }} 52 | > 53 | 57 | 58 | 59 | )} 60 | 61 | ); 62 | } 63 | 64 | export default RestaurantPreview; 65 | -------------------------------------------------------------------------------- /client/src/components/RestaurantPreview/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | import { motion } from 'framer-motion'; 4 | 5 | export const RestaurantPreviewLayout = styled.div` 6 | z-index: ${palette.MAP_CONTROLLER_Z_INDEX}; 7 | `; 8 | 9 | export const RestaurantPreviewBox = styled(motion.div)` 10 | width: 100%; 11 | border-top-right-radius: 5px; 12 | border-top-left-radius: 5px; 13 | padding: 1% 3% 2% 3%; 14 | `; 15 | -------------------------------------------------------------------------------- /client/src/components/RestaurantRow/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactComponent as StarIcon } from '@assets/images/star-icon.svg'; 2 | import { ReactComponent as FakeWordIcon } from '@assets/images/fake-word.svg'; 3 | import * as palette from '@styles/Variables'; 4 | import { useMeetLocationStore } from '@store/index'; 5 | import { getDistance } from 'geolib'; 6 | import RestaurantDefaultImg from '@assets/images/restaurant-default.jpg'; 7 | import { RESTAURANT_LIST_TYPES } from '@constants/modal'; 8 | import RestaurantVoteButton from '@components/RestaurantVoteButton'; 9 | import { distanceToDisplay } from '@utils/distance'; 10 | 11 | import { 12 | ThumbnailFakeBox, 13 | RestaurantRowBox, 14 | DistanceBox, 15 | ImageBox, 16 | InfoBox, 17 | NameBox, 18 | RatingBox, 19 | ThumbnailImage, 20 | CategoryBox, 21 | } from './styles'; 22 | 23 | interface PropsType { 24 | restaurant: RestaurantType; 25 | restaurantListType: RESTAURANT_LIST_TYPES; 26 | // eslint-disable-next-line react/require-default-props 27 | likeCnt?: number; 28 | } 29 | 30 | function RestaurantRow({ restaurant, restaurantListType, likeCnt }: PropsType) { 31 | const { id, name, category, lat, lng, rating, photoUrlList } = restaurant; 32 | const { meetLocation } = useMeetLocationStore(); 33 | // 렌더링 순서 때문에 as 로 타입 지정을 직접 해주어도 괜찮겠다고 판단 34 | const { lat: roomLat, lng: roomLng } = meetLocation as LocationType; 35 | const straightDistance = getDistance({ lat, lng }, { lat: roomLat, lng: roomLng }); 36 | 37 | const thumbnailSrc = photoUrlList && photoUrlList[0] ? photoUrlList[0] : ''; 38 | 39 | return ( 40 | 41 | 42 | { 45 | const target = e.target as HTMLImageElement; 46 | target.src = RestaurantDefaultImg; 47 | }} 48 | /> 49 | 50 | 51 | 52 | 53 | 54 | {name} 55 | {category} 56 | 57 | 58 | {rating || '-'} 59 | 60 | 61 | 모임 위치에서 {straightDistance ? distanceToDisplay(straightDistance) : ''} 62 | 63 | 64 | 65 | 66 | ); 67 | } 68 | 69 | export default RestaurantRow; 70 | -------------------------------------------------------------------------------- /client/src/components/RestaurantRow/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | import { motion } from 'framer-motion'; 4 | 5 | export const RestaurantRowBox = styled(motion.div)` 6 | width: 100%; 7 | height: 30%; 8 | background-color: white; 9 | box-sizing: border-box; 10 | border-radius: 10px; 11 | box-shadow: 0 0 3px 2px ${palette.BORDER}; 12 | flex: none; 13 | display: flex; 14 | flex-direction: row; 15 | padding: 20px 10px; 16 | align-items: center; 17 | position: relative; 18 | gap: 20px; 19 | `; 20 | 21 | export const ImageBox = styled.div` 22 | height: 100px; 23 | width: 100px; 24 | position: relative; 25 | background-color: gray; 26 | border-radius: 10px; 27 | box-sizing: content-box; 28 | `; 29 | 30 | export const ThumbnailImage = styled.img` 31 | height: 100%; 32 | width: 100%; 33 | border-radius: 10px; 34 | background-position: 50% 50%; 35 | object-fit: cover; 36 | background-repeat: no-repeat; 37 | `; 38 | 39 | export const ThumbnailFakeBox = styled.div` 40 | position: absolute; 41 | top: 0; 42 | left: 0; 43 | width: 100%; 44 | height: 100%; 45 | display: flex; 46 | justify-content: center; 47 | align-items: center; 48 | 49 | svg { 50 | width: 70%; 51 | height: 70%; 52 | } 53 | `; 54 | 55 | export const InfoBox = styled.div` 56 | display: flex; 57 | flex-direction: column; 58 | height: 100%; 59 | width: 50%; 60 | gap: 10px; 61 | box-sizing: border-box; 62 | `; 63 | 64 | export const NameBox = styled.div` 65 | width: 80%; 66 | font-weight: 700; 67 | height: 15%; 68 | font-size: 1rem; 69 | overflow: hidden; 70 | white-space: nowrap; 71 | text-overflow: ellipsis; 72 | word-break: break-all; 73 | `; 74 | 75 | export const CategoryBox = styled.div` 76 | width: 100%; 77 | height: 15%; 78 | font-size: 0.875rem; 79 | font-weight: bold; 80 | color: #6b6b6b; 81 | `; 82 | 83 | export const RatingBox = styled.div` 84 | display: flex; 85 | flex-direction: row; 86 | width: 100%; 87 | height: 15%; 88 | gap: 10px; 89 | `; 90 | 91 | export const DistanceBox = styled.div` 92 | width: 100%; 93 | height: 15%; 94 | color: ${palette.PRIMARY}; 95 | font-size: 0.75rem; 96 | `; 97 | 98 | export const LikeButton = styled.div` 99 | display: flex; 100 | flex-direction: row; 101 | justify-content: center; 102 | align-items: center; 103 | position: absolute; 104 | top: 15px; 105 | right: 15px; 106 | border-radius: 5px; 107 | width: 15%; 108 | height: 15%; 109 | background-color: white; 110 | font-size: 10px; 111 | border: 0.1px solid ${palette.BORDER}; 112 | box-shadow: 0 0 2px 2px ${palette.BORDER}; 113 | `; 114 | -------------------------------------------------------------------------------- /client/src/components/RestaurantVoteButton/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | import { motion } from 'framer-motion'; 4 | 5 | interface VoteButtonPropsType { 6 | isVoted?: boolean; 7 | onClick: React.MouseEventHandler; 8 | } 9 | 10 | interface LikeButtonPropsType { 11 | isVoted?: boolean; 12 | onClick: React.MouseEventHandler; 13 | } 14 | 15 | export const VoteLayout = styled(motion.div)` 16 | display: flex; 17 | flex-direction: row; 18 | justify-content: center; 19 | align-items: center; 20 | position: absolute; 21 | top: 15px; 22 | right: 15px; 23 | width: 3.8rem; 24 | height: 1.6rem; 25 | `; 26 | 27 | export const VoteButton = styled.button` 28 | width: 100%; 29 | height: 100%; 30 | border: none; 31 | outline: none; 32 | display: flex; 33 | flex-direction: row; 34 | justify-content: center; 35 | align-items: center; 36 | padding: 0 0.5rem; 37 | 38 | background-color: ${({ isVoted }) => (isVoted ? palette.BUTTON_COLOR_GREEN : palette.PRIMARY)}; 39 | font-size: 0.8rem; 40 | border-radius: 10px; 41 | color: white; 42 | 43 | box-shadow: 0 0 1px 1px ${palette.BORDER}; 44 | 45 | z-index: 1000; 46 | `; 47 | 48 | export const LikeButton = styled.button` 49 | width: 100%; 50 | height: 100%; 51 | border: none; 52 | outline: none; 53 | display: flex; 54 | flex-direction: row; 55 | justify-content: center; 56 | align-items: center; 57 | gap: 0.6rem; 58 | 59 | background-color: white; 60 | color: ${({ isVoted }) => (isVoted ? palette.PRIMARY : 'black')}; 61 | font-size: 1rem; 62 | border-radius: 10px; 63 | box-shadow: 0 0 2px 2px ${palette.BORDER}; 64 | 65 | z-index: 1000; 66 | `; 67 | 68 | export const LikeCountSpan = styled.span``; 69 | -------------------------------------------------------------------------------- /client/src/components/Toast/index.tsx: -------------------------------------------------------------------------------- 1 | import { useToastStore } from '@store/index'; 2 | import { ToastLayout, ToastContentSpan } from './styles'; 3 | 4 | function Toast() { 5 | const { isOpen, content, bottom } = useToastStore(); 6 | 7 | return ( 8 | 9 | {content} 10 | 11 | ); 12 | } 13 | 14 | export default Toast; 15 | -------------------------------------------------------------------------------- /client/src/components/Toast/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components'; 2 | 3 | // animations 4 | const fadeIn = keyframes` 5 | 0% { 6 | opacity: 0; 7 | } 8 | 100% { 9 | opacity: 1; 10 | } 11 | `; 12 | 13 | const fadeOut = keyframes` 14 | 0% { 15 | opacity: 1; 16 | } 17 | 100% { 18 | opacity: 0; 19 | } 20 | `; 21 | 22 | interface ToastStylePropsType { 23 | visible: boolean; 24 | bottom: number; 25 | } 26 | 27 | export const ToastLayout = styled.div` 28 | position: absolute; 29 | width: 14rem; 30 | bottom: ${({ bottom }) => bottom}px; 31 | z-index: 1000; 32 | background: rgba(0, 0, 0, 0.75); 33 | border-radius: 30px; 34 | padding: 15px 20px; 35 | text-align: center; 36 | 37 | /* 중앙정렬 */ 38 | left: 50%; 39 | margin-left: -7rem; 40 | 41 | /* show/hide */ 42 | visibility: ${({ visible }) => (visible ? 'visible' : 'hidden')}; 43 | animation: ${({ visible }) => (visible ? fadeIn : fadeOut)} 0.15s ease-out; 44 | transition: visibility 0.15s ease-out; 45 | `; 46 | 47 | export const ToastContentSpan = styled.span` 48 | color: #fff; 49 | font-size: 0.8rem; 50 | `; 51 | -------------------------------------------------------------------------------- /client/src/components/VirtualizedRestaurantList/index.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, useRef, useState, useEffect } from 'react'; 2 | import { VariableSizeList } from 'react-window'; 3 | import InfiniteLoader from 'react-window-infinite-loader'; 4 | import AutoSizer from 'react-virtualized-auto-sizer'; 5 | 6 | import RestaurantRow from '@components/RestaurantRow'; 7 | import LoadingScreen from '@components/LoadingScreen'; 8 | 9 | import { RESTAURANT_LIST_TYPES, RESTAURANT_DETAIL_TYPES } from '@constants/modal'; 10 | import { useRestaurantDetailLayerStatusStore, useSelectedRestaurantDataStore } from '@store/index'; 11 | import { RestaurantFilteredList, RestaurantFilteredItem } from './styles'; 12 | 13 | interface PropsType { 14 | filteredRestaurantList: RestaurantType[]; 15 | } 16 | 17 | function VirtualizedRestaurantList({ filteredRestaurantList }: PropsType) { 18 | const { updateRestaurantDetailLayerStatus } = useRestaurantDetailLayerStatusStore( 19 | (state) => state 20 | ); 21 | const { updateSelectedRestaurantData } = useSelectedRestaurantDataStore((state) => state); 22 | 23 | const [hasNextPage, setHasNextPage] = useState(true); // 다음 페이지 존재 여부 24 | const [isNextPageLoading, setIsNextPageLoading] = useState(false); // 다음 페이지 로딩중 25 | const [items, setItems] = useState([]); // 여태껏 로드된 아이템(가상화 목록에 추가된 아이템) 26 | const itemRef = useRef(null); // TODO: 요소의 높이를 받아오기 위해 필요, 방법 찾는 중 27 | 28 | let itemCount = hasNextPage ? items.length + 1 : items.length; 29 | 30 | const isItemLoaded = (index: number) => !hasNextPage || index < items.length; 31 | 32 | const getItemSize = (index: any) => { 33 | // console.log(itemRef.current.offsetHeight); 34 | // TODO: 아이템 행 높이를 받아와서 반환해야함 35 | return 160; 36 | }; 37 | 38 | // 필터 조건이 변경돼 필터링 데이터가 바뀌는 경우 39 | useEffect(() => { 40 | // 가상화 목록 초기화 41 | setItems([]); 42 | itemCount = 0; 43 | 44 | // 다음페이지 로드를 위한 상태 변경 45 | setHasNextPage(true); 46 | }, [filteredRestaurantList]); 47 | 48 | // eslint-disable-next-line react/no-unstable-nested-components 49 | function Row({ index, style }: { index: number; style: CSSProperties }) { 50 | const restaurant = items[index]; 51 | 52 | return !isItemLoaded(index) ? ( 53 |
    54 | 55 |
    56 | ) : ( 57 |
    58 | { 60 | updateRestaurantDetailLayerStatus(RESTAURANT_DETAIL_TYPES.show); 61 | updateSelectedRestaurantData(restaurant); 62 | }} 63 | key={restaurant.id} 64 | > 65 | 69 | 70 |
    71 | ); 72 | } 73 | 74 | const loadMoreItems = (startIndex: number) => { 75 | if (isNextPageLoading) { 76 | return; 77 | } 78 | 79 | // 현재 스크롤 시 axios 요청을 안보내기 때문에, 의도적으로 delay time 걸음 80 | // 초기 로딩 시엔 delay time을 200ms로 짧게 설정 81 | const delayTime = startIndex === 0 ? 200 : 1000; 82 | 83 | setIsNextPageLoading(true); 84 | 85 | // eslint-disable-next-line consistent-return 86 | return new Promise((resolve) => { 87 | setTimeout(() => { 88 | setIsNextPageLoading(false); 89 | setHasNextPage(items.length < filteredRestaurantList.length); // 가상화 목록 아이템 수 < 보여줘야할 데이터 개수 일때만 true 90 | setItems([...items].concat(filteredRestaurantList.slice(startIndex, startIndex + 5))); 91 | resolve(); 92 | }, delayTime); 93 | }); 94 | }; 95 | 96 | return ( 97 | 98 | {({ height, width }) => ( 99 | 100 | { 102 | return index < items.length; 103 | }} 104 | itemCount={itemCount} 105 | loadMoreItems={loadMoreItems} 106 | > 107 | {({ onItemsRendered, ref }) => ( 108 | 116 | {Row} 117 | 118 | )} 119 | 120 | 121 | )} 122 | 123 | ); 124 | } 125 | 126 | export default VirtualizedRestaurantList; 127 | -------------------------------------------------------------------------------- /client/src/components/VirtualizedRestaurantList/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const RestaurantFilteredList = styled.ul` 4 | width: 100%; 5 | `; 6 | 7 | export const RestaurantFilteredItem = styled.li` 8 | /* height: 180px; // 가상화 목록 적용을 위해서는 고정 필요? */ 9 | list-style: none; 10 | padding-bottom: 5%; 11 | &:last-child { 12 | padding-bottom: 0; 13 | } 14 | margin: 10% 5%; 15 | `; 16 | -------------------------------------------------------------------------------- /client/src/constants/category.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | 3 | export enum CATEGORY_TYPE { 4 | 일식 = '일식', 5 | 중식 = '중식', 6 | 양식 = '양식', 7 | 치킨 = '치킨', 8 | 패스트푸드 = '패스트푸드', 9 | 분식 = '분식', 10 | 한식 = '한식', 11 | } 12 | -------------------------------------------------------------------------------- /client/src/constants/error.ts: -------------------------------------------------------------------------------- 1 | export const ERROR_REASON = Object.freeze({ 2 | FAIL_CREATE_ROOM: '모임방 생성에 실패했습니다.', 3 | NOT_FOUND_PAGE: '존재하지 않는 페이지입니다.', 4 | INVALID_ROOM: '유효하지 않은 모임방 링크입니다.', 5 | INTERNAL_SERVER_ERROR: 'Internal Server Error', 6 | }); 7 | -------------------------------------------------------------------------------- /client/src/constants/map.ts: -------------------------------------------------------------------------------- 1 | export const NAVER_LAT = 37.358; 2 | export const NAVER_LNG = 127.1055; 3 | export const NAVER_ADDRESS = '경기 성남시 분당구 정자일로 95'; 4 | export const MAIN_MAPS_MIN_ZOOM_LEVEL = 7; 5 | export const DEFAULT_ZOOM = 14; 6 | -------------------------------------------------------------------------------- /client/src/constants/modal.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-shadow */ 2 | 3 | export const enum RESTAURANT_LIST_TYPES { 4 | hidden = 'hidden', 5 | filtered = 'filtered', // 카테고리별로 필터링 된 식당 리스트 6 | candidate = 'candidate', // 후보 식당 리스트 7 | } 8 | 9 | export const enum RESTAURANT_DETAIL_TYPES { 10 | hidden = 'hidden', 11 | show = 'show', 12 | } 13 | -------------------------------------------------------------------------------- /client/src/constants/toast.ts: -------------------------------------------------------------------------------- 1 | export const TOAST_DURATION_TIME = 2500; 2 | export const SUCCCESS_COPY_MESSAGE = '✅ 클립보드에 복사되었습니다.'; 3 | export const FAIL_COPY_MESSAGE = '❌ 클립보드 복사에 실패했습니다.'; 4 | export const NO_RESULTS_MESSAGE = '검색결과가 없습니다.'; 5 | export const FAIL_SEARCH_MESSAGE = '검색에 실패했습니다.'; 6 | export const FAIL_UPDATE_ADDR_MESSAGE = '주소 변환에 실패했습니다.'; 7 | export const FAIL_VOTE_MESSAGE = '투표에 실패했습니다.'; 8 | export const FAIL_CANCEL_VOTE_MESSAGE = '투표 취소에 실패했습니다.'; 9 | -------------------------------------------------------------------------------- /client/src/constants/url.ts: -------------------------------------------------------------------------------- 1 | export const URL_PATH = Object.freeze({ 2 | HOME: '/', 3 | INIT_ROOM: '/init-room', 4 | JOIN_ROOM: '/room', 5 | FAIL_CREATE_ROOM: '/fail-create-room', 6 | INVALID_ROOM: '/error/invalid-room', 7 | INTERNAL_SERVER_ERROR: '/error/internal-server', 8 | }); 9 | 10 | export const API_URL = Object.freeze({ 11 | GET: Object.freeze({ 12 | ROOM_VALID: '/api/room/valid', 13 | DRIVING_INFO: '/api/map/driving', 14 | }), 15 | 16 | POST: Object.freeze({ 17 | CREATE_ROOM: '/api/room', 18 | }), 19 | }); 20 | -------------------------------------------------------------------------------- /client/src/constants/vote.ts: -------------------------------------------------------------------------------- 1 | export const FAIL_VOTE = '투표 실패'; 2 | export const FAIL_CANCEL_VOTE = '투표 취소 실패'; 3 | -------------------------------------------------------------------------------- /client/src/hooks/useCurrentLocation.tsx: -------------------------------------------------------------------------------- 1 | import { NAVER_LAT, NAVER_LNG } from '@constants/map'; 2 | import { useUserLocationStore } from '@store/index'; 3 | 4 | const useCurrentLocation = () => { 5 | const { userLocation, updateUserLocation } = useUserLocationStore(); 6 | 7 | const handleSuccess = (position: GeolocationPosition) => { 8 | updateUserLocation({ lat: position.coords.latitude, lng: position.coords.longitude }); 9 | }; 10 | 11 | const handleError = () => { 12 | updateUserLocation({ lat: NAVER_LAT, lng: NAVER_LNG }); 13 | }; 14 | 15 | const updateCurrentPosition = () => { 16 | // 위치 사용 불가 장치인 경우 17 | if (!('geolocation' in navigator)) { 18 | handleError(); 19 | return; 20 | } 21 | 22 | navigator.geolocation.getCurrentPosition(handleSuccess, handleError); 23 | }; 24 | 25 | const getCurrentLocation = (): Promise => { 26 | return new Promise((resolve) => { 27 | navigator.geolocation.getCurrentPosition( 28 | (position: GeolocationPosition) => { 29 | resolve({ lat: position.coords.latitude, lng: position.coords.longitude }); 30 | }, 31 | () => { 32 | resolve({ lat: NAVER_LAT, lng: NAVER_LNG }); 33 | } 34 | ); 35 | }); 36 | }; 37 | 38 | return { userLocation, updateCurrentPosition, updateUserLocation, getCurrentLocation }; 39 | }; 40 | 41 | export default useCurrentLocation; 42 | -------------------------------------------------------------------------------- /client/src/hooks/useNaverMaps.tsx: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, RefObject, useEffect, useRef } from 'react'; 2 | import { MAIN_MAPS_MIN_ZOOM_LEVEL, NAVER_LAT, NAVER_LNG, DEFAULT_ZOOM } from '@constants/map'; 3 | 4 | export const useNaverMaps = (): [ 5 | MutableRefObject, 6 | RefObject 7 | ] => { 8 | const mapRef = useRef(null); 9 | const mapDivRef = useRef(null); 10 | 11 | useEffect(() => { 12 | if (!mapDivRef.current) { 13 | return; 14 | } 15 | 16 | mapRef.current = new naver.maps.Map(mapDivRef.current, { 17 | center: new naver.maps.LatLng(NAVER_LAT, NAVER_LNG), 18 | zoom: DEFAULT_ZOOM, 19 | // 7로 잡아도 대한민국 전역을 커버 가능 20 | minZoom: MAIN_MAPS_MIN_ZOOM_LEVEL, 21 | }); 22 | }, []); 23 | 24 | return [mapRef, mapDivRef]; 25 | }; 26 | -------------------------------------------------------------------------------- /client/src/hooks/useSocket.ts: -------------------------------------------------------------------------------- 1 | import { io, Socket } from 'socket.io-client'; 2 | import { MutableRefObject, useRef } from 'react'; 3 | import { useSocketStore } from '@store/socket'; 4 | 5 | export const useSocket = (): [MutableRefObject, () => Promise, () => void] => { 6 | const socketRef = useRef(null); 7 | const { setSocket } = useSocketStore((state) => state); 8 | 9 | const connectSocket = (): Promise => { 10 | return new Promise((resolve, reject) => { 11 | if (socketRef.current instanceof Socket) { 12 | resolve(); 13 | return; 14 | } 15 | 16 | socketRef.current = io('/room'); 17 | 18 | const socket = socketRef.current; 19 | setSocket(socket); 20 | 21 | socket.on('connect', () => { 22 | resolve(); 23 | }); 24 | 25 | socket.on('connect_error', () => { 26 | reject(); 27 | }); 28 | }); 29 | }; 30 | 31 | const disconnectSocket = (): void => { 32 | if (!(socketRef.current instanceof Socket)) { 33 | return; 34 | } 35 | 36 | socketRef.current.close(); 37 | socketRef.current = null; 38 | }; 39 | 40 | return [socketRef, connectSocket, disconnectSocket]; 41 | }; 42 | -------------------------------------------------------------------------------- /client/src/hooks/useToast.tsx: -------------------------------------------------------------------------------- 1 | import { useToastStore } from '@store/index'; 2 | 3 | interface ToastType { 4 | content: string; 5 | bottom?: number; 6 | duration?: number; 7 | } 8 | 9 | export function useToast() { 10 | const { updateIsOpen, updateToast } = useToastStore((state) => state); 11 | 12 | const fireToast = (toast: ToastType) => { 13 | const { content, bottom, duration } = toast; 14 | 15 | updateIsOpen(true); 16 | updateToast(content, bottom, duration); 17 | 18 | setTimeout(() => { 19 | updateIsOpen(false); 20 | }, toast.duration); 21 | }; 22 | 23 | return { fireToast }; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(); 6 | -------------------------------------------------------------------------------- /client/src/pages/ErrorPage/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { URL_PATH } from '@constants/url'; 4 | import { Button, ErrorBox, ErrorPageLayout } from './styles'; 5 | 6 | interface PropsType { 7 | reason: string; 8 | } 9 | 10 | function ErrorPage({ reason }: PropsType) { 11 | return ( 12 | 13 | 14 |

    {reason}

    15 | 16 | 17 | 18 |
    19 |
    20 | ); 21 | } 22 | 23 | export default ErrorPage; 24 | -------------------------------------------------------------------------------- /client/src/pages/ErrorPage/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const ErrorPageLayout = styled.div` 5 | background-color: ${palette.PRIMARY}; 6 | width: 100%; 7 | height: 100%; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | position: relative; 12 | `; 13 | 14 | export const ErrorBox = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: center; 18 | align-items: center; 19 | width: 100%; 20 | height: 100%; 21 | gap: 1.5rem; 22 | `; 23 | 24 | export const Button = styled.button` 25 | background: black; 26 | border: none; 27 | border-radius: 5px; 28 | padding: 16px 30px; 29 | font-size: 14px; 30 | font-weight: bolder; 31 | color: white; 32 | cursor: pointer; 33 | `; 34 | -------------------------------------------------------------------------------- /client/src/pages/HomePage/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactComponent as LogoImage } from '@assets/images/logo.svg'; 2 | import { Link } from 'react-router-dom'; 3 | import { useEffect, useRef, useState } from 'react'; 4 | import { URL_PATH } from '@constants/url'; 5 | import { 6 | Button, 7 | ButtonBox, 8 | FooterBox, 9 | HomePageBox, 10 | HomePageLayout, 11 | ModalBox, 12 | ModalInput, 13 | ModalInputButton, 14 | ModalInputError, 15 | ModalLayout, 16 | } from './styles'; 17 | 18 | function Modal() { 19 | const inputRef = useRef(null); 20 | const [inputError, setInputError] = useState(''); 21 | 22 | return ( 23 | 24 | 25 | {inputError} 26 | { 28 | e.preventDefault(); 29 | if (!inputRef.current) { 30 | return; 31 | } 32 | // 공백 제거 33 | inputRef.current.value = inputRef.current.value.replace(/^\s+|\s+$/gm, ''); 34 | 35 | // 입력된 값이 없을 경우 36 | if (!inputRef.current.value) { 37 | setInputError('입력은 공백일 수 없습니다!'); 38 | return; 39 | } 40 | 41 | // url 이동 42 | document.location.href = inputRef.current.value; 43 | }} 44 | > 45 | 입장 46 | 47 | 48 | ); 49 | } 50 | 51 | function HomePage() { 52 | const [isModalOpen, setIsModalOpen] = useState(false); 53 | const modalRef = useRef(null); 54 | 55 | useEffect(() => { 56 | if (!modalRef.current) { 57 | return; 58 | } 59 | 60 | modalRef.current.style.visibility = isModalOpen ? 'visible' : 'hidden'; 61 | }, [isModalOpen]); 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |

    밥을 골라주마.

    77 |

    Copyright 2022. Chobab. All Right Reserved.

    78 |
    79 | { 82 | if (!modalRef.current || modalRef.current !== e.target) { 83 | return; 84 | } 85 | setIsModalOpen(!isModalOpen); 86 | }} 87 | > 88 | 89 | 90 |
    91 | ); 92 | } 93 | 94 | export default HomePage; 95 | -------------------------------------------------------------------------------- /client/src/pages/HomePage/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const HomePageLayout = styled.div` 5 | background-color: ${palette.PRIMARY}; 6 | width: 100%; 7 | height: 100%; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | position: relative; 12 | `; 13 | 14 | export const HomePageBox = styled.div` 15 | display: flex; 16 | flex-direction: column; 17 | justify-content: space-between; 18 | align-items: center; 19 | gap: 100px; 20 | `; 21 | 22 | export const Button = styled.button` 23 | background: black; 24 | border: none; 25 | border-radius: 5px; 26 | padding: 16px 30px; 27 | font-size: 14px; 28 | font-weight: bolder; 29 | color: white; 30 | cursor: pointer; 31 | `; 32 | 33 | export const FooterBox = styled.button` 34 | position: absolute; 35 | bottom: 0; 36 | background: transparent; 37 | border: 0; 38 | p { 39 | text-align: center; 40 | margin: 15px 0; 41 | color: rgba(255, 255, 255, 50%); 42 | } 43 | `; 44 | 45 | export const ButtonBox = styled.div` 46 | display: flex; 47 | flex-direction: column; 48 | gap: 20px; 49 | `; 50 | 51 | export const ModalLayout = styled.div` 52 | display: flex; 53 | visibility: hidden; 54 | position: absolute; 55 | width: 100%; 56 | height: 100%; 57 | background: rgba(0, 0, 0, 60%); 58 | `; 59 | 60 | export const ModalBox = styled.div` 61 | margin: auto; 62 | display: flex; 63 | flex-direction: column; 64 | justify-content: center; 65 | align-items: center; 66 | width: 50%; 67 | height: 20%; 68 | background-color: white; 69 | border-radius: 15px; 70 | gap: 5px; 71 | `; 72 | 73 | export const ModalInput = styled.input` 74 | width: 85%; 75 | height: 40px; 76 | border: 2px solid gray; 77 | border-radius: 5px; 78 | padding: 0 10px; 79 | `; 80 | 81 | export const ModalInputError = styled.div` 82 | width: 85%; 83 | height: 12px; 84 | color: red; 85 | font-size: 12px; 86 | display: flex; 87 | justify-content: center; 88 | align-items: center; 89 | `; 90 | 91 | export const ModalInputButton = styled.button` 92 | background: black; 93 | border: none; 94 | border-radius: 5px; 95 | padding: 10px 20px; 96 | font-weight: bolder; 97 | color: white; 98 | `; 99 | -------------------------------------------------------------------------------- /client/src/pages/InitRoomPage/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { InitRoomPageLayout } from '@pages/InitRoomPage/styles'; 4 | import MeetLocationSettingMap from '@components/MeetLocationSettingMap'; 5 | import MeetLocationSettingFooter from '@components/MeetLocationSettingFooter'; 6 | import LoadingScreen from '@components/LoadingScreen'; 7 | 8 | import useCurrentLocation from '@hooks/useCurrentLocation'; 9 | import { useMeetLocationStore } from '@store/index'; 10 | 11 | function InitRoomPage() { 12 | const { getCurrentLocation } = useCurrentLocation(); 13 | const { updateMeetLocation } = useMeetLocationStore((state) => state); 14 | const [isGPSReady, setGPSReady] = useState(false); 15 | 16 | const setUserLocation = async () => { 17 | const location = await getCurrentLocation(); 18 | updateMeetLocation(location); 19 | setGPSReady(true); 20 | }; 21 | 22 | useEffect(() => { 23 | setUserLocation(); 24 | }, []); 25 | 26 | return !isGPSReady ? ( 27 | 28 | ) : ( 29 | 30 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export default InitRoomPage; 37 | -------------------------------------------------------------------------------- /client/src/pages/InitRoomPage/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export const InitRoomPageLayout = styled.div` 4 | width: 100%; 5 | height: 100%; 6 | position: relative; 7 | `; 8 | -------------------------------------------------------------------------------- /client/src/pages/MainPage/styles.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | export const MainPageLayout = styled.div` 5 | width: 100%; 6 | height: 100%; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | position: relative; 11 | `; 12 | 13 | export const MapBox = styled.div` 14 | width: 100%; 15 | height: 100%; 16 | z-index: 1; 17 | position: absolute; 18 | &:focus-visible { 19 | outline: none; 20 | } 21 | `; 22 | 23 | export const HeaderBox = styled.div` 24 | display: flex; 25 | flex-direction: column; 26 | width: 100%; 27 | height: ${palette.HEADER_HEIGHT_RATIO}%; 28 | background-color: ${palette.BORDER}; 29 | position: absolute; 30 | top: 0px; 31 | z-index: ${palette.HEADER_Z_INDEX}; 32 | display: flex; 33 | `; 34 | 35 | export const Header = styled.header` 36 | display: flex; 37 | justify-content: space-between; 38 | background-color: ${palette.PRIMARY}; 39 | width: 100%; 40 | height: 100%; 41 | `; 42 | 43 | export const CategoryBox = styled.div` 44 | position: absolute; 45 | top: ${palette.HEADER_HEIGHT_RATIO}%; 46 | width: 100%; 47 | height: ${palette.CATEGORY_HEIGHT_RATIO}%; 48 | background-color: white; 49 | box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.15); 50 | z-index: ${palette.CATEGORY_Z_INDEX}; 51 | `; 52 | 53 | export const CandidateListButton = styled.button` 54 | background-color: ${palette.PRIMARY}; 55 | position: absolute; 56 | z-index: ${palette.CONTROLLER_Z_INDEX}; 57 | margin-bottom: 10px; 58 | left: calc(50% - 55px / 2); 59 | bottom: 0px; 60 | width: 55px; 61 | height: 55px; 62 | border-radius: 50%; 63 | border: none; 64 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.5); 65 | display: flex; 66 | justify-content: center; 67 | align-items: center; 68 | cursor: pointer; 69 | `; 70 | 71 | export const MapOrListButton = styled.button` 72 | background-color: white; 73 | position: absolute; 74 | z-index: ${palette.CONTROLLER_Z_INDEX}; 75 | display: flex; 76 | justify-content: center; 77 | align-items: center; 78 | width: 110px; 79 | height: 36px; 80 | margin-bottom: 18px; 81 | margin-right: 8px; 82 | bottom: 0px; 83 | right: 0px; 84 | border: none; 85 | border-radius: 20px; 86 | box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.5); 87 | gap: 5px; 88 | cursor: pointer; 89 | &img { 90 | height: 20px; 91 | } 92 | `; 93 | 94 | export const ButtonInnerTextBox = styled.div` 95 | font-size: 14px; 96 | font-weight: 500; 97 | color: black; 98 | `; 99 | 100 | export const FooterBox = styled.div` 101 | position: absolute; 102 | bottom: 0; 103 | left: 0; 104 | right: 0; 105 | display: flex; 106 | flex-direction: column; 107 | `; 108 | 109 | export const ControllerBox = styled.div` 110 | position: relative; 111 | `; 112 | -------------------------------------------------------------------------------- /client/src/store/index.tsx: -------------------------------------------------------------------------------- 1 | import create from 'zustand'; 2 | import { RESTAURANT_LIST_TYPES, RESTAURANT_DETAIL_TYPES } from '@constants/modal'; 3 | import { CATEGORY_TYPE } from '@constants/category'; 4 | 5 | /** 6 | * <유저의 현재 위치정보를 관리하는 저장소> 7 | */ 8 | interface UserLocationStoreType { 9 | userLocation: LocationType | null; 10 | updateUserLocation: (location: LocationType | null) => void; 11 | } 12 | 13 | export const useUserLocationStore = create((set) => ({ 14 | userLocation: null, 15 | updateUserLocation: (location: LocationType | null) => 16 | set((state) => ({ ...state, userLocation: location })), 17 | })); 18 | 19 | /** 20 | * <방의 모임 장소 위치를 관리하는 저장소> 21 | */ 22 | interface MeetLocationStoreType { 23 | meetLocation: LocationType | null; 24 | updateMeetLocation: (location: LocationType | null) => void; 25 | } 26 | 27 | export const useMeetLocationStore = create((set) => ({ 28 | meetLocation: null, 29 | updateMeetLocation: (location: LocationType | null) => set(() => ({ meetLocation: location })), 30 | })); 31 | 32 | /** 33 | * <식당 목록 레이어(RestaurantListLayer)의 화면 상태를 관리하는 저장소> 34 | */ 35 | interface RestaurantListLayerStatusStore { 36 | restaurantListLayerStatus: RESTAURANT_LIST_TYPES; 37 | updateRestaurantListLayerStatus: (restaurantListType: RESTAURANT_LIST_TYPES) => void; 38 | } 39 | 40 | export const useRestaurantListLayerStatusStore = create((set) => ({ 41 | restaurantListLayerStatus: RESTAURANT_LIST_TYPES.hidden, 42 | updateRestaurantListLayerStatus: (restaurantListType: RESTAURANT_LIST_TYPES) => 43 | set(() => ({ restaurantListLayerStatus: restaurantListType })), 44 | })); 45 | 46 | /** 47 | * <식당 상세정보 레이어(RestaurantDetailLayer)의 화면 상태를 관리하는 저장소> 48 | */ 49 | interface RestaurantDetailLayerStatusStore { 50 | restaurantDetailLayerStatus: RESTAURANT_DETAIL_TYPES; 51 | updateRestaurantDetailLayerStatus: (restaurantDetailType: RESTAURANT_DETAIL_TYPES) => void; 52 | } 53 | 54 | export const useRestaurantDetailLayerStatusStore = create( 55 | (set) => ({ 56 | restaurantDetailLayerStatus: RESTAURANT_DETAIL_TYPES.hidden, 57 | updateRestaurantDetailLayerStatus: (restaurantDetailType: RESTAURANT_DETAIL_TYPES) => 58 | set(() => ({ restaurantDetailLayerStatus: restaurantDetailType })), 59 | }) 60 | ); 61 | 62 | /** 63 | * <선택된 식당 정보를 저장하는 저장소> 64 | */ 65 | interface SelectedRestaurantDataStore { 66 | selectedRestaurantData: RestaurantType | null; 67 | updateSelectedRestaurantData: (restaurantType: RestaurantType | null) => void; 68 | } 69 | 70 | export const useSelectedRestaurantDataStore = create((set) => ({ 71 | selectedRestaurantData: null, 72 | updateSelectedRestaurantData: (restaurantType: RestaurantType | null) => 73 | set(() => ({ selectedRestaurantData: restaurantType })), 74 | })); 75 | 76 | /** 77 | * <선택된 식당 요약정보를 저장하는 저장소> 78 | * 상세정보 페이지에서 사용되는 SelectedRestaurantDataStore 79 | * 를 마커 클릭 시 나오는 미리보기 화면에서도 80 | * 함께 사용할 경우 생기는 문제를 해결하기 위해 만든 저장소입니다. 81 | */ 82 | interface SelectedRestaurantPreviewDataStore { 83 | selectedRestaurantPreviewData: RestaurantType | null; 84 | updateSelectedRestaurantPreviewData: (restaurantType: RestaurantType | null) => void; 85 | } 86 | 87 | export const useSelectedRestaurantPreviewDataStore = create( 88 | (set) => ({ 89 | selectedRestaurantPreviewData: null, 90 | updateSelectedRestaurantPreviewData: (restaurantType: RestaurantType | null) => 91 | set(() => ({ selectedRestaurantPreviewData: restaurantType })), 92 | }) 93 | ); 94 | 95 | /** 96 | * <카테고리 선택정보를 저장하는 전역 저장소> 97 | * Set이 비어있으면 전체선택을 의미하고, 98 | * 비어있지 않으면 들어있는 값은 필터링 할 카테고리들을 의미한다. 99 | */ 100 | interface SelectedCategoryDataStore { 101 | selectedCategoryData: Set; 102 | updateSelectedCategoryData: (categoryData: Set) => void; 103 | } 104 | 105 | export const useSelectedCategoryStore = create((set) => ({ 106 | selectedCategoryData: new Set(), 107 | updateSelectedCategoryData: (categoryData: Set) => 108 | set(() => ({ selectedCategoryData: categoryData })), 109 | })); 110 | 111 | /** 112 | * 113 | * bottom: 아래에서 몇 px 띄울건지 지정 114 | */ 115 | interface ToastStoreType { 116 | isOpen: boolean; 117 | content: string; 118 | bottom: number; 119 | duration: number; 120 | updateIsOpen: (isOpen: boolean) => void; 121 | updateToast: (content: string, bottom?: number, duration?: number) => void; 122 | } 123 | 124 | export const useToastStore = create((set) => ({ 125 | isOpen: false, 126 | content: '', 127 | bottom: 100, 128 | duration: 2000, 129 | updateIsOpen: (isOpen) => set(() => ({ isOpen })), 130 | updateToast: (content, bottom, duration) => set(() => ({ content, bottom, duration })), 131 | })); 132 | 133 | /** 134 | * <지도 Ref의 값을 공유하는 저장소> 135 | */ 136 | interface MapStoreType { 137 | map: naver.maps.Map | null; 138 | updateMap: (map: naver.maps.Map | null) => void; 139 | } 140 | 141 | export const useMapStore = create((set) => ({ 142 | map: null, 143 | updateMap: (map: naver.maps.Map | null) => set(() => ({ map })), 144 | })); 145 | -------------------------------------------------------------------------------- /client/src/store/socket.tsx: -------------------------------------------------------------------------------- 1 | import create from 'zustand'; 2 | import { Socket } from 'socket.io-client'; 3 | 4 | interface SocketStoreType { 5 | socket: Socket | null; 6 | setSocket: (socket: Socket) => void; 7 | } 8 | 9 | // props drilling을 막기 위해 소켓 객체를 전역으로 저장 10 | export const useSocketStore = create((set) => ({ 11 | socket: null, 12 | setSocket: (socket) => set(() => ({ socket })), 13 | })); 14 | -------------------------------------------------------------------------------- /client/src/styles/GlobalStyle.tsx: -------------------------------------------------------------------------------- 1 | import styled, { createGlobalStyle } from 'styled-components'; 2 | import * as palette from '@styles/Variables'; 3 | 4 | const GlobalStyle = createGlobalStyle` 5 | :root { 6 | --border-color: #ABABAB; 7 | 8 | --infowindow-max-width: 140px; 9 | --infowindow-border-width: 1px; 10 | 11 | --anchor-size: 7px; 12 | --anchor-bg-size: calc(var(--anchor-size) + var(--infowindow-border-width)); 13 | 14 | --restaurant-marker-size: 30px; 15 | --user-marker-size: 36px; 16 | } 17 | 18 | html, body, #root { 19 | height: 100%; 20 | } 21 | 22 | button { 23 | cursor: pointer; 24 | } 25 | 26 | * { 27 | font-family: 'BM Hanna'; 28 | margin: 0; 29 | padding: 0; 30 | box-sizing: border-box; 31 | } 32 | `; 33 | 34 | export const MainLayout = styled.div` 35 | height: 100%; 36 | aspect-ratio: 9 / 16; 37 | zoom: 1.25; 38 | margin: 0 auto; 39 | border: 3px solid ${palette.BORDER}; 40 | overflow: hidden; 41 | 42 | @media (max-width: ${palette.BREAKPOINT_TABLET}) { 43 | border: 0; 44 | zoom: 0; 45 | aspect-ratio: auto; 46 | } 47 | `; 48 | 49 | export default GlobalStyle; 50 | -------------------------------------------------------------------------------- /client/src/styles/Variables.ts: -------------------------------------------------------------------------------- 1 | // 색상 뿐 아니라 다양한 값을 반환하는데 palette 라는 이름으로 가져다 사용하는게 맞을까? 2 | 3 | // color 4 | export const PRIMARY = '#EF5F21'; 5 | export const BORDER = '#E5E5E5'; 6 | export const SCROLL_THUMB_COLOR = '#a3a3a3'; 7 | export const SCROLL_BAR_COLOR = '#dfdfdf'; 8 | export const BUTTON_COLOR_GREEN = '#3EAD16'; 9 | 10 | // size 11 | export const BREAKPOINT_TABLET = '767px'; 12 | export const HEADER_HEIGHT_RATIO = 10; 13 | export const CATEGORY_HEIGHT_RATIO = 6; 14 | 15 | // z-index 16 | 17 | export const MAP_CONTROLLER_Z_INDEX = 4; 18 | 19 | export const RESTAURANT_FILTERED_LIST_LAYER_Z_INDEX = 5; 20 | 21 | export const CATEGORY_Z_INDEX = 6; 22 | 23 | export const RESTAURANT_CANDIDATE_LIST_LAYER_Z_INDEX = 7; 24 | 25 | export const HEADER_Z_INDEX = 8; 26 | 27 | export const CONTROLLER_Z_INDEX = 8; 28 | 29 | export const RESTAURANT_DETAIL_LAYER_Z_INDEX = 10; 30 | export const DETAIL_MODAL_HEADER_BUTTON_LAYOUT_Z_INDEX = 999; 31 | -------------------------------------------------------------------------------- /client/src/styles/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'BM Hanna'; 3 | src: url('@assets/fonts/BMHANNAAir_ttf.ttf'); 4 | font-weight: 400; 5 | font-display: block; 6 | } 7 | 8 | @font-face { 9 | font-family: 'BM Hanna'; 10 | src: url('@assets/fonts/BMHANNAPro.ttf'); 11 | font-weight: 700; 12 | font-display: block; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/styles/marker.module.css: -------------------------------------------------------------------------------- 1 | .clusterMarkerLayout { 2 | position: relative; 3 | } 4 | 5 | .clusterMarkerCountBox { 6 | position: absolute; 7 | top: -10px; 8 | right: -10px; 9 | background: rgba(0, 0, 0, 40%); 10 | border-radius: 30px; 11 | width: 20px; 12 | height: 20px; 13 | color: white; 14 | font-size: 9px; 15 | display: flex; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | 20 | .infoWindowBox { 21 | position: absolute; 22 | top: -10px; 23 | width: 120%; 24 | padding: 4%; 25 | border-radius: 5px; 26 | border: var(--infowindow-border-width) solid var(--border-color); 27 | background-color: white; 28 | display: flex; 29 | justify-content: center; 30 | align-items: center; 31 | max-width: var(--infowindow-max-width); 32 | } 33 | 34 | .infoWindowBox::before { 35 | position: absolute; 36 | left: calc(50% - var(--anchor-bg-size)); 37 | bottom: calc(var(--anchor-bg-size) * 2 * -1); 38 | content: ''; 39 | border-top: var(--anchor-bg-size) solid var(--border-color); 40 | border-left: var(--anchor-bg-size) solid transparent; 41 | border-right: var(--anchor-bg-size) solid transparent; 42 | border-bottom: var(--anchor-bg-size) solid transparent; 43 | } 44 | 45 | .infoWindowBox::after { 46 | position: absolute; 47 | left: calc(50% - var(--anchor-size)); 48 | bottom: calc(var(--anchor-size) * 2 * -1); 49 | content: ''; 50 | border-top: var(--anchor-size) solid white; 51 | border-left: var(--anchor-size) solid transparent; 52 | border-right: var(--anchor-size) solid transparent; 53 | border-bottom: var(--anchor-size) solid transparent; 54 | } 55 | 56 | .infoWindowParagraph { 57 | white-space: nowrap; 58 | overflow: hidden; 59 | text-overflow: ellipsis; 60 | } 61 | 62 | .restaurantMarker { 63 | position: absolute; 64 | left: calc(var(--anchor-size) * -1); 65 | width: var(--restaurant-marker-size); 66 | height: var(--restaurant-marker-size); 67 | } 68 | 69 | .userMarker { 70 | border-radius: 30px; 71 | width: var(--user-marker-size); 72 | height: var(--user-marker-size); 73 | position: absolute; 74 | z-index: 100; 75 | overflow: hidden; 76 | box-shadow: 0px 0px 4px rgb(0 0 0 / 50%); 77 | } 78 | -------------------------------------------------------------------------------- /client/src/types/location.d.ts: -------------------------------------------------------------------------------- 1 | declare interface LocationType { 2 | lat: number; 3 | lng: number; 4 | } 5 | -------------------------------------------------------------------------------- /client/src/types/naverMapsMarkerClustering.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Marker Clustering 기능은 Naver Maps JavaScript API v3 에서 따로 제공해주지 않는다고 합니다. 3 | * https://developers.naver.com/forum/posts/14968 4 | * 5 | * 대신 navermaps/marker-tools.js github repository 에 예제 코드로 구현되어 있어 이를 가져다 사용하면 됩니다. 6 | * 단, 타입 파일은 별도로 제공해주지 않아서 아래 파일을 보고 따로 타입을 선언해 주었습니다. 7 | * https://github.com/navermaps/marker-tools.js/blob/master/marker-clustering/src/MarkerClustering.js 8 | */ 9 | 10 | type HTMLString = string; 11 | 12 | interface MarkerIconType { 13 | content: HTMLString; 14 | size?: N.Size; 15 | anchor?: N.Point; 16 | } 17 | 18 | interface MarkerClusteringOptionsType { 19 | // 클러스터 마커를 올릴 지도입니다. 20 | map?: naver.maps.Map | null; 21 | 22 | // 클러스터 마커를 구성할 마커입니다. 23 | markers?: naver.maps.Marker[]; 24 | 25 | // 클러스터 마커 클릭 시 줌 동작 여부입니다. 26 | disableClickZoom?: boolean; 27 | 28 | // 클러스터를 구성할 최소 마커 수입니다. 29 | minClusterSize?: number; 30 | 31 | // 클러스터 마커로 표현할 최대 줌 레벨입니다. 해당 줌 레벨보다 높으면, 클러스터를 구성하고 있는 마커를 노출합니다. 32 | maxZoom?: number; 33 | 34 | // 클러스터를 구성할 그리드 크기입니다. 단위는 픽셀입니다. 35 | gridSize?: number; 36 | 37 | // 클러스터 마커의 아이콘입니다. NAVER Maps JavaScript API v3에서 제공하는 아이콘, 심볼, HTML 마커 유형을 모두 사용할 수 있습니다. 38 | icons?: MarkerIconType[]; 39 | 40 | // 클러스터 마커의 아이콘 배열에서 어떤 아이콘을 선택할 것인지 인덱스를 결정합니다. 41 | indexGenerator?: number[]; 42 | 43 | // 클러스터 마커의 위치를 클러스터를 구성하고 있는 마커의 평균 좌표로 할 것인지 여부입니다. 44 | averageCenter?: boolean; 45 | 46 | // 클러스터 마커를 갱신할 때 호출하는 콜백함수입니다. 이 함수를 통해 클러스터 마커에 개수를 표현하는 등의 엘리먼트를 조작할 수 있습니다. 47 | stylingFunction?: (clusterMarker: naver.maps.Marker, count: number) => void; 48 | } 49 | 50 | declare class MarkerClustering { 51 | constructor(options: MarkerClusteringOptionsType); 52 | 53 | setMarkers(markers: naver.maps.Marker[]): void; 54 | 55 | getMarkers(): naver.maps.Marker[]; 56 | } 57 | -------------------------------------------------------------------------------- /client/src/types/socket.d.ts: -------------------------------------------------------------------------------- 1 | declare interface UserType { 2 | userId: string; 3 | userLat: number; 4 | userLng: number; 5 | userName?: string; 6 | isOnline: boolean; 7 | } 8 | 9 | declare interface RestaurantType { 10 | id: string; 11 | name: string; 12 | category: string; 13 | phone: string; 14 | lat: number; 15 | lng: number; 16 | address: string; 17 | url: string; 18 | rating?: number; 19 | photoUrlList?: string[]; 20 | } 21 | 22 | declare interface ResTemplateType { 23 | message: string; 24 | data: T; 25 | } 26 | 27 | declare interface RoomValidType { 28 | isRoomValid: boolean; 29 | } 30 | 31 | declare interface RoomCodeType { 32 | roomCode: string; 33 | } 34 | 35 | declare interface RoomDataType extends RoomCodeType { 36 | lat: number; 37 | lng: number; 38 | userList: { [index: string]: UserType }; 39 | restaurantList: RestaurantType[]; 40 | candidateList: { [index: string]: number }; 41 | userId: string; 42 | userName: string; 43 | } 44 | 45 | declare interface DrivingInfoType { 46 | start: number[]; 47 | goal: number[]; 48 | distance: number; 49 | duration: number; 50 | tollFare: number; 51 | taxiFare: number; 52 | fuelPrice: number; 53 | path: number[][]; 54 | } 55 | 56 | declare type UserIdType = string; 57 | 58 | declare interface JoinListType { 59 | [index: UserIdType]: UserType; 60 | } 61 | -------------------------------------------------------------------------------- /client/src/types/svg.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | import React from 'react'; 3 | 4 | // vite-plugin-svgr (svg to ReactComopnent) 5 | // import { ReactComponent as Logo } from './Logo.svg'; 6 | // 7 | export const ReactComponent: React.FunctionComponent>; 8 | 9 | // import logoPath from './Logo.svg'; 10 | // 11 | const src: string; 12 | export default src; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/utils/distance.ts: -------------------------------------------------------------------------------- 1 | export const distanceToDisplay = (distance: number) => 2 | distance > 1000 ? `${Math.round(distance / 100) / 10}km` : `${distance}m`; 3 | -------------------------------------------------------------------------------- /client/src/utils/time.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * milliseconds 단위 시간을 화면에 표시할 시간과 분 단위로 변환 3 | */ 4 | export const msToTimeDisplay = (time: number) => { 5 | const hour = Math.floor((time / (1000 * 60 * 60)) % 24); // 시간 6 | const minutes = Math.floor((time / (1000 * 60)) % 60); // 분 7 | 8 | return hour > 0 ? `${hour}시간 ${minutes}분` : `${minutes}분`; 9 | }; 10 | -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "paths": { 19 | "@apis/*": ["./src/apis/*"], 20 | "@assets/*": ["./src/assets/*"], 21 | "@components/*": ["./src/components/*"], 22 | "@constants/*": ["./src/constants/*"], 23 | "@hooks/*": ["./src/hooks/*"], 24 | "@pages/*": ["./src/pages/*"], 25 | "@types/*": ["./src/types/*"], 26 | "@utils/*": ["./src/utils/*"], 27 | "@store/*": ["./src/store/*"], 28 | "@styles/*": ["./src/styles/*"] 29 | } 30 | }, 31 | "include": ["src"], 32 | "references": [{ "path": "./tsconfig.node.json" }] 33 | } 34 | -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true 7 | }, 8 | "include": ["vite.config.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | import { resolve } from 'path'; 4 | import svgr from 'vite-plugin-svgr'; 5 | import VitePluginHtmlEnv from 'vite-plugin-html-env'; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [react(), svgr(), VitePluginHtmlEnv()], 10 | resolve: { 11 | alias: { 12 | '@apis': resolve(__dirname, 'src/apis'), 13 | '@assets': resolve(__dirname, 'src/assets'), 14 | '@components': resolve(__dirname, 'src/components'), 15 | '@constants': resolve(__dirname, 'src/constants'), 16 | '@hooks': resolve(__dirname, 'src/hooks'), 17 | '@pages': resolve(__dirname, 'src/pages'), 18 | '@types': resolve(__dirname, 'src/types'), 19 | '@utils': resolve(__dirname, 'src/utils'), 20 | '@store': resolve(__dirname, 'src/store'), 21 | '@styles': resolve(__dirname, 'src/styles'), 22 | }, 23 | }, 24 | server: { 25 | proxy: { 26 | '/socket.io': { 27 | target: 'http://localhost:3000', 28 | }, 29 | '/api': { 30 | target: 'http://localhost:3000', 31 | }, 32 | }, 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /script/client-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd client 3 | 4 | npm cache clean --force && npm ci 5 | if [ $? -eq 0 ];then 6 | echo "Client dependencies installed successfully!" 7 | else 8 | echo "Client dependencies installed failed!" 9 | exit 100 10 | fi 11 | 12 | npm run build 13 | if [ $? -eq 0 ];then 14 | echo "Client build successfully!" 15 | else 16 | echo "Client build failed!" 17 | exit 100 18 | fi 19 | -------------------------------------------------------------------------------- /script/server-deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | cd server 3 | 4 | npm cache clean --force && npm ci 5 | if [ $? -eq 0 ];then 6 | echo "Server dependencies installed successfully!" 7 | else 8 | echo "Server dependencies installed failed!" 9 | exit 100 10 | fi 11 | 12 | npm run build 13 | if [ $? -eq 0 ];then 14 | echo "Server build successfully!" 15 | else 16 | echo "Server build failed!" 17 | exit 100 18 | fi 19 | 20 | pm2 reload chobab 21 | if [ $? -eq 0 ];then 22 | echo "Server deployed successfully!" 23 | else 24 | pm2 start dist/main.js --name chobab 25 | if [ $? -eq 0 ];then 26 | echo "Server deployed successfully!" 27 | else 28 | echo "Server deployment failed!" 29 | exit 100 30 | fi 31 | fi 32 | -------------------------------------------------------------------------------- /server/.env.template: -------------------------------------------------------------------------------- 1 | MONGODB_URI= 2 | KAKAO_API_KEY= 3 | NAVER_MAP_API_CLIENT_ID= 4 | NAVER_MAP_API_CLIENT_SECRET= 5 | REDIS_HOST= 6 | REDIS_PORT= 7 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: ['plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'], 10 | root: true, 11 | env: { 12 | node: true, 13 | jest: true, 14 | }, 15 | ignorePatterns: ['.eslintrc.js'], 16 | rules: { 17 | '@typescript-eslint/interface-name-prefix': 'off', 18 | '@typescript-eslint/explicit-function-return-type': 'off', 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | '@typescript-eslint/no-explicit-any': 'off', 21 | 'import/prefer-default-export': 'off', 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /server/.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | 5 | cd client && echo '-------- Linting, client --------' && npx lint-staged && cd .. 6 | cd server && echo '-------- Linting, server --------' && npx lint-staged && cd .. -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 |

    2 | Nest Logo 3 |

    4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

    A progressive Node.js framework for building efficient and scalable server-side applications.

    9 |

    10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

    22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", 17 | "test": "jest --passWithNoTests", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json", 22 | "prepare": "cd .. && husky install server/.husky" 23 | }, 24 | "dependencies": { 25 | "@nestjs/common": "^9.0.0", 26 | "@nestjs/config": "^2.2.0", 27 | "@nestjs/core": "^9.0.0", 28 | "@nestjs/mongoose": "^9.2.1", 29 | "@nestjs/platform-express": "^9.2.0", 30 | "@nestjs/schedule": "^2.1.0", 31 | "@nestjs/websockets": "^9.2.0", 32 | "axios": "^0.27.2", 33 | "cache-manager": "^5.1.4", 34 | "cache-manager-redis-store": "^3.0.1", 35 | "class-transformer": "^0.5.1", 36 | "class-validator": "^0.13.2", 37 | "dotenv": "^16.0.3", 38 | "express-session": "^1.17.3", 39 | "joi": "^17.7.0", 40 | "mongoose": "^6.7.2", 41 | "reflect-metadata": "^0.1.13", 42 | "rimraf": "^3.0.2", 43 | "rxjs": "^7.2.0", 44 | "session-file-store": "^1.5.0", 45 | "socket.io": "^4.5.3", 46 | "uuid": "^9.0.0" 47 | }, 48 | "devDependencies": { 49 | "@nestjs/cli": "^9.0.0", 50 | "@nestjs/schematics": "^9.0.0", 51 | "@nestjs/testing": "^9.0.0", 52 | "@types/cron": "^2.0.0", 53 | "@types/express": "^4.17.13", 54 | "@types/express-session": "^1.17.5", 55 | "@types/jest": "28.1.8", 56 | "@types/node": "^16.0.0", 57 | "@types/supertest": "^2.0.11", 58 | "@types/uuid": "^8.3.4", 59 | "@typescript-eslint/eslint-plugin": "^5.0.0", 60 | "@typescript-eslint/parser": "^5.0.0", 61 | "eslint": "^8.27.0", 62 | "eslint-config-prettier": "^8.5.0", 63 | "eslint-plugin-prettier": "^4.2.1", 64 | "husky": "^8.0.2", 65 | "jest": "28.1.3", 66 | "lint-staged": "^13.0.3", 67 | "prettier": "^2.7.1", 68 | "source-map-support": "^0.5.20", 69 | "supertest": "^6.1.3", 70 | "ts-jest": "28.0.8", 71 | "ts-loader": "^9.2.3", 72 | "ts-node": "^10.0.0", 73 | "tsconfig-paths": "4.1.0", 74 | "typescript": "^4.7.4" 75 | }, 76 | "jest": { 77 | "moduleFileExtensions": [ 78 | "js", 79 | "json", 80 | "ts" 81 | ], 82 | "rootDir": "src", 83 | "testRegex": ".*\\.spec\\.ts$", 84 | "transform": { 85 | "^.+\\.(t|j)s$": "ts-jest" 86 | }, 87 | "collectCoverageFrom": [ 88 | "**/*.(t|j)s" 89 | ], 90 | "coverageDirectory": "../coverage", 91 | "testEnvironment": "node" 92 | }, 93 | "engines": { 94 | "node": ">=16.18.1" 95 | }, 96 | "lint-staged": { 97 | "*.{ts,tsx}": [ 98 | "eslint --fix" 99 | ] 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { RoomModule } from '@room/room.module'; 5 | import * as Joi from 'joi'; 6 | import { SocketModule } from '@socket/socket.module'; 7 | import { MapModule } from '@map/map.module'; 8 | import { TaskModule } from '@task/task.module'; 9 | import { RestaurantModule } from '@restaurant/restaurant.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | ConfigModule.forRoot({ 14 | isGlobal: true, 15 | envFilePath: '.env', 16 | validationSchema: Joi.object({ 17 | MONGODB_URI: Joi.string().required(), 18 | KAKAO_API_KEY: Joi.string().required(), 19 | NAVER_MAP_API_CLIENT_ID: Joi.string().required(), 20 | NAVER_MAP_API_CLIENT_SECRET: Joi.string().required(), 21 | REDIS_HOST: Joi.string().default('localhost'), 22 | REDIS_PORT: Joi.number().default(6379), 23 | }), 24 | }), 25 | MongooseModule.forRootAsync({ 26 | useFactory: async (configService: ConfigService) => ({ 27 | uri: configService.get('MONGODB_URI'), 28 | }), 29 | inject: [ConfigService], 30 | }), 31 | RoomModule, 32 | RestaurantModule, 33 | SocketModule, 34 | MapModule, 35 | TaskModule, 36 | ], 37 | controllers: [], 38 | providers: [], 39 | }) 40 | export class AppModule {} 41 | -------------------------------------------------------------------------------- /server/src/cache/redis.module.ts: -------------------------------------------------------------------------------- 1 | import { REDIS_TTL } from '@constants/time'; 2 | import { CacheModule, Module } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { redisStore } from 'cache-manager-redis-store'; 5 | import { RedisService } from './redis.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | CacheModule.registerAsync({ 10 | isGlobal: true, 11 | useFactory: async (configService: ConfigService) => { 12 | const redisHost = await configService.get('REDIS_HOST'); 13 | const redisPort = await configService.get('REDIS_PORT'); 14 | console.log(redisHost, redisPort); 15 | const store = await redisStore({ 16 | socket: { host: redisHost, port: redisPort }, 17 | ttl: REDIS_TTL, 18 | }); 19 | return { 20 | store: () => store, 21 | }; 22 | }, 23 | inject: [ConfigService], 24 | }), 25 | ], 26 | controllers: [], 27 | providers: [RedisService], 28 | exports: [RedisService], 29 | }) 30 | export class RedisModule {} 31 | -------------------------------------------------------------------------------- /server/src/common/exceptions/custom.exception.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus } from '@nestjs/common'; 2 | 3 | // exceptions 폴더 구조 유지를 위한 임시 파일 4 | export class CustomException extends HttpException { 5 | constructor(message) { 6 | super(message, HttpStatus.BAD_REQUEST); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/common/filters/exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | 4 | @Catch() 5 | export class AllExceptionsFilter implements ExceptionFilter { 6 | public catch(exception: Error, host: ArgumentsHost) { 7 | const ctx = host.switchToHttp(); 8 | const response = ctx.getResponse(); 9 | const status = 10 | exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; 11 | 12 | return response.status(status).json({ message: exception.message }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/common/interceptors/template.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; 2 | import { Observable } from 'rxjs'; 3 | import { map } from 'rxjs/operators'; 4 | 5 | export interface ResTemplateType { 6 | message: string; 7 | data: T; 8 | } 9 | 10 | @Injectable() 11 | export class TemplateInterceptor implements NestInterceptor> { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable> { 13 | return next.handle().pipe( 14 | map((data) => ({ 15 | message: data.message, 16 | data: data.data, 17 | })) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/src/constants/api.ts: -------------------------------------------------------------------------------- 1 | export const RESTAURANT_LIST_API_URL = 'https://dapi.kakao.com/v2/local/search/keyword.json'; 2 | 3 | export const RESTAURANT_DETAIL_API_URL = 4 | 'https://maps.googleapis.com/maps/api/place/findplacefromtext/json'; 5 | 6 | export const NAVER_DRIVING_API_URL = 7 | 'https://naveropenapi.apigw.ntruss.com/map-direction/v1/driving'; 8 | -------------------------------------------------------------------------------- /server/src/constants/location.ts: -------------------------------------------------------------------------------- 1 | export const LOCATION_BOUNDARY = Object.freeze({ 2 | LAT: Object.freeze({ 3 | min: 33, 4 | max: 43, 5 | }), 6 | LNG: Object.freeze({ 7 | min: 124, 8 | max: 132, 9 | }), 10 | }); 11 | 12 | export const MAX_RADIUS = 5000; 13 | 14 | export const DEFAULT_RADIUS = 1000; 15 | 16 | export const MAX_DETAIL_SEARCH_RADIUS = 1000; 17 | -------------------------------------------------------------------------------- /server/src/constants/nickname.ts: -------------------------------------------------------------------------------- 1 | export const NICKNAME_ADJECTIVE = [ 2 | '멋진', 3 | '신난', 4 | '춤추는', 5 | '신중한', 6 | '진지한', 7 | '멍때리는', 8 | '용감한', 9 | '즐거운', 10 | '행복한', 11 | '피곤한', 12 | '부지런한', 13 | '생각하는', 14 | '집중하는', 15 | '외출하는', 16 | '놀러가는', 17 | '고민하는', 18 | '고민중인', 19 | '혼란스러운', 20 | '맛집 찾는', 21 | '먹는데 진심인', 22 | ]; 23 | 24 | export const NICKNAME_NOUN = [ 25 | '육개장', 26 | '갈비찜', 27 | '쫄면', 28 | '라면', 29 | '카레', 30 | '떡볶이', 31 | '라볶이', 32 | '돈까스', 33 | '돈부리', 34 | '튀김우동', 35 | '새우튀김', 36 | '계란초밥', 37 | '치즈피자', 38 | '햄버거', 39 | '파스타', 40 | '쌀국수', 41 | '짜장면', 42 | '짬뽕', 43 | '탕수육', 44 | '샌드위치', 45 | ]; 46 | -------------------------------------------------------------------------------- /server/src/constants/response/common.ts: -------------------------------------------------------------------------------- 1 | import { failRes } from '@response/index'; 2 | 3 | export const COMMON_EXCEPTION = { 4 | INVALID_QUERY_PARAMS: failRes('올바르지 않은 Query Params입니다.'), 5 | }; 6 | -------------------------------------------------------------------------------- /server/src/constants/response/index.ts: -------------------------------------------------------------------------------- 1 | export interface SuccessResType { 2 | message: string; 3 | data: any; 4 | } 5 | 6 | export interface FailResType { 7 | message: string; 8 | } 9 | 10 | export const successRes = (message: string, data: any): SuccessResType => { 11 | return { message, data }; 12 | }; 13 | 14 | export const failRes = (message: string): FailResType => { 15 | return { message }; 16 | }; 17 | -------------------------------------------------------------------------------- /server/src/constants/response/location.ts: -------------------------------------------------------------------------------- 1 | import { failRes } from '@response/index'; 2 | 3 | export const LOCATION_EXCEPTION = { 4 | OUT_OF_KOREA: failRes('대한민국을 벗어난 입력입니다.'), 5 | OUT_OF_MAX_RADIUS: failRes('최대 탐색 반경을 벗어난 입력입니다.'), 6 | }; 7 | -------------------------------------------------------------------------------- /server/src/constants/restaurant.ts: -------------------------------------------------------------------------------- 1 | export const RESTAURANT_CATEGORY = Object.freeze([ 2 | '한식', 3 | '일식', 4 | '중식', 5 | '양식', 6 | '패스트푸드', 7 | '치킨', 8 | '분식', 9 | ]); 10 | 11 | export const RESTAURANT_DETAIL_FIELD = Object.freeze([ 12 | 'rating', 13 | 'opening_hours', 14 | 'photo', 15 | 'price_level', 16 | ]); 17 | -------------------------------------------------------------------------------- /server/src/constants/time.ts: -------------------------------------------------------------------------------- 1 | export const ONE_HOUR_MILLISECOND = 1000 * 60 * 60; 2 | export const MONGO_TTL = ONE_HOUR_MILLISECOND * 6; 3 | 4 | export const REDIS_TTL = 60 * 60 * 6; 5 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { AllExceptionsFilter } from '@common/filters/exception.filter'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | import { TemplateInterceptor } from '@common/interceptors/template.interceptor'; 5 | import { ValidationPipe } from '@nestjs/common'; 6 | import { sessionMiddleware } from '@utils/session'; 7 | 8 | async function bootstrap() { 9 | const app = await NestFactory.create(AppModule); 10 | app.use(sessionMiddleware); 11 | app.setGlobalPrefix('api'); 12 | app.useGlobalFilters(new AllExceptionsFilter()); 13 | app.useGlobalInterceptors(new TemplateInterceptor()); 14 | app.useGlobalPipes( 15 | new ValidationPipe({ 16 | whitelist: true, // 엔티티 데코레이터에 없는 프로퍼티 값은 무조건 거름 17 | transform: true, // 컨트롤러가 값을 받을때 컨트롤러에 정의한 타입으로 형변환 18 | }) 19 | ); 20 | await app.listen(3000); 21 | } 22 | 23 | bootstrap(); 24 | -------------------------------------------------------------------------------- /server/src/map/dto/get-driving-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class GetDrivingQueryDto { 5 | @Transform(({ value }) => value.split(',').map((v) => Number(v))) 6 | @IsNumber({}, { each: true }) 7 | start: number[]; 8 | 9 | @Transform(({ value }) => value.split(',').map((v) => Number(v))) 10 | @IsNumber({}, { each: true }) 11 | goal: number[]; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/map/map.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { GetDrivingQueryDto } from '@map/dto/get-driving-query.dto'; 3 | import { MapService } from '@map/map.service'; 4 | import { MAP_RES } from '@map/map.response'; 5 | 6 | @Controller('map') 7 | export class MapController { 8 | constructor(private readonly mapService: MapService) {} 9 | 10 | @Get('driving') 11 | async getDrivingInfo(@Query() getDrivingQuery: GetDrivingQueryDto) { 12 | const drivingInfo = await this.mapService.drivingInfo( 13 | getDrivingQuery.start, 14 | getDrivingQuery.goal 15 | ); 16 | 17 | return MAP_RES.SUCCESS_GET_DRIVING_INFO(drivingInfo); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/src/map/map.d.ts: -------------------------------------------------------------------------------- 1 | interface StartOrGoalType { 2 | location: number[]; 3 | } 4 | 5 | export interface SummaryType { 6 | start: StartOrGoalType; // 출발지 7 | goal: StartOrGoalType; // 도착지 8 | distance: number; // 총 거리 9 | duration: number; // 소요 시간(millisecond) 10 | tollFare: number; // 통행 요금(톨게이트) 11 | taxiFare: number; // 택시 요금 12 | fuelPrice: number; // 전국 평균 유류비와 연비를 감안한 유류비 13 | } 14 | 15 | export interface TraoptimalType { 16 | summary: SummaryType; // 요약 정보 17 | path: number[][]; // 경로를 구성하는 모든 좌표열 18 | } 19 | 20 | interface RouteType { 21 | traoptimal: TraoptimalType[]; 22 | } 23 | 24 | export interface NaverDrivingResType { 25 | code: number; // 응답 결과 코드 26 | route: RouteType; // 응답 결과 27 | } 28 | 29 | export interface DrivingInfoType { 30 | start: number[]; 31 | goal: number[]; 32 | distance: number; 33 | duration: number; 34 | tollFare: number; 35 | taxiFare: number; 36 | fuelPrice: number; 37 | path: number[][]; 38 | } 39 | -------------------------------------------------------------------------------- /server/src/map/map.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MapController } from '@map/map.controller'; 3 | import { MapService } from '@map/map.service'; 4 | 5 | @Module({ 6 | controllers: [MapController], 7 | providers: [MapService], 8 | }) 9 | export class MapModule {} 10 | -------------------------------------------------------------------------------- /server/src/map/map.response.ts: -------------------------------------------------------------------------------- 1 | import { failRes, successRes } from '@response/index'; 2 | import { DrivingInfoType } from '@map/map'; 3 | 4 | export const MAP_RES = { 5 | SUCCESS_GET_DRIVING_INFO: (data: DrivingInfoType) => { 6 | return successRes('성공적으로 경로 정보를 가져왔습니다.', data); 7 | }, 8 | }; 9 | 10 | export const MAP_EXCEPTION = { 11 | FAIL_GET_DRIVING_INFO: failRes('길찾기 정보를 가져오는데 실패했습니다.'), 12 | INVALID_GOAL: failRes('출발지와 도착지가 같습니다.'), 13 | }; 14 | -------------------------------------------------------------------------------- /server/src/map/map.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import axios from 'axios'; 4 | import { CustomException } from '@common/exceptions/custom.exception'; 5 | import { isInKorea } from '@utils/location'; 6 | import { NaverDrivingResType, SummaryType, TraoptimalType } from '@map/map'; 7 | import { NAVER_DRIVING_API_URL } from '@constants/api'; 8 | import { COMMON_EXCEPTION } from '@response/common'; 9 | import { LOCATION_EXCEPTION } from '@response/location'; 10 | import { MAP_EXCEPTION } from '@map/map.response'; 11 | 12 | @Injectable() 13 | export class MapService { 14 | private readonly NAVER_MAP_API_CLIENT_ID: string; 15 | private readonly NAVER_MAP_API_CLIENT_SECRET: string; 16 | 17 | constructor(private readonly configService: ConfigService) { 18 | this.NAVER_MAP_API_CLIENT_ID = configService.get('NAVER_MAP_API_CLIENT_ID'); 19 | this.NAVER_MAP_API_CLIENT_SECRET = configService.get('NAVER_MAP_API_CLIENT_SECRET'); 20 | } 21 | 22 | /** 23 | * 좌표 데이터 유효성 검사 24 | */ 25 | private validPosData(pos: number[]) { 26 | if (pos.length !== 2) { 27 | throw new CustomException(COMMON_EXCEPTION.INVALID_QUERY_PARAMS); 28 | } 29 | 30 | const [lng, lat] = pos; 31 | if (!isInKorea(lat, lng)) { 32 | throw new CustomException(LOCATION_EXCEPTION.OUT_OF_KOREA); 33 | } 34 | } 35 | 36 | async drivingInfo(start: number[], goal: number[]) { 37 | // 출발지, 도착지 좌표 데이터 유효성 검사 38 | this.validPosData(start); 39 | this.validPosData(goal); 40 | 41 | const startPos = start.join(','); 42 | const goalPos = goal.join(','); 43 | if (startPos === goalPos) { 44 | throw new CustomException(MAP_EXCEPTION.INVALID_GOAL); 45 | } 46 | 47 | const { summary, path } = await this.getDrivingInfo(startPos, goalPos); 48 | return { ...this.summaryDataProcessing(summary), path }; 49 | } 50 | 51 | /** 52 | * Naver Map Direction5를 통해 출발/도착지에 대한 길찾기 요청을 보내고 응답을 받음 53 | */ 54 | async getDrivingInfo(startPos: string, goalPos: string): Promise { 55 | try { 56 | const { data } = await axios.get(NAVER_DRIVING_API_URL, { 57 | headers: { 58 | 'X-NCP-APIGW-API-KEY-ID': this.NAVER_MAP_API_CLIENT_ID, 59 | 'X-NCP-APIGW-API-KEY': this.NAVER_MAP_API_CLIENT_SECRET, 60 | }, 61 | params: { start: startPos, goal: goalPos }, 62 | }); 63 | 64 | if (data?.code !== 0) { 65 | throw new Error(); 66 | } 67 | return data.route.traoptimal[0]; 68 | } catch (error) { 69 | throw new CustomException(MAP_EXCEPTION.FAIL_GET_DRIVING_INFO); 70 | } 71 | } 72 | 73 | /** 74 | * 길찾기 요청에 대한 summary 정보를 필요한 부분만 사용하고 필요한 형태로 가공 75 | */ 76 | private summaryDataProcessing(summary: SummaryType) { 77 | const { distance, duration, tollFare, taxiFare, fuelPrice } = summary; 78 | const start = summary.start.location; 79 | const goal = summary.goal.location; 80 | return { start, goal, distance, duration, tollFare, taxiFare, fuelPrice }; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /server/src/restaurant/dto/get-restaurant-detail-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsNumber, IsString } from 'class-validator'; 3 | 4 | export class GetRestaurantDetailQueryDto { 5 | @IsString() 6 | restaurantId: string; 7 | 8 | @IsString() 9 | name: string; 10 | 11 | @IsString() 12 | address: string; 13 | 14 | @Transform(({ value }) => Number(value)) 15 | @IsNumber() 16 | lat: number; 17 | 18 | @Transform(({ value }) => Number(value)) 19 | @IsNumber() 20 | lng: number; 21 | } 22 | -------------------------------------------------------------------------------- /server/src/restaurant/restaurant.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { RestaurantService } from '@restaurant/restaurant.service'; 3 | 4 | @Controller('restaurant') 5 | export class RestaurantController { 6 | constructor(private readonly restaurantService: RestaurantService) {} 7 | 8 | // 당장 이를 사용하지 않기로 9 | // @Get() 10 | // async getRestaurantDetail(@Query() getRestaurantDetailDto: GetRestaurantDetailQueryDto) { 11 | // const { name, address, lat, lng, restaurantId: id } = getRestaurantDetailDto; 12 | // const { rating, priceLevel } = await this.restaurantService.getRestaurantDetail( 13 | // id, 14 | // address, 15 | // name, 16 | // lat, 17 | // lng 18 | // ); 19 | 20 | // return RESTAURANT_RES.SUCCESS_SEARCH_RESTAURANT_DETAIL(rating, priceLevel); 21 | // } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/restaurant/restaurant.d.ts: -------------------------------------------------------------------------------- 1 | export type RestaurantIdType = string; 2 | 3 | export interface OriginRestaurantType { 4 | id: RestaurantIdType; 5 | road_address_name: string; 6 | category_name: string; 7 | phone: string; 8 | place_name: string; 9 | place_url: string; 10 | x: string; 11 | y: string; 12 | } 13 | 14 | export interface PreprocessedRestaurantType { 15 | id: RestaurantIdType; 16 | name: string; 17 | category: string; 18 | phone: string; 19 | lat: number; 20 | lng: number; 21 | url: string; 22 | address: string; 23 | } 24 | 25 | // 기본 음식점 데이터와 상세 정보를 취합한 데이터 타입 26 | export interface MergedRestaurantType extends PreprocessedRestaurantType { 27 | rating?: number; 28 | photoUrlList?: string[]; 29 | } 30 | 31 | export interface RestaurantApiResultType { 32 | meta: { 33 | is_end: boolean; 34 | pageable_count: number; 35 | total_count: number; 36 | }; 37 | documents: OriginRestaurantType[]; 38 | } 39 | 40 | export interface CandidateHashType { 41 | [index: RestaurantIdType]: UserIdType[]; 42 | } 43 | -------------------------------------------------------------------------------- /server/src/restaurant/restaurant.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { RestaurantCategory, RestaurantCategorySchema } from '@restaurant/restaurant.schema'; 4 | import { RestaurantService } from '@restaurant/restaurant.service'; 5 | 6 | @Module({ 7 | imports: [ 8 | MongooseModule.forFeature([ 9 | { name: RestaurantCategory.name, schema: RestaurantCategorySchema }, 10 | ]), 11 | ], 12 | controllers: [], 13 | providers: [RestaurantService], 14 | exports: [RestaurantService], 15 | }) 16 | export class RestaurantModule {} 17 | -------------------------------------------------------------------------------- /server/src/restaurant/restaurant.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | export type RestaurantCategoryDocument = RestaurantCategory & Document; 5 | 6 | @Schema() 7 | export class RestaurantCategory { 8 | @Prop({ required: true, unique: true }) 9 | category: string; 10 | 11 | @Prop({ required: true, default: [] }) 12 | photoUrl: [string]; 13 | } 14 | 15 | export const RestaurantCategorySchema = SchemaFactory.createForClass(RestaurantCategory); 16 | -------------------------------------------------------------------------------- /server/src/room/dto/connect-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class ConnectRoomDto { 4 | @IsString() 5 | roomCode: string; 6 | 7 | @IsString() 8 | userId: string; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/room/dto/create-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_RADIUS } from '@constants/location'; 2 | import { IsNumber } from 'class-validator'; 3 | 4 | export class CreateRoomDto { 5 | @IsNumber() 6 | readonly lat: number; 7 | 8 | @IsNumber() 9 | readonly lng: number; 10 | 11 | @IsNumber() 12 | readonly radius: number = DEFAULT_RADIUS; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/room/room.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Get, Post, Query } from '@nestjs/common'; 2 | import { CreateRoomDto } from '@room/dto/create-room.dto'; 3 | import { ResTemplateType } from '@common/interceptors/template.interceptor'; 4 | import { RoomService } from '@room/room.service'; 5 | import { CustomException } from '@common/exceptions/custom.exception'; 6 | import { ROOM_EXCEPTION, ROOM_RES } from '@room/room.response'; 7 | 8 | @Controller('room') 9 | export class RoomController { 10 | constructor(private readonly roomService: RoomService) {} 11 | 12 | @Post() 13 | async createRoom(@Body() createRoomDto: CreateRoomDto): Promise> { 14 | const { lat, lng, radius } = createRoomDto; 15 | const roomCode = await this.roomService.createRoom(lat, lng, radius); 16 | return ROOM_RES.SUCCESS_CREATE_ROOM(roomCode); 17 | } 18 | 19 | @Get('valid') 20 | async validRoom(@Query('roomCode') roomCode: string) { 21 | const isRoomValid = await this.roomService.validRoom(roomCode); 22 | if (!isRoomValid) { 23 | throw new CustomException(ROOM_EXCEPTION.FAIL_VALID_ROOM); 24 | } 25 | 26 | return ROOM_RES.SUCCESS_VALID_ROOM; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /server/src/room/room.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { Room, RoomSchema } from './room.schema'; 4 | import { RoomController } from './room.controller'; 5 | import { RoomService } from './room.service'; 6 | import { RedisModule } from '@cache/redis.module'; 7 | import { RestaurantModule } from '@restaurant/restaurant.module'; 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([{ name: Room.name, schema: RoomSchema }]), 11 | RedisModule, 12 | RestaurantModule, 13 | ], 14 | controllers: [RoomController], 15 | providers: [RoomService], 16 | }) 17 | export class RoomModule {} 18 | -------------------------------------------------------------------------------- /server/src/room/room.response.ts: -------------------------------------------------------------------------------- 1 | import { failRes, successRes } from '@response/index'; 2 | 3 | export const ROOM_RES = { 4 | SUCCESS_CREATE_ROOM: (roomCode: string) => { 5 | return successRes('성공적으로 모임방을 생성했습니다.', { roomCode }); 6 | }, 7 | SUCCESS_VALID_ROOM: successRes('유효한 roomCode입니다.', { isRoomValid: true }), 8 | }; 9 | 10 | export const ROOM_EXCEPTION = { 11 | FAIL_CREATE_ROOM: failRes('모임방 생성에 실패했습니다.'), 12 | FAIL_VALID_ROOM: failRes('유효하지 않은 모임방입니다.'), 13 | FAIL_SEARCH_ROOM: failRes('모임방 검색에 실패했습니다.'), 14 | IS_NOT_EXIST_ROOM: failRes('존재하지 않는 모임방입니다.'), 15 | ALREADY_DELETED_ROOM: failRes('이미 삭제된 모임방입니다.'), 16 | }; 17 | -------------------------------------------------------------------------------- /server/src/room/room.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { Document } from 'mongoose'; 3 | 4 | export type RoomDocument = Room & Document; 5 | 6 | @Schema() 7 | export class Room { 8 | @Prop({ required: true, unique: true }) 9 | roomCode: string; 10 | 11 | @Prop({ required: true, default: Date.now }) 12 | createdAt: Date; 13 | 14 | @Prop() 15 | deletedAt: Date; 16 | 17 | @Prop({ required: true }) 18 | lng: number; 19 | 20 | @Prop({ required: true }) 21 | lat: number; 22 | } 23 | 24 | export const RoomSchema = SchemaFactory.createForClass(Room); 25 | -------------------------------------------------------------------------------- /server/src/room/room.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { v4 as uuid } from 'uuid'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { Room, RoomDocument } from './room.schema'; 5 | import { Model } from 'mongoose'; 6 | import { CustomException } from '@common/exceptions/custom.exception'; 7 | import { isInKorea } from '@utils/location'; 8 | import { RestaurantService } from '@restaurant/restaurant.service'; 9 | import { LOCATION_EXCEPTION } from '@response/location'; 10 | import { RedisService } from '@cache/redis.service'; 11 | import { ROOM_EXCEPTION } from '@room/room.response'; 12 | 13 | @Injectable() 14 | export class RoomService { 15 | constructor( 16 | @InjectModel(Room.name) private roomModel: Model, 17 | private restaurantService: RestaurantService, 18 | private readonly redisService: RedisService 19 | ) {} 20 | 21 | async createRoom(lat: number, lng: number, radius: number): Promise { 22 | if (!isInKorea(lat, lng)) { 23 | throw new CustomException(LOCATION_EXCEPTION.OUT_OF_KOREA); 24 | } 25 | 26 | try { 27 | const roomCode = uuid(); 28 | const restaurantMap = await this.restaurantService.getRestaurantList(lat, lng, radius); 29 | 30 | const restaurantDetailList = await Promise.all( 31 | Object.keys(restaurantMap).map((restaurantId) => { 32 | const restaurant = restaurantMap[restaurantId]; 33 | const { category } = restaurant; 34 | return this.restaurantService.getRestaurantDetail(restaurantId, category); 35 | }) 36 | ); 37 | 38 | const mergedRestaurantDataList = restaurantDetailList.map((restaurantDetailData) => { 39 | const { id } = restaurantDetailData; 40 | const restaurantData = restaurantMap[id]; 41 | return { ...restaurantData, ...restaurantDetailData }; 42 | }); 43 | 44 | // 방 생성 시 필요한 세팅을 비동기적으로 동시에 처리 45 | // - MongoDB 내 room 생성 46 | // - Redis 내 음식점 데이터 적재 47 | // - Redis 내 빈 음식점 후보 리스트 생성 48 | // - Redis 내 빈 접속자 리스트 생성 49 | await Promise.all([ 50 | this.roomModel.create({ roomCode, lat, lng }), 51 | this.redisService.restaurantList.setRestaurantListForRoom( 52 | roomCode, 53 | mergedRestaurantDataList 54 | ), 55 | this.redisService.candidateList.createEmptyCandidateListForRoom( 56 | roomCode, 57 | mergedRestaurantDataList 58 | ), 59 | this.redisService.joinList.createEmptyJoinListForRoom(roomCode), 60 | ]); 61 | 62 | return roomCode; 63 | } catch (error) { 64 | console.log(error); 65 | throw new CustomException(ROOM_EXCEPTION.FAIL_CREATE_ROOM); 66 | } 67 | } 68 | 69 | async validRoom(roomCode: string): Promise { 70 | try { 71 | const room = await this.roomModel.findOne({ roomCode }); 72 | if (!room || room.deletedAt) { 73 | return false; 74 | } 75 | const roomDynamic = await this.redisService.restaurantList.getRestaurantListForRoom(roomCode); 76 | if (!roomDynamic) { 77 | await this.roomModel.findOneAndUpdate({ roomCode }, { deletedAt: Date.now() }); 78 | return false; 79 | } 80 | 81 | return !!room && !room.deletedAt; 82 | } catch (error) { 83 | throw new CustomException(ROOM_EXCEPTION.FAIL_SEARCH_ROOM); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/src/socket/dto/connect-room.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, IsString } from 'class-validator'; 2 | 3 | export class ConnectRoomDto { 4 | @IsString() 5 | roomCode: string; 6 | 7 | @IsNumber() 8 | userLat: number; 9 | 10 | @IsNumber() 11 | userLng: number; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/socket/dto/user-location.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | 3 | export class UserLocationDto { 4 | @IsNumber() 5 | userLat: number; 6 | 7 | @IsNumber() 8 | userLng: number; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/socket/dto/vote-restaurant.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString } from 'class-validator'; 2 | 3 | export class VoteRestaurantDto { 4 | @IsString() 5 | restaurantId: string; 6 | } 7 | -------------------------------------------------------------------------------- /server/src/socket/socket.d.ts: -------------------------------------------------------------------------------- 1 | import 'socket.io'; // 이거 없으면 declare module 'socket.io' 오작동 2 | 3 | declare module 'http' { 4 | interface IncomingMessage { 5 | session: session & { 6 | nickName: string; 7 | }; 8 | sessionID: string; 9 | } 10 | } 11 | 12 | declare module 'socket.io' { 13 | class Socket { 14 | sessionID: string; 15 | roomCode: string; 16 | } 17 | } 18 | 19 | export interface VoteResultType { 20 | restaurantId: string; 21 | count: number; 22 | } 23 | -------------------------------------------------------------------------------- /server/src/socket/socket.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { EventsGateway } from '@socket/socket.gateway'; 3 | 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { Room, RoomSchema } from '@room/room.schema'; 6 | import { RedisModule } from '@cache/redis.module'; 7 | 8 | @Module({ 9 | imports: [MongooseModule.forFeature([{ name: Room.name, schema: RoomSchema }]), RedisModule], 10 | controllers: [], 11 | providers: [EventsGateway], 12 | }) 13 | export class SocketModule {} 14 | -------------------------------------------------------------------------------- /server/src/socket/socket.response.ts: -------------------------------------------------------------------------------- 1 | import { PreprocessedRestaurantType as RestaurantType } from '@restaurant/restaurant'; 2 | import { VoteResultType } from '@socket/socket'; 3 | 4 | interface UserType { 5 | userId: string; 6 | userLat: number; 7 | userLng: number; 8 | userName?: string; 9 | isOnline?: boolean; // 변경필요.. 10 | } 11 | 12 | const dataTemplate = (message: string, data?: unknown) => { 13 | if (!data) { 14 | return { message }; 15 | } 16 | return { 17 | message, 18 | data, 19 | }; 20 | }; 21 | 22 | export const SOCKET_RES = { 23 | CONNECT_FAIL: dataTemplate('접속 실패'), 24 | 25 | CONNECT_SUCCESS: ( 26 | roomCode: string, 27 | lat: number, 28 | lng: number, 29 | restaurantList: RestaurantType[], 30 | userList: { [index: string]: UserType }, 31 | userId: string, 32 | userName: string 33 | ) => 34 | dataTemplate('접속 성공', { 35 | roomCode, 36 | lat, 37 | lng, 38 | restaurantList, 39 | userList, 40 | userId, 41 | userName, 42 | }), 43 | 44 | JOIN_USER: (userInfo: UserType) => dataTemplate('유저 입장', userInfo), 45 | 46 | LEAVE_USER: (userId: string) => dataTemplate('유저 퇴장', userId), 47 | 48 | CHANGED_USER_LOCATION: (userInfo: UserType) => dataTemplate('사용자의 위치 변경', userInfo), 49 | 50 | VOTE_RESTAURANT_SUCCESS: (restaurantId: string) => dataTemplate('투표 성공', { restaurantId }), 51 | 52 | VOTE_RESTAURANT_FAIL: dataTemplate('투표 실패'), 53 | 54 | UPDATE_VOTE_RESULT: (candidateList: VoteResultType[]) => 55 | dataTemplate('투표 결과 업데이트', { candidateList }), 56 | 57 | CANCEL_VOTE_RESTAURANT_SUCCESS: (restaurantId: string) => 58 | dataTemplate('투표 취소 성공', { restaurantId }), 59 | 60 | CANCEL_VOTE_RESTAURANT_FAIL: dataTemplate('투표 취소 실패'), 61 | 62 | CURRENT_VOTE_RESULT: (candidateList: VoteResultType[]) => 63 | dataTemplate('현재 투표 결과', { candidateList }), 64 | 65 | USER_VOTE_RESTAURANT_ID_LIST: (voteRestaurantIdList: string[]) => 66 | dataTemplate('사용자 투표 식당 ID 리스트', { voteRestaurantIdList }), 67 | }; 68 | -------------------------------------------------------------------------------- /server/src/task/task.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TaskService } from '@task/task.service'; 3 | import { ScheduleModule } from '@nestjs/schedule'; 4 | import { MongooseModule } from '@nestjs/mongoose'; 5 | import { Room, RoomSchema } from '@room/room.schema'; 6 | 7 | @Module({ 8 | imports: [ 9 | ScheduleModule.forRoot(), 10 | MongooseModule.forFeature([{ name: Room.name, schema: RoomSchema }]), 11 | ], 12 | providers: [TaskService], 13 | }) 14 | export class TaskModule {} 15 | -------------------------------------------------------------------------------- /server/src/task/task.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { Cron, CronExpression } from '@nestjs/schedule'; 3 | import { InjectModel } from '@nestjs/mongoose'; 4 | import { Room, RoomDocument } from '@room/room.schema'; 5 | import { FilterQuery, Model } from 'mongoose'; 6 | import { MONGO_TTL } from '@constants/time'; 7 | 8 | @Injectable() 9 | export class TaskService { 10 | private readonly logger = new Logger(TaskService.name); 11 | 12 | constructor(@InjectModel(Room.name) private roomModel: Model) {} 13 | 14 | /** 15 | * 매 시각 정각마다 실행하는 room 삭제 작업 16 | */ 17 | @Cron(CronExpression.EVERY_HOUR) 18 | async deleteRoomCron() { 19 | this.logger.debug('[Delete Room Cron] Called every hour'); 20 | const now = new Date(); 21 | 22 | // 현재로 부터 6시간 전 시간 23 | const criteria = new Date(now.getTime() - MONGO_TTL); 24 | 25 | // 기준 시각 이전에 생성된 방을 찾을 때 필요한 조건 26 | const condition: FilterQuery = { 27 | $and: [{ createdAt: { $lt: criteria } }, { deletedAt: { $exists: false } }], 28 | }; 29 | 30 | // 기준 시각 이전에 생성된 room soft delete 31 | await this.roomModel.updateMany(condition, { deletedAt: now }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/utils/location.ts: -------------------------------------------------------------------------------- 1 | import { LOCATION_BOUNDARY } from '@constants/location'; 2 | 3 | export const isInKorea = (lat: number, lng: number) => { 4 | return ( 5 | LOCATION_BOUNDARY.LAT.min < lat && 6 | lat < LOCATION_BOUNDARY.LAT.max && 7 | LOCATION_BOUNDARY.LNG.min < lng && 8 | lng < LOCATION_BOUNDARY.LNG.max 9 | ); 10 | }; 11 | -------------------------------------------------------------------------------- /server/src/utils/nickname.ts: -------------------------------------------------------------------------------- 1 | import { NICKNAME_ADJECTIVE, NICKNAME_NOUN } from '@constants/nickname'; 2 | 3 | export const makeUserRandomNickname = () => { 4 | const nickname = `${NICKNAME_ADJECTIVE[Math.floor(Math.random() * NICKNAME_ADJECTIVE.length)]} ${ 5 | NICKNAME_NOUN[Math.floor(Math.random() * NICKNAME_NOUN.length)] 6 | }`; 7 | return nickname; 8 | }; 9 | -------------------------------------------------------------------------------- /server/src/utils/random.ts: -------------------------------------------------------------------------------- 1 | export const getRandomNum = (lastNum: number) => { 2 | const lastInt = Math.floor(lastNum); 3 | const randomNum = Math.floor(Math.random() * lastInt); 4 | return randomNum; 5 | }; 6 | 7 | export const getRandomRating = () => 3 + Math.floor(Math.random() * 2 * 10) / 10; 8 | -------------------------------------------------------------------------------- /server/src/utils/session.ts: -------------------------------------------------------------------------------- 1 | import * as session from 'express-session'; 2 | import * as fileStoreCreateFunction from 'session-file-store'; 3 | import { ONE_HOUR_MILLISECOND } from '@constants/time'; 4 | 5 | const FileStore = fileStoreCreateFunction(session); 6 | 7 | export const sessionMiddleware = session({ 8 | resave: false, 9 | saveUninitialized: true, 10 | // TODO: 추후 secret 값 환경변수로 이동 11 | secret: 'cookie-secret', 12 | cookie: { 13 | httpOnly: true, 14 | secure: false, 15 | // expires 를 따로 설정해주지 않으면 브라우저가 닫혔을 때 세션쿠키가 삭제되기 때문에 16 | // 적당한 세션쿠키 유효시간을 설정해 줌 (1시간) 17 | maxAge: ONE_HOUR_MILLISECOND, 18 | }, 19 | store: new FileStore(), 20 | }); 21 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "paths": { 14 | "@common/*": ["./src/common/*"], 15 | "@constants/*": ["./src/constants/*"], 16 | "@utils/*": ["./src/utils/*"], 17 | "@restaurant/*": ["./src/restaurant/*"], 18 | "@room/*": ["./src/room/*"], 19 | "@response/*": ["./src/constants/response/*"], 20 | "@socket/*": ["./src/socket/*"], 21 | "@map/*": ["./src/map/*"], 22 | "@cache/*": ["./src/cache/*"], 23 | "@task/*": ["./src/task/*"] 24 | }, 25 | "incremental": true, 26 | "skipLibCheck": true, 27 | "strictNullChecks": false, 28 | "noImplicitAny": false, 29 | "strictBindCallApply": false, 30 | "forceConsistentCasingInFileNames": false, 31 | "noFallthroughCasesInSwitch": false 32 | } 33 | } 34 | --------------------------------------------------------------------------------