-
Notifications
You must be signed in to change notification settings - Fork 0
[Feat] 알림 페이지 제작 #117
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
[Feat] 알림 페이지 제작 #117
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
d864b85
feat: group type 정의
Chiman2937 ca2abe1
feat: notification type 정의
Chiman2937 8c15bc5
feat: group, notification mock data 생성
Chiman2937 71b77cb
feat: SSE test용 next api 생성
Chiman2937 0454c12
feat: useNotifications 훅 생성
Chiman2937 328b08b
feat: notification 페이지 제작
Chiman2937 56b1d85
Merge branch 'main' into chiyoung-feat/notification
Chiman2937 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| }); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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> | ||
| ); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { NotificationCard } from './notification-card'; |
110 changes: 110 additions & 0 deletions
110
src/components/pages/notification/notification-card/index.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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}년 전`; | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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', | ||
| }, | ||
| ]; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SSE 돌아가는 방식이 이런 흐름이군요!
머리로는 이해했지만 작성은 못하겠는 그런..
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
어차피 해당 구문은 백엔드분들이 담당하실 내용이니까, 가볍게 넘기셔도 될 것 같아요!
클라이언트에서 서버에 접속할 때 사용하는 구문이 좀 더 중요하겠네요 :)