diff --git a/package.json b/package.json index 331a39e1..8a1cc2f0 100644 --- a/package.json +++ b/package.json @@ -18,9 +18,11 @@ "@svgr/webpack": "^8.1.0", "@tanstack/react-query": "^5.59.16", "@tanstack/react-query-devtools": "^5.59.16", + "@types/event-source-polyfill": "^1.0.5", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "event-source-polyfill": "^1.0.31", "framer-motion": "^11.11.11", "json-server": "^1.0.0-beta.3", "lottie-react": "^2.4.0", diff --git a/public/icons/NotificationIcon.tsx b/public/icons/NotificationIcon.tsx new file mode 100644 index 00000000..bc1f002b --- /dev/null +++ b/public/icons/NotificationIcon.tsx @@ -0,0 +1,23 @@ +import type { Props } from '@/components/shared/Svg'; +import { Svg } from '@/components/shared/Svg'; + +export default function NotificationIcon({ + color = '#FFFFFF', + className, + ...props +}: Props) { + + return ( + + + + + ); +} diff --git a/public/icons/XIcon.tsx b/public/icons/XIcon.tsx new file mode 100644 index 00000000..b0b1c440 --- /dev/null +++ b/public/icons/XIcon.tsx @@ -0,0 +1,23 @@ +import type { Props } from '@/components/shared/Svg'; +import { Svg } from '@/components/shared/Svg'; + +export default function XIcon({ + color = '#FFFFFF', + className, + ...props +}: Props) { + + return ( + + + + + ); +} diff --git a/src/apis/deleteNotificationData.ts b/src/apis/deleteNotificationData.ts new file mode 100644 index 00000000..5e44bb5f --- /dev/null +++ b/src/apis/deleteNotificationData.ts @@ -0,0 +1,19 @@ +import { instance } from '@/apis/api'; +import type { GetNotificationResponse } from '@manchui-api'; + +export async function deleteNotificationData({ notificationId }: { notificationId: number }): Promise { + const accessToken = localStorage.getItem('accessToken'); + + if (!accessToken) { + throw new Error('Access token이 없습니다.'); + } + + try { + const res = await instance.delete(`/api/notifications/${notificationId}`); + + return res.data; + } catch (e) { + console.error('deleteNotificationData 함수에서 오류 발생', e); + throw new Error('알림 데이터를 삭제하는데 실패했습니다.'); + } +} diff --git a/src/apis/getNotificationData.ts b/src/apis/getNotificationData.ts new file mode 100644 index 00000000..653b0dd7 --- /dev/null +++ b/src/apis/getNotificationData.ts @@ -0,0 +1,21 @@ +import { instance } from '@/apis/api'; +import type { GetNotificationRequest, GetNotificationResponse } from '@manchui-api'; + +export async function getNotificationData(request: GetNotificationRequest): Promise { + const { cursor, size, unreadOnly } = request; + + const params = { + cursor, + size: size.toString(), + unreadOnly, + }; + + try { + const res = await instance.get('/api/notifications', { params }); + + return res.data; + } catch (e) { + console.error('getNotificationData 함수에서 오류 발생', e); + throw new Error('알림 데이터를 불러오는데 실패했습니다.'); + } +} diff --git a/src/components/Drawer/index.tsx b/src/components/Drawer/index.tsx index 4f5a91b0..94794cc9 100644 --- a/src/components/Drawer/index.tsx +++ b/src/components/Drawer/index.tsx @@ -5,6 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/router'; import { logout } from '@/apis/user/postUser'; +import Notification from '@/components/shared/GNB/Notification'; import { userStore } from '@/store/userStore'; import { useQueryClient } from '@tanstack/react-query'; @@ -63,6 +64,7 @@ export default function Drawer({ isLoggedIn, userData }: DrawerProps) { return (
+ +
+

알림

