Skip to content

Commit 40c2021

Browse files
authored
feat : 알림 기능 구현 + QA (#114)
* feat : 안읽은 알림 count갯수 ui 밑작업(전역변수 만듬) * feat : 알림 안읽은 메시지 UI처리 + SSE로직 1차 수정 * feat : 알림 토스트UI에 담기는 제목 바인딩 * feat : 알림 받을시의 코드 오류 수정 * feat : SSE 에러 5번 발생시 재귀 중지 코드 추가 * feat : 실시간 알림, UI 구현 완료 + 신고 모달 토스트UI 적용 * chore : 편지 받아올떄마다 console 출력하는 코드 주석처리
1 parent 2fb1cc6 commit 40c2021

File tree

12 files changed

+154
-35
lines changed

12 files changed

+154
-35
lines changed

src/apis/admin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const postReports = async (postReportRequest: PostReportRequest) => {
77
return res;
88
} catch (error) {
99
console.error(error);
10+
return null;
1011
}
1112
};
1213

src/apis/notification.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,15 @@ const patchReadNotificationAll = async () => {
3131
}
3232
};
3333

34-
export { getTimeLines, patchReadNotification, patchReadNotificationAll };
34+
const getNotReadCount = async () => {
35+
try {
36+
const res = await client.get('/api/notifications/not-read');
37+
if (!res) throw new Error('안 읽은 알림 수를 가져오는 도중 오류가 발생했습니다.');
38+
console.log(res);
39+
return res;
40+
} catch (error) {
41+
console.error(error);
42+
}
43+
};
44+
45+
export { getTimeLines, patchReadNotification, patchReadNotificationAll, getNotReadCount };
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getNotReadCount } from '@/apis/notification';
2+
import { AlarmIcon } from '@/assets/icons';
3+
import useNotificationStore from '@/stores/notificationStore';
4+
import { useEffect } from 'react';
5+
import { Link } from 'react-router';
6+
import { twMerge } from 'tailwind-merge';
7+
8+
export default function NotificationButton() {
9+
const notReadCount = useNotificationStore((state) => state.notReadCount);
10+
const setNotReadCount = useNotificationStore((state) => state.setNotReadCount);
11+
const notReadStyle = twMerge(
12+
`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`,
13+
notReadCount >= 100 && 'w-4 h-4',
14+
);
15+
16+
const handleGetNotReadCount = async () => {
17+
const res = await getNotReadCount();
18+
if (res?.status === 200) {
19+
const updateNotReadCount: number = res.data.data.notReadCount;
20+
setNotReadCount(updateNotReadCount);
21+
}
22+
};
23+
24+
useEffect(() => {
25+
handleGetNotReadCount();
26+
});
27+
28+
return (
29+
<Link to="/mypage/notifications" className="relative">
30+
{notReadCount > 0 && (
31+
<div className={notReadStyle}>{notReadCount < 100 ? notReadCount : '99+'}</div>
32+
)}
33+
<AlarmIcon className="h-6 w-6 text-white" />
34+
</Link>
35+
);
36+
}

src/components/ReportModal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { postReports } from '@/apis/admin';
55

66
import ConfirmModal from './ConfirmModal';
77
import TextareaField from './TextareaField';
8+
import useToastStore from '@/stores/toastStore';
89

