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}`);
- }}
- >
-
- {showTypeTitle && {`[${getTypeLabel(post.type)}] `}}
- {post.title} {post.commentCount > 0 && `(${post.commentCount})`}
- |
-
- {post.notionId} {post.emoji}
-
- {dayjs(post.createdDate).format('YYYY-MM-DD')}
-
- ))}
-
-
- );
-}
-
-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);
+ }}
+ >
+
+
+ )}
-
-
-
);
}
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: '기타' },
+};