diff --git a/package.json b/package.json index 9514795..b25cece 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@tailwindcss/vite": "^4.0.6", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", + "event-source-polyfill": "^1.0.31", "gsap": "^3.12.7", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -31,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@tanstack/eslint-plugin-query": "^5.66.1", + "@types/event-source-polyfill": "^1.0.5", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react-swc": "^3.5.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dac4254..b22f4db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: axios: specifier: ^1.7.9 version: 1.7.9 + event-source-polyfill: + specifier: ^1.0.31 + version: 1.0.31 gsap: specifier: ^3.12.7 version: 3.12.7 @@ -66,6 +69,9 @@ importers: '@tanstack/eslint-plugin-query': specifier: ^5.66.1 version: 5.66.1(eslint@9.20.1(jiti@2.4.2))(typescript@5.7.3) + '@types/event-source-polyfill': + specifier: ^1.0.5 + version: 1.0.5 '@types/react': specifier: ^19.0.8 version: 19.0.8 @@ -948,6 +954,9 @@ packages: '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + '@types/event-source-polyfill@1.0.5': + resolution: {integrity: sha512-iaiDuDI2aIFft7XkcwMzDWLqo7LVDixd2sR6B4wxJut9xcp/Ev9bO4EFg4rm6S9QxATLBj5OPxdeocgmhjwKaw==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1394,6 +1403,9 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + event-source-polyfill@1.0.31: + resolution: {integrity: sha512-4IJSItgS/41IxN5UVAVuAyczwZF7ZIEsM1XAoUzIHA6A+xzusEZUutdXz2Nr+MQPLxfTiCvqE79/C8HT8fKFvA==} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -3137,6 +3149,8 @@ snapshots: '@types/estree@1.0.6': {} + '@types/event-source-polyfill@1.0.5': {} + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -3741,6 +3755,8 @@ snapshots: esutils@2.0.3: {} + event-source-polyfill@1.0.31: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: diff --git a/src/App.tsx b/src/App.tsx index 29e7dfa..646ba43 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { Route, Routes } from 'react-router'; +import { useServerSentEvents } from './hooks/useServerSentEvents'; import useViewport from './hooks/useViewport'; import Layout from './layouts/Layout'; import MobileLayout from './layouts/MobileLayout'; @@ -28,6 +29,7 @@ import WritePage from './pages/Write'; const App = () => { useViewport(); + useServerSentEvents(); return ( diff --git a/src/apis/notification.ts b/src/apis/notification.ts new file mode 100644 index 0000000..869df0a --- /dev/null +++ b/src/apis/notification.ts @@ -0,0 +1,34 @@ +import client from './client'; + +const getTimeLines = async () => { + try { + const res = await client.get('/api/timelines'); + if (!res) throw new Error('타임라인을 받아오는 도중 오류가 발생했습니다.'); + console.log(res); + return res; + } catch (error) { + console.error(error); + } +}; + +const patchReadNotification = async (timelineId: number) => { + try { + const res = await client.patch(`/api/notifications/${timelineId}/read`); + if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +const patchReadNotificationAll = async () => { + try { + const res = await client.patch(`/api/notifications/read`); + if (!res) throw new Error('편지 개별 읽음 처리를 하는 도중 오류가 발생했습니다.'); + return res; + } catch (error) { + console.error(error); + } +}; + +export { getTimeLines, patchReadNotification, patchReadNotificationAll }; diff --git a/src/hooks/useServerSentEvents.tsx b/src/hooks/useServerSentEvents.tsx new file mode 100644 index 0000000..2db424f --- /dev/null +++ b/src/hooks/useServerSentEvents.tsx @@ -0,0 +1,65 @@ +import { EventSourcePolyfill } from 'event-source-polyfill'; +import { useEffect, useRef } from 'react'; + +import useAuthStore from '@/stores/authStore'; + +export const useServerSentEvents = () => { + const accessToken = useAuthStore.getState().accessToken; + const sourceRef = useRef(null); + + useEffect(() => { + if (!accessToken) { + console.log('로그인 정보 확인불가'); + return; + } + + const connectSSE = () => { + try { + console.log('구독 시작'); + sourceRef.current = new EventSourcePolyfill( + `${import.meta.env.VITE_API_URL}/api/notifications/sub`, + { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + sourceRef.current.onmessage = (event) => { + console.log(event); + console.log('알림 전송'); + }; + + sourceRef.current.addEventListener('notification', (event) => { + console.log(event); + console.log('알림 전송 dd'); + }); + + sourceRef.current.onerror = (error) => { + console.log(error); + console.log('에러 발생함'); + sourceRef.current?.close(); + // 재연결 로직 추가 가능 + setTimeout(connectSSE, 5000); // 5초 후 재연결 시도 + }; + } catch (error) { + console.error(error); + } + }; + + connectSSE(); + + return () => { + console.log('컴포넌트 언마운트로 인한 구독해제'); + closeSSE(); + }; + }, [accessToken]); + + // 바깥으로 보낼 closeSSE 함수 + const closeSSE = () => { + sourceRef.current?.close(); + sourceRef.current = null; + }; + + return { closeSSE }; +}; diff --git a/src/pages/Notifications/components/NotificationItem.tsx b/src/pages/Notifications/components/NotificationItem.tsx index ff9428e..52f0db5 100644 --- a/src/pages/Notifications/components/NotificationItem.tsx +++ b/src/pages/Notifications/components/NotificationItem.tsx @@ -4,12 +4,12 @@ import { NOTIFICATION_ICON } from '../constants'; interface NotificationItemProps { type: string; - message: string; - isRead: boolean; + title: string; + read: boolean; onClick: () => void; } -const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemProps) => { +const NotificationItem = ({ type, title, read, onClick }: NotificationItemProps) => { const Icon = NOTIFICATION_ICON[type]; const handleClick = (e: React.MouseEvent) => { @@ -18,11 +18,11 @@ const NotificationItem = ({ type, message, isRead, onClick }: NotificationItemPr }; return ( - +
- {isRead &&
} + {read &&
} -

