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
Original file line number Diff line number Diff line change
Expand Up @@ -25,55 +25,55 @@ type Story = StoryObj<typeof meta>;

export const Follow: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'follow')!,
item: mockNotificationItems.find((item) => item.type === 'FOLLOW')!,
},
};

export const GroupJoin: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-join')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_JOIN')!,
},
};

export const GroupLeave: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-leave')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_LEAVE')!,
},
};

export const GroupCreate: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-create')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_CREATE')!,
},
};

export const GroupDelete: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-delete')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_DELETE')!,
},
};

export const GroupJoinRequest: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-join-request')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_JOIN_REQUEST')!,
},
};

export const GroupJoinApproved: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-join-approved')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_JOIN_APPROVED')!,
},
};

export const GroupJoinRejected: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-join-rejected')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_JOIN_REJECTED')!,
},
};

export const GroupJoinKicked: Story = {
args: {
item: mockNotificationItems.find((item) => item.type === 'group-join-kicked')!,
item: mockNotificationItems.find((item) => item.type === 'GROUP_JOIN_KICKED')!,
},
};

Expand Down
64 changes: 35 additions & 29 deletions src/components/pages/notification/notification-card/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,19 @@ export const NotificationCard = ({ item }: Props) => {
} catch {}
};

const handleNotificationHover = () => {
if (!redirectUrl) return;
router.prefetch(redirectUrl);
};