910
interface ReportModalProps {
1011
reportType: ReportType;
@@ -40,14 +41,16 @@ const ReportModal = ({ reportType, letterId, onClose }: ReportModalProps) => {
4041
else setPostReportRequest((cur) => ({ ...cur, reasonType: reason }));
4142
};
4243

44+
const setToastActive = useToastStore((state) => state.setToastActive);
45+
4346
const handleSubmit = async () => {
4447
const res = await postReports(postReportRequest);
4548
if (res?.status === 200) {
46-
alert('신고 처리되었습니다.');
49+
setToastActive({ title: '신고가 접수되었습니다.', toastType: 'Success' });
4750
console.log(res);
4851
onClose();
49-
} else if (res?.status === 409) {
50-
alert('신고한 이력이 있습니다.');
52+
} else {
53+
setToastActive({ title: '신고한 이력이 있습니다.', toastType: 'Error' });
5154
onClose();
5255
}
5356
};

src/components/ToastItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export default function ToastItem({ toastObj, index }: { toastObj: ToastObj; ind
2626

2727
const animation = `toast-blink ${toastObj.time}s ease-in-out forwards`;
2828
const toastStyle = twMerge(
29-
'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)]',
29+
'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)]',
3030
TOAST_POSITION[toastObj.position],
3131
TOAST_DESIGN[toastObj.toastType].style,
3232
);

src/hooks/useServerSentEvents.tsx

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,55 @@ import { useEffect, useRef } from 'react';
44
import useAuthStore from '@/stores/authStore';
55
import useToastStore from '@/stores/toastStore';
66
import { useNavigate } from 'react-router';
7+
import useNotificationStore from '@/stores/notificationStore';
8+
import { getNewToken } from '@/apis/auth';
9+
10+
interface MessageEventData {
11+
title: string;
12+
alarmType: AlarmType;
13+
}
714

815
export const useServerSentEvents = () => {
16+
let reconnect: number | undefined;
17+
918
const navigate = useNavigate();
19+
const recallCountRef = useRef(1);
1020

1121
const accessToken = useAuthStore((state) => state.accessToken);
22+
const setAccessToken = useAuthStore((state) => state.setAccessToken);
1223
const sourceRef = useRef<EventSourcePolyfill | null>(null);
1324

1425
const setToastActive = useToastStore((state) => state.setToastActive);
1526

27+
const incrementNotReadCount = useNotificationStore((state) => state.incrementNotReadCount);
28+
29+
const ALARM_TYPE: AlarmType[] = ['SENDING', 'LETTER', 'REPORT', 'SHARE', 'POSTED'];
30+
const handleOnMessage = async (data: string) => {
31+
const message: MessageEventData = await JSON.parse(data);
32+
if (ALARM_TYPE.includes(message.alarmType)) {
33+
incrementNotReadCount();
34+
setToastActive({
35+
toastType: 'Info',
36+
title: message.title,
37+
position: 'Top',
38+
time: 5,
39+
onClick: () => navigate('/mypage/notifications'),
40+
});
41+
}
42+
};
43+
44+
// 토큰 재발급 함수
45+
const callReissue = async () => {
46+
try {
47+
const response = await getNewToken();
48+
if (response?.status !== 200) throw new Error('error while fetching newToken');
49+
const newToken = response?.data.data.accessToken;
50+
return setAccessToken(newToken);
51+
} catch (e) {
52+
return Promise.reject(e);
53+
}
54+
};
55+
1656
useEffect(() => {
1757
if (!accessToken) {
1858
console.log('로그인 정보 확인불가');
@@ -32,23 +72,24 @@ export const useServerSentEvents = () => {
3272
);
3373

3474
sourceRef.current.onmessage = (event) => {
35-
console.log(event);
36-
console.log('알림 수신');
37-
setToastActive({
38-
toastType: 'Info',
39-
title: '새 알림이 도착했어요!',
40-
position: 'Top',
41-
time: 5,
42-
onClick: () => navigate('/mypage/notifications'),
43-
});
75+
// console.log(event);
76+
// console.log('알림 수신');
77+
handleOnMessage(event.data);
4478
};
4579

46-
sourceRef.current.onerror = (error) => {
47-
console.log(error);
48-
console.log('에러 발생함');
80+
sourceRef.current.onerror = () => {
81+
// 에러 발생시 해당 에러가 45초를 넘어서 발생한 에러인지, 401에러인지 판단할 수 있는게 없어서 그냥 에러 발생하면 reissue 넣는걸로 때움
82+
callReissue();
4983
closeSSE();
84+
recallCountRef.current += 1;
85+
console.log('SSE연결 에러 발생');
86+
5087
// 재연결 로직 추가 가능
51-
setTimeout(connectSSE, 5000); // 5초 후 재연결 시도
88+
if (recallCountRef.current < 5) {
89+
reconnect = setTimeout(connectSSE, 5000);
90+
} else {
91+
console.log('5회 이상 에러발생으로 구독기능 제거');
92+
}
5293
};
5394
} catch (error) {
5495
console.error(error);
@@ -64,6 +105,7 @@ export const useServerSentEvents = () => {
64105
}, [accessToken]);
65106

66107
const closeSSE = () => {
108+
if (reconnect) clearTimeout(reconnect);
67109
sourceRef.current?.close();
68110
sourceRef.current = null;
69111
};

src/layouts/Header.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,18 @@
11
import { Link, useNavigate } from 'react-router';
22

3-
import { AlarmIcon, ArrowLeftIcon, PersonIcon } from '@/assets/icons';
3+
import { ArrowLeftIcon, PersonIcon } from '@/assets/icons';
4+
import NotificationButton from '@/components/NotificationButton';
45

56
const Header = () => {
67
const navigate = useNavigate();
8+
79
return (
810
<header className="fixed top-0 z-40 flex h-16 w-full max-w-150 items-center justify-between p-5">
911
<button onClick={() => navigate(-1)}>
1012
<ArrowLeftIcon className="h-6 w-6 text-white" />
1113
</button>
1214
<div className="flex items-center gap-3">
13-
<Link to="/mypage/notifications">
14-
<AlarmIcon className="h-6 w-6 text-white" />
15-
</Link>
15+
<NotificationButton />
1616
<Link to="/mypage">
1717
<PersonIcon className="h-6 w-6 text-white" />
1818
</Link>

src/pages/Home/components/HomeHeader.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
import { Link } from 'react-router';
22

3-
import { AlarmIcon, PersonIcon } from '@/assets/icons';
3+
import { PersonIcon } from '@/assets/icons';
4+
import NotificationButton from '@/components/NotificationButton';
45

56
const HomeHeader = () => {
67
return (
78
<header className="fixed top-0 z-40 flex h-16 w-full max-w-150 items-center justify-end p-5">
89
<div className="flex items-center gap-3">
9-
<Link to="/mypage/notifications">
10-
<AlarmIcon className="h-6 w-6 text-white" />
11-
</Link>
10+
<NotificationButton />
1211
<Link to="/mypage">
1312
<PersonIcon className="h-6 w-6 text-white" />
1413
</Link>

src/pages/Notifications/components/WarningModal.tsx

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,7 @@ const WarningModal = ({ isOpen, reportContent, onClose }: WarningModalProps) =>
3434
<p className="caption-r mb-5 text-black">{`${divideContents[1]} 회`}</p>
3535

3636
<h2 className="body-sb mb-1.5 text-gray-100">경고 규칙</h2>
37-
<p className="caption-r text-black">
38-
1회 경고: 주의 안내
39-
<br />
40-
2회 경고: 7일 동안 서비스 이용 제한
41-
<br />
42-
3회 경고: 서비스 이용 불가능
43-
</p>
37+
<p className="caption-r text-black">3회 경고: 서비스 이용 불가능</p>
4438
</div>
4539
</article>
4640
</ModalOverlay>

src/pages/Notifications/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,14 @@ import PageTitle from '@/components/PageTitle';
77
import NotificationItem from './components/NotificationItem';
88
import WarningModal from './components/WarningModal';
99
import SendingModal from './components/SendingModal';
10+
import useNotificationStore from '@/stores/notificationStore';
1011

1112
const NotificationsPage = () => {
1213
const navigate = useNavigate();
1314

15+
const decrementNotReadCount = useNotificationStore((state) => state.decrementNotReadCount);
16+
const setNotReadCount = useNotificationStore((state) => state.setNotReadCount);
17+
1418
const [noti, setNoti] = useState<Noti[]>([]);
1519

1620
const [isOpenWarningModal, setIsOpenWarningModal] = useState(false);
@@ -51,7 +55,8 @@ const NotificationsPage = () => {
5155
if (res?.status === 200) {
5256
setNoti((curNoti) =>
5357
curNoti.map((noti) => {
54-
if (noti.timelineId === timelineId) {
58+
if (noti.timelineId === timelineId && !noti.read) {
59+
decrementNotReadCount();
5560
return { ...noti, read: true };
5661
}
5762
return noti;
@@ -73,6 +78,7 @@ const NotificationsPage = () => {
7378
return noti;
7479
});
7580
});
81+
setNotReadCount(0);
7682
} else {
7783
console.log('모두 읽음처리 에러 발생');
7884
}

0 commit comments

Comments
 (0)