diff --git a/frontend/babel.config.js b/frontend/babel.config.js index 43dfc43a8..2c635521b 100644 --- a/frontend/babel.config.js +++ b/frontend/babel.config.js @@ -6,6 +6,7 @@ module.exports = { targets: { browsers: ['last 2 versions', 'not dead', 'not ie <= 11'], }, + modules: false, }, ], ['@babel/preset-react'], diff --git a/frontend/src/apis/deleteApi.ts b/frontend/src/apis/deleteApi.ts index 28ded4896..3ce0f2152 100644 --- a/frontend/src/apis/deleteApi.ts +++ b/frontend/src/apis/deleteApi.ts @@ -1,12 +1,6 @@ -// const API_URL = -// process.env.NODE_ENV === 'production' -// ? process.env.REACT_APP_API_DEFAULT_PROD -// : process.env.REACT_APP_API_DEFAULT_DEV; - -const API_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : DEFAULT_PROD_URL - import { DEFAULT_PROD_URL } from '../constants'; import { ContentTypeType } from '../types/Api'; +import withTokenRefresh from './utils'; interface Headers { 'content-type': string; @@ -14,26 +8,28 @@ interface Headers { } export const deleteApi = async (url: string, contentType?: ContentTypeType) => { - const apiUrl = `${DEFAULT_PROD_URL + url}`; - const userToken = localStorage.getItem('userToken'); - const headers: Headers = { - 'content-type': 'application/json', - }; - - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } - - if (contentType) { - headers['content-type'] = contentType; - } - - const response = await fetch(apiUrl, { - method: 'DELETE', - headers, + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + const headers: Headers = { + 'content-type': 'application/json', + }; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + if (contentType) { + headers['content-type'] = contentType; + } + + const response = await fetch(apiUrl, { + method: 'DELETE', + headers, + }); + + if (response.status >= 400) { + throw new Error('[SERVER] DELETE 요청에 실패했습니다.'); + } }); - - if (response.status >= 400) { - throw new Error('[SERVER] DELETE 요청에 실패했습니다.'); - } }; diff --git a/frontend/src/apis/getApi.ts b/frontend/src/apis/getApi.ts index e4d3d4520..f4c99b910 100644 --- a/frontend/src/apis/getApi.ts +++ b/frontend/src/apis/getApi.ts @@ -1,98 +1,5 @@ -let refreshResponse: Promise | null = null; - -const decodeToken = (token: string) => { - const tokenParts = token.split('.'); - if (tokenParts.length !== 3) { - throw new Error('토큰이 잘못되었습니다.'); - } - - const decodedPayloadString = atob(tokenParts[1]); - - return JSON.parse(decodedPayloadString); -}; - -async function refreshToken(headers: Headers): Promise { - if (refreshResponse !== null) { - console.log('if 블록 내부에서 refreshResponse : ', refreshResponse); - return refreshResponse; - } - - console.log('refreshResponse:', refreshResponse); - - const accessToken = localStorage.getItem('userToken'); - try { - // 서버에 새로운 엑세스 토큰을 요청하기 위한 네트워크 요청을 보냅니다. - refreshResponse = fetch(`${DEFAULT_PROD_URL}/refresh-token`, { - method: 'POST', - headers, - body: JSON.stringify({ - accessToken: accessToken, - }), - }); - - const responseData = await refreshResponse; - refreshResponse = null; - - // 서버 응답이 성공적인지 확인합니다. - if (!responseData.ok) { - console.log('refreshResponse.ok하지 못함'); - throw new Error('Failed to refresh access token.'); - } - - // 새로운 엑세스 토큰을 반환합니다. - return responseData; - } catch (error) { - // 네트워크 요청 실패 또는 예외 발생 시 예외를 캐치하여 처리합니다. - console.error('네트워크 요청 실패 또는 예외 발생:', error); - throw error; // 예외를 다시 throw하여 상위 코드로 전파합니다. - } -} - -const isTokenExpired = (token: string) => { - const decodedPayloadObject = decodeToken(token); - return decodedPayloadObject.exp * 1000 < Date.now(); -}; - -async function updateToken(headers: Headers) { - const response = await refreshToken(headers); - const responseCloned = response.clone(); - - try { - const newToken = await responseCloned.json(); - - console.log('newToken:', newToken); - - localStorage.setItem('userToken', newToken.accessToken); - } catch (e) { - console.error(e); - - return; - } -} - -async function withTokenRefresh(callback: () => Promise): Promise { - const userToken = localStorage.getItem('userToken'); - - if (userToken && isTokenExpired(userToken)) { - console.log('AccessToken 만료되어 재요청합니다'); - - const headers: any = { - 'content-type': 'application/json', - Authorization: `Bearer ${userToken}`, - }; - - await updateToken(headers); - } - - return callback(); -} - import { DEFAULT_PROD_URL } from '../constants'; - -interface Headers { - 'content-type': string; - [key: string]: string; -} +import withTokenRefresh from './utils'; export const getApi = async (url: string) => { return await withTokenRefresh(async () => { @@ -106,18 +13,13 @@ export const getApi = async (url: string) => { headers['Authorization'] = `Bearer ${userToken}`; } - console.log('response', DEFAULT_PROD_URL + url); - const response = await fetch(apiUrl, { method: 'GET', headers }); - console.log(response); - if (response.status >= 400) { throw new Error('[SERVER] GET 요청에 실패했습니다.'); } const responseData: T = await response.json(); - console.log('responseData L84까지 성공', responseData); return responseData; }); }; diff --git a/frontend/src/apis/postApi.ts b/frontend/src/apis/postApi.ts index fb78cf44f..f05dec3de 100644 --- a/frontend/src/apis/postApi.ts +++ b/frontend/src/apis/postApi.ts @@ -1,34 +1,52 @@ import { DEFAULT_PROD_URL } from '../constants'; import { ContentTypeType } from '../types/Api'; - -interface Headers { - 'content-type': string; - [key: string]: string; -} - -interface HeadersForm { - [key: string]: string; -} +import withTokenRefresh from './utils'; export const postApi = async ( url: string, payload: {} | FormData, contentType?: ContentTypeType, ) => { - const apiUrl = `${DEFAULT_PROD_URL + url}`; - const userToken = localStorage.getItem('userToken'); + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + + if (payload instanceof FormData) { + const headers: any = {}; + + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } + + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: payload, + }); + + if (response.status >= 400) { + throw new Error('[SERVER] POST 요청에 실패했습니다.'); + } + + return response; + } - if (payload instanceof FormData) { - const headers: HeadersForm = {}; + const headers: any = { + 'content-type': 'application/json', + }; if (userToken) { headers['Authorization'] = `Bearer ${userToken}`; } + if (contentType) { + headers['content-type'] = contentType; + } + const response = await fetch(apiUrl, { method: 'POST', headers, - body: payload, + body: JSON.stringify(payload), }); if (response.status >= 400) { @@ -36,29 +54,5 @@ export const postApi = async ( } return response; - } - - const headers: Headers = { - 'content-type': 'application/json', - }; - - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } - - if (contentType) { - headers['content-type'] = contentType; - } - - const response = await fetch(apiUrl, { - method: 'POST', - headers, - body: JSON.stringify(payload), }); - - if (response.status >= 400) { - throw new Error('[SERVER] POST 요청에 실패했습니다.'); - } - - return response; }; diff --git a/frontend/src/apis/putApi.ts b/frontend/src/apis/putApi.ts index 7e467265c..6335503c9 100644 --- a/frontend/src/apis/putApi.ts +++ b/frontend/src/apis/putApi.ts @@ -1,38 +1,37 @@ import { DEFAULT_PROD_URL } from '../constants'; import { ContentTypeType } from '../types/Api'; -interface Headers { - 'content-type': string; - [key: string]: string; -} +import withTokenRefresh from './utils'; export const putApi = async ( url: string, data: {}, contentType?: ContentTypeType, ) => { - const apiUrl = `${DEFAULT_PROD_URL + url}`; - const userToken = localStorage.getItem('userToken'); - const headers: Headers = { - 'content-type': 'application/json', - }; + return await withTokenRefresh(async () => { + const apiUrl = `${DEFAULT_PROD_URL + url}`; + const userToken = localStorage.getItem('userToken'); + const headers: any = { + 'content-type': 'application/json', + }; - if (userToken) { - headers['Authorization'] = `Bearer ${userToken}`; - } + if (userToken) { + headers['Authorization'] = `Bearer ${userToken}`; + } - if (contentType) { - headers['content-type'] = contentType; - } + if (contentType) { + headers['content-type'] = contentType; + } - const response = await fetch(apiUrl, { - method: 'PUT', - headers, - body: JSON.stringify(data), - }); + const response = await fetch(apiUrl, { + method: 'PUT', + headers, + body: JSON.stringify(data), + }); - if (response.status >= 400) { - throw new Error('[SERVER] PUT 요청에 실패했습니다.'); - } + if (response.status >= 400) { + throw new Error('[SERVER] PUT 요청에 실패했습니다.'); + } - return response; + return response; + }); }; diff --git a/frontend/src/apis/utils.ts b/frontend/src/apis/utils.ts new file mode 100644 index 000000000..c5b69af36 --- /dev/null +++ b/frontend/src/apis/utils.ts @@ -0,0 +1,84 @@ +import { DEFAULT_PROD_URL } from '../constants'; + +let refreshResponse: Promise | null = null; + +const decodeToken = (token: string) => { + const tokenParts = token.split('.'); + if (tokenParts.length !== 3) { + throw new Error('토큰이 잘못되었습니다.'); + } + + const decodedPayloadString = atob(tokenParts[1]); + + return JSON.parse(decodedPayloadString); +}; + +async function refreshToken(headers: Headers): Promise { + if (refreshResponse !== null) { + return refreshResponse; + } + + const accessToken = localStorage.getItem('userToken'); + try { + // 서버에 새로운 엑세스 토큰을 요청하기 위한 네트워크 요청을 보냅니다. + refreshResponse = fetch(`${DEFAULT_PROD_URL}/refresh-token`, { + method: 'POST', + headers, + body: JSON.stringify({ + accessToken: accessToken, + }), + }); + + const responseData = await refreshResponse; + refreshResponse = null; + + // 서버 응답이 성공적인지 확인합니다. + if (!responseData.ok) { + throw new Error('Failed to refresh access token.'); + } + + // 새로운 엑세스 토큰을 반환합니다. + return responseData; + } catch (error) { + // 네트워크 요청 실패 또는 예외 발생 시 예외를 캐치하여 처리합니다. + console.error('네트워크 요청 실패 또는 예외 발생:', error); + throw error; // 예외를 다시 throw하여 상위 코드로 전파합니다. + } +} + +const isTokenExpired = (token: string) => { + const decodedPayloadObject = decodeToken(token); + return decodedPayloadObject.exp * 1000 < Date.now(); +}; + +async function updateToken(headers: Headers) { + const response = await refreshToken(headers); + const responseCloned = response.clone(); + + try { + const newToken = await responseCloned.json(); + + localStorage.setItem('userToken', newToken.accessToken); + } catch (e) { + console.error(e); + + return; + } +} + +export default async function withTokenRefresh( + callback: () => Promise, +): Promise { + const userToken = localStorage.getItem('userToken'); + + if (userToken && isTokenExpired(userToken)) { + const headers: any = { + 'content-type': 'application/json', + Authorization: `Bearer ${userToken}`, + }; + + await updateToken(headers); + } + + return callback(); +} diff --git a/frontend/src/components/Layout/Navbar.tsx b/frontend/src/components/Layout/Navbar.tsx index d7d202461..7a3b95e55 100644 --- a/frontend/src/components/Layout/Navbar.tsx +++ b/frontend/src/components/Layout/Navbar.tsx @@ -4,7 +4,6 @@ import useNavigator from '../../hooks/useNavigator'; import Flex from '../common/Flex'; import Button from '../common/Button'; import Space from '../common/Space'; -import Text from '../common/Text'; import Home from '../../assets/nav_home.svg'; import SeeTogether from '../../assets/nav_seeTogether.svg'; import Favorite from '../../assets/nav_favorite.svg'; @@ -16,162 +15,72 @@ import FocusFavorite from '../../assets/nav_favorite_focus.svg'; import FocusAddMapOrPin from '../../assets/nav_addMapOrPin_focus.svg'; import FocusProfile from '../../assets/nav_profile_focus.svg'; import Modal from '../Modal'; -import { ModalContext } from '../../context/ModalContext'; -import { NavbarHighlightsContext } from '../../context/NavbarHighlightsContext'; -import { useParams } from 'react-router-dom'; -import SeeTogetherCounter from '../SeeTogetherCounter'; -import useKeyDown from '../../hooks/useKeyDown'; +import { + NavbarHighlightKeys, + NavbarHighlightsContext, +} from '../../context/NavbarHighlightsContext'; +import NavbarItem from './NavbarItem'; interface NavBarProps { $layoutWidth: '100vw' | '372px'; } -const Navbar = ({ $layoutWidth }: NavBarProps) => { - const { routePage } = useNavigator(); - const { topicId } = useParams(); - const { openModal, closeModal } = useContext(ModalContext); - const { navbarHighlights } = useContext(NavbarHighlightsContext); - const { elementRef: firstElement, onElementKeyDown: firstKeyDown } = - useKeyDown(); - const { elementRef: secondElement, onElementKeyDown: secondKeyDown } = - useKeyDown(); - const { elementRef: thirdElement, onElementKeyDown: thirdKeyDown } = - useKeyDown(); - const { elementRef: fourElement, onElementKeyDown: fourKeyDown } = - useKeyDown(); - const { elementRef: FifthElement, onElementKeyDown: FifthKeyDown } = - useKeyDown(); - - const goToHome = () => { - routePage('/'); - }; - - const goToSeeTogether = () => { - routePage('/see-together'); - }; - - const onClickAddMapOrPin = () => { - openModal('addMapOrPin'); - }; - - const goToFavorite = () => { - routePage('/favorite'); - }; - - const goToProfile = () => { - routePage('/my-page'); - }; - - const goToNewTopic = () => { - routePage('/new-topic'); - closeModal('addMapOrPin'); - }; +interface NavbarItemProps { + key: NavbarHighlightKeys; + label: string; + icon: React.FunctionComponent; + focusIcon: React.FunctionComponent; +} - const goToNewPin = () => { - routePage('/new-pin', topicId); - closeModal('addMapOrPin'); - }; +const NAV_ITEMS: NavbarItemProps[] = [ + { key: 'home', label: '홈', icon: Home, focusIcon: FocusHome }, + { + key: 'seeTogether', + label: '모아보기', + icon: SeeTogether, + focusIcon: FocusSeeTogether, + }, + { + key: 'addMapOrPin', + label: '추가하기', + icon: AddMapOrPin, + focusIcon: FocusAddMapOrPin, + }, + { + key: 'favorite', + label: '즐겨찾기', + icon: Favorite, + focusIcon: FocusFavorite, + }, + { + key: 'profile', + label: '내 정보', + icon: Profile, + focusIcon: FocusProfile, + }, +]; +const Navbar = ({ $layoutWidth }: NavBarProps) => { + const { routingHandlers } = useNavigator(); + const { navbarHighlights } = useContext(NavbarHighlightsContext); return ( - <> - - - {navbarHighlights.home ? : } - - 홈 - - - - - {navbarHighlights.seeTogether ? ( - - ) : ( - - )} - - 모아보기 - - - - - - {navbarHighlights.addMapOrPin ? ( - - ) : ( - - )} - - 추가하기 - - - - - {navbarHighlights.favorite ? : } - - 즐겨찾기 - - - - - {navbarHighlights.profile ? : } - - 내 정보 - - - + + {NAV_ITEMS.map((item) => { + return ( + routingHandlers[item.key]()} + $layoutWidth={$layoutWidth} + /> + ); + })} { left={$layoutWidth === '100vw' ? '' : `${372 / 2}px`} > - + 지도 추가하기 - + 핀 추가하기 @@ -222,25 +139,6 @@ const Wrapper = styled.nav<{ } `; -const IconWrapper = styled.div<{ $layoutWidth: '100vw' | '372px' }>` - position: relative; - display: flex; - flex-direction: column; - align-items: center; - width: 52px; - cursor: pointer; - margin-right: ${({ $layoutWidth }) => - $layoutWidth === '100vw' ? '48px' : '0'}; - - &:last-of-type { - margin-right: 0; - } - - @media (max-width: 1076px) { - margin-right: 0; - } -`; - const RouteButton = styled(Button)` box-shadow: 2px 4px 4px rgba(0, 0, 0, 0.5); `; diff --git a/frontend/src/components/Layout/NavbarItem.tsx b/frontend/src/components/Layout/NavbarItem.tsx new file mode 100644 index 000000000..c637db2c2 --- /dev/null +++ b/frontend/src/components/Layout/NavbarItem.tsx @@ -0,0 +1,66 @@ +import { FunctionComponent } from 'react'; +import styled from 'styled-components'; +import useKeyDown from '../../hooks/useKeyDown'; +import Text from '../common/Text'; +import SeeTogetherCounter from '../SeeTogetherCounter'; + +interface NavbarItemProps { + label: string; + icon: FunctionComponent; + focusIcon: FunctionComponent; + isHighlighted?: boolean; + onClick: () => void; + $layoutWidth: '100vw' | '372px'; +} + +const NavbarItem = ({ + label, + icon: Icon, + focusIcon: FocusIcon, + isHighlighted = false, + onClick, + $layoutWidth, +}: NavbarItemProps) => { + const { elementRef, onElementKeyDown } = useKeyDown(); + + return ( + + {isHighlighted ? : } + + {label} + + {label === '모아보기' ? : null} + + ); +}; + +const IconWrapper = styled.div<{ $layoutWidth: '100vw' | '372px' }>` + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 52px; + cursor: pointer; + margin-right: ${({ $layoutWidth }) => + $layoutWidth === '100vw' ? '48px' : '0'}; + + &:last-of-type { + margin-right: 0; + } + + @media (max-width: 1076px) { + margin-right: 0; + } +`; + +export default NavbarItem; diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index 957b19378..77f76cde4 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -1,14 +1,38 @@ -import { useNavigate } from 'react-router-dom'; +import { useContext } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { ModalContext } from '../context/ModalContext'; const useNavigator = () => { const navigator = useNavigate(); + const { openModal, closeModal } = useContext(ModalContext); + const { topicId } = useParams(); const routePage = (url: string | -1, value?: string | number | number[]) => { if (typeof url === 'string') navigator(url, { state: value }); if (url === -1) navigator(url); }; - return { routePage }; + return { + routingHandlers: { + home: () => routePage('/'), + seeTogether: () => routePage('/see-together'), + addMapOrPin: () => openModal('addMapOrPin'), + favorite: () => routePage('/favorite'), + profile: () => routePage('/my-page'), + newTopic: () => { + routePage('/new-topic'); + closeModal('addMapOrPin'); + }, + newPin: () => { + routePage('/new-pin', topicId); + closeModal('addMapOrPin'); + }, + goToPopularTopics: () => routePage('see-all/popularity'), + goToNearByMeTopics: () => routePage('see-all/near'), + goToLatestTopics: () => routePage('see-all/latest'), + }, + routePage, + }; }; export default useNavigator; diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index ee07737b0..5ca139b9d 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,5 +1,4 @@ import Space from '../components/common/Space'; -import Box from '../components/common/Box'; import useNavigator from '../hooks/useNavigator'; import { css, styled } from 'styled-components'; import useSetLayoutWidth from '../hooks/useSetLayoutWidth'; @@ -15,24 +14,16 @@ const TopicListContainer = lazy( ); const Home = () => { - const { routePage } = useNavigator(); + const { routingHandlers } = useNavigator(); + const { goToPopularTopics, goToLatestTopics, goToNearByMeTopics } = + routingHandlers; + const { markers, removeMarkers, removeInfowindows } = useContext(MarkerContext); + useSetLayoutWidth(FULLSCREEN); useSetNavbarHighlight('home'); - const goToPopularTopics = () => { - routePage('see-all/popularity'); - }; - - const goToNearByMeTopics = () => { - routePage('see-all/near'); - }; - - const goToLatestTopics = () => { - routePage('see-all/latest'); - }; - useEffect(() => { if (markers && markers.length > 0) { removeMarkers(); diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index ce56ae7ad..4ae819044 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -1,20 +1,21 @@ import { createBrowserRouter } from 'react-router-dom'; import Home from './pages/Home'; -import NewPin from './pages/NewPin'; -import NewTopic from './pages/NewTopic'; import RootPage from './pages/RootPage'; -import SelectedTopic from './pages/SelectedTopic'; -import SeeAllPopularTopics from './pages/SeeAllPopularTopics'; -import SeeAllNearTopics from './pages/SeeAllNearTopics'; -import SeeAllLatestTopics from './pages/SeeAllLatestTopics'; -import KakaoRedirect from './pages/KakaoRedirect'; -import { ReactNode } from 'react'; +import { ReactNode, lazy } from 'react'; import AuthLayout from './components/Layout/AuthLayout'; import NotFound from './pages/NotFound'; -import SeeTogetherTopics from './pages/SeeTogetherTopics'; -import Profile from './pages/Profile'; -import AskLogin from './pages/AskLogin'; -import Bookmark from './pages/Bookmark'; + +const SelectedTopic = lazy(() => import('./pages/SelectedTopic')); +const NewPin = lazy(() => import('./pages/NewPin')); +const NewTopic = lazy(() => import('./pages/NewTopic')); +const SeeAllPopularTopics = lazy(() => import('./pages/SeeAllPopularTopics')); +const SeeAllNearTopics = lazy(() => import('./pages/SeeAllNearTopics')); +const SeeAllLatestTopics = lazy(() => import('./pages/SeeAllLatestTopics')); +const KakaoRedirect = lazy(() => import('./pages/KakaoRedirect')); +const SeeTogetherTopics = lazy(() => import('./pages/SeeTogetherTopics')); +const Profile = lazy(() => import('./pages/Profile')); +const AskLogin = lazy(() => import('./pages/AskLogin')); +const Bookmark = lazy(() => import('./pages/Bookmark')); interface routeElement { path: string;