diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 8e989c55..4783daef 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -26,10 +26,18 @@ const preview: Preview = { date: /Date$/i, }, }, + nextjs: { + appDirectory: true, + navigation: { + push() {}, + replace() {}, + prefetch() {}, + }, + }, }, decorators: [ (Story) => ( - + ), diff --git a/src/components/pages/notification/notification-card/index.stories.tsx b/src/components/pages/notification/notification-card/index.stories.tsx new file mode 100644 index 00000000..ab202908 --- /dev/null +++ b/src/components/pages/notification/notification-card/index.stories.tsx @@ -0,0 +1,116 @@ +import type { Meta, StoryObj } from '@storybook/nextjs'; + +import { mockNotificationItems } from '@/mock/service/notification/notification-mocks'; + +import { NotificationCard } from '.'; + +const meta = { + title: 'Components/NotificationCard', + component: NotificationCard, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Follow: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'follow')!, + }, +}; + +export const GroupJoin: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-join')!, + }, +}; + +export const GroupLeave: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-leave')!, + }, +}; + +export const GroupCreate: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-create')!, + }, +}; + +export const GroupDelete: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-delete')!, + }, +}; + +export const GroupJoinRequest: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-join-request')!, + }, +}; + +export const GroupJoinApproved: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-join-approved')!, + }, +}; + +export const GroupJoinRejected: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-join-rejected')!, + }, +}; + +export const GroupJoinKicked: Story = { + args: { + item: mockNotificationItems.find((item) => item.type === 'group-join-kicked')!, + }, +}; + +export const Unread: Story = { + args: { + item: { + ...mockNotificationItems[0], + readAt: '', + }, + }, +}; + +export const Read: Story = { + args: { + item: { + ...mockNotificationItems[0], + readAt: '2024-01-01T12:00:00Z', + }, + }, +}; + +export const AllNotifications: Story = { + args: { + item: mockNotificationItems[0], // 더미 args + }, + render: () => ( +
+ {mockNotificationItems.map((item) => ( + + ))} +
+ ), + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/src/components/pages/notification/notification-card/index.tsx b/src/components/pages/notification/notification-card/index.tsx index 62eb90f4..11f6cf91 100644 --- a/src/components/pages/notification/notification-card/index.tsx +++ b/src/components/pages/notification/notification-card/index.tsx @@ -1,6 +1,8 @@ import { useRouter } from 'next/navigation'; import { Icon } from '@/components/icon'; +import { Toast } from '@/components/ui'; +import { useToast } from '@/components/ui/toast/core'; import { useUpdateNotificationRead } from '@/hooks/use-notification/use-notification-update-read'; import { formatTimeAgo } from '@/lib/formatDateTime'; import { cn } from '@/lib/utils'; @@ -13,17 +15,22 @@ interface Props { export const NotificationCard = ({ item }: Props) => { const router = useRouter(); const { mutateAsync } = useUpdateNotificationRead(); + const { run } = useToast(); const NotificationIcon = IconMap[item.type]; const title = getTitle(item); const description = getDescription(item); const timeAgo = getTimeAgo(item); + const redirectUrl = getRedirectUrl(item); const handleNotificationClick = () => { try { mutateAsync(item.id); - - router.push(`${item.redirectUrl}`); + if (redirectUrl) { + router.push(redirectUrl); + } else { + run(이미 마감되었거나 삭제된 모임입니다.); + } } catch {} }; @@ -50,45 +57,91 @@ export const NotificationCard = ({ item }: Props) => { }; const IconMap: Record = { - FOLLOW: , - GROUP_CREATE: , - GROUP_DELETE: , - GROUP_JOIN: , - EXIT: , + follow: , + 'group-join': , + 'group-leave': , + 'group-create': , + 'group-delete': , + 'group-join-request': , + 'group-join-approved': , + 'group-join-rejected': , + 'group-join-kicked': , }; const getTitle = (data: NotificationItem) => { switch (data.type) { - case 'FOLLOW': + case 'follow': return `새 팔로워`; - case 'GROUP_CREATE': - return `모임 생성`; - case 'GROUP_DELETE': - return `모임 취소`; - case 'GROUP_JOIN': + case 'group-join': return `모임 현황`; - case 'EXIT': + case 'group-leave': return `모임 현황`; + case 'group-create': + return `모임 생성`; + case 'group-delete': + return `모임 취소`; + case 'group-join-request': + return `모임 참여 신청`; + case 'group-join-approved': + return `모임 신청 승인`; + case 'group-join-rejected': + return `모임 신청 거절`; + case 'group-join-kicked': + return `모임 강퇴`; } }; const getDescription = (data: NotificationItem) => { - // switch (data.type) { - // case 'FOLLOW': - // return `${data.actorNickname} 님이 팔로우 했습니다.`; - // case 'GROUP_CREATE': - // return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 생성했습니다.`; - // case 'CANCLE': - // return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 취소했습니다.`; - // case 'ENTER': - // return `${data.actorNickname} 님이 "${data.actorNickname}" 모임에 참가했습니다.`; - // case 'EXIT': - // return `${data.actorNickname} 님이 "${data.actorNickname}" 모임을 떠났습니다.`; - // } - return data.message; + // user type 알림 + switch (data.type) { + case 'follow': + return `${data.user.nickname} 님이 팔로우했어요.`; + } + + // group type 알림 + // 알림 필드 type 변경 전 데이터는 group 필드가 null로 조회되므로 fallback 처리 + if (!data.group) return data.message; + + // group 필드가 null이 아닐 경우 + switch (data.type) { + case 'group-join': + return `${data.user.nickname} 님이 "${data.group.title}" 모임에 참여했어요.`; + case 'group-leave': + return `${data.user.nickname} 님이 "${data.group.title}" 모임을 탈퇴했어요.`; + case 'group-create': + return `${data.user.nickname} 님이 "${data.group.title}" 모임을 생성했어요.`; + case 'group-delete': + return `${data.user.nickname} 님이 "${data.group.title}" 모임을 취소했어요.`; + case 'group-join-request': + return `${data.user.nickname} 님이 "${data.group.title}" 모임에 참여를 요청했어요.`; + case 'group-join-approved': + return `"${data.group.title}" 모임 참여 신청이 승인됐어요.`; + case 'group-join-rejected': + return `"${data.group.title}" 모임 참여 신청이 거절됐어요.`; + case 'group-join-kicked': + return `"${data.group.title}" 모임에서 퇴장됐어요.`; + } }; const getTimeAgo = (data: NotificationItem) => { const { createdAt } = data; return formatTimeAgo(createdAt); }; + +const getRedirectUrl = (data: NotificationItem) => { + // user type 알림 + switch (data.type) { + case 'follow': + return `/profile/${data.user.id}`; + } + + // 알림 필드 type 변경 전 데이터는 group 필드가 null로 조회되므로 fallback 처리 + if (!data.group) return null; + + switch (data.type) { + case 'group-join-request': + return `/pending/${data.group.id}`; + default: + return `/group/${data.group.id}`; + } +}; diff --git a/src/mock/service/notification/notification-mocks.ts b/src/mock/service/notification/notification-mocks.ts new file mode 100644 index 00000000..844549f6 --- /dev/null +++ b/src/mock/service/notification/notification-mocks.ts @@ -0,0 +1,136 @@ +import { NotificationItem } from '@/types/service/notification'; + +export const mockNotificationItems: NotificationItem[] = [ + { + id: 1, + message: 'A 님이 팔로우했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'follow', + user: { + id: 1, + nickname: 'A', + }, + group: null, + }, + { + id: 2, + message: 'B님이 "A가 만든 모임" 모임에 참가했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-join', + user: { + id: 1, + nickname: 'B', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 3, + message: 'B님이 "A가 만든 모임" 모임을 탈퇴했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-leave', + user: { + id: 1, + nickname: 'B', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 4, + message: 'A님이 "A가 만든 모임" 모임을 생성했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-create', + user: { + id: 1, + nickname: 'A', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 5, + message: 'A님이 "A가 만든 모임" 모임을 취소했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-delete', + user: { + id: 1, + nickname: 'A', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 6, + message: 'B님이 "A가 만든 모임" 모임에 참여 신청했습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-join-request', + user: { + id: 1, + nickname: 'B', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 7, + message: '"A가 만든 모임" 모임 참여 신청이 승인되었습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-join-approved', + user: { + id: 1, + nickname: 'A', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 8, + message: '"A가 만든 모임" 모임 참여 신청이 거절되었습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-join-rejected', + user: { + id: 1, + nickname: 'A', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, + { + id: 9, + message: '"A가 만든 모임" 모임에서 추방되었습니다.', + readAt: null, + createdAt: '2025-12-25T08:10:38.747958', + type: 'group-join-kicked', + user: { + id: 1, + nickname: 'A', + }, + group: { + id: 1, + title: 'A가 만든 모임', + }, + }, +]; diff --git a/src/types/service/notification.ts b/src/types/service/notification.ts index 439bee2b..0b092672 100644 --- a/src/types/service/notification.ts +++ b/src/types/service/notification.ts @@ -1,20 +1,43 @@ -export type NotificationType = 'FOLLOW' | 'GROUP_JOIN' | 'EXIT' | 'GROUP_CREATE' | 'GROUP_DELETE'; +export type NotificationType = + | 'follow' + | 'group-join' + | 'group-leave' + | 'group-create' + | 'group-delete' + | 'group-join-request' + | 'group-join-approved' + | 'group-join-rejected' + | 'group-join-kicked'; -export interface NotificationItem { +type NotificationTypeWithoutGroup = 'follow'; +type NotificationTypeWithGroup = Exclude; + +interface BaseNotification { id: number; - receiverId: number; - actorId: number; - actorNickname: string; - actorProfileImage: string; - type: NotificationType; + message: string; readAt: string | null; - relatedId: number; - relatedType: NotificationType; - redirectUrl: string; createdAt: string; - message: string; + user: { + id: number; + nickname: string; + }; +} + +interface NotificationWithoutGroup extends BaseNotification { + type: NotificationTypeWithoutGroup; + group: null; +} + +interface NotificationWithGroup extends BaseNotification { + type: NotificationTypeWithGroup; + group: { + id: number; + title: string; + } | null; } +export type NotificationItem = NotificationWithoutGroup | NotificationWithGroup; + export interface NotificationList { notifications: NotificationItem[]; nextCursor: number | null;