diff --git a/src/app/api/notifications/stream/route.ts b/src/app/api/notifications/stream/route.ts new file mode 100644 index 00000000..dc72d83b --- /dev/null +++ b/src/app/api/notifications/stream/route.ts @@ -0,0 +1,36 @@ +import { NextRequest } from 'next/server'; + +import { notificationMockItems } from '@/mock/service/notification/notification-mock'; + +export const GET = async (req: NextRequest) => { + const stream = new ReadableStream({ + start(controller) { + let index = 0; + + const intervalId = setInterval(() => { + if (index < notificationMockItems.length) { + const data = JSON.stringify(notificationMockItems[index]); + // \n\n\ : SSE 메시지 종료를 의미하는 구분자(두줄 바꿈) + controller.enqueue(`data: ${data}\n\n`); + index++; + } else { + clearInterval(intervalId); + controller.close(); + } + }, 0); + + req.signal.addEventListener('abort', () => { + clearInterval(intervalId); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +}; diff --git a/src/app/notification/page.tsx b/src/app/notification/page.tsx new file mode 100644 index 00000000..82d51d25 --- /dev/null +++ b/src/app/notification/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +import { NotificationCard } from '@/components/pages/notification'; +import { useNotifications } from '@/hooks/use-notifications'; + +export default function NotificationPage() { + const messages = useNotifications(); + + return ( +
+ {messages.map((data, idx) => ( + + ))} +
+ ); +} diff --git a/src/components/pages/notification/index.ts b/src/components/pages/notification/index.ts new file mode 100644 index 00000000..de7f2df6 --- /dev/null +++ b/src/components/pages/notification/index.ts @@ -0,0 +1 @@ +export { NotificationCard } from './notification-card'; diff --git a/src/components/pages/notification/notification-card/index.tsx b/src/components/pages/notification/notification-card/index.tsx new file mode 100644 index 00000000..d55f03c9 --- /dev/null +++ b/src/components/pages/notification/notification-card/index.tsx @@ -0,0 +1,110 @@ +import Link from 'next/link'; + +import { Icon } from '@/components/icon'; +import { formatTimeAgo } from '@/lib/format-time-ago'; +import { Notification, NotificationType } from '@/types/service/notification'; + +interface Props { + data: Notification; +} + +export const NotificationCard = ({ data }: Props) => { + const NotificationIcon = IconMap[data.type]; + const title = getTitle(data); + const description = getDescription(data); + const route = getRoute(data); + const routeCaption = getRouteCaption(data); + const timeAgo = getTimeAgo(data); + return ( +
+
+ {NotificationIcon} +
+
+
+

{title}

+ {timeAgo} +
+

{description}

+ {route && ( + + {routeCaption} + + )} +
+
+ ); +}; + +const IconMap: Record = { + follow: , + 'group-create': , + 'group-delete': , + 'group-join': , + 'group-leave': , +}; + +const getTitle = (data: Notification) => { + switch (data.type) { + case 'follow': + return `새 팔로워`; + case 'group-create': + return `모임 생성`; + case 'group-delete': + return `모임 취소`; + case 'group-join': + return `모임 현황`; + case 'group-leave': + return `모임 현황`; + } +}; + +const getDescription = (data: Notification) => { + switch (data.type) { + case 'follow': + return `${data.user.nickName} 님이 팔로우 했습니다.`; + case 'group-create': + return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 생성했습니다.`; + case 'group-delete': + return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 취소했습니다.`; + case 'group-join': + return `${data.user.nickName} 님이 "${data.group?.title}" 모임에 참가했습니다.`; + case 'group-leave': + return `${data.user.nickName} 님이 "${data.group?.title}" 모임을 떠났습니다.`; + } +}; + +const getRoute = (data: Notification) => { + switch (data.type) { + case 'follow': + return `/profile/${data.user.id}`; + case 'group-create': + return `/profile/${data.user.id}`; + case 'group-delete': + return ``; + case 'group-join': + return `/meetup/${data.group?.id}`; + case 'group-leave': + return ``; + } +}; + +const getRouteCaption = (data: Notification) => { + switch (data.type) { + case 'follow': + return `프로필 바로가기`; + case 'group-create': + return `프로필 바로가기`; + case 'group-delete': + return ``; + case 'group-join': + return `모임 바로가기`; + case 'group-leave': + return ``; + } +}; + +const getTimeAgo = (data: Notification) => { + const { createdAt } = data; + return formatTimeAgo(createdAt); +}; diff --git a/src/hooks/use-notifications/index.ts b/src/hooks/use-notifications/index.ts new file mode 100644 index 00000000..649cbe0f --- /dev/null +++ b/src/hooks/use-notifications/index.ts @@ -0,0 +1,28 @@ +'use client'; +import { useEffect, useState } from 'react'; + +import { Notification } from '@/types/service/notification'; + +export const useNotifications = () => { + const [messages, setMessages] = useState([]); + + useEffect(() => { + const eventSource = new EventSource('/api/notifications/stream'); + + eventSource.onmessage = (event) => { + const data: Notification = JSON.parse(event.data); + setMessages((prev) => [...prev, data]); + }; + + eventSource.onerror = (err) => { + console.error('SSE 에러', err); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, []); + + return messages; +}; diff --git a/src/lib/format-time-ago.ts b/src/lib/format-time-ago.ts new file mode 100644 index 00000000..0ad60a06 --- /dev/null +++ b/src/lib/format-time-ago.ts @@ -0,0 +1,22 @@ +export const formatTimeAgo = (isoString: string) => { + const dateInput = new Date(isoString); + const dateNow = new Date(); + + const diffPerSec = (dateNow.getTime() - dateInput.getTime()) / 1000; + if (diffPerSec < 60) return `${Math.ceil(diffPerSec)}초 전`; + + const diffPerMin = diffPerSec / 60; + if (diffPerMin < 60) return `${Math.ceil(diffPerMin)}분 전`; + + const diffPerHour = diffPerMin / 60; + if (diffPerHour < 24) return `${Math.ceil(diffPerHour)}시간 전`; + + const diffPerDay = diffPerHour / 30; + if (diffPerDay < 30) return `${Math.ceil(diffPerDay)}일 전`; + + const yearDiff = dateNow.getFullYear() - dateInput.getFullYear(); + const monthDiff = dateNow.getMonth() - dateInput.getMonth(); + const diffPerMonth = yearDiff * 12 + monthDiff; + if (diffPerMonth < 12) return `${diffPerMonth}개월 전`; + return `${yearDiff}년 전`; +}; diff --git a/src/mock/service/group/group-mock.ts b/src/mock/service/group/group-mock.ts new file mode 100644 index 00000000..baf9db90 --- /dev/null +++ b/src/mock/service/group/group-mock.ts @@ -0,0 +1,28 @@ +import { Group } from '@/types/service/group'; + +export const groupMockItem: Group[] = [ + { + id: 1, + title: '동탄 호수공원에서 피크닉하실 분!', + location: '화성시', + locationDetail: '동탄 호수공원', + startTime: '2025-12-07T17:00:00+09:00', + endTime: '2025-12-07T19:00:00+09:00', + images: [ + 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?q=80&w=717&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + ], + tags: ['게임', '피크닉'], + description: '동탄 호수공원에서 어쩌구 저쩌구', + participantCount: 3, + maxParticipants: 12, + createdBy: { + userId: 1, + nickName: '리오넬 메시', + profileImage: + 'https://images.unsplash.com/photo-1518020382113-a7e8fc38eac9?q=80&w=717&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D', + }, + createdAt: '2025-12-06T17:00:00+09:00', + updatedAt: '2025-12-06T17:00:00+09:00', + joinedCount: 3, + }, +]; diff --git a/src/mock/service/notification/notification-mock.ts b/src/mock/service/notification/notification-mock.ts new file mode 100644 index 00000000..9fba5447 --- /dev/null +++ b/src/mock/service/notification/notification-mock.ts @@ -0,0 +1,37 @@ +import { Notification } from '@/types/service/notification'; + +import { groupMockItem } from '../group/group-mock'; +import { mockUserItems } from '../user/users-mock'; + +export const notificationMockItems: Notification[] = [ + { + type: 'follow', + user: mockUserItems[1], + group: groupMockItem[0], + createdAt: '2025-12-08T17:00:00+09:00', + }, + { + type: 'group-create', + user: mockUserItems[1], + group: groupMockItem[0], + createdAt: '2025-12-08T17:00:00+09:00', + }, + { + type: 'group-delete', + user: mockUserItems[1], + group: groupMockItem[0], + createdAt: '2025-12-08T21:00:00+09:00', + }, + { + type: 'group-join', + user: mockUserItems[2], + group: groupMockItem[0], + createdAt: '2025-12-08T21:00:00+09:00', + }, + { + type: 'group-leave', + user: mockUserItems[2], + group: groupMockItem[0], + createdAt: '2025-12-08T21:00:00+09:00', + }, +]; diff --git a/src/types/service/group.ts b/src/types/service/group.ts new file mode 100644 index 00000000..fe7eee6b --- /dev/null +++ b/src/types/service/group.ts @@ -0,0 +1,21 @@ +export interface Group { + id: number; + title: string; + location: string; + locationDetail: string; + startTime: string; + endTime: string; + images: string[]; + tags: string[]; + description: string; + participantCount: number; + maxParticipants: number; + createdBy: { + userId: number; + nickName: string; + profileImage: null | string; + }; + createdAt: string; + updatedAt: string; + joinedCount: number; +} diff --git a/src/types/service/notification.ts b/src/types/service/notification.ts new file mode 100644 index 00000000..b5cb1d97 --- /dev/null +++ b/src/types/service/notification.ts @@ -0,0 +1,16 @@ +import { Group } from './group'; +import { User } from './user'; + +export type NotificationType = + | 'follow' + | 'group-join' + | 'group-leave' + | 'group-create' + | 'group-delete'; + +export interface Notification { + type: NotificationType; + user: User; + group?: Group; + createdAt: string; +}