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 (
- - - + diff --git a/src/pages/Home/components/HomeHeader.tsx b/src/pages/Home/components/HomeHeader.tsx index 3c1db95..a1ea28f 100644 --- a/src/pages/Home/components/HomeHeader.tsx +++ b/src/pages/Home/components/HomeHeader.tsx @@ -1,14 +1,13 @@ import { Link } from 'react-router'; -import { AlarmIcon, PersonIcon } from '@/assets/icons'; +import { PersonIcon } from '@/assets/icons'; +import NotificationButton from '@/components/NotificationButton'; const HomeHeader = () => { return (
- - - + diff --git a/src/pages/Notifications/components/WarningModal.tsx b/src/pages/Notifications/components/WarningModal.tsx index 4470eb3..3b6ea46 100644 --- a/src/pages/Notifications/components/WarningModal.tsx +++ b/src/pages/Notifications/components/WarningModal.tsx @@ -34,13 +34,7 @@ const WarningModal = ({ isOpen, reportContent, onClose }: WarningModalProps) =>

{`${divideContents[1]} 회`}

경고 규칙

-

- 1회 경고: 주의 안내 -
- 2회 경고: 7일 동안 서비스 이용 제한 -
- 3회 경고: 서비스 이용 불가능 -

+

3회 경고: 서비스 이용 불가능

diff --git a/src/pages/Notifications/index.tsx b/src/pages/Notifications/index.tsx index a3c0cdc..257453e 100644 --- a/src/pages/Notifications/index.tsx +++ b/src/pages/Notifications/index.tsx @@ -7,10 +7,14 @@ import PageTitle from '@/components/PageTitle'; import NotificationItem from './components/NotificationItem'; import WarningModal from './components/WarningModal'; import SendingModal from './components/SendingModal'; +import useNotificationStore from '@/stores/notificationStore'; const NotificationsPage = () => { const navigate = useNavigate(); + const decrementNotReadCount = useNotificationStore((state) => state.decrementNotReadCount); + const setNotReadCount = useNotificationStore((state) => state.setNotReadCount); + const [noti, setNoti] = useState([]); const [isOpenWarningModal, setIsOpenWarningModal] = useState(false); @@ -51,7 +55,8 @@ const NotificationsPage = () => { if (res?.status === 200) { setNoti((curNoti) => curNoti.map((noti) => { - if (noti.timelineId === timelineId) { + if (noti.timelineId === timelineId && !noti.read) { + decrementNotReadCount(); return { ...noti, read: true }; } return noti; @@ -73,6 +78,7 @@ const NotificationsPage = () => { return noti; }); }); + setNotReadCount(0); } else { console.log('모두 읽음처리 에러 발생'); } diff --git a/src/stores/notificationStore.ts b/src/stores/notificationStore.ts new file mode 100644 index 0000000..0a789a5 --- /dev/null +++ b/src/stores/notificationStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +interface NotificationStore { + notReadCount: number; + incrementNotReadCount: () => void; + decrementNotReadCount: () => void; + setNotReadCount: (updateCount: number) => void; +} +const useNotificationStore = create((set) => ({ + notReadCount: 0, + incrementNotReadCount: () => + set((state) => ({ + notReadCount: state.notReadCount + 1, + })), + decrementNotReadCount: () => + set((state) => ({ + notReadCount: state.notReadCount - 1, + })), + setNotReadCount: (updateCount) => + set(() => ({ + notReadCount: updateCount, + })), +})); + +export default useNotificationStore; diff --git a/src/types/notifications.d.ts b/src/types/notifications.d.ts index 672864a..6c131a2 100644 --- a/src/types/notifications.d.ts +++ b/src/types/notifications.d.ts @@ -1,6 +1,8 @@ +type AlarmType = 'SENDING' | 'LETTER' | 'REPORT' | 'SHARE' | 'POSTED'; + interface Noti { timelineId: number; - alarmType: string; + alarmType: AlarmType; content: string | number; title: string; read: boolean;