diff --git a/src/apis/admin.ts b/src/apis/admin.ts
index 5eb1580..f768098 100644
--- a/src/apis/admin.ts
+++ b/src/apis/admin.ts
@@ -7,6 +7,7 @@ const postReports = async (postReportRequest: PostReportRequest) => {
return res;
} catch (error) {
console.error(error);
+ return null;
}
};
diff --git a/src/apis/notification.ts b/src/apis/notification.ts
index 869df0a..3453135 100644
--- a/src/apis/notification.ts
+++ b/src/apis/notification.ts
@@ -31,4 +31,15 @@ const patchReadNotificationAll = async () => {
}
};
-export { getTimeLines, patchReadNotification, patchReadNotificationAll };
+const getNotReadCount = async () => {
+ try {
+ const res = await client.get('/api/notifications/not-read');
+ if (!res) throw new Error('안 읽은 알림 수를 가져오는 도중 오류가 발생했습니다.');
+ console.log(res);
+ return res;
+ } catch (error) {
+ console.error(error);
+ }
+};
+
+export { getTimeLines, patchReadNotification, patchReadNotificationAll, getNotReadCount };
diff --git a/src/components/NotificationButton.tsx b/src/components/NotificationButton.tsx
new file mode 100644
index 0000000..0047067
--- /dev/null
+++ b/src/components/NotificationButton.tsx
@@ -0,0 +1,36 @@
+import { getNotReadCount } from '@/apis/notification';
+import { AlarmIcon } from '@/assets/icons';
+import useNotificationStore from '@/stores/notificationStore';
+import { useEffect } from 'react';
+import { Link } from 'react-router';
+import { twMerge } from 'tailwind-merge';
+
+export default function NotificationButton() {
+ const notReadCount = useNotificationStore((state) => state.notReadCount);
+ const setNotReadCount = useNotificationStore((state) => state.setNotReadCount);
+ const notReadStyle = twMerge(
+ `absolute -right-1 -bottom-1 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-red-400 text-[8px] text-white`,
+ notReadCount >= 100 && 'w-4 h-4',
+ );
+
+ const handleGetNotReadCount = async () => {
+ const res = await getNotReadCount();
+ if (res?.status === 200) {
+ const updateNotReadCount: number = res.data.data.notReadCount;
+ setNotReadCount(updateNotReadCount);
+ }
+ };
+
+ useEffect(() => {
+ handleGetNotReadCount();
+ });
+
+ return (
+
+ {notReadCount > 0 && (
+
{notReadCount < 100 ? notReadCount : '99+'}
+ )}
+
+
+ );
+}
diff --git a/src/components/ReportModal.tsx b/src/components/ReportModal.tsx
index b4dd2c8..ce84c91 100644
--- a/src/components/ReportModal.tsx
+++ b/src/components/ReportModal.tsx
@@ -5,6 +5,7 @@ import { postReports } from '@/apis/admin';
import ConfirmModal from './ConfirmModal';
import TextareaField from './TextareaField';
+import useToastStore from '@/stores/toastStore';
interface ReportModalProps {
reportType: ReportType;
@@ -40,14 +41,16 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => {
else setPostReportRequest((cur) => ({ ...cur, reasonType: reason }));
};
+ const setToastActive = useToastStore((state) => state.setToastActive);
+
const handleSubmit = async () => {
const res = await postReports(postReportRequest);
if (res?.status === 200) {
- alert('신고 처리되었습니다.');
+ setToastActive({ title: '신고가 접수되었습니다.', toastType: 'Success' });
console.log(res);
onClose();
- } else if (res?.status === 409) {
- alert('신고한 이력이 있습니다.');
+ } else {
+ setToastActive({ title: '신고한 이력이 있습니다.', toastType: 'Error' });
onClose();
}
};
diff --git a/src/components/ToastItem.tsx b/src/components/ToastItem.tsx
index 2465e5b..75a459d 100644
--- a/src/components/ToastItem.tsx
+++ b/src/components/ToastItem.tsx
@@ -26,7 +26,7 @@ export default function ToastItem({ toastObj, index }: { toastObj: ToastObj; ind
const animation = `toast-blink ${toastObj.time}s ease-in-out forwards`;
const toastStyle = twMerge(
- 'fixed bottom-5 left-1/2 z-40 flex h-[40px] max-w-150 min-w-[300px] w-[100%] -translate-1/2 items-center justify-center rounded-lg caption-sb shadow-[0_1px_6px_rgba(200,200,200,0.2)]',
+ 'fixed bottom-5 left-1/2 z-40 flex h-[40px] max-w-150 min-w-[300px] w-[80%] -translate-1/2 items-center justify-center rounded-lg caption-sb shadow-[0_1px_6px_rgba(200,200,200,0.2)]',
TOAST_POSITION[toastObj.position],
TOAST_DESIGN[toastObj.toastType].style,
);
diff --git a/src/hooks/useServerSentEvents.tsx b/src/hooks/useServerSentEvents.tsx
index 1ad9850..a0b406b 100644
--- a/src/hooks/useServerSentEvents.tsx
+++ b/src/hooks/useServerSentEvents.tsx
@@ -4,15 +4,55 @@ import { useEffect, useRef } from 'react';
import useAuthStore from '@/stores/authStore';
import useToastStore from '@/stores/toastStore';
import { useNavigate } from 'react-router';
+import useNotificationStore from '@/stores/notificationStore';
+import { getNewToken } from '@/apis/auth';
+
+interface MessageEventData {
+ title: string;
+ alarmType: AlarmType;
+}
export const useServerSentEvents = () => {
+ let reconnect: number | undefined;
+
const navigate = useNavigate();
+ const recallCountRef = useRef(1);
const accessToken = useAuthStore((state) => state.accessToken);
+ const setAccessToken = useAuthStore((state) => state.setAccessToken);
const sourceRef = useRef(null);
const setToastActive = useToastStore((state) => state.setToastActive);
+ const incrementNotReadCount = useNotificationStore((state) => state.incrementNotReadCount);
+
+ const ALARM_TYPE: AlarmType[] = ['SENDING', 'LETTER', 'REPORT', 'SHARE', 'POSTED'];
+ const handleOnMessage = async (data: string) => {
+ const message: MessageEventData = await JSON.parse(data);
+ if (ALARM_TYPE.includes(message.alarmType)) {
+ incrementNotReadCount();
+ setToastActive({
+ toastType: 'Info',
+ title: message.title,
+ position: 'Top',
+ time: 5,
+ onClick: () => navigate('/mypage/notifications'),
+ });
+ }
+ };
+
+ // 토큰 재발급 함수
+ const callReissue = async () => {
+ try {
+ const response = await getNewToken();
+ if (response?.status !== 200) throw new Error('error while fetching newToken');
+ const newToken = response?.data.data.accessToken;
+ return setAccessToken(newToken);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ };
+
useEffect(() => {
if (!accessToken) {
console.log('로그인 정보 확인불가');
@@ -32,23 +72,24 @@ export const useServerSentEvents = () => {
);
sourceRef.current.onmessage = (event) => {
- console.log(event);
- console.log('알림 수신');
- setToastActive({
- toastType: 'Info',
- title: '새 알림이 도착했어요!',
- position: 'Top',
- time: 5,
- onClick: () => navigate('/mypage/notifications'),
- });
+ // console.log(event);
+ // console.log('알림 수신');
+ handleOnMessage(event.data);
};
- sourceRef.current.onerror = (error) => {
- console.log(error);
- console.log('에러 발생함');
+ sourceRef.current.onerror = () => {
+ // 에러 발생시 해당 에러가 45초를 넘어서 발생한 에러인지, 401에러인지 판단할 수 있는게 없어서 그냥 에러 발생하면 reissue 넣는걸로 때움
+ callReissue();
closeSSE();
+ recallCountRef.current += 1;
+ console.log('SSE연결 에러 발생');
+
// 재연결 로직 추가 가능
- setTimeout(connectSSE, 5000); // 5초 후 재연결 시도
+ if (recallCountRef.current < 5) {
+ reconnect = setTimeout(connectSSE, 5000);
+ } else {
+ console.log('5회 이상 에러발생으로 구독기능 제거');
+ }
};
} catch (error) {
console.error(error);
@@ -64,6 +105,7 @@ export const useServerSentEvents = () => {
}, [accessToken]);
const closeSSE = () => {
+ if (reconnect) clearTimeout(reconnect);
sourceRef.current?.close();
sourceRef.current = null;
};
diff --git a/src/layouts/Header.tsx b/src/layouts/Header.tsx
index 8efa549..ae58d9c 100644
--- a/src/layouts/Header.tsx
+++ b/src/layouts/Header.tsx
@@ -1,20 +1,20 @@
import { Link, useNavigate } from 'react-router';
-import { AlarmIcon, ArrowLeftIcon, PersonIcon } from '@/assets/icons';
+import { ArrowLeftIcon, PersonIcon } from '@/assets/icons';
+import NotificationButton from '@/components/NotificationButton';
const Header = () => {
// TODO: 뒤로 가기 버튼이 보이는 조건 추가
// TODO: 스크롤 발생 시, 어떻게 보여져야 하는지
const navigate = useNavigate();
+
return (