Skip to content

Commit

Permalink
✨ Feat : 실시간 알림 전역 상태관리 구현 및 애니메이션 적용 #104
Browse files Browse the repository at this point in the history
  • Loading branch information
JOEIH committed Dec 7, 2024
1 parent 7036a9c commit 631e948
Show file tree
Hide file tree
Showing 7 changed files with 140 additions and 124 deletions.
97 changes: 5 additions & 92 deletions src/components/LogoAndNotification.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import Logo from '@assets/images/roomit_logo.png';
import { RiNotification3Line } from 'react-icons/ri';
import { Link, useNavigate } from 'react-router-dom';
import { SseAlarm } from '@typings/types';
import { getAuthToken, getRole } from '@utils/auth';
import { useEffect, useState } from 'react';
import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill';
import { BASE_URL } from '@constants/constants';
import NotiContainer from './NotiContainer';
import { getRole } from '@utils/auth';

interface HeaderProps {
isLogin: boolean;
}
interface AlarmContent {
type: string | null;
workplaceId: number | null;
}

const LogoAndNotification = ({ isLogin }: HeaderProps) => {
const navigate = useNavigate();
Expand All @@ -28,76 +19,6 @@ const LogoAndNotification = ({ isLogin }: HeaderProps) => {
}
};

const [message, setMessage] = useState<AlarmContent>({
type: null,
workplaceId: null,
});

useEffect(() => {
const connect = () => {
if (!isLogin || role === 'ROLE_USER') return;

const EventSource = EventSourcePolyfill || NativeEventSource;
const token = getAuthToken() || '';
const eventSource = new EventSource(`${BASE_URL}/api/subscribe`, {
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
heartbeatTimeout: 1860000,
});

eventSource.onmessage = (event) => {
const newMessage: SseAlarm = JSON.parse(event.data);
console.log(newMessage);

if (newMessage.content !== 'connected!') {
if (newMessage.notificationType === 'REVIEW_CREATED') {
setMessage({
type: '새 리뷰 등록',
workplaceId: newMessage.workplaceId,
});
}

if (newMessage.notificationType === 'RESERVATION_CONFIRMED') {
setMessage({
type: '새로운 예약',
workplaceId: newMessage.workplaceId,
});
}

setTimeout(() => {
setMessage({
type: null,
workplaceId: null,
});
}, 2000);
}
};

eventSource.onerror = async (error) => {
console.error('SSE Error:', error);
eventSource.close();

setTimeout(connect, 1000);
};

// eslint 에러 제거 주석
// eslint-disable-next-line consistent-return
return () => {
try {
// 페이지 연결 시 구독 끊기
eventSource.close();
console.log('구독 끊기');
} catch (error) {
console.warn('EventSource 종료 중 에러:', error);
}
};
};

return connect();
}, [isLogin, role]);

