Skip to content

Commit aeab0e6

Browse files
authored
feat : 알림 2차 기능 구현 (#81)
* feat:알림 컨텐츠 상호작용, 라우팅 기능 구현 * feat : SSE구현시도 * feat : SSE전역변수로 관리할지 store 하나 만들어서 만들면서 고민중 * feat : 알림구독 테스트용 App.tsx에 훅 호출한 코드
1 parent 70051d3 commit aeab0e6

File tree

11 files changed

+266
-36
lines changed

11 files changed

+266
-36
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@tailwindcss/vite": "^4.0.6",
1919
"@tanstack/react-query": "^5.66.0",
2020
"axios": "^1.7.9",
21+
"event-source-polyfill": "^1.0.31",
2122
"gsap": "^3.12.7",
2223
"react": "^18.3.1",
2324
"react-dom": "^18.3.1",
@@ -31,6 +32,7 @@
3132
"devDependencies": {
3233
"@eslint/js": "^9.19.0",
3334
"@tanstack/eslint-plugin-query": "^5.66.1",
35+
"@types/event-source-polyfill": "^1.0.5",
3436
"@types/react": "^19.0.8",
3537
"@types/react-dom": "^19.0.3",
3638
"@vitejs/plugin-react-swc": "^3.5.0",

pnpm-lock.yaml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Route, Routes } from 'react-router';
22

3+
import { useServerSentEvents } from './hooks/useServerSentEvents';
34
import useViewport from './hooks/useViewport';
45
import Layout from './layouts/Layout';
56
import MobileLayout from './layouts/MobileLayout';
@@ -29,6 +30,7 @@ import WritePage from './pages/Write';
2930

3031
const App = () => {
3132
useViewport();
33+
useServerSentEvents();
3234

3335
return (
3436
<Routes>

src/apis/notification.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import client from './client';
2+
3+
const getTimeLines = async () => {
4+
try {
5+
const res = await client.get('/api/timelines');
6+
if (!res) throw new Error('타임라인을 받아오는 도중 오류가 발생했습니다.');
7+
console.log(res);
8+
return res;
9+
} catch (error) {
10+
console.error(error);
11+
}
12+
};
13+
14+
const patchReadNotification = async (timelineId: number) => {
15+
try {
16+
const res = await client.patch(`/api/notifications/${timelineId}/read`);
17+
if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.');
18+
return res;
19+
} catch (error) {
20+
console.error(error);
21+
}
22+
};
23+
24+
const patchReadNotificationAll = async () => {
25+
try {
26+
const res = await client.patch(`/api/notifications/read`);
27+
if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.');
28+
return res;
29+
} catch (error) {
30+
console.error(error);
31+
}
32+
};
33+
34+
export { getTimeLines, patchReadNotification, patchReadNotificationAll };

src/hooks/useServerSentEvents.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { EventSourcePolyfill } from 'event-source-polyfill';
2+
import { useEffect, useRef } from 'react';
3+
4+
import useAuthStore from '@/stores/authStore';
5+
6+
export const useServerSentEvents = () => {
7+
const accessToken = useAuthStore.getState().accessToken;
8+
const sourceRef = useRef<EventSourcePolyfill | null>(null);
9+
10+
useEffect(() => {
11+
if (!accessToken) {
12+
console.log('로그인 정보 확인불가');
13+
return;
14+
}
15+
16+
const connectSSE = () => {
17+
try {
18+
console.log('구독 시작');
19+
sourceRef.current = new EventSourcePolyfill(
20+
`${import.meta.env.VITE_API_URL}/api/notifications/sub`,
21+
{
22+
headers: {
23+
Authorization: `Bearer ${accessToken}`,
24+
},
25+
},
26+
);
27+
28+
sourceRef.current.onmessage = (event) => {
29+
console.log(event);
30+
console.log('알림 전송');
31+
};
32+
33+
sourceRef.current.addEventListener('notification', (event) => {
34+
console.log(event);
35+
console.log('알림 전송 dd');
36+
});
37+
38+
sourceRef.current.onerror = (error) => {
39+
console.log(error);
40+
console.log('에러 발생함');
41+
sourceRef.current?.close();
42+
// 재연결 로직 추가 가능
43+
setTimeout(connectSSE, 5000); // 5초 후 재연결 시도
44+
};
45+
} catch (error) {
46+
console.error(error);
47+
}
48+
};
49+
50+
connectSSE();
51+
52+
return () => {
53+
console.log('컴포넌트 언마운트로 인한 구독해제');
54+
closeSSE();
55+
};
56+
}, [accessToken]);
57+
58+
// 바깥으로 보낼 closeSSE 함수
59+
const closeSSE = () => {
60+
sourceRef.current?.close();
61+
sourceRef.current = null;
62+
};
63+
64+
return { closeSSE };
65+
};

src/pages/Notifications/components/NotificationItem.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ import { NOTIFICATION_ICON } from '../constants';
44

55
interface NotificationItemProps {
66
type: string;
7-
message: string;
8-
isRead: boolean;
7+
title: string;
8+
read: boolean;
99
onClick: () => void;
1010
}
1111

12-
const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemProps) => {
12+
const NotificationItem = ({ type, title, read, onClick }: NotificationItemProps) => {
1313
const Icon = NOTIFICATION_ICON[type];
1414

1515
const handleClick = (e: React.MouseEvent<HTMLElement>) => {
@@ -18,11 +18,11 @@ const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemPr
1818
};
1919

2020
return (
21-
<LetterWrapper isSender={type === 'warning'} onClick={handleClick}>
21+
<LetterWrapper isSender={type === 'REPORT'} onClick={handleClick}>
2222
<div className="flex items-center gap-3">
23-
{isRead && <div className="absolute inset-0 z-10 bg-white/60" />}
23+
{read && <div className="absolute inset-0 z-10 bg-white/60" />}
2424
<Icon className="z-0 h-6 w-6 text-white" />
25-
<p className="body-m text-gray-80 z-0">{message}</p>
25+
<p className="body-m text-gray-80 z-0">{title}</p>
2626
</div>
2727
</LetterWrapper>
2828
);

src/pages/Notifications/components/WarningModal.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import ModalOverlay from '@/components/ModalOverlay';
44

55
interface WarningModalProps {
66
isOpen: boolean;
7+
adminText: string;
78
onClose: () => void;
89
}
910

10-
const WarningModal = ({ isOpen, onClose }: WarningModalProps) => {
11+
const WarningModal = ({ isOpen, adminText, onClose }: WarningModalProps) => {
1112
if (!isOpen) return null;
1213

1314
return (
@@ -20,11 +21,15 @@ const WarningModal = ({ isOpen, onClose }: WarningModalProps) => {
2021
>
2122
<div className="absolute inset-0 h-full w-full bg-white/90 blur-[25px]" />
2223
<div className="relative">
24+
<h2 className="body-sb mb-1.5 text-gray-100">관리자 코멘트</h2>
25+
<p className="caption-r mb-5 text-black">{adminText}</p>
26+
2327
<h2 className="body-sb mb-1.5 text-gray-100">경고 안내</h2>
2428
<p className="caption-r mb-5 text-black">
2529
따사로운 서비스 이용을 위해, 부적절하다고 판단되는 편지는 반려하고 있어요. 서로를
2630
존중하는 따뜻한 공간을 만들기 위해 협조 부탁드립니다.
2731
</p>
32+
2833
<h2 className="body-sb mb-1.5 text-gray-100">경고 규칙</h2>
2934
<p className="caption-r text-black">
3035
1회 경고: 주의 안내

src/pages/Notifications/constants/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ export const NOTIFICATION_ICON: Record<
44
string,
55
React.ComponentType<React.SVGProps<SVGSVGElement>>
66
> = {
7-
letter: EnvelopeIcon,
8-
warning: SirenFilledIcon,
9-
board: BoardIcon,
7+
LETTER: EnvelopeIcon,
8+
REPORT: SirenFilledIcon,
9+
SHARE: BoardIcon,
10+
POSTED: BoardIcon,
1011
};

src/pages/Notifications/index.tsx

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,93 @@
1-
import { useState } from 'react';
1+
import { useEffect, useState } from 'react';
2+
import { useNavigate } from 'react-router';
23

4+
import { getTimeLines, patchReadNotification, patchReadNotificationAll } from '@/apis/notification';
35
import PageTitle from '@/components/PageTitle';
46

57
import NotificationItem from './components/NotificationItem';
68
import WarningModal from './components/WarningModal';
79

8-
const DUMMY_NOTI = [
9-
{ id: 1, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false },
10-
{ id: 2, type: 'warning', message: '따숨님, 욕설로 인해 경고를 받으셨어요.', isRead: false },
11-
{ id: 3, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false },
12-
{ id: 4, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: true },
13-
{ id: 5, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false },
14-
{ id: 6, type: 'board', message: '12E31님과의 대화가 게시판에 공유되었어요.', isRead: false },
15-
{
16-
id: 7,
17-
type: 'board',
18-
message: '12E31님과의 게시글에 대한 공유요청을 보냈어요.',
19-
isRead: false,
20-
},
21-
];
22-
2310
const NotificationsPage = () => {
11+
const navigate = useNavigate();
12+
13+
const [noti, setNoti] = useState<Noti[]>([]);
14+
2415
const [isOpenWarningModal, setIsOpenWarningModal] = useState(false);
2516

26-
const handleClickItem = (type: string) => {
27-
if (type === 'warning') {
17+
const [adminText, setAdmintext] = useState<string>('');
18+
19+
// MEMO : 편지 데이터 전송중 데이터도 추가될건데 나중에 데이터 추가되면 코드 업데이트 하긔
20+
const handleClickItem = (alarmType: string, content?: string | number) => {
21+
if (alarmType === 'LETTER') {
22+
navigate(`/letter/${content}`);
23+
}
24+
if (alarmType === 'REPORT') {
2825
setIsOpenWarningModal(true);
26+
if (typeof content === 'string') setAdmintext(content);
27+
}
28+
if (alarmType === 'SHARE') {
29+
navigate(`/board/letter/${content}`, { state: { isShareLetterPreview: true } });
30+
}
31+
if (alarmType === 'POSTED') {
32+
navigate(`/board/letter/${content}`);
2933
}
3034
};
3135

36+
const handleGetTimeLines = async () => {
37+
const res = await getTimeLines();
38+
if (res?.status === 200) {
39+
console.log(res);
40+
setNoti(res.data.data.content);
41+
}
42+
};
43+
44+
const handlePatchReadNotification = async (timelineId: number) => {
45+
const res = await patchReadNotification(timelineId);
46+
if (res?.status !== 200) {
47+
console.log('읽음처리 에러 발생');
48+
}
49+
};
50+
51+
const handlePatchReadNotificationAll = async () => {
52+
const res = await patchReadNotificationAll();
53+
if (res?.status !== 200) {
54+
console.log('모두 읽음처리 에러 발생');
55+
}
56+
};
57+
58+
useEffect(() => {
59+
handleGetTimeLines();
60+
}, []);
61+
3262
return (
3363
<>
34-
<WarningModal isOpen={isOpenWarningModal} onClose={() => setIsOpenWarningModal(false)} />
64+
<WarningModal
65+
isOpen={isOpenWarningModal}
66+
adminText={adminText}
67+
onClose={() => setIsOpenWarningModal(false)}
68+
/>
3569
<main className="flex grow flex-col items-center px-5 pt-20 pb-9">
3670
<PageTitle className="mb-10">알림</PageTitle>
37-
<button type="button" className="body-sb text-gray-60 place-self-end">
71+
<button
72+
type="button"
73+
className="body-sb text-gray-60 place-self-end"
74+
onClick={() => {
75+
handlePatchReadNotificationAll();
76+
}}
77+
>
3878
모두 읽음
3979
</button>
4080
<ul className="mt-2 flex h-full w-full flex-col gap-2 pb-10">
41-
{DUMMY_NOTI.map((notification) => (
42-
<li key={notification.id}>
81+
{noti.map((notification) => (
82+
<li key={notification.timelineId}>
4383
<NotificationItem
44-
type={notification.type}
45-
message={notification.message}
46-
isRead={notification.isRead}
47-
onClick={() => handleClickItem(notification.type)}
84+
type={notification.alarmType}
85+
title={notification.title}
86+
read={notification.read}
87+
onClick={() => {
88+
handleClickItem(notification.alarmType, notification.content);
89+
handlePatchReadNotification(notification.timelineId);
90+
}}
4891
/>
4992
</li>
5093
))}

0 commit comments

Comments
 (0)