{message}

+

{title}

); diff --git a/src/pages/Notifications/components/WarningModal.tsx b/src/pages/Notifications/components/WarningModal.tsx index 8e7e922..e76b17d 100644 --- a/src/pages/Notifications/components/WarningModal.tsx +++ b/src/pages/Notifications/components/WarningModal.tsx @@ -4,10 +4,11 @@ import ModalOverlay from '@/components/ModalOverlay'; interface WarningModalProps { isOpen: boolean; + adminText: string; onClose: () => void; } -const WarningModal = ({ isOpen, onClose }: WarningModalProps) => { +const WarningModal = ({ isOpen, adminText, onClose }: WarningModalProps) => { if (!isOpen) return null; return ( @@ -20,11 +21,15 @@ const WarningModal = ({ isOpen, onClose }: WarningModalProps) => { >
+

관리자 코멘트

+

{adminText}

+

경고 안내

따사로운 서비스 이용을 위해, 부적절하다고 판단되는 편지는 반려하고 있어요. 서로를 존중하는 따뜻한 공간을 만들기 위해 협조 부탁드립니다.

+

경고 규칙

1회 경고: 주의 안내 diff --git a/src/pages/Notifications/constants/index.ts b/src/pages/Notifications/constants/index.ts index 1134831..0b09832 100644 --- a/src/pages/Notifications/constants/index.ts +++ b/src/pages/Notifications/constants/index.ts @@ -4,7 +4,8 @@ export const NOTIFICATION_ICON: Record< string, React.ComponentType> > = { - letter: EnvelopeIcon, - warning: SirenFilledIcon, - board: BoardIcon, + LETTER: EnvelopeIcon, + REPORT: SirenFilledIcon, + SHARE: BoardIcon, + POSTED: BoardIcon, }; diff --git a/src/pages/Notifications/index.tsx b/src/pages/Notifications/index.tsx index bef3d9c..d2bc5de 100644 --- a/src/pages/Notifications/index.tsx +++ b/src/pages/Notifications/index.tsx @@ -1,50 +1,93 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router'; +import { getTimeLines, patchReadNotification, patchReadNotificationAll } from '@/apis/notification'; import PageTitle from '@/components/PageTitle'; import NotificationItem from './components/NotificationItem'; import WarningModal from './components/WarningModal'; -const DUMMY_NOTI = [ - { id: 1, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 2, type: 'warning', message: '따숨님, 욕설로 인해 경고를 받으셨어요.', isRead: false }, - { id: 3, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 4, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: true }, - { id: 5, type: 'letter', message: '12E31님이 편지를 보냈습니다.', isRead: false }, - { id: 6, type: 'board', message: '12E31님과의 대화가 게시판에 공유되었어요.', isRead: false }, - { - id: 7, - type: 'board', - message: '12E31님과의 게시글에 대한 공유요청을 보냈어요.', - isRead: false, - }, -]; - const NotificationsPage = () => { + const navigate = useNavigate(); + + const [noti, setNoti] = useState([]); + const [isOpenWarningModal, setIsOpenWarningModal] = useState(false); - const handleClickItem = (type: string) => { - if (type === 'warning') { + const [adminText, setAdmintext] = useState(''); + + // MEMO : 편지 데이터 전송중 데이터도 추가될건데 나중에 데이터 추가되면 코드 업데이트 하긔 + const handleClickItem = (alarmType: string, content?: string | number) => { + if (alarmType === 'LETTER') { + navigate(`/letter/${content}`); + } + if (alarmType === 'REPORT') { setIsOpenWarningModal(true); + if (typeof content === 'string') setAdmintext(content); + } + if (alarmType === 'SHARE') { + navigate(`/board/letter/${content}`, { state: { isShareLetterPreview: true } }); + } + if (alarmType === 'POSTED') { + navigate(`/board/letter/${content}`); } }; + const handleGetTimeLines = async () => { + const res = await getTimeLines(); + if (res?.status === 200) { + console.log(res); + setNoti(res.data.data.content); + } + }; + + const handlePatchReadNotification = async (timelineId: number) => { + const res = await patchReadNotification(timelineId); + if (res?.status !== 200) { + console.log('읽음처리 에러 발생'); + } + }; + + const handlePatchReadNotificationAll = async () => { + const res = await patchReadNotificationAll(); + if (res?.status !== 200) { + console.log('모두 읽음처리 에러 발생'); + } + }; + + useEffect(() => { + handleGetTimeLines(); + }, []); + return ( <> - setIsOpenWarningModal(false)} /> + setIsOpenWarningModal(false)} + />

알림 -
    - {DUMMY_NOTI.map((notification) => ( -
  • + {noti.map((notification) => ( +
  • handleClickItem(notification.type)} + type={notification.alarmType} + title={notification.title} + read={notification.read} + onClick={() => { + handleClickItem(notification.alarmType, notification.content); + handlePatchReadNotification(notification.timelineId); + }} />
  • ))} diff --git a/src/stores/sseStore.ts b/src/stores/sseStore.ts new file mode 100644 index 0000000..3d52593 --- /dev/null +++ b/src/stores/sseStore.ts @@ -0,0 +1,55 @@ +import { EventSourcePolyfill } from 'event-source-polyfill'; +import { create } from 'zustand'; + +import useAuthStore from '@/stores/authStore'; // 액세스 토큰을 가져올 Zustand 스토어 + +interface SSEState { + messages: string[]; + connectSSE: () => void; + closeSSE: () => void; +} + +export const useSSEStore = create((set, get) => { + let source: EventSourcePolyfill | null = null; // SSE 인스턴스 저장 + + return { + messages: [], + + connectSSE: () => { + const accessToken = useAuthStore.getState().accessToken; // authStore에서 변수 가져오기 + if (!accessToken) { + console.log('엑세스 토큰이 존재하지 않습니다. 구독 불가'); + return; + } + + console.log('🟢 SSE 구독 시작'); + + // 기존 SSE 연결 종료 + get().closeSSE(); + + // 새로운 SSE 연결 생성 + source = new EventSourcePolyfill(`${import.meta.env.VITE_API_URL}/api/notifications/sub`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + + source.onmessage = (event) => { + console.log('SSE 메시지 수신:', event.data); + set((state) => ({ messages: [...state.messages, event.data] })); // 메시지 전역 저장 + }; + + source.onerror = (error) => { + console.log('SSE 오류 발생:', error); + get().closeSSE(); + setTimeout(() => get().connectSSE(), 5000); // 5초 후 재연결 + }; + }, + + // 🔥 SSE 종료 함수 (로그아웃 시 실행) + closeSSE: () => { + console.log('SSE 수동 종료'); + source?.close(); + source = null; + set({ messages: [] }); // 상태 초기화 + }, + }; +}); diff --git a/src/types/notifications.d.ts b/src/types/notifications.d.ts new file mode 100644 index 0000000..672864a --- /dev/null +++ b/src/types/notifications.d.ts @@ -0,0 +1,7 @@ +interface Noti { + timelineId: number; + alarmType: string; + content: string | number; + title: string; + read: boolean; +}