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%;
+ `}
+ >
+ 학과 선택하러가기
+
+ )}
+
+
+ 학과 공지사항 알림받기
+
+
+