Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/app/api/notifications/stream/route.ts
Copy link
Contributor

@wooktori wooktori Dec 9, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSE 돌아가는 방식이 이런 흐름이군요!
머리로는 이해했지만 작성은 못하겠는 그런..

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어차피 해당 구문은 백엔드분들이 담당하실 내용이니까, 가볍게 넘기셔도 될 것 같아요!

클라이언트에서 서버에 접속할 때 사용하는 구문이 좀 더 중요하겠네요 :)

  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();
    };
  }, []);

Original file line number Diff line number Diff line change
@@ -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',
},
});
};
16 changes: 16 additions & 0 deletions src/app/notification/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
{messages.map((data, idx) => (
<NotificationCard key={idx} data={data} />
))}
</section>
);
}
1 change: 1 addition & 0 deletions src/components/pages/notification/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { NotificationCard } from './notification-card';
110 changes: 110 additions & 0 deletions src/components/pages/notification/notification-card/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<article className='bg-mono-white flex flex-row gap-3 px-5 py-6'>
<div className='flex-center mt-0.5 size-10 shrink-0 rounded-xl bg-gray-100'>
{NotificationIcon}
</div>
<div className='w-full'>
<div className='flex flex-row justify-between'>
<p className='text-text-md-bold mb-1 text-gray-800'>{title}</p>
<span className='text-text-xs-medium text-gray-500'>{timeAgo}</span>
</div>
<p className='text-gray-600'>{description}</p>
{route && (
<Link href={route} className='text-mint-500'>
{routeCaption}
</Link>
)}
</div>
</article>
);
};

const IconMap: Record<NotificationType, React.ReactNode> = {
follow: <Icon id='heart' className='text-mint-500 size-6' />,
'group-create': <Icon id='map-pin-2' className='size-6 text-[#FFBA1A]' />,
'group-delete': <Icon id='x-2' className='size-6 text-gray-500' />,
'group-join': <Icon id='symbol' className='text-mint-500 size-6' />,
'group-leave': <Icon id='x-2' className='size-6 text-gray-500' />,
};

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);
};
28 changes: 28 additions & 0 deletions src/hooks/use-notifications/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';
import { useEffect, useState } from 'react';

import { Notification } from '@/types/service/notification';

export const useNotifications = () => {
const [messages, setMessages] = useState<Notification[]>([]);

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;
};
22 changes: 22 additions & 0 deletions src/lib/format-time-ago.ts
Original file line number Diff line number Diff line change
@@ -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}년 전`;
};
28 changes: 28 additions & 0 deletions src/mock/service/group/group-mock.ts
Original file line number Diff line number Diff line change
@@ -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,
},
];
37 changes: 37 additions & 0 deletions src/mock/service/notification/notification-mock.ts
Original file line number Diff line number Diff line change
@@ -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',
},
];
21 changes: 21 additions & 0 deletions src/types/service/group.ts
Original file line number Diff line number Diff line change
@@ -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;
}
16 changes: 16 additions & 0 deletions src/types/service/notification.ts
Original file line number Diff line number Diff line change
@@ -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;
}