diff --git a/public/assets/map1.png b/public/assets/map1.png deleted file mode 100644 index 74f96349..00000000 Binary files a/public/assets/map1.png and /dev/null differ diff --git a/public/assets/map2.png b/public/assets/map2.png deleted file mode 100644 index baf87936..00000000 Binary files a/public/assets/map2.png and /dev/null differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 00000000..71f366e9 --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,61 @@ +{ + "name": "부림이", + "short_name": "부림이", + "start_url": "/", + "display": "fullscreen", + "background_color": "#ffffff", + "lang": "en", + "scope": "/", + "orientation": "portrait", + "theme_color": "#ffffff", + "icons": [ + { + "src": "icons/icon-48x48.png", + "sizes": "48x48", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-72x72.png", + "sizes": "72x72", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-96x96.png", + "sizes": "96x96", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-128x128.png", + "sizes": "128x128", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-144x144.png", + "sizes": "144x144", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-152x152.png", + "sizes": "152x152", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "icons/icon-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { "src": "icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" } + ] +} diff --git a/sw.js b/public/sw.js similarity index 66% rename from sw.js rename to public/sw.js index bde768c9..35f46647 100644 --- a/sw.js +++ b/public/sw.js @@ -12,3 +12,11 @@ self.addEventListener('activate', (e) => { self.addEventListener('fetch', (e) => { console.log('[Service Worker] fetched resource ' + e.request.url); }); + +self.addEventListener('push', (e) => { + const data = e.data.json(); + self.registration.showNotification(data.title, { + body: data.body, + icon: data.icon, + }); +}); diff --git a/src/@types/map.ts b/src/@types/map.ts new file mode 100644 index 00000000..2e9e3962 --- /dev/null +++ b/src/@types/map.ts @@ -0,0 +1,12 @@ +export type BuildingType = 'A' | 'B' | 'C' | 'D' | 'E'; + +export interface PKNUBuilding { + readonly buildingNumber: string; + readonly buildingName: string; + readonly latlng: [number, number]; +} + +export interface Location { + LAT: number; + LNG: number; +} diff --git a/src/@types/styles/icon.ts b/src/@types/styles/icon.ts index ea8b12f3..582bb318 100644 --- a/src/@types/styles/icon.ts +++ b/src/@types/styles/icon.ts @@ -13,4 +13,8 @@ export type IconKind = | 'checkedRadio' | 'uncheckedRadio' | 'cancel' - | 'speaker'; + | 'speaker' + | 'reset' + | 'locationOn' + | 'locationOff' + | 'search'; diff --git a/src/App.tsx b/src/App.tsx index 814cfa40..182cbdee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,30 @@ import FooterTab from '@components/FooterTab'; import Header from '@components/Header'; -import styled from '@emotion/styled'; import Announcement from '@pages/Announcement'; +import BodyLayout from '@pages/BodyLayout'; import Home from '@pages/Home'; import MajorDecision from '@pages/MajorDecision'; import Map from '@pages/Map'; import My from '@pages/My'; -import { Routes, Route } from 'react-router-dom'; +import { Routes, Route, useLocation } from 'react-router-dom'; const App = () => { + const location = useLocation(); return ( <> -
- - + {location.pathname !== '/map' &&
} + + }> } /> } /> - } /> - } /> } /> - - + } /> + + } /> + ); }; export default App; - -const Body = styled.div` - padding: 8.5vh 0 8.5vh 0; -`; diff --git a/src/components/Button/Toggle/index.tsx b/src/components/Button/Toggle/index.tsx new file mode 100644 index 00000000..4b3199e7 --- /dev/null +++ b/src/components/Button/Toggle/index.tsx @@ -0,0 +1,50 @@ +import styled from '@emotion/styled'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { MouseEventHandler } from 'react'; + +interface Props { + isOn: boolean; + changeState: MouseEventHandler; + animation: boolean; +} + +interface Circle { + isOn: boolean; + animation: boolean; +} + +const ToggleButton = (props: Props) => { + const { isOn, changeState, animation } = props; + + return ( + + ); +}; + +export default ToggleButton; + +const Button = styled.button` + position: relative; + border: none; + width: 3.2rem; + height: 1.8rem; + + transition: ${(prop) => (prop.animation ? 'all 0.3s ease-in-out' : 'none')}; + + background-color: ${(prop) => (prop.isOn ? THEME.PRIMARY : THEME.BACKGROUND)}; + border-radius: 1rem; +`; + +const Circle = styled.div` + position: absolute; + border-radius: 50%; + width: 1.2rem; + height: 1.2rem; + transition: ${(prop) => (prop.animation ? 'all 0.3s ease-in-out' : 'none')}; + + background-color: #f5f5f5; + top: 0.3rem; + left: ${(prop) => (prop.isOn ? '1.7rem' : '0.4rem')}; +`; diff --git a/src/components/Icon/index.tsx b/src/components/Icon/index.tsx index 35df1dda..ddf7b9f7 100644 --- a/src/components/Icon/index.tsx +++ b/src/components/Icon/index.tsx @@ -16,6 +16,10 @@ import { MdRadioButtonChecked, MdOutlineCancel, MdCampaign, + MdOutlineRestartAlt, + MdOutlineLocationOn, + MdOutlineSearch, + MdOutlineLocationOff, } from 'react-icons/md'; const ICON: { [key in IconKind]: IconType } = { @@ -34,6 +38,10 @@ const ICON: { [key in IconKind]: IconType } = { uncheckedRadio: MdRadioButtonUnchecked, cancel: MdOutlineCancel, speaker: MdCampaign, + reset: MdOutlineRestartAlt, + locationOn: MdOutlineLocationOn, + locationOff: MdOutlineLocationOff, + search: MdOutlineSearch, }; interface IconProps { diff --git a/src/components/List/DepartmentList/index.tsx b/src/components/List/DepartmentList/index.tsx index 813752d8..c98b6f71 100644 --- a/src/components/List/DepartmentList/index.tsx +++ b/src/components/List/DepartmentList/index.tsx @@ -3,6 +3,7 @@ import Button from '@components/Button'; import Icon from '@components/Icon'; import AlertModal from '@components/Modal/AlertModal'; import ConfirmModal from '@components/Modal/ConfirmModal'; +import { SERVER_URL } from '@config/index'; import { MODAL_MESSAGE } from '@constants/modal-messages'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -18,7 +19,7 @@ const DepartmentList = () => { const [selected, setSelected] = useState(''); const [buttonDisable, setButtonDisable] = useState(true); const { routerTo, goBack } = useRouter(); - const { setMajor } = useMajor(); + const { major, setMajor } = useMajor(); const { college } = useParams(); const { openModal, closeModal } = useModals(); @@ -40,6 +41,13 @@ const DepartmentList = () => { const handlerMajorSetModal = () => { closeModal(ConfirmModal); + const storedSubscribe = localStorage.getItem('subscribe'); + if (major && storedSubscribe) { + http.delete(`${SERVER_URL}/api/subscription/major`, { + data: { subscription: JSON.parse(storedSubscribe), major }, + }); + localStorage.removeItem('subscribe'); + } const afterSpace = selected.substring(selected.indexOf(' ') + 1); localStorage.setItem('major', afterSpace); setMajor(afterSpace); diff --git a/src/constants/pknu-map.ts b/src/constants/pknu-map.ts index 5b197915..5b84b759 100644 --- a/src/constants/pknu-map.ts +++ b/src/constants/pknu-map.ts @@ -1,228 +1,258 @@ -interface PknuBuilding { - readonly buildingNumber: string; - readonly buildingName: string; - readonly latlng: [number, number]; -} +import { BuildingType, Location, PKNUBuilding } from '@type/map'; +import { CSSProperties } from 'react'; -export const PKNU_BUILDINGS: readonly PknuBuilding[] = [ - { - buildingNumber: 'A11', - buildingName: '대학본부', - latlng: [35.134009202001316, 129.10309425666023], - }, - { - buildingNumber: 'A12', - buildingName: '웅비관', - latlng: [35.13448507894045, 129.10319425736515], - }, - { - buildingNumber: 'A13', - buildingName: '누리관', - latlng: [35.13477742833195, 129.10309482297396], - }, - { - buildingNumber: 'A15', - buildingName: '워커하우스', - latlng: [35.13541800394449, 129.10382983225276], - }, - { - buildingNumber: 'A15', - buildingName: '향파관', - latlng: [35.135348948698365, 129.10287641587055], - }, - { - buildingNumber: 'A21', - buildingName: '미래관', - latlng: [35.133959884079516, 129.10217153151783], - }, - { - buildingNumber: 'A22', - buildingName: '디자인관', - latlng: [35.13425800209641, 129.10147987520727], - }, - { - buildingNumber: 'A23', - buildingName: '나래관', - latlng: [35.13486547457253, 129.10178619206036], - }, - { - buildingNumber: 'A26', - buildingName: '부산창업카페 2호점', - latlng: [35.135198384658516, 129.10116672530137], - }, - { - buildingNumber: 'B11', - buildingName: '위드센터', - latlng: [35.133987101586925, 129.10566334469846], - }, +type PKNUBuildings = { + [key in BuildingType]: { + activeColor: CSSProperties['color']; + buildings: PKNUBuilding[]; + }; +}; - { - buildingNumber: 'B12', - buildingName: '나비센터', - latlng: [35.13405879317518, 129.10633434445595], - }, - { - buildingNumber: 'B13', - buildingName: '충무관', - latlng: [35.13503097184985, 129.1052294985021], - }, - { - buildingNumber: 'B14', - buildingName: '환경해양관', - latlng: [35.135040804141354, 129.10634867710766], - }, - { - buildingNumber: 'B15', - buildingName: '자연과학1관', - latlng: [35.13555903353968, 129.10543781395603], - }, - { - buildingNumber: 'B16', - buildingName: '수위실(후문)', - latlng: [35.13625012336413, 129.1065059931721], - }, - { - buildingNumber: 'B21', - buildingName: '가온관', - latlng: [35.13401856418254, 129.1050196840027], - }, - { - buildingNumber: 'B22', - buildingName: '청운관', - latlng: [35.134428237691154, 129.10478067047796], - }, - { - buildingNumber: 'C11', - buildingName: '수산질병관리원', - latlng: [35.13377917592698, 129.10868013710467], - }, - { - buildingNumber: 'C12', - buildingName: '장영실관', - latlng: [35.13481165172098, 129.1089014835836], - }, - { - buildingNumber: 'C13', - buildingName: '해양공동연구관', - latlng: [35.13545371419755, 129.10877269623805], - }, - { - buildingNumber: 'C14', - buildingName: '부경대학교 어린이집', - latlng: [35.13488945181894, 129.10947940662655], - }, - { - buildingNumber: 'C21', - buildingName: '수산과학관', - latlng: [35.133540074964145, 129.10779091303866], - }, - { - buildingNumber: 'C22', - buildingName: '건축관', - latlng: [35.134692788637274, 129.10770545102733], - }, - { - buildingNumber: 'C23', - buildingName: '호연관', - latlng: [35.135246989425255, 129.10770602771154], - }, - { - buildingNumber: 'C24', - buildingName: '자연과학2관', - latlng: [35.13567570498883, 129.10766771682054], - }, - { - buildingNumber: 'C25', - buildingName: '인문사회경영관', - latlng: [35.13422315762162, 129.1077646460986], - }, +export const PKNU_BUILDINGS: PKNUBuildings = { + A: { + activeColor: '#FF6F91', - { - buildingNumber: 'C26', - buildingName: '해양수산LMO격리사육동', - latlng: [35.1330329, 129.1073065], - }, - { - buildingNumber: 'C27', - buildingName: '수조실험동', - latlng: [35.133012828243714, 129.1075359886025], - }, - { - buildingNumber: 'C28', - buildingName: '아름관', - latlng: [35.13303461466008, 129.1079671063524], - }, - { - buildingNumber: 'D12', - buildingName: '테니스장', - latlng: [35.13187281113036, 129.1065028981342], - }, - { - buildingNumber: 'D13', - buildingName: '대운동장', - latlng: [35.1326944387007, 129.1062827382093], - }, - { - buildingNumber: 'D14', - buildingName: '한울관', - latlng: [35.13233118040464, 129.1069617150524], - }, - { - buildingNumber: 'D21', - buildingName: '대학극장', - latlng: [35.13237660740733, 129.10499660318473], - }, - { - buildingNumber: 'D22', - buildingName: '체육관', - latlng: [35.133109374732555, 129.10496336478204], - }, - { - buildingNumber: 'D23', - buildingName: '안전관리관', - latlng: [35.132382375868424, 129.1051832332297], - }, - { - buildingNumber: 'D24', - buildingName: '수상레저관', - latlng: [35.13266402562087, 129.10492173196255], - }, - { - buildingNumber: 'E11', - buildingName: '세종1관', - latlng: [35.1312221, 129.1049895], - }, - { - buildingNumber: 'E12', - buildingName: '세종2관', - latlng: [35.13121085834351, 129.10414663049266], - }, - { - buildingNumber: 'E13', - buildingName: '공학1관', - latlng: [35.13164265713184, 129.1034118237889], - }, - { - buildingNumber: 'E14', - buildingName: '학술정보관', - latlng: [35.13265930161191, 129.10376706621912], - }, - { - buildingNumber: 'E21', - buildingName: '공학2관', - latlng: [35.13161038816922, 129.1028049341183], - }, - { - buildingNumber: 'E22', - buildingName: '동원 장보고관', - latlng: [35.13317876403389, 129.10291383413244], - }, - { - buildingNumber: 'E29', - buildingName: '양어장관리사', - latlng: [35.1330880354213, 129.10152110317765], - }, -]; + buildings: [ + { + buildingNumber: 'A11', + buildingName: '대학본부', + latlng: [35.134009202001316, 129.10309425666023], + }, + { + buildingNumber: 'A12', + buildingName: '웅비관', + latlng: [35.13448507894045, 129.10319425736515], + }, + { + buildingNumber: 'A13', + buildingName: '누리관', + latlng: [35.13477742833195, 129.10309482297396], + }, + { + buildingNumber: 'A15', + buildingName: '워커하우스', + latlng: [35.13541800394449, 129.10382983225276], + }, + { + buildingNumber: 'A15', + buildingName: '향파관', + latlng: [35.135348948698365, 129.10287641587055], + }, + { + buildingNumber: 'A21', + buildingName: '미래관', + latlng: [35.133959884079516, 129.10217153151783], + }, + { + buildingNumber: 'A22', + buildingName: '디자인관', + latlng: [35.13425800209641, 129.10147987520727], + }, + { + buildingNumber: 'A23', + buildingName: '나래관', + latlng: [35.13486547457253, 129.10178619206036], + }, + { + buildingNumber: 'A26', + buildingName: '부산창업카페 2호점', + latlng: [35.135198384658516, 129.10116672530137], + }, + ], + }, + B: { + activeColor: '#FF9671', + buildings: [ + { + buildingNumber: 'B11', + buildingName: '위드센터', + latlng: [35.133987101586925, 129.10566334469846], + }, + + { + buildingNumber: 'B12', + buildingName: '나비센터', + latlng: [35.13405879317518, 129.10633434445595], + }, + { + buildingNumber: 'B13', + buildingName: '충무관', + latlng: [35.13503097184985, 129.1052294985021], + }, + { + buildingNumber: 'B14', + buildingName: '환경해양관', + latlng: [35.135040804141354, 129.10634867710766], + }, + { + buildingNumber: 'B15', + buildingName: '자연과학1관', + latlng: [35.13555903353968, 129.10543781395603], + }, + { + buildingNumber: 'B16', + buildingName: '수위실(후문)', + latlng: [35.13625012336413, 129.1065059931721], + }, + { + buildingNumber: 'B21', + buildingName: '가온관', + latlng: [35.13401856418254, 129.1050196840027], + }, + { + buildingNumber: 'B22', + buildingName: '청운관', + latlng: [35.134428237691154, 129.10478067047796], + }, + ], + }, + C: { + activeColor: '#81C26E', + buildings: [ + { + buildingNumber: 'C11', + buildingName: '수산질병관리원', + latlng: [35.13377917592698, 129.10868013710467], + }, + { + buildingNumber: 'C12', + buildingName: '장영실관', + latlng: [35.13481165172098, 129.1089014835836], + }, + { + buildingNumber: 'C13', + buildingName: '해양공동연구관', + latlng: [35.13545371419755, 129.10877269623805], + }, + { + buildingNumber: 'C14', + buildingName: '부경대학교 어린이집', + latlng: [35.13488945181894, 129.10947940662655], + }, + { + buildingNumber: 'C21', + buildingName: '수산과학관', + latlng: [35.133540074964145, 129.10779091303866], + }, + { + buildingNumber: 'C22', + buildingName: '건축관', + latlng: [35.134692788637274, 129.10770545102733], + }, + { + buildingNumber: 'C23', + buildingName: '호연관', + latlng: [35.135246989425255, 129.10770602771154], + }, + { + buildingNumber: 'C24', + buildingName: '자연과학2관', + latlng: [35.13567570498883, 129.10766771682054], + }, + { + buildingNumber: 'C25', + buildingName: '인문사회경영관', + latlng: [35.13422315762162, 129.1077646460986], + }, + + { + buildingNumber: 'C26', + buildingName: '해양수산LMO격리사육동', + latlng: [35.1330329, 129.1073065], + }, + { + buildingNumber: 'C27', + buildingName: '수조실험동', + latlng: [35.133012828243714, 129.1075359886025], + }, + { + buildingNumber: 'C28', + buildingName: '아름관', + latlng: [35.13303461466008, 129.1079671063524], + }, + ], + }, + D: { + activeColor: '#FFC75F', + buildings: [ + { + buildingNumber: 'D12', + buildingName: '테니스장', + latlng: [35.13187281113036, 129.1065028981342], + }, + { + buildingNumber: 'D13', + buildingName: '대운동장', + latlng: [35.1326944387007, 129.1062827382093], + }, + { + buildingNumber: 'D14', + buildingName: '한울관', + latlng: [35.13233118040464, 129.1069617150524], + }, + { + buildingNumber: 'D21', + buildingName: '대학극장', + latlng: [35.13237660740733, 129.10499660318473], + }, + { + buildingNumber: 'D22', + buildingName: '체육관', + latlng: [35.133109374732555, 129.10496336478204], + }, + { + buildingNumber: 'D23', + buildingName: '안전관리관', + latlng: [35.132382375868424, 129.1051832332297], + }, + { + buildingNumber: 'D24', + buildingName: '수상레저관', + latlng: [35.13266402562087, 129.10492173196255], + }, + ], + }, + E: { + activeColor: '#D65DB1', + buildings: [ + { + buildingNumber: 'E11', + buildingName: '세종1관', + latlng: [35.1312221, 129.1049895], + }, + { + buildingNumber: 'E12', + buildingName: '세종2관', + latlng: [35.13121085834351, 129.10414663049266], + }, + { + buildingNumber: 'E13', + buildingName: '공학1관', + latlng: [35.13164265713184, 129.1034118237889], + }, + { + buildingNumber: 'E14', + buildingName: '학술정보관', + latlng: [35.13265930161191, 129.10376706621912], + }, + { + buildingNumber: 'E21', + buildingName: '공학2관', + latlng: [35.13161038816922, 129.1028049341183], + }, + { + buildingNumber: 'E22', + buildingName: '동원 장보고관', + latlng: [35.13317876403389, 129.10291383413244], + }, + { + buildingNumber: 'E29', + buildingName: '양어장관리사', + latlng: [35.1330880354213, 129.10152110317765], + }, + ], + }, +}; export const PKNU_MAP_LIMIT = { LEVEL: 4, @@ -232,7 +262,12 @@ export const PKNU_MAP_LIMIT = { LEFT: 129.09694451219139, } as const; -export const PKNU_MAP_CENTER = { +export const PKNU_MAP_CENTER: Location = { LAT: 35.132990223842, LNG: 129.1052030382, } as const; + +export const NO_PROVIDE_LOCATION: Location = { + LAT: -1, + LNG: -1, +} as const; diff --git a/src/hooks/urlBase64ToUint8Array.ts b/src/hooks/urlBase64ToUint8Array.ts new file mode 100644 index 00000000..2bcdbaec --- /dev/null +++ b/src/hooks/urlBase64ToUint8Array.ts @@ -0,0 +1,14 @@ +const urlBase64ToUint8Array = (base64String: string) => { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +export default urlBase64ToUint8Array; diff --git a/src/hooks/useUserLocation.ts b/src/hooks/useUserLocation.ts new file mode 100644 index 00000000..b0a5d4eb --- /dev/null +++ b/src/hooks/useUserLocation.ts @@ -0,0 +1,37 @@ +import { NO_PROVIDE_LOCATION, PKNU_MAP_CENTER } from '@constants/pknu-map'; +import { Location } from '@type/map'; +import { useEffect, useState } from 'react'; + +const useUserLocation = () => { + const [location, setLocation] = useState(null); + + const success = (position: any) => { + setLocation({ + LAT: position.coords.latitude, + LNG: position.coords.longitude, + }); + }; + const failed = () => { + setLocation({ + ...NO_PROVIDE_LOCATION, + }); + }; + + useEffect(() => { + if (navigator.geolocation) { + navigator.geolocation.getCurrentPosition(success, failed, { + enableHighAccuracy: true, + timeout: 1000 * 10, // 10초안에 위치 정보를 가져오지 못하면 고정 위치로 설정함 + maximumAge: 1000 * 60 * 2, // 가져온 위치 정보가 유효한 시간 2분 + }); + } else { + setLocation({ + ...NO_PROVIDE_LOCATION, + }); + } + }, []); + + return location; +}; + +export default useUserLocation; diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index 6829a3f6..b6bcc096 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -3,6 +3,7 @@ import { setupWorker } from 'msw'; import { announceHandlers } from './handlers/announceHandlers'; import { graduationHandler } from './handlers/graudationHandler'; import { majorHandlers } from './handlers/majorHandlers'; +import { subscribeHandler } from './handlers/subscribeHandler'; import { testHandlers } from './handlers/testHandlers'; export const worker = setupWorker( @@ -10,4 +11,5 @@ export const worker = setupWorker( ...testHandlers, ...announceHandlers, ...graduationHandler, + ...subscribeHandler, ); // 브라우저 환경 서버 diff --git a/src/mocks/handlers/subscribeHandler.ts b/src/mocks/handlers/subscribeHandler.ts new file mode 100644 index 00000000..03542204 --- /dev/null +++ b/src/mocks/handlers/subscribeHandler.ts @@ -0,0 +1,11 @@ +import { SERVER_URL } from '@config/index'; +import { RequestHandler, rest } from 'msw'; + +export const subscribeHandler: RequestHandler[] = [ + rest.post(SERVER_URL + '/api/subscription', (req, res, ctx) => { + return res(ctx.status(200)); + }), + rest.delete(SERVER_URL + '/api/subscription', (req, res, ctx) => { + return res(ctx.status(200)); + }), +]; diff --git a/src/mocks/server.ts b/src/mocks/server.ts index 405f393e..bd20f016 100644 --- a/src/mocks/server.ts +++ b/src/mocks/server.ts @@ -2,10 +2,12 @@ import { setupServer } from 'msw/node'; import { announceHandlers } from './handlers/announceHandlers'; import { majorHandlers } from './handlers/majorHandlers'; +import { subscribeHandler } from './handlers/subscribeHandler'; import { testHandlers } from './handlers/testHandlers'; export const server = setupServer( ...majorHandlers, ...testHandlers, ...announceHandlers, + ...subscribeHandler, ); diff --git a/src/pages/BodyLayout/index.tsx b/src/pages/BodyLayout/index.tsx new file mode 100644 index 00000000..2d529176 --- /dev/null +++ b/src/pages/BodyLayout/index.tsx @@ -0,0 +1,19 @@ +import styled from '@emotion/styled'; +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +const BodyLayout = () => { + return ( + + + + ); +}; + +export default BodyLayout; + +const StyledBodyLayout = styled.div` + height: calc(100vh - 17vh); + padding: 8.5vh 0 8.5vh 0; + overflow-y: scroll; +`; diff --git a/src/pages/Map/MapLevelObserver.tsx b/src/pages/Map/MapLevelObserver.tsx deleted file mode 100644 index 5b7b1f35..00000000 --- a/src/pages/Map/MapLevelObserver.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import AlertModal from '@components/Modal/AlertModal'; -import { MODAL_MESSAGE } from '@constants/modal-messages'; -import { PKNU_MAP_LIMIT } from '@constants/pknu-map'; -import useModals from '@hooks/useModals'; -import React, { useEffect } from 'react'; - -interface MapLevelObserverProps { - map: any; - centerLocation: any; -} - -const MapLevelObserver = ({ map, centerLocation }: MapLevelObserverProps) => { - if (!map) { - return null; - } - - const { openModal, closeModal } = useModals(); - useEffect(() => { - const levelLimitHandler = () => { - if (map.getLevel() <= PKNU_MAP_LIMIT.LEVEL) { - return; - } - map.setLevel(PKNU_MAP_LIMIT.LEVEL); - map.setCenter(centerLocation); - openModal(AlertModal, { - message: MODAL_MESSAGE.ALERT.OVER_MAP_LEVEL, - buttonMessage: '닫기', - onClose: () => closeModal(AlertModal), - }); - }; - window.kakao.maps.event.addListener(map, 'zoom_changed', levelLimitHandler); - return () => { - window.kakao.maps.event.removeListener( - map, - 'zoom_changed', - levelLimitHandler, - ); - }; - }); - - return <>; -}; - -export default MapLevelObserver; diff --git a/src/pages/Map/MapboundaryObserver.tsx b/src/pages/Map/MapboundaryObserver.tsx deleted file mode 100644 index 28ac248b..00000000 --- a/src/pages/Map/MapboundaryObserver.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import AlertModal from '@components/Modal/AlertModal'; -import { MODAL_MESSAGE } from '@constants/modal-messages'; -import { PKNU_MAP_LIMIT } from '@constants/pknu-map'; -import useModals from '@hooks/useModals'; -import React, { useEffect } from 'react'; - -interface MapBounds { - map: any; - centerLocation: any; -} - -const MapboundaryObserver = ({ map, centerLocation }: MapBounds) => { - if (!map) { - return null; - } - - const { openModal, closeModal } = useModals(); - useEffect(() => { - const boundayLimitHandler = () => { - const { La, Ma } = map.getCenter(); - if ( - Ma >= PKNU_MAP_LIMIT.TOP || - La >= PKNU_MAP_LIMIT.RIGHT || - Ma <= PKNU_MAP_LIMIT.BOTTOM || - La <= PKNU_MAP_LIMIT.LEFT - ) { - map.setLevel(4); - map.setCenter(centerLocation); - openModal(AlertModal, { - message: MODAL_MESSAGE.ALERT.OVER_MAP_BOUNDARY, - buttonMessage: '닫기', - onClose: () => closeModal(AlertModal), - }); - } - }; - window.kakao.maps.event.addListener(map, 'dragend', boundayLimitHandler); - - return () => { - window.kakao.maps.event.removeListener( - map, - 'dragend', - boundayLimitHandler, - ); - }; - }, []); - - return <>; -}; - -export default MapboundaryObserver; diff --git a/src/pages/Map/PknuBuildingNumbers.tsx b/src/pages/Map/PknuBuildingNumbers.tsx deleted file mode 100644 index 2a1ff408..00000000 --- a/src/pages/Map/PknuBuildingNumbers.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PKNU_BUILDINGS } from '@constants/pknu-map'; -import React from 'react'; - -interface BuildingNumbersProps { - map: any; -} - -const PknuBuildingNumbers = ({ map }: BuildingNumbersProps) => { - if (!map) { - return null; - } - - return ( - <> - {PKNU_BUILDINGS.forEach((PKNU_BUILDING) => { - new window.kakao.maps.CustomOverlay({ - map: map, - position: new window.kakao.maps.LatLng( - PKNU_BUILDING.latlng[0], - PKNU_BUILDING.latlng[1], - ), - content: ` - ${PKNU_BUILDING.buildingNumber} - `, - removable: false, - yAnchor: -0.05, - }); - })} - - ); -}; - -export default PknuBuildingNumbers; diff --git a/src/pages/Map/components/BuildingFilterButtons.tsx b/src/pages/Map/components/BuildingFilterButtons.tsx new file mode 100644 index 00000000..8e67d338 --- /dev/null +++ b/src/pages/Map/components/BuildingFilterButtons.tsx @@ -0,0 +1,107 @@ +import { PKNU_BUILDINGS } from '@constants/pknu-map'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { BuildingType } from '@type/map'; +import React, { CSSProperties, SetStateAction, useState } from 'react'; + +interface BuildingFilterButtonsProps { + buildingTypes: BuildingType[]; + setBuildingTypes: React.Dispatch>; +} + +const BuildingFilterButtons = ({ + buildingTypes, + setBuildingTypes, +}: BuildingFilterButtonsProps) => { + const [activeButtons, setActiveButtons] = useState< + Record + >({ + A: true, + B: false, + C: false, + D: false, + E: false, + }); + + const buildingTypesHandler = (type: BuildingType) => { + setActiveButtons((prevActiceButtons) => { + return { + ...prevActiceButtons, + [type]: !prevActiceButtons[type], + }; + }); + if (!buildingTypes.includes(type)) { + setBuildingTypes((prevTypes) => [...prevTypes, type]); + return; + } + setBuildingTypes((prevTypes) => + prevTypes.filter((prevType) => prevType !== type), + ); + }; + const buttonClickHandler: React.MouseEventHandler = (e) => { + if (e.target instanceof HTMLSpanElement) { + buildingTypesHandler(e.target.innerText as BuildingType); + } else if ( + e.target instanceof HTMLButtonElement && + typeof e.target.textContent === 'string' + ) { + buildingTypesHandler(e.target.textContent as BuildingType); + } + }; + + return ( +
+ {Object.keys(activeButtons).map((type) => ( + + {type} + + ))} +
+ ); +}; + +export default BuildingFilterButtons; + +interface FilterButtonProps { + isActive: boolean; + activeColor: CSSProperties['color']; +} + +const FilterButton = styled.button` + background-color: ${({ activeColor }) => activeColor}; + color: ${({ isActive }) => (isActive ? THEME.TEXT.WHITE : THEME.TEXT.BLACK)}; + font-weight: bold; + text-shadow: ${({ isActive }) => + isActive && '0px 2px 4px rgba(0, 0, 0, 0.5)'}; + opacity: ${({ isActive }) => (isActive ? 1 : 0.5)}; + transition: text-shadow 0.2s ease-in-out; + + padding: 10px; + margin-left: 5px; + border-radius: 30px; + + &:active { + box-shadow: inset -0.3rem -0.1rem 1.4rem #fbfbfb, + inset 0.3rem 0.4rem 0.8rem #bec5d0; + } + + span { + display: inline-block; + position: relative; + transform: translateY(${({ isActive }) => (isActive ? '0' : '2px')}); + transition: transform 0.2s ease-in-out; + } +`; diff --git a/src/pages/Map/components/MapHeader.tsx b/src/pages/Map/components/MapHeader.tsx new file mode 100644 index 00000000..7e746d5d --- /dev/null +++ b/src/pages/Map/components/MapHeader.tsx @@ -0,0 +1,230 @@ +import Icon from '@components/Icon'; +import AlertModal from '@components/Modal/AlertModal'; +import ConfirmModal from '@components/Modal/ConfirmModal'; +import { + NO_PROVIDE_LOCATION, + PKNU_BUILDINGS, + PKNU_MAP_CENTER, +} from '@constants/pknu-map'; +import styled from '@emotion/styled'; +import useModals from '@hooks/useModals'; +import useUserLocation from '@hooks/useUserLocation'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { BuildingType, Location, PKNUBuilding } from '@type/map'; +import React, { memo, useRef, useState } from 'react'; + +import distanceHandler from '../handlers/distance-handler'; +import NumberOverlay from '../handlers/overlay-handler'; + +interface BuildingInfo extends PKNUBuilding { + buildingType: string; + buildingIndex: number; +} + +interface MapHeaderProps { + map: any; +} + +const MapHeader = ({ map }: MapHeaderProps) => { + const inputRef = useRef(null); + const [buildingInfo, setBuildingInfo] = useState(null); + const { openModal, closeModal } = useModals(); + const userLocation: Location | null = useUserLocation(); + + const setCenterHandler = (location: Location | null) => { + map.setCenter( + location && new window.kakao.maps.LatLng(location.LAT, location.LNG), + ); + map.setLevel(4); + }; + const zoomInHandler = () => { + map.setLevel(2); + map.setCenter( + buildingInfo && + new window.kakao.maps.LatLng( + buildingInfo.latlng[0], + buildingInfo.latlng[1], + ), + ); + if ( + document.querySelector( + `.${buildingInfo?.buildingType}-${buildingInfo?.buildingIndex}`, + ) || + !buildingInfo + ) { + return; + } + const buildingNumberOverlay = new NumberOverlay( + buildingInfo, + openModal, + closeModal, + userLocation, + ).createOverlay( + buildingInfo.buildingType as BuildingType, + buildingInfo.buildingIndex, + ); + + buildingNumberOverlay.setMap(map); + }; + + const getRouteUrl = (): string | undefined => { + if (!userLocation || !buildingInfo) return ''; + return `https://map.kakao.com/link/from/내위치,${userLocation.LAT},${userLocation.LNG}/to/${buildingInfo.buildingName},${buildingInfo.latlng[0]},${buildingInfo.latlng[1]}`; + }; + const routeHandler = () => { + const routeUrl = getRouteUrl(); + JSON.stringify(userLocation) !== JSON.stringify(NO_PROVIDE_LOCATION) + ? openModal(ConfirmModal, { + message: `목적지(${buildingInfo?.buildingNumber})로 길찾기를 시작할까요?`, + onConfirmButtonClick: () => { + window.open(routeUrl, '_blank'), closeModal(ConfirmModal); + }, + onCancelButtonClick: () => closeModal(ConfirmModal), + }) + : openModal(AlertModal, { + message: '위치정보를 제공하지 않아 길찾기 기능을 사용할 수 없어요!', + buttonMessage: '닫기', + onClose: () => closeModal(AlertModal), + }); + }; + + const searchHandler = () => { + if (!inputRef.current || inputRef.current.value.length < 1) { + return openModal(AlertModal, { + message: '검색어를 입력해주세요!', + buttonMessage: '닫기', + onClose: () => closeModal(AlertModal), + }); + } + const searchResult = searchBuildingInfo(inputRef.current?.value); + if (searchResult === -1) { + return openModal(AlertModal, { + message: '찾으시는 건물이 존재하지 않아요! 검색어를 다시 확인해주세요.', + buttonMessage: '닫기', + onClose: () => closeModal(AlertModal), + }); + } + const [buildingType, index] = searchResult; + setBuildingInfo({ + ...PKNU_BUILDINGS[buildingType].buildings[index], + buildingType, + buildingIndex: index, + }); + inputRef.current.value = ''; + }; + const searchBuildingInfo = (keyword: string): [BuildingType, number] | -1 => { + keyword = keyword.split(' ').join('').toUpperCase(); + for (const buildingType of Object.keys(PKNU_BUILDINGS)) { + const index = PKNU_BUILDINGS[ + buildingType as BuildingType + ].buildings.findIndex( + (PKNU_BUILDING) => + PKNU_BUILDING.buildingName === keyword || + PKNU_BUILDING.buildingNumber === keyword, + ); + if (index !== -1) return [buildingType as BuildingType, index]; + } + return -1; + }; + + return ( + + + + + + + {buildingInfo && ( + + {buildingInfo.buildingName} + {buildingInfo.buildingNumber} + 확대 + 길찾기 + + )} + + + {userLocation && distanceHandler(userLocation.LAT, userLocation.LNG) ? ( + setCenterHandler(userLocation)} + /> + ) : ( + + )} + setCenterHandler(PKNU_MAP_CENTER)} + /> + + + ); +}; + +export default memo(MapHeader); + +const HeaderContainer = styled.div` + position: relative; + height: 8vh; +`; + +const SearchContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + align-items: center; +`; + +const InputContainer = styled.div` + width: 100%; + display: flex; + justify-content: center; + margin-top: 5px; + margin-bottom: 5px; +`; + +const StyledInput = styled.input` + border: 0; + border-bottom: 2px solid ${THEME.TEXT.GRAY}; + border-radius: 0px; + + font-size: 12px; + width: 40%; + + &::placeholder { + color: ${THEME.TEXT.GRAY}; + font-size: 12px; + } + + &:focus { + border-bottom: 2px solid ${THEME.PRIMARY}; + outline: none; + } +`; + +const BuildingInfo = styled.div` + display: flex; + font-size: 12px; + + & > span { + flex: 2; + margin: 0 10px; + text-align: center; + white-space: nowrap; + } +`; + +const IconContainer = styled.div` + position: absolute; + right: 5px; + bottom: -76vh; + z-index: 999; +`; diff --git a/src/pages/Map/components/PknuBuildingNumbers.tsx b/src/pages/Map/components/PknuBuildingNumbers.tsx new file mode 100644 index 00000000..8e6c9912 --- /dev/null +++ b/src/pages/Map/components/PknuBuildingNumbers.tsx @@ -0,0 +1,50 @@ +import { PKNU_BUILDINGS } from '@constants/pknu-map'; +import useModals from '@hooks/useModals'; +import useUserLocation from '@hooks/useUserLocation'; +import { BuildingType } from '@type/map'; + +import NumberOverlay from '../handlers/overlay-handler'; + +interface BuildingNumbersProps { + buildingTypes: BuildingType[]; + map: any; +} + +const PknuBuildingNumbers = ({ map, buildingTypes }: BuildingNumbersProps) => { + if (!map) { + return null; + } + const location = useUserLocation(); + const { openModal, closeModal } = useModals(); + + return ( + <> + {Object.keys(PKNU_BUILDINGS).forEach((buildingType) => { + if (!buildingTypes.includes(buildingType as BuildingType)) { + const removeElements = document.querySelectorAll( + `[class^=${buildingType}]`, + ); + removeElements.forEach((removeElement) => { + removeElement.remove(); + }); + return; + } + + PKNU_BUILDINGS[buildingType as BuildingType].buildings.forEach( + (PKNU_BUILDING, index) => { + const buildingNumberOverlay = new NumberOverlay( + PKNU_BUILDING, + openModal, + closeModal, + location, + ).createOverlay(buildingType as BuildingType, index); + + buildingNumberOverlay.setMap(map); + }, + ); + })} + + ); +}; + +export default PknuBuildingNumbers; diff --git a/src/pages/Map/components/UserLocation.tsx b/src/pages/Map/components/UserLocation.tsx new file mode 100644 index 00000000..97b64730 --- /dev/null +++ b/src/pages/Map/components/UserLocation.tsx @@ -0,0 +1,96 @@ +import { NO_PROVIDE_LOCATION } from '@constants/pknu-map'; +import styled from '@emotion/styled'; +import useUserLocation from '@hooks/useUserLocation'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { memo, useEffect, useState } from 'react'; + +import distanceHandler from '../handlers/distance-handler'; + +interface UserLocationProps { + map: any; +} + +const UserLocation = ({ map }: UserLocationProps) => { + const userLocation = useUserLocation(); + const [roadAddress, setRoadAddress] = useState(null); + + if ( + userLocation && + JSON.stringify(userLocation) !== JSON.stringify(NO_PROVIDE_LOCATION) && + distanceHandler(userLocation.LAT, userLocation.LNG) + ) { + const marker = new window.kakao.maps.Marker({ + position: new window.kakao.maps.LatLng( + userLocation.LAT, + userLocation.LNG, + ), + }); + marker.setMap(map); + } + + function getAddr() { + const geocoder = new window.kakao.maps.services.Geocoder(); + const coord = new window.kakao.maps.LatLng( + userLocation?.LAT, + userLocation?.LNG, + ); + const callback = function (result: any, status: any) { + if (status === window.kakao.maps.services.Status.OK) { + const { road_address } = { ...result }[0]; + setRoadAddress(road_address.address_name); + } + }; + geocoder.coord2Address(coord.getLng(), coord.getLat(), callback); + } + + useEffect(() => { + if ( + userLocation && + JSON.stringify(userLocation) !== JSON.stringify(NO_PROVIDE_LOCATION) + ) { + getAddr(); + } + }); + + return ( + + {!userLocation && 위치 정보를 가져오고 있어요!} + +
+ {userLocation && + JSON.stringify(userLocation) === JSON.stringify(NO_PROVIDE_LOCATION) ? ( + + 위치정보를 제공하지 않아 길찾기 기능을 이용할 수 없습니다. + + ) : ( + userLocation && + !distanceHandler(userLocation.LAT, userLocation.LNG) && ( + + 현재 학교 외부에 있어요!
+ 길찾기 기능을 이용해보세요
+ {roadAddress && <>현재 주소 : {roadAddress}} +
+ ) + )} +
+
+ ); +}; + +export default memo(UserLocation); + +const LocationMessage = styled.div` + flex: 5; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-weight: bold; + font-size: 18px; + color: ${THEME.TEXT.GRAY}; + + & > div > span { + line-height: 15px; + font-size: 12px; + } +`; diff --git a/src/pages/Map/handlers/distance-handler.ts b/src/pages/Map/handlers/distance-handler.ts new file mode 100644 index 00000000..91103f2d --- /dev/null +++ b/src/pages/Map/handlers/distance-handler.ts @@ -0,0 +1,28 @@ +import { PKNU_MAP_CENTER } from '@constants/pknu-map'; + +const deg2rad = (deg: number) => deg * (Math.PI / 180); + +const haversineDistance = (lat: number, lng: number) => { + const R = 6371000; // 지구 반지름 (단위: m) + + const dLat = deg2rad(PKNU_MAP_CENTER.LAT - lat); + const dLon = deg2rad(PKNU_MAP_CENTER.LNG - lng); + + const halfSideLength = + Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(deg2rad(PKNU_MAP_CENTER.LAT)) * + Math.cos(deg2rad(lat)) * + Math.sin(dLon / 2) * + Math.sin(dLon / 2); + const angularDistance = + 2 * Math.atan2(Math.sqrt(halfSideLength), Math.sqrt(1 - halfSideLength)); + + return R * angularDistance; +}; + +const distanceHandler = (lat: number, lng: number) => { + const maxDistance = 450; + return haversineDistance(lat, lng) <= maxDistance; +}; + +export default distanceHandler; diff --git a/src/pages/Map/handlers/limit-handler.ts b/src/pages/Map/handlers/limit-handler.ts new file mode 100644 index 00000000..b76f1832 --- /dev/null +++ b/src/pages/Map/handlers/limit-handler.ts @@ -0,0 +1,71 @@ +import AlertModal from '@components/Modal/AlertModal'; +import { MODAL_MESSAGE } from '@constants/modal-messages'; +import { PKNU_MAP_LIMIT } from '@constants/pknu-map'; +import { ComponentProps, FunctionComponent } from 'react'; + +type MapLimitHandler = ( + map: any, + centerLocation: any, + openModal: ( + Component: FunctionComponent, + props: Omit, 'open'>, + ) => void, + closeModal: (Component: FunctionComponent) => void, +) => void; + +export const mapLevelHandler: MapLimitHandler = ( + map, + centerLocation, + openModal, + closeModal, +) => { + if (!map) { + return null; + } + + const levelLimitHandler = () => { + if (map.getLevel() <= PKNU_MAP_LIMIT.LEVEL) { + return; + } + map.setLevel(PKNU_MAP_LIMIT.LEVEL); + map.setCenter(centerLocation); + openModal(AlertModal, { + message: MODAL_MESSAGE.ALERT.OVER_MAP_LEVEL, + buttonMessage: '닫기', + onClose: () => closeModal(AlertModal), + }); + }; + window.kakao.maps.event.addListener(map, 'zoom_changed', levelLimitHandler); + return null; +}; + +export const mapBoundaryHandler: MapLimitHandler = ( + map, + centerLocation, + openModal, + closeModal, +) => { + if (!map) { + return null; + } + + const boundayLimitHandler = () => { + const { La, Ma } = map.getCenter(); + if ( + Ma >= PKNU_MAP_LIMIT.TOP || + La >= PKNU_MAP_LIMIT.RIGHT || + Ma <= PKNU_MAP_LIMIT.BOTTOM || + La <= PKNU_MAP_LIMIT.LEFT + ) { + map.setLevel(4); + map.setCenter(centerLocation); + openModal(AlertModal, { + message: MODAL_MESSAGE.ALERT.OVER_MAP_BOUNDARY, + buttonMessage: '닫기', + onClose: () => closeModal(AlertModal), + }); + } + }; + + window.kakao.maps.event.addListener(map, 'dragend', boundayLimitHandler); +}; diff --git a/src/pages/Map/handlers/overlay-handler.ts b/src/pages/Map/handlers/overlay-handler.ts new file mode 100644 index 00000000..bd3854e4 --- /dev/null +++ b/src/pages/Map/handlers/overlay-handler.ts @@ -0,0 +1,91 @@ +import AlertModal from '@components/Modal/AlertModal'; +import ConfirmModal from '@components/Modal/ConfirmModal'; +import { NO_PROVIDE_LOCATION, PKNU_BUILDINGS } from '@constants/pknu-map'; +import { THEME } from '@styles/ThemeProvider/theme'; +import { BuildingType, Location, PKNUBuilding } from '@type/map'; +import { ComponentProps, FunctionComponent } from 'react'; + +class NumberOverlay { + private PKNU_BUILDING: PKNUBuilding; + private openModal: ( + Component: FunctionComponent, + props: Omit, 'open'>, + ) => void; + private closeModal: (Component: FunctionComponent) => void; + private userLocation: Location | null; + + constructor( + PKNU_BUILDING: PKNUBuilding, + openModal: ( + Component: FunctionComponent, + props: Omit, 'open'>, + ) => void, + closeModal: (Component: FunctionComponent) => void, + userLocation: Location | null, + ) { + this.PKNU_BUILDING = PKNU_BUILDING; + this.openModal = openModal; + this.closeModal = closeModal; + this.userLocation = userLocation; + } + + private routeHandler() { + const { + buildingName, + buildingNumber, + latlng: [lat, lng], + } = this.PKNU_BUILDING; + const routeUrl = !this.userLocation + ? '' + : `https://map.kakao.com/link/from/내위치,${this.userLocation.LAT},${this.userLocation.LNG}/to/${buildingName},${lat},${lng}`; + + JSON.stringify(this.userLocation) !== JSON.stringify(NO_PROVIDE_LOCATION) + ? this.openModal(ConfirmModal, { + message: `목적지(${buildingNumber})로 길찾기를 시작할까요?`, + onConfirmButtonClick: () => { + window.open(routeUrl, '_blank'), this.closeModal(ConfirmModal); + }, + onCancelButtonClick: () => this.closeModal(ConfirmModal), + }) + : this.openModal(AlertModal, { + message: '위치정보를 제공하지 않아 길찾기 기능을 사용할 수 없어요!', + buttonMessage: '닫기', + onClose: () => this.closeModal(AlertModal), + }); + } + + private createOverlayContent(buildingType: BuildingType, index: number) { + const content = document.createElement('span') as HTMLSpanElement; + Object.assign(content.style, { + backgroundColor: `${PKNU_BUILDINGS[buildingType].activeColor}`, + color: THEME.TEXT.WHITE, + padding: '5px', + borderRadius: '8px', + fontSize: '10px', + fontWeight: 'bold', + }); + content.classList.add(`${buildingType}-${index}`); + const buildingNumber = document.createTextNode( + this.PKNU_BUILDING.buildingNumber, + ); + content.appendChild(buildingNumber); + content.onclick = () => this.routeHandler(); + + return content; + } + + createOverlay(buildingType: BuildingType, index: number) { + const overlayContent = this.createOverlayContent(buildingType, index); + return new window.kakao.maps.CustomOverlay({ + position: new window.kakao.maps.LatLng( + this.PKNU_BUILDING.latlng[0], + this.PKNU_BUILDING.latlng[1], + ), + content: overlayContent, + removable: false, + yAnchor: -0.05, + }); + } +} + +export default NumberOverlay; diff --git a/src/pages/Map/index.tsx b/src/pages/Map/index.tsx index a28c9684..865399af 100644 --- a/src/pages/Map/index.tsx +++ b/src/pages/Map/index.tsx @@ -1,11 +1,15 @@ import { PKNU_MAP_CENTER } from '@constants/pknu-map'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import useModals from '@hooks/useModals'; +import { BuildingType } from '@type/map'; import { useEffect, useState } from 'react'; -import MapboundaryObserver from './MapboundaryObserver'; -import MapLevelObserver from './MapLevelObserver'; -import PknuBuildingNumbers from './PknuBuildingNumbers'; +import BuildingFilterButtons from './components/BuildingFilterButtons'; +import MapHeader from './components/MapHeader'; +import PknuBuildingNumbers from './components/PknuBuildingNumbers'; +import UserLocation from './components/UserLocation'; +import { mapBoundaryHandler, mapLevelHandler } from './handlers/limit-handler'; declare global { interface Window { @@ -14,7 +18,10 @@ declare global { } const Map = () => { + const { openModal, closeModal } = useModals(); const [map, setMap] = useState(null); + const [buildingTypes, setBuildingTypes] = useState(['A']); + const PKNU_MAP_CENTER_LOCATION = new window.kakao.maps.LatLng( PKNU_MAP_CENTER.LAT, PKNU_MAP_CENTER.LNG, @@ -25,34 +32,43 @@ const Map = () => { center: PKNU_MAP_CENTER_LOCATION, level: 4, }; - const map = new window.kakao.maps.Map(container, options); + const map = new window.kakao.maps.Map(container as HTMLDivElement, options); setMap(map); }, []); + mapLevelHandler(map, PKNU_MAP_CENTER_LOCATION, openModal, closeModal); + mapBoundaryHandler(map, PKNU_MAP_CENTER_LOCATION, openModal, closeModal); return (
+ - - - + + + + +
); }; export default Map; +const MapFooter = styled.div` + height: 8vh; + display: flex; +`; + const KakaoMap = styled.div` - width: 100%; height: 100%; + width: 100%; border-radius: 15px; `; diff --git a/src/pages/My/index.test.tsx b/src/pages/My/index.test.tsx index f04875d8..afb87635 100644 --- a/src/pages/My/index.test.tsx +++ b/src/pages/My/index.test.tsx @@ -62,7 +62,7 @@ describe('마이 페이지 동작 테스트', () => { { wrapper: MemoryRouter }, ); - const majorEditButton = screen.getByTestId('edit'); + const majorEditButton = screen.getByText('학과 선택하러가기'); await userEvent.click(majorEditButton); expect(mockRouterTo).toHaveBeenCalledWith('/major-decision'); }); diff --git a/src/pages/My/index.tsx b/src/pages/My/index.tsx index 8e20ae72..18a47df8 100644 --- a/src/pages/My/index.tsx +++ b/src/pages/My/index.tsx @@ -1,14 +1,24 @@ +import http from '@apis/http'; import Button from '@components/Button'; +import ToggleButton from '@components/Button/Toggle'; import Icon from '@components/Icon'; +import AlertModal from '@components/Modal/AlertModal'; +import ConfirmModal from '@components/Modal/ConfirmModal'; import SuggestionModal from '@components/Modal/SuggestionModal'; +import { SERVER_URL } from '@config/index'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import urlBase64ToUint8Array from '@hooks/urlBase64ToUint8Array'; import useMajor from '@hooks/useMajor'; import useModals from '@hooks/useModals'; import useRouter from '@hooks/useRouter'; import { THEME } from '@styles/ThemeProvider/theme'; +import { MouseEventHandler, useEffect, useState } from 'react'; const My = () => { + const [subscribe, setSubscribe] = useState(null); + const [animation, setAnimation] = useState(false); + const { major } = useMajor(); const { routerTo } = useRouter(); const routerToMajorDecision = () => routerTo('/major-decision'); @@ -22,26 +32,107 @@ const My = () => { }); }; + const subscribeTopic: MouseEventHandler = async () => { + if (!animation) setAnimation(true); // 토글 버튼 클릭 애니메이션을 위해 사용 + + if (subscribe) { + openModal(ConfirmModal, { + message: '알림을 그만 받을까요?', + onConfirmButtonClick: async () => { + await http.delete(`${SERVER_URL}/api/subscription/major`, { + data: { subscription: subscribe, major }, + }); + setSubscribe(null); + closeModal(ConfirmModal); + localStorage.removeItem('subscribe'); + }, + onCancelButtonClick: () => { + closeModal(ConfirmModal); + }, + }); + return; + } + + if (!('serviceWorker' in navigator)) return; + if (!major) { + openModal(AlertModal, { + message: '학과를 선택해주세요', + buttonMessage: '확인', + onClose: () => closeModal(AlertModal), + routerTo: () => { + closeModal(AlertModal); + routerToMajorDecision(); + }, + }); + return; + } + + try { + const registration = await navigator.serviceWorker.ready; + const VAPID_PUBLIC_KEY = + 'BMTktqZlaL5Bqx7rR2h_fbqBsWROO4k2RnXxwbJXDsP99RSaihgNEkA3JT1iQVT2XRQMRHYMJUyDQS7_r8S5BMc'; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY), + }); + + const res = await http.post(`${SERVER_URL}/api/subscription/major`, { + data: { + subscription, + major, + }, + }); + if (res.status === 200) { + localStorage.setItem('subscribe', JSON.stringify(subscription)); + setSubscribe(subscription); + } + } catch (error) { + return; + } + }; + + useEffect(() => { + const storedSubscribe = localStorage.getItem('subscribe'); + if (storedSubscribe) setSubscribe(JSON.parse(storedSubscribe)); + }, []); + return ( <> -

마이페이지

+ 마이페이지 - 전공 -
- {major} - -
+ + + {major ? ( + <> +
{major}
+ {' '} + + ) : ( +
routerToMajorDecision()} + css={css` + opacity: 0.5; + width: 100%; + `} + > + 학과 선택하러가기 +
+ )} +
+ + 학과 공지사항 알림받기 + + +