return (
<article
className={cn(
'bg-mono-white flex cursor-pointer flex-row gap-3 px-5 py-6',
!item.readAt && 'bg-mint-50',
)}
onClick={handleNotificationClick}
onMouseEnter={handleNotificationHover}
>
<div className={cn('flex-center mt-0.5 size-10 shrink-0 rounded-xl bg-gray-100')}>
{NotificationIcon}
Expand All @@ -57,44 +63,44 @@ export const NotificationCard = ({ item }: Props) => {
};

const IconMap: Record<NotificationType, React.ReactNode> = {
follow: <Icon id='heart' className='text-mint-500 size-6' />,
'group-join': <Icon id='symbol' className='text-mint-500 size-6' />,
'group-leave': <Icon id='x-2' className='size-6 text-gray-500' />,
'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-request': <Icon id='send' className='text-mint-500 size-6' />,
'group-join-approved': <Icon id='congratulate' className='size-6' />,
'group-join-rejected': <Icon id='kick' className='size-6' />,
'group-join-kicked': <Icon id='kick' className='size-6' />,
FOLLOW: <Icon id='heart' className='text-mint-500 size-6' />,
GROUP_JOIN: <Icon id='symbol' className='text-mint-500 size-6' />,
GROUP_LEAVE: <Icon id='x-2' className='size-6 text-gray-500' />,
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_REQUEST: <Icon id='send' className='text-mint-500 size-6' />,
GROUP_JOIN_APPROVED: <Icon id='congratulate' className='size-6' />,
GROUP_JOIN_REJECTED: <Icon id='kick' className='size-6' />,
GROUP_JOIN_KICKED: <Icon id='kick' className='size-6' />,
};

const getTitle = (data: NotificationItem) => {
switch (data.type) {
case 'follow':
case 'FOLLOW':
return `새 팔로워`;
case 'group-join':
case 'GROUP_JOIN':
return `모임 현황`;
case 'group-leave':
case 'GROUP_LEAVE':
return `모임 현황`;
case 'group-create':
case 'GROUP_CREATE':
return `모임 생성`;
case 'group-delete':
case 'GROUP_DELETE':
return `모임 취소`;
case 'group-join-request':
case 'GROUP_JOIN_REQUEST':
return `모임 참여 신청`;
case 'group-join-approved':
case 'GROUP_JOIN_APPROVED':
return `모임 신청 승인`;
case 'group-join-rejected':
case 'GROUP_JOIN_REJECTED':
return `모임 신청 거절`;
case 'group-join-kicked':
case 'GROUP_JOIN_KICKED':
return `모임 강퇴`;
}
};

const getDescription = (data: NotificationItem) => {
// user type 알림
switch (data.type) {
case 'follow':
case 'FOLLOW':
return `${data.user.nickname} 님이 팔로우했어요.`;
}

Expand All @@ -104,21 +110,21 @@ const getDescription = (data: NotificationItem) => {

// group 필드가 null이 아닐 경우
switch (data.type) {
case 'group-join':
case 'GROUP_JOIN':
return `${data.user.nickname} 님이 "${data.group.title}" 모임에 참여했어요.`;
case 'group-leave':
case 'GROUP_LEAVE':
return `${data.user.nickname} 님이 "${data.group.title}" 모임을 탈퇴했어요.`;
case 'group-create':
case 'GROUP_CREATE':
return `${data.user.nickname} 님이 "${data.group.title}" 모임을 생성했어요.`;
case 'group-delete':
case 'GROUP_DELETE':
return `${data.user.nickname} 님이 "${data.group.title}" 모임을 취소했어요.`;
case 'group-join-request':
case 'GROUP_JOIN_REQUEST':
return `${data.user.nickname} 님이 "${data.group.title}" 모임에 참여를 요청했어요.`;
case 'group-join-approved':
case 'GROUP_JOIN_APPROVED':
return `"${data.group.title}" 모임 참여 신청이 승인됐어요.`;
case 'group-join-rejected':
case 'GROUP_JOIN_REJECTED':
return `"${data.group.title}" 모임 참여 신청이 거절됐어요.`;
case 'group-join-kicked':
case 'GROUP_JOIN_KICKED':
return `"${data.group.title}" 모임에서 퇴장됐어요.`;
}
};
Expand All @@ -131,15 +137,15 @@ const getTimeAgo = (data: NotificationItem) => {
const getRedirectUrl = (data: NotificationItem) => {
// user type 알림
switch (data.type) {
case 'follow':
case 'FOLLOW':
return `/profile/${data.user.id}`;
}

// 알림 필드 type 변경 전 데이터는 group 필드가 null로 조회되므로 fallback 처리
if (!data.group) return null;

switch (data.type) {
case 'group-join-request':
case 'GROUP_JOIN_REQUEST':
return `/pending/${data.group.id}`;
default:
return `/group/${data.group.id}`;
Expand Down
44 changes: 42 additions & 2 deletions src/hooks/use-notification/use-notification-connect-sse/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,11 @@ import { useEffect, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import Cookies from 'js-cookie';

import { groupKeys } from '@/lib/query-key/query-key-group';
import { notificationKeys } from '@/lib/query-key/query-key-notification';
import { userKeys } from '@/lib/query-key/query-key-user';
import { useAuth } from '@/providers/provider-auth';
import { NotificationItem } from '@/types/service/notification';

export const useConnectSSE = () => {
const [receivedNewNotification, setReceivedNewNotification] = useState(false);
Expand Down Expand Up @@ -51,12 +54,49 @@ export const useConnectSSE = () => {
// SSE 알림 수신 시
es.addEventListener('notification', (event) => {
try {
const data = JSON.parse(event.data);
const data = JSON.parse(event.data) as NotificationItem;
console.log('[DEBUG] SSE 수신 성공:', data);
setReceivedNewNotification(true);

// Query Key 무효화
// 공통
queryClient.invalidateQueries({ queryKey: notificationKeys.unReadCount() });
queryClient.invalidateQueries({ queryKey: notificationKeys.list() });
// TODO: 알림 타입별 처리 추가 예정

switch (data.type) {
case 'FOLLOW': // 서버 문제 해결 후 검증 필요
queryClient.invalidateQueries({ queryKey: userKeys.me() });
queryClient.invalidateQueries({ queryKey: userKeys.item(data.user.id) });
return;
case 'GROUP_CREATE': // 모임 목록이 react query 아니라서 업데이트 안됨
queryClient.invalidateQueries({ queryKey: groupKeys.lists() });
return;
case 'GROUP_DELETE': // 모임 목록이 react query 아니라서 업데이트 안됨
queryClient.invalidateQueries({ queryKey: groupKeys.lists() });
return;
case 'GROUP_JOIN': //OK
if (data.group === null) return;
queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) });
return;
case 'GROUP_LEAVE': //OK
if (data.group === null) return;
queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) });
return;
case 'GROUP_JOIN_REQUEST': //OK
if (data.group === null) return;
queryClient.invalidateQueries({
queryKey: groupKeys.joinRequests(String(data.group.id), 'PENDING'),
});
case 'GROUP_JOIN_APPROVED': //OK
if (data.group === null) return;
queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) });
case 'GROUP_JOIN_REJECTED': //OK
if (data.group === null) return;
queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) });
case 'GROUP_JOIN_KICKED': //OK
if (data.group === null) return;
queryClient.invalidateQueries({ queryKey: groupKeys.detail(String(data.group.id)) });
}
} catch (error) {
console.error('[DEBUG] SSE 데이터 파싱 실패:', error);
}
Expand Down
18 changes: 9 additions & 9 deletions src/mock/service/notification/notification-mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'A 님이 팔로우했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'follow',
type: 'FOLLOW',
user: {
id: 1,
nickname: 'A',
Expand All @@ -18,7 +18,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'B님이 "A가 만든 모임" 모임에 참가했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-join',
type: 'GROUP_JOIN',
user: {
id: 1,
nickname: 'B',
Expand All @@ -33,7 +33,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'B님이 "A가 만든 모임" 모임을 탈퇴했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-leave',
type: 'GROUP_LEAVE',
user: {
id: 1,
nickname: 'B',
Expand All @@ -48,7 +48,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'A님이 "A가 만든 모임" 모임을 생성했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-create',
type: 'GROUP_CREATE',
user: {
id: 1,
nickname: 'A',
Expand All @@ -63,7 +63,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'A님이 "A가 만든 모임" 모임을 취소했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-delete',
type: 'GROUP_DELETE',
user: {
id: 1,
nickname: 'A',
Expand All @@ -78,7 +78,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: 'B님이 "A가 만든 모임" 모임에 참여 신청했습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-join-request',
type: 'GROUP_JOIN_REQUEST',
user: {
id: 1,
nickname: 'B',
Expand All @@ -93,7 +93,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: '"A가 만든 모임" 모임 참여 신청이 승인되었습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-join-approved',
type: 'GROUP_JOIN_APPROVED',
user: {
id: 1,
nickname: 'A',
Expand All @@ -108,7 +108,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: '"A가 만든 모임" 모임 참여 신청이 거절되었습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-join-rejected',
type: 'GROUP_JOIN_REJECTED',
user: {
id: 1,
nickname: 'A',
Expand All @@ -123,7 +123,7 @@ export const mockNotificationItems: NotificationItem[] = [
message: '"A가 만든 모임" 모임에서 추방되었습니다.',
readAt: null,
createdAt: '2025-12-25T08:10:38.747958',
type: 'group-join-kicked',
type: 'GROUP_JOIN_KICKED',
user: {
id: 1,
nickname: 'A',
Expand Down
20 changes: 10 additions & 10 deletions src/types/service/notification.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
export type NotificationType =
| 'follow'
| 'group-join'
| 'group-leave'
| 'group-create'
| 'group-delete'
| 'group-join-request'
| 'group-join-approved'
| 'group-join-rejected'
| 'group-join-kicked';
| 'FOLLOW'
| 'GROUP_JOIN'
| 'GROUP_LEAVE'
| 'GROUP_CREATE'
| 'GROUP_DELETE'
| 'GROUP_JOIN_REQUEST'
| 'GROUP_JOIN_APPROVED'
| 'GROUP_JOIN_REJECTED'
| 'GROUP_JOIN_KICKED';

type NotificationTypeWithoutGroup = 'follow';
type NotificationTypeWithoutGroup = 'FOLLOW';
type NotificationTypeWithGroup = Exclude<NotificationType, NotificationTypeWithoutGroup>;

interface BaseNotification {
Expand Down