diff --git a/src/api/notify.js b/src/api/notify.js new file mode 100644 index 0000000..c2bbc58 --- /dev/null +++ b/src/api/notify.js @@ -0,0 +1,15 @@ +import axios from 'axios'; +import { getHeaderRefreshTokenConfig } from 'utils/auth'; + +const PREFIX_URL = '/api/v1/notify'; + +/** + * notifyId에 해당하는 알림을 읽음처리 한다. + */ +export function readNotification(notifyId) { + return axios.put( + `${PREFIX_URL}/read`, + { id: notifyId }, + getHeaderRefreshTokenConfig(), + ); +} diff --git "a/src/components/\bNotificationPopup/index.jsx" "b/src/components/\bNotificationPopup/index.jsx" new file mode 100644 index 0000000..e62a307 --- /dev/null +++ "b/src/components/\bNotificationPopup/index.jsx" @@ -0,0 +1,71 @@ +import React, { useCallback } from 'react'; +import { + Container, + Message, + NoNotification, + Notification, + NotifyType, + Read, +} from './style'; +import { isLoginUser } from 'utils/auth'; +import fetcher from 'utils/fetcher'; +import { notificationType } from 'utils/notification'; +import { NOTIFY_PREFIX_URL, USER_PREFIX_URL } from 'utils/constants'; +import useSWR from 'swr'; +import { toast } from 'react-toastify'; +import { readNotification } from 'api/notify'; +import { useNavigate } from 'react-router-dom'; + +function NotificationPopup() { + const { data: loginUser } = useSWR( + isLoginUser() ? `${USER_PREFIX_URL}/auth/parse/boj` : '', + fetcher, + ); + const { data: notificationList, mutate } = useSWR( + `${NOTIFY_PREFIX_URL}/search/receiver?receiver=${loginUser.claim}`, + fetcher, + ); + const { mutate: mutateNotificationCount } = useSWR( + loginUser ? `${NOTIFY_PREFIX_URL}/search/unread/count` : null, + fetcher, + ); + const readNotificationProc = useCallback(async (id) => { + try { + await readNotification(id); + mutate(); + mutateNotificationCount(); + } catch { + toast.error('알림을 읽는데 문제가 발생하였습니다.'); + } + }, []); + + const navigate = useNavigate(); + const hasNotification = notificationList?.length > 0; + + return ( + + {!hasNotification && ( + 도착한 알림이 없습니다. + )} + {notificationList?.map((notification) => ( + { + if (notification.relatedBoardId) { + navigate(`/board/${notification.relatedBoardId}`); + } + if (notification.isRead) return; + readNotificationProc(notification.id); + }} + > + {notificationType[notification.type].label} + {notification.message} + {notification.isRead ? '읽음' : ''} + + ))} + + ); +} + +export default NotificationPopup; diff --git "a/src/components/\bNotificationPopup/style.jsx" "b/src/components/\bNotificationPopup/style.jsx" new file mode 100644 index 0000000..880c8e5 --- /dev/null +++ "b/src/components/\bNotificationPopup/style.jsx" @@ -0,0 +1,74 @@ +import styled from '@emotion/styled'; + +export const Container = styled.div` + position: fixed; + top: 50px; + right: 70px; + width: 30rem; + max-width: 75%; + max-height: 25rem; + height: ${({ fixHeight }) => (fixHeight ? '25rem' : 'auto')}; + /* border-radius: 10px; */ + border: 1px solid var(--color-border); + background-color: #fff; + z-index: 2999; + overflow: scroll; + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera*/ + } + @media all and (max-width: 520px) { + width: 95%; + max-width: 100%; + height: 100vh; + right: 5px; + } +`; + +export const Notification = styled.div` + display: flex; + flex-direction: column; + gap: 0.5rem; + border-bottom: 1px solid #e0e0e0; + padding: 1.5rem 1rem; + position: relative; + cursor: pointer; + ${(props) => props.isRead && 'background-color: var(--color-button-gray);'} + :hover { + background-color: var(--color-button-gray); + } + :last-of-type { + border-bottom: none; + } +`; + +export const NotifyType = styled.div` + font-size: 0.8rem; + font-weight: 600; + color: var(--color-primary); +`; + +export const Message = styled.div` + font-weight: 400; + font-size: 0.9rem; +`; + +export const Read = styled.div` + position: absolute; + top: 1rem; + right: 1rem; + font-size: 0.7rem; + font-weight: 400 !important; + color: var(--color-text-gray); +`; + +export const NoNotification = styled.div` + display: flex; + align-items: center; + justify-content: center; + font-weight: 400; + font-size: 0.9rem; + width: 100%; + height: 100%; +`; diff --git a/src/components/BoardTable/index.jsx b/src/components/BoardTable/index.jsx deleted file mode 100644 index c4570e7..0000000 --- a/src/components/BoardTable/index.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import { Table, PostInfo } from './style'; -import { useNavigate } from 'react-router-dom'; -import dayjs from 'dayjs'; -import { getTypeLabel } from 'utils/board'; - -/** - * 게시판 테이블 컴포넌트 - */ -function BoardTable({ postList, showTypeTitle }) { - const navigate = useNavigate(); - return ( - - - - - - - - - - {postList.map((post) => ( - { - navigate(`/board/${post.id}`); - }} - > - - - {post.notionId} {post.emoji} - - {dayjs(post.createdDate).format('YYYY-MM-DD')} - - ))} - -
제목작성자작성일
- {showTypeTitle && {`[${getTypeLabel(post.type)}] `}} - {post.title} {post.commentCount > 0 && `(${post.commentCount})`} -
- ); -} - -export default BoardTable; diff --git a/src/components/BoardTable/style.jsx b/src/components/BoardTable/style.jsx deleted file mode 100644 index 60165f4..0000000 --- a/src/components/BoardTable/style.jsx +++ /dev/null @@ -1,57 +0,0 @@ -import styled from '@emotion/styled'; - -export const Table = styled.table` - border-collapse: collapse; - /* background-color: white; */ - margin-top: 15px; - width: 100%; - font-size: 0.9rem; - /* 테이블 행 */ - & td { - padding: 10px 15px 10px 15px; - text-align: left; - border-bottom: 1px solid var(--color-bordergrey); - cursor: pointer; - & b { - font-weight: bold; - } - } - & th { - text-align: left; - padding: 15px; - border-top: 2px solid var(--color-bordergrey); - border-bottom: 1px solid var(--color-bordergrey); - font-weight: bold; - } - /* 테이블 비율 */ - & th:nth-of-type(1), - & td:nth-of-type(1) { - width: 60%; - @media all and (max-width: 544px) { - width: 40%; - } - } - & th:nth-of-type(2), - & td:nth-of-type(2) { - width: 15%; - @media all and (max-width: 544px) { - width: 20%; - } - } - & th:nth-of-type(3), - & td:nth-of-type(3) { - width: 15%; - @media all and (max-width: 544px) { - width: 30%; - } - } - & th, - & td { - border-left: none; - border-right: none; - } -`; - -export const PostInfo = styled.td` - color: var(--color-textgrey); -`; diff --git a/src/components/OverlayMenu.jsx b/src/components/OverlayMenu.jsx new file mode 100644 index 0000000..54e1e1c --- /dev/null +++ b/src/components/OverlayMenu.jsx @@ -0,0 +1,21 @@ +import { useEffect, useRef } from 'react'; + +export default function OverlayMenu({ children, onClose }) { + const popupRef = useRef(null); + useEffect(() => { + const handler = (e) => { + if (!popupRef) return; + const { current } = popupRef; + // 클릭한 요소가 팝업이 아닐때 팝업을 닫는다. + if (!current || current.contains(e?.target || null)) { + return; + } + onClose(e); + }; + document.addEventListener('mouseup', handler); + return () => { + document.removeEventListener('mouseup', handler); + }; + }, [onClose]); + return
{children}
; +} diff --git a/src/layouts/Layout/index.jsx b/src/layouts/Layout/index.jsx index 5b45e97..29a2b16 100644 --- a/src/layouts/Layout/index.jsx +++ b/src/layouts/Layout/index.jsx @@ -21,11 +21,12 @@ import { LoginButton, MobileLoginButton, MyPageMenu, - CreateModal, + NotificationIcon, + NewNotificationIcon, } from './style'; import Modal from 'layouts/Modal'; import ProblemRecommend from 'pages/ProblemRecommend'; -import Store from 'pages/Store'; +import { FaBell } from 'react-icons/fa'; import { FiLogOut } from 'react-icons/fi'; import { RxHamburgerMenu } from 'react-icons/rx'; import { useLocation } from 'react-router-dom'; @@ -35,16 +36,21 @@ import { isEmpty } from 'lodash'; import dayjs from 'dayjs'; import useSWR from 'swr'; import fetcher from 'utils/fetcher'; -import { EVT_PREFIX_URL, USER_PREFIX_URL } from 'utils/constants'; +import { + EVT_PREFIX_URL, + USER_PREFIX_URL, + NOTIFY_PREFIX_URL, +} from 'utils/constants'; import { IoArrowBackOutline } from 'react-icons/io5'; import { isLoginUser } from 'utils/auth'; import { MdPerson } from 'react-icons/md'; +import OverlayMenu from 'components/OverlayMenu'; +import NotificationPopup from 'components/\bNotificationPopup'; function Layout({ children }) { const isLogin = useMemo(() => { return isLoginUser(); }, []); - const [showStoreModal, setShowStoreModal] = useState(false); const [showRecommendModal, setShowRecommendModal] = useState(false); const { data: loginUser } = useSWR( isLogin ? `${USER_PREFIX_URL}/auth/parse/boj` : '', @@ -61,6 +67,7 @@ function Layout({ children }) { const [showMobileMenu, setShowMobileMenu] = useState(false); const [showMyPageMenu, setShowMyPageMenu] = useState(false); + const [showNotification, setShowNotification] = useState(false); // 좌측 탭 목록 const [tabs, setTabs] = useState({ @@ -159,8 +166,12 @@ function Layout({ children }) { fetcher, ); + const { data: notificationCount } = useSWR( + loginUser ? `${NOTIFY_PREFIX_URL}/search/unread/count` : null, + fetcher, + ); + const onCloseModal = useCallback(() => { - setShowStoreModal(false); setShowRecommendModal(false); }, []); @@ -270,6 +281,14 @@ function Layout({ children }) { {isLogin ? ( + { + setShowNotification((prev) => !prev); + }} + > + + {notificationCount?.count > 0 && } + !prev); }} /> - {showMyPageMenu && ( - { - setShowMyPageMenu(false); - }} - > - -
{ - onClickUserProfile(); - setShowMyPageMenu(false); - }} - > - 내 프로필 -
-
{ - onClickLogout(); - setShowMyPageMenu(false); - }} - > - 로그아웃 -
-
-
- )}
) : ( )} + {showMyPageMenu && ( + { + setShowMyPageMenu(false); + }} + > + +
{ + onClickUserProfile(); + setShowMyPageMenu(false); + }} + > + 내 프로필 +
+
{ + onClickLogout(); + setShowMyPageMenu(false); + }} + > + 로그아웃 +
+
+
+ )} + {showNotification && ( + { + setShowNotification(false); + }} + > + + + )}
{children}
- - - ); } diff --git a/src/layouts/Layout/style.jsx b/src/layouts/Layout/style.jsx index b07f8a3..f6c9801 100644 --- a/src/layouts/Layout/style.jsx +++ b/src/layouts/Layout/style.jsx @@ -115,7 +115,7 @@ export const SideMyInfo = styled.div` display: flex; align-items: center; text-align: center; - gap: 7px; + gap: 1.2rem; font-size: 18px; font-weight: bold; cursor: pointer; @@ -290,6 +290,9 @@ export const MobileLoginButton = styled.div` `; export const MyPageMenu = styled.div` + position: fixed; + top: 50px; + right: 20px; display: flex; flex-direction: column; font-size: 0.9rem; @@ -300,11 +303,10 @@ export const MyPageMenu = styled.div` border-radius: 10px; border: 1px solid var(--color-border); font-weight: 400; - position: absolute; - top: 50px; - right: 20px; background-color: #fff; width: 5.5rem; + cursor: pointer; + z-index: 2999; > div { display: flex; align-items: center; @@ -312,12 +314,18 @@ export const MyPageMenu = styled.div` } `; -export const CreateModal = styled.div` - position: fixed; - text-align: center; - left: 0; - bottom: 0; +export const NotificationIcon = styled.div` + color: var(--color-deep-gray); + position: relative; + margin-top: 3px; +`; + +export const NewNotificationIcon = styled.div` + height: 8px; + width: 8px; + border-radius: 50%; + background-color: #f24343; + position: absolute; top: 0; - right: 0; - z-index: 1022; + right: 1px; `; diff --git a/src/pages/MyPage/MyInfoCard/index.jsx b/src/pages/MyPage/MyInfoCard/index.jsx index 823e829..449015b 100644 --- a/src/pages/MyPage/MyInfoCard/index.jsx +++ b/src/pages/MyPage/MyInfoCard/index.jsx @@ -23,6 +23,7 @@ import { IoIosArrowUp, IoIosArrowDown } from 'react-icons/io'; import ProblemCard from 'pages/Users/ProblemCard'; import RandomProblemCard from 'pages/Users/RandomProblemCard'; import SettingRandomPopup from './SettingRandomPopup'; +import OverlayMenu from 'components/OverlayMenu'; /** * 마이페이지 내 정보 카드 @@ -74,40 +75,46 @@ function MyInfoCard({ userInfo, isUser, loadData }) { /> {showSettinMenu && ( - -
{ - setShowPwChangeModal(true); - setShowSettingMenu(false); - }} - > - 비밀번호 변경 -
-
{ - setShowRandomSetting(true); - setShowSettingMenu(false); - }} - > - 랜덤 문제 추천 설정 -
-
- 정보 업데이트 - { + setShowSettingMenu(false); + }} + > + +
{ + setShowPwChangeModal(true); + setShowSettingMenu(false); + }} + > + 비밀번호 변경 +
+
{ + setShowRandomSetting(true); + setShowSettingMenu(false); + }} + > + 랜덤 문제 추천 설정 +
+
+ 정보 업데이트 + +
+ + 20분에 한 번만 업데이트 할 수 있습니다. +
+ } /> - - - 20분에 한 번만 업데이트 할 수 있습니다. - - } - /> -
+ + )} )} diff --git a/src/utils/constants.js b/src/utils/constants.js index 50f6cac..b729f84 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -19,3 +19,4 @@ export const COMPLAINT_PROCESSOR_PREFIX_URL = PREFIX_URL + '/complaint/processor'; export const COMPLAINT_REQUESTER_PREFIX_URL = PREFIX_URL + '/complaint/requester'; +export const NOTIFY_PREFIX_URL = '/api/v1/notify'; diff --git a/src/utils/notification.js b/src/utils/notification.js new file mode 100644 index 0000000..be56165 --- /dev/null +++ b/src/utils/notification.js @@ -0,0 +1,10 @@ +export const notificationType = { + MESSAGE: { label: '메시지' }, + NOTICE: { label: '공지' }, + EVENT: { label: '이벤트' }, + SYSTEM: { label: '시스템' }, + ADMIN: { label: '관리자' }, + USER: { label: '사용자' }, + ALL: { label: '전체' }, + ETC: { label: '기타' }, +};