├── .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 |
30 |
31 |
32 |
33 |
34 | ## :hammer_and_wrench: Skills: 기술스택
35 |
36 |
37 | ## :gear: System Architecture: 시스템 아키텍처
38 |
39 |
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 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/images/backward-arrow-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/candidate-list.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/images/fake-word.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/images/filled-like.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/images/flag.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/client/src/assets/images/gps.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/images/hamburger.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
41 |
--------------------------------------------------------------------------------
/client/src/assets/images/hotdog.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/list-icon.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/client/src/assets/images/map-icon.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/assets/images/map-location-dot.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/map-location.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/assets/images/phone-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/point-circle.svg:
--------------------------------------------------------------------------------
1 |
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 |
5 |
--------------------------------------------------------------------------------
/client/src/assets/images/share.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/client/src/assets/images/shortcut.svg:
--------------------------------------------------------------------------------
1 |
5 |
--------------------------------------------------------------------------------
/client/src/assets/images/star-icon.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/sushi.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/src/assets/images/unfilled-like.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/client/src/assets/images/user.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------