+ {!isLoading && {`${notifications.length}개`}} +
+
+ +
+ {!isError && notifications.length === 0 &&

알림이 없습니다.

} + {!isError && notifications.map((notification) => )} + {isError &&

에러가 발생했습니다.

} + +
+
+
+)); + +MobileUI.displayName = 'MobileUI'; diff --git a/src/components/shared/GNB/Notification/NotificationItem/MobileUI/index.tsx b/src/components/shared/GNB/Notification/NotificationItem/MobileUI/index.tsx new file mode 100644 index 00000000..2b91c8b4 --- /dev/null +++ b/src/components/shared/GNB/Notification/NotificationItem/MobileUI/index.tsx @@ -0,0 +1,78 @@ +/* eslint-disable tailwindcss/no-custom-classname */ +import { useRef, useState } from 'react'; +import { deleteNotificationData } from '@/apis/deleteNotificationData'; +import type { NotificationItemProps } from '@/components/shared/GNB/Notification/NotificationItem'; +import useNotificationDrag from '@/hooks/useNotificationDrag'; +import { formatTimeAgo } from '@/utils/dateUtils'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export default function MobileUI({ data }: NotificationItemProps) { + const [isDeleting, setIsDeleting] = useState(false); + const containerRef = useRef(null); + + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: deleteNotificationData, + onMutate: async ({ notificationId }) => { + // 알림 데이터에 대한 모든 퀴리요청을 취소하여 이전 서버 데이터가 낙관적 업데이트를 덮어쓰지 않도록 함 -> refetch 취소시킴 + // 모든 관련 쿼리 요청 취소 + await queryClient.cancelQueries({ queryKey: ['notification'] }); + + // 현재 캐시된 데이터 가져오기 + // 이전 상태 저장(rollback을 사용하기 위해서) + const previousNotifications = queryClient.getQueryData(['notification']); + + // 낙관적 업데이트: 삭제된 항목을 제외한 상태로 캐시를 갱신 + queryClient.setQueryData(['notification'], (oldNotification: NotificationItemProps[] | undefined) => { + if (!oldNotification) return undefined; + + return oldNotification.filter((notificaiton) => notificaiton.data.notificationId !== notificationId); + }); + + return { previousNotifications }; + }, + onError: (e) => { + console.log('알림 삭제 실패:', e); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ['notification'] }); + }, + }); + + const handleDelete = () => { + setIsDeleting(true); + mutation.mutate({ notificationId: data.notificationId }); + }; + + const { handleMouseDown, handleMouseLeave, handleMouseUp, handleMouseMove, handleTouchStart, handleTouchCancel, handleTouchEnd, handleTouchMove } = + useNotificationDrag({ + ref: containerRef, + handleDelete, + }); + + return ( +
+
+

{data.content}

+ + {formatTimeAgo(String(data.createdAt))} +
+ +
+ 삭제 +
+
+ ); +} diff --git a/src/components/shared/GNB/Notification/NotificationItem/TabletPCUI/index.tsx b/src/components/shared/GNB/Notification/NotificationItem/TabletPCUI/index.tsx new file mode 100644 index 00000000..aff543f7 --- /dev/null +++ b/src/components/shared/GNB/Notification/NotificationItem/TabletPCUI/index.tsx @@ -0,0 +1,52 @@ +/* eslint-disable tailwindcss/no-custom-classname */ +import Link from 'next/link'; +import XIcon from 'public/icons/XIcon'; +import { deleteNotificationData } from '@/apis/deleteNotificationData'; +import type { NotificationItemProps } from '@/components/shared/GNB/Notification/NotificationItem'; +import { formatTimeAgo } from '@/utils/dateUtils'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +export default function TabletPCUI({ data }: NotificationItemProps) { + const queryClient = useQueryClient(); + + const mutation = useMutation({ + mutationFn: deleteNotificationData, + onMutate: async ({ notificationId }) => { + // 알림 데이터에 대한 모든 퀴리요청을 취소하여 이전 서버 데이터가 낙관적 업데이트를 덮어쓰지 않도록 함 -> refetch 취소시킴 + // 모든 관련 쿼리 요청 취소 + await queryClient.cancelQueries({ queryKey: ['notification'] }); + + // 현재 캐시된 데이터 가져오기 + // 이전 상태 저장(rollback을 사용하기 위해서) + const previousNotifications = queryClient.getQueryData(['notification']); + + // 낙관적 업데이트: 삭제된 항목을 제외한 상태로 캐시를 갱신 + queryClient.setQueryData(['notification'], (oldNotification: NotificationItemProps[] | undefined) => { + if (!oldNotification) return undefined; + + return oldNotification.filter((notificaiton) => notificaiton.data.notificationId !== notificationId); + }); + + return { previousNotifications }; + }, + onError: (e) => { + console.log('알림 삭제 실패:', e); + }, + onSettled: async () => { + await queryClient.invalidateQueries({ queryKey: ['notification'] }); + }, + }); + + const handleDelete = (e: React.MouseEvent) => { + e.preventDefault(); + mutation.mutate({ notificationId: data.notificationId }); + }; + + return ( + + +

{data.content}

+ {formatTimeAgo(String(data.createdAt))} + + ); +} diff --git a/src/components/shared/GNB/Notification/NotificationItem/index.tsx b/src/components/shared/GNB/Notification/NotificationItem/index.tsx new file mode 100644 index 00000000..a9d1426b --- /dev/null +++ b/src/components/shared/GNB/Notification/NotificationItem/index.tsx @@ -0,0 +1,20 @@ +import MobileUI from '@/components/shared/GNB/Notification/NotificationItem/MobileUI'; +import TabletPCUI from '@/components/shared/GNB/Notification/NotificationItem/TabletPCUI'; +import type { NotificationContent } from '@manchui-api'; + +export interface NotificationItemProps { + data: NotificationContent; +} + +export default function NotificationItem({ data }: NotificationItemProps) { + return ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/src/components/shared/GNB/Notification/TabletPCUI/index.tsx b/src/components/shared/GNB/Notification/TabletPCUI/index.tsx new file mode 100644 index 00000000..fd3143c9 --- /dev/null +++ b/src/components/shared/GNB/Notification/TabletPCUI/index.tsx @@ -0,0 +1,42 @@ +import { forwardRef } from 'react'; +import * as m from 'framer-motion/m'; +import ArrowBtn from 'public/icons/ArrowBtn'; +import NotificationItem from '@/components/shared/GNB/Notification/NotificationItem'; +import type { NotificationContent } from '@manchui-api'; + +export interface TabletPCUIProps { + isError: boolean; + isLoading: boolean; + notifications: NotificationContent[]; + onDropClick: () => void; +} + +export const TabletPCUI = forwardRef(({ notifications, isLoading, onDropClick, isError }, ref) => ( + +
+ +
+

알림

+ {!isLoading && {`${notifications.length}개`}} +
+
+ +
+ {!isError && notifications.length === 0 &&

알림이 없습니다.

} + {!isError && notifications.map((notification) => )} + {isError &&

에러가 발생했습니다.

} + +
+
+ +)); + +TabletPCUI.displayName = 'TabletPCUI'; diff --git a/src/components/shared/GNB/Notification/index.tsx b/src/components/shared/GNB/Notification/index.tsx new file mode 100644 index 00000000..8d9dd800 --- /dev/null +++ b/src/components/shared/GNB/Notification/index.tsx @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { EventSourcePolyfill, NativeEventSource } from 'event-source-polyfill'; +import NotificationIcon from 'public/icons/NotificationIcon'; +import { MobileUI } from '@/components/shared/GNB/Notification/MobileUI'; +import { TabletPCUI } from '@/components/shared/GNB/Notification/TabletPCUI'; +import useGetNotificationData from '@/hooks/useGetNotificationData'; +import useIntersectionObserver from '@/hooks/useIntersectionObserver'; +import type { NotificationContent } from '@manchui-api'; + +export default function Notification() { + const [sseNotifications, setSseNotifications] = useState([]); + const [dropOpen, setDropOpen] = useState(false); + + const EventSource = EventSourcePolyfill || NativeEventSource; + + const sentinelRef = useRef(null); + const mobileSentinelRef = useRef(null); + const isIntersecting = useIntersectionObserver(sentinelRef); + const isIntersectingInMobile = useIntersectionObserver(mobileSentinelRef); + + const { data, isLoading, isError, hasNextPage, fetchNextPage } = useGetNotificationData({ size: 5, cursor: undefined }); + + const handelDropClick = () => setDropOpen((prev) => !prev); + + const pagedNotifications = data?.pages.flatMap((page) => page.data.notificationContent) || []; + + const notifications = [...sseNotifications, ...pagedNotifications]; + + useEffect( + function handleScrollFetch() { + if ((isIntersecting || isIntersectingInMobile) && hasNextPage) { + void fetchNextPage(); + } + }, + [isIntersecting, hasNextPage, isIntersectingInMobile, fetchNextPage], + ); + + useEffect(() => { + const accessToken = localStorage.getItem('accessToken'); + + if (!accessToken) { + console.error('accessToken이 만료되었습니다.'); + return undefined; + } + + const source = new EventSource(`${process.env.NEXT_PUBLIC_API_URL}/api/notifications/subscribe`, { + headers: { + Authorization: accessToken, + }, + heartbeatTimeout: 1000 * 60 * 30, + withCredentials: true, // 다른 도메인에 요청을 보낼 때, 요청에 인증 정보를 담을지 여부를 결정하는 옵션, 이 옵션을 설정하지 않으면 서버로 요청을 보낼 때 쿠키가 포함되지 않습니다. + }); + + source.onopen = () => console.log('SSE 연결 성공'); + + source.onmessage = (e) => { + try { + const notiData = e.data as string; + console.log(e); + + if (notiData.trim() === ':ping') { + return; + } + + const newNotification: NotificationContent = JSON.parse(notiData); + + if (!newNotification.content) { + return; + } + + setSseNotifications((prev) => { + const isDuplicate = prev.some((notification) => notification.notificationId === newNotification.notificationId); + if (isDuplicate) { + return prev; // 중복 데이터는 추가 안함 + } + return [newNotification, ...prev]; + }); + } catch (err) { + console.error('SSE 데이터 파싱 실패:', err); + } + }; + source.onerror = (e) => { + if (e) { + console.error('SSE 연결 실패:', e); + source.close(); + } + }; + + return () => { + console.log('연결 끝'); + source.close(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + ); +} diff --git a/src/components/shared/GNB/index.tsx b/src/components/shared/GNB/index.tsx index 1ba5509d..b234e71c 100644 --- a/src/components/shared/GNB/index.tsx +++ b/src/components/shared/GNB/index.tsx @@ -7,6 +7,7 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import { getUserInfo } from '@/apis/user/getUser'; import Drawer from '@/components/Drawer'; +import Notification from '@/components/shared/GNB/Notification'; import Toggle from '@/components/shared/GNB/Toggle'; import { formatDate } from '@/libs/formatDate'; import { userStore } from '@/store/userStore'; @@ -49,76 +50,79 @@ export default function GNB() { if (isError) return
Error: {error.message}
; return ( -