return (
<div className='flex w-custom items-center justify-between'>
<Link to='/'>
Expand All @@ -108,18 +29,10 @@ const LogoAndNotification = ({ isLogin }: HeaderProps) => {
/>
</Link>
{isLogin && (
<div className='relative'>
<RiNotification3Line
className='h-[24px] w-[24px] cursor-pointer'
onClick={handleMoveToNotiPageClick}
/>
{message?.type && message?.workplaceId && (
<NotiContainer
message={message.type}
workplaceId={message.workplaceId}
/>
)}
</div>
<RiNotification3Line
className='h-[24px] w-[24px] cursor-pointer'
onClick={handleMoveToNotiPageClick}
/>
)}
</div>
);
Expand Down
19 changes: 5 additions & 14 deletions src/components/NotiContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { AiFillNotification } from 'react-icons/ai';
import { Link } from 'react-router-dom';

const NotiContainer = ({
message,
workplaceId,
}: {
message: string;
workplaceId: number;
}) => {
const NotiContainer = ({ message }: { message: string }) => {
return (
<Link to={`/detail/${workplaceId}`}>
<div className='notification absolute -top-4 right-[calc(50%-12px)] z-[2000] flex h-[70px] w-custom items-center justify-start gap-3 rounded-lg bg-white px-4 py-2 text-base text-black shadow-custom'>
<AiFillNotification className='size-5 text-primary' />
{message}
</div>
</Link>
<div className='notification fixed left-1/2 top-7 z-[2000] flex h-[70px] w-custom -translate-x-1/2 items-center justify-start gap-3 rounded-lg bg-white px-4 py-2 text-base text-black shadow-custom'>
<AiFillNotification className='size-5 text-primary' />
{message}
</div>
);
};

Expand Down
26 changes: 26 additions & 0 deletions src/components/NotificationProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import useAuthStore from '@store/authStore';
import useNotificationStore from '@store/notificationStore';
import { useCallback, useEffect } from 'react';
import NotiContainer from './NotiContainer';

const NotificationProvider = () => {
const { isLogin } = useAuthStore();
const { message, connect, state } = useNotificationStore();

const connectSSE = useCallback(() => {
connect();
}, [connect]);

useEffect(() => {
if (!isLogin) return;
connectSSE();

if (!state) {
connectSSE();
}
}, [isLogin, connectSSE, state]);

return <>{isLogin && message && <NotiContainer message={message} />}</>;
};

export default NotificationProvider;
19 changes: 5 additions & 14 deletions src/components/notiContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,11 @@
import { AiFillNotification } from 'react-icons/ai';
import { Link } from 'react-router-dom';

const NotiContainer = ({
message,
workplaceId,
}: {
message: string;
workplaceId: number;
}) => {
const NotiContainer = ({ message }: { message: string }) => {
return (
<Link to={`/detail/${workplaceId}`}>
<div className='notification absolute -top-4 right-[calc(50%-12px)] z-[2000] flex h-[70px] w-custom items-center justify-start gap-3 rounded-lg bg-white px-4 py-2 text-base text-black shadow-custom'>
<AiFillNotification className='size-5 text-primary' />
{message}
</div>
</Link>
<div className='notification fixed left-1/2 top-7 z-[2000] flex h-[70px] w-custom -translate-x-1/2 items-center justify-start gap-3 rounded-lg bg-white px-4 py-2 text-base text-black shadow-custom'>
<AiFillNotification className='size-5 text-primary' />
{message}
</div>
);
};

Expand Down
8 changes: 4 additions & 4 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,19 @@

@keyframes slideDown {
0% {
transform: translateY(-100%);
transform: translate(-50%, -100%); /* translateX(-50%) 유지 */
opacity: 0;
}
20% {
transform: translateY(0);
transform: translate(-50%, 0); /* Y축만 변경 */
opacity: 1;
}
80% {
transform: translateY(0);
transform: translate(-50%, 0); /* Y축만 유지 */
opacity: 1;
}
100% {
transform: translateY(-100%);
transform: translate(-50%, -100%); /* translateX(-50%) 유지 */
opacity: 0;
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from 'react-router-dom';
import 'react-toastify/dist/ReactToastify.css';
import Toastify from '@components/Toastify';
import NotificationProvider from '@components/NotificationProvider';
import router from './routes/Router';

const queryClient = new QueryClient();

createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<NotificationProvider />
<Toastify />
<RouterProvider router={router} />
</QueryClientProvider>,
Expand Down
93 changes: 93 additions & 0 deletions src/store/notificationStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { create } from 'zustand';
import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill';
import { getAuthToken, getRole } from '@utils/auth';
import { BASE_URL } from '@constants/constants';
import { SseAlarm } from '@typings/types';

interface NotificationState {
message: string | null;
connect: () => void;
disconnect: () => void;
state: boolean;
}

const EventSource = EventSourcePolyfill || NativeEventSource;
const role = getRole();
const ssePath =
role === 'ROLE_USER'
? `${BASE_URL}/api/subscribe/user`
: `${BASE_URL}/api/subscribe`;

const useNotificationStore = create<NotificationState>((set) => ({
message: null,
state: true,
link: '/',

connect: () => {
const token = getAuthToken() || '';

const eventSource = new EventSource(ssePath, {
headers: {
Authorization: `Bearer ${token}`,
},
withCredentials: true,
heartbeatTimeout: 1860000,
});

eventSource.onopen = () => {
console.log('연결됨');
set(() => ({ state: true }));
};

eventSource.onmessage = (event) => {
const newMessage: SseAlarm = JSON.parse(event.data);
console.log(newMessage);

if (
newMessage.content === 'connected!' ||
newMessage.notificationType === undefined
) {
return;
}

if (newMessage.content !== 'connected!' && newMessage.notificationType) {
const newText =
newMessage.notificationType === 'REVIEW_CREATED'
? '새 리뷰가 등록되었습니다.'
: '새로운 예약이 등록되었습니다.';

set((state) => {
if (state.message === newText) {
return state;
}
return { message: newText };
});

setTimeout(() => {
set(() => ({ message: null }));
}, 2000);
}
};

// sse 에러 발생하면 연결 종료
eventSource.onerror = (error) => {
console.error('SSE Error:', error);
eventSource.close();
set(() => ({ message: null }));
set(() => ({ state: false }));
};

set(() => ({
disconnect: () => {
console.log('연결 해제');
eventSource.close();
},
}));
},

disconnect: () => {
console.warn('연결 해제 시도');
},
}));

export default useNotificationStore;

0 comments on commit 631e948

Please sign in to comment.