diff --git a/src/components/main/CardSection/CardImage/index.tsx b/src/components/main/CardSection/CardImage/index.tsx
index a966271c..b36a3f70 100644
--- a/src/components/main/CardSection/CardImage/index.tsx
+++ b/src/components/main/CardSection/CardImage/index.tsx
@@ -41,7 +41,7 @@ export default function CardImage({ gathering }: CardImageProps) {
sizes="(max-width: 820px) 50vw, (max-width: 1240px) 50vw, 50vw"
className="transform rounded-t-2xl object-cover transition-transform duration-300 ease-in-out group-hover:scale-110 mobile:rounded-l-2xl mobile:rounded-tr-none tablet:rounded-t-2xl tablet:rounded-bl-none"
/>
-
{showTag && }
+
{showTag && }
{(currentUsers >= maxUsers || closed) && (
{closed ? 'CLOSED' : 'FULL'}
diff --git a/src/components/main/Carousel/PopularCategorySlide/index.tsx b/src/components/main/Carousel/PopularCategorySlide/index.tsx
index 0148ad5a..dac42bba 100644
--- a/src/components/main/Carousel/PopularCategorySlide/index.tsx
+++ b/src/components/main/Carousel/PopularCategorySlide/index.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable tailwindcss/no-custom-classname */
import { useCallback } from 'react';
import Image from 'next/image';
import { useSetCategory } from '@/store/useFilterStore';
@@ -34,7 +35,7 @@ export default function PopularCategorySlide({ handleScrollToFilter }: PopularCa
{/* 텍스트와 카테고리 리스트 */}
-
🔥 인기 카테고리 🔥
+
🔥 인기 카테고리 🔥
실시간으로 모임수가 증가하고 있어요!
{categories.map(({ rank, category, imageSrc }) => (
@@ -43,7 +44,7 @@ export default function PopularCategorySlide({ handleScrollToFilter }: PopularCa
onClick={() => handleCategoryClick(category)}
className="relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-md transition-transform duration-300 hover:scale-105"
>
-
+
{rank}위
diff --git a/src/components/shared/GNB/Notification/MobileUI/index.tsx b/src/components/shared/GNB/Notification/MobileUI/index.tsx
new file mode 100644
index 00000000..67cda775
--- /dev/null
+++ b/src/components/shared/GNB/Notification/MobileUI/index.tsx
@@ -0,0 +1,36 @@
+/* eslint-disable tailwindcss/no-custom-classname */
+import { forwardRef } from 'react';
+import ArrowBtn from 'public/icons/ArrowBtn';
+import NotificationItem from '@/components/shared/GNB/Notification/NotificationItem';
+import type { NotificationContent } from '@manchui-api';
+
+export interface MobileUIProps {
+ isError: boolean;
+ isLoading: boolean;
+ notifications: NotificationContent[];
+ onDropClick: () => void;
+}
+
+export const MobileUI = forwardRef
(({ notifications, isLoading, onDropClick, isError }, ref) => (
+
+
+
+
+
알림
+ {!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 (
-