diff --git a/src/app/(non-header)/login/components/LoginForm.tsx b/src/app/(non-header)/login/components/LoginForm.tsx index 41edf1a..2eb9a05 100644 --- a/src/app/(non-header)/login/components/LoginForm.tsx +++ b/src/app/(non-header)/login/components/LoginForm.tsx @@ -96,7 +96,7 @@ export default function LoginForm() { return (
-
+
diff --git a/src/app/(non-header)/signup/components/SignupForm.tsx b/src/app/(non-header)/signup/components/SignupForm.tsx index b437411..2b6ed6e 100644 --- a/src/app/(non-header)/signup/components/SignupForm.tsx +++ b/src/app/(non-header)/signup/components/SignupForm.tsx @@ -114,7 +114,7 @@ export default function SignupForm() { return (
-
+
diff --git a/src/app/api/my-notifications/[notificationId]/route.ts b/src/app/api/my-notifications/[notificationId]/route.ts new file mode 100644 index 0000000..71525da --- /dev/null +++ b/src/app/api/my-notifications/[notificationId]/route.ts @@ -0,0 +1,43 @@ +import { ServerErrorResponse } from '@/types/apiErrorResponseType'; +import axios, { AxiosError } from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function DELETE(req: NextRequest) { + const url = new URL(req.url); + const segments = url.pathname.split('/'); + const id = Number(segments.pop()); + + if (isNaN(id)) { + return NextResponse.json( + { message: '유효하지 않은 알림 ID' }, + { status: 400 }, + ); + } + + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json({ message: '액세스 토큰 없음' }, { status: 401 }); + } + + try { + const res = await axios.delete( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/my-notifications/${id}`, + { headers: { Authorization: `Bearer ${accessToken}` } }, + ); + + if (res.status === 204) { + return new NextResponse(null, { status: 204 }); + } + + return NextResponse.json({ message: '삭제 실패' }, { status: res.status }); + } catch (err) { + const error = err as AxiosError; + const message = error.response?.data?.error || '알람 데이터 조회 실패'; + const status = error.response?.status || 500; + + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/my-notifications/route.ts b/src/app/api/my-notifications/route.ts new file mode 100644 index 0000000..2ab1add --- /dev/null +++ b/src/app/api/my-notifications/route.ts @@ -0,0 +1,71 @@ +import { ServerErrorResponse } from '@/types/apiErrorResponseType'; +import axios, { AxiosError } from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +/** + * [GET] /api/my-notifications + * + * 클라이언트로부터 전달받은 액세스 토큰을 기반으로 + * 사용자 본인의 알림 목록을 백엔드에서 조회하는 API 라우트 핸들러입니다. + * + * @param {NextRequest} req - Next.js에서 제공하는 요청 객체. + * - `searchParams.cursorId` (선택): 커서 기반 페이지네이션을 위한 알림 ID + * - `searchParams.size` (선택): 한 번에 가져올 알림 개수 (기본값: 10) + * + * @returns {Promise} 응답 객체 + * - 200 OK: 알림 목록(JSON) 반환 + * - 401 Unauthorized: 액세스 토큰이 없을 경우 + * - 500 또는 기타 상태: 백엔드 오류 또는 알 수 없는 오류 발생 시 + * + * @example + * // 요청 예시 + * GET /api/my-notifications?cursorId=30&size=10 + * + * // 성공 응답 예시 + * { + * "notifications": [{ id: 31, content: "새 알림", ... }], + * "cursorId": 41, + * "totalCount": 99 + * } + * + * @description + * - 이 핸들러는 클라이언트 쿠키에서 accessToken을 추출하여, + * 백엔드 `/my-notifications` 엔드포인트에 요청을 보냅니다. + * - `cursorId`와 `size`는 쿼리 파라미터로 전달되며, 둘 다 선택입니다. + * - 오류 발생 시 적절한 상태 코드 및 메시지를 포함하여 응답합니다. + */ +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url); + const rawCursor = searchParams.get('cursorId'); + const cursorId = rawCursor !== null ? Number(rawCursor) : undefined; + + const size = Number(searchParams.get('size')) || 10; + + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json({ message: '액세스 토큰 없음' }, { status: 401 }); + } + + try { + const res = await axios.get( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/my-notifications`, + { + params: cursorId !== undefined ? { cursorId, size } : { size }, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + return NextResponse.json(res.data); + } catch (err) { + const error = err as AxiosError; + const message = error.response?.data?.error || '알람 데이터 조회 실패'; + const status = error.response?.status || 500; + + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 666defe..125b230 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -6,22 +6,35 @@ import IconBell from '@assets/svg/bell'; import useUserStore from '@/stores/authStore'; import { useRouter } from 'next/navigation'; import ProfileDropdown from '@/components/ProfileDropdown'; +import useLogout from '@/hooks/useLogout'; +import { toast } from 'sonner'; +import { useState } from 'react'; +import NotificationDropdown from './Notification/NotificationDropdown'; export default function Header() { const router = useRouter(); const user = useUserStore((state) => state.user); const setUser = useUserStore((state) => state.setUser); const isLoggedIn = !!user; + const logout = useLogout(); + const [isOpen, setIsOpen] = useState(false); + + const toggleOpen = () => setIsOpen((prev) => !prev); // 로그아웃 처리 - const handleLogout = () => { - setUser(null); - router.push('/'); + const handleLogout = async () => { + try { + await logout(); + setUser(null); + router.push('/'); + } catch { + toast.error('로그아웃 실패'); + } }; return (
-
+
{/* 로고 */} {/* 우측 메뉴 */} -
+
{isLoggedIn ? ( <> {/* 알림 아이콘 */} - + {isOpen && ( + setIsOpen(false)} + /> + )} + {/* 구분선 */}
diff --git a/src/components/Loading.tsx b/src/components/Loading.tsx index 854913c..161cb0e 100644 --- a/src/components/Loading.tsx +++ b/src/components/Loading.tsx @@ -2,7 +2,7 @@ export default function Loading() { return ( -
+
void; +} + +const statusColorMap: Record< + NotificationStatus, + { dot: string; text: string } +> = { + confirmed: { dot: 'bg-blue-300', text: 'text-blue-300' }, + declined: { dot: 'bg-red-300', text: 'text-red-300' }, +}; + +const statusKeywordMap: Record = { + 승인: 'confirmed', + 거절: 'declined', +}; + +/** + * 알림 카드를 표시하는 UI 컴포넌트입니다. + * 알림 내용, 생성 시간, 상태(승인/거절)에 따라 스타일을 다르게 보여주며, + * 알림 삭제 버튼도 함께 제공합니다. + * + * @component + * @param {NotificationCardProps} props - 알림 카드에 전달되는 속성들 + * @param {string} props.content - 알림 본문 내용 + * @param {number} props.id - 알림 ID + * @param {string} props.createdAt - 알림 생성 시각 (ISO 형식) + * @param {(id: number) => void} props.onDelete - 삭제 시 실행할 콜백 함수 + * + * @description + * - 알림 내용 중 '승인' 또는 '거절'이라는 키워드를 포함하면 상태를 감지해 색상을 다르게 표시합니다. + * - 날짜 정보가 포함된 경우 날짜 앞에 줄바꿈을 추가하여 보기 좋게 포맷합니다. + * - 삭제 버튼 클릭 시 알림을 UI에서 제거하고, 서버에도 삭제 요청을 보냅니다. + * - 삭제 버튼 클릭 시 이벤트 전파로 인해 드롭다운이 닫히는 현상을 막기 위해 `setTimeout`을 사용하여 처리 순서를 조정합니다. + */ +export default function NotificationCard({ + content, + createdAt, + id, + onDelete, +}: NotificationCardProps) { + const { mutate: deleteNotification } = useDeleteNotification(); + + const formattedContent = content.replace(/(\(\d{4}-\d{2}-\d{2})\s+/, '$1\n'); + + const handleDelete = () => { + onDelete(id); + deleteNotification(id); + }; + + const keywordMatch = Object.entries(statusKeywordMap).find(([k]) => + content.includes(k), + ); + + const status = keywordMatch?.[1]; + + return ( +
+
+ {status && ( +
+ )} + +
+ +

+ {formattedContent.split(/(승인|거절)/).map((text, i) => { + const matchedStatus = statusKeywordMap[text]; + return ( + + {text} + + ); + })} +

+ +

{relativeTime(createdAt)}

+
+ ); +} diff --git a/src/components/Notification/NotificationCardList.tsx b/src/components/Notification/NotificationCardList.tsx new file mode 100644 index 0000000..0c741ad --- /dev/null +++ b/src/components/Notification/NotificationCardList.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { getMyNotifications } from '@/hooks/useNotification'; +import { Notification } from '@/types/notificationType'; +import { useEffect, useRef, useState } from 'react'; +import NotificationCard from './NotificationCard'; +import { toast } from 'sonner'; + +type NotificationCardListProps = { + notification: Notification['notifications']; +}; + +/** + * 알림 목록을 렌더링하고, 무한 스크롤 방식으로 알림을 추가 로드하는 컴포넌트입니다. + * + * @component + * @param {NotificationCardListProps} props - 컴포넌트에 전달되는 props + * @param {Notification['notifications']} props.notification - 초기 알림 목록 데이터 + * + * @description + * - 최초 렌더링 시 props로 전달된 알림 데이터를 기반으로 초기 알림 리스트를 구성합니다. + * - IntersectionObserver를 사용하여 마지막 요소가 뷰포트에 들어올 때 추가 알림을 비동기적으로 요청합니다. + * - 이미 렌더링된 알림 ID는 `Set`으로 추적하여 중복 알림이 리스트에 추가되지 않도록 방지합니다. + * - 알림 삭제 시 `onDelete` 콜백을 통해 해당 알림을 `currentNotifications` 상태에서 제거합니다. + * - 알림이 하나도 없을 경우 "알림이 없습니다"라는 메시지를 출력합니다. + */ +export default function NotificationCardList({ + notification, +}: NotificationCardListProps) { + // 현재 화면에 표시할 알림 목록 상태 + const [currentNotifications, setCurrentNotifications] = + useState(notification); + + // 마지막 요소를 감지하기 위한 ref (IntersectionObserver 대상) + const observerRef = useRef(null); + + // 더 가져올 알림이 있는지 여부 + const [hasMore, setHasMore] = useState(notification.length > 0); + + // 추가 알림을 가져오는 중인지 여부 + const [isFetching, setIsFetching] = useState(false); + + // 중복 알림을 막기 위한 ID 저장소 (Set) + const existingId = useRef(new Set()); + + /** + * 새로운 알림을 비동기로 불러오는 함수 + * - 중복 알림 필터링 + */ + const fetchNotifications = async () => { + if (isFetching || !hasMore) return; + + setIsFetching(true); + try { + const data = await getMyNotifications({ + size: 10, + }); + + if (!data?.notifications?.length) { + setHasMore(false); + return; + } + + // 중복 제거: 이미 렌더링된 ID 제외 + const newItems = data.notifications.filter( + (n) => !existingId.current.has(n.id), + ); + + // 새로 들어온 알림 ID 등록 + newItems.forEach((n) => existingId.current.add(n.id)); + + if (newItems.length === 0) { + setHasMore(false); + return; + } + + // 새로운 알림을 기존 목록에 추가 + setCurrentNotifications((prev) => [...prev, ...newItems]); + + // 다음 커서가 없으면 더 이상 불러올 데이터 없음 + if (data.cursorId === null) { + setHasMore(false); + } + } catch { + toast.error('알림 추가 실패'); + } finally { + setIsFetching(false); + } + }; + + /** + * 초기 렌더링 시: + * - 기존 알림 ID Set 구성 + * - 상태 초기화 + */ + useEffect(() => { + if (existingId.current.size === 0) { + const initialId = new Set(notification.map((n) => n.id)); + existingId.current = initialId; + } + + setCurrentNotifications(notification); + setHasMore(notification.length > 0); + }, [notification]); + + /** + * IntersectionObserver로 스크롤 하단 감지 → fetchNotifications 호출 + */ + useEffect(() => { + const target = observerRef.current; + if (!target || !hasMore || isFetching) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) fetchNotifications(); + }, + { rootMargin: '100px', threshold: 0.1 }, + ); + + observer.observe(target); + + return () => observer.disconnect(); + }, [hasMore, isFetching]); + + if (currentNotifications.length === 0) { + return ( +
+ 알림이 없습니다. +
+ ); + } + + return ( +
+ {currentNotifications.map((n) => ( + + setCurrentNotifications((prev) => + prev.filter((item) => item.id !== id), + ) + } + /> + ))} + +
+
+ ); +} diff --git a/src/components/Notification/NotificationDropdown.tsx b/src/components/Notification/NotificationDropdown.tsx new file mode 100644 index 0000000..d9e43e0 --- /dev/null +++ b/src/components/Notification/NotificationDropdown.tsx @@ -0,0 +1,75 @@ +'use Client'; + +import { useNotifications } from '@/hooks/useNotification'; +import useOutsideClick from '@/hooks/useOutsideClick'; +import IconClose from '@assets/svg/close'; +import { useEffect, useRef } from 'react'; +import { toast } from 'sonner'; +import Loading from '../Loading'; +import NotificationCardList from './NotificationCardList'; +import cn from '@/lib/cn'; + +type NotificationDropdownProps = { + onClose: () => void; + className?: string; +}; + +/** + * 알림 드롭다운 컴포넌트 + * + * @component + * @param {NotificationDropdownProps} props - 알림 드롭다운 props + * @param {() => void} props.onClose - 드롭다운을 닫는 콜백 함수 + * + * @description + * - 알림 목록을 보여주는 드롭다운 컴포넌트입니다. + * - `useNotifications` 훅을 사용하여 알림 데이터를 가져옵니다. + * - 로딩 중에는 Loading 컴포넌트를, 에러 발생 시 토스트 알림을 표시합니다. + * - `NotificationCardList` 컴포넌트를 통해 알림 목록을 렌더링합니다. + * - 드롭다운 외부를 클릭하면 `onClose` 콜백을 호출하여 닫힙니다. + * - 반응형 대응이 되어 있으며, 모바일에서는 전체 화면으로, 데스크탑에서는 작은 박스로 표시됩니다. + */ +export default function NotificationDropdown({ + onClose, + className, +}: NotificationDropdownProps) { + const dropdownRef = useRef(null); + useOutsideClick(dropdownRef, onClose); + + const { data, isLoading, isError } = useNotifications({ + size: 10, + }); + + useEffect(() => { + if (isError && !data) { + toast.error('알림을 불러오지 못했어요.'); + } + }, [isError, data]); + + return ( +
+
+

알림 {data?.totalCount ?? 0}개

+ +
+ + {isLoading && ( +
+ +
+ )} + + {!isLoading && data && ( + + )} +
+ ); +} diff --git a/src/hooks/useDeleteNotification.ts b/src/hooks/useDeleteNotification.ts new file mode 100644 index 0000000..66f95f4 --- /dev/null +++ b/src/hooks/useDeleteNotification.ts @@ -0,0 +1,44 @@ +import { privateInstance } from '@/apis/privateInstance'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +/** + * 특정 알림을 삭제하는 비동기 함수입니다. + * + * @param {number} notificationId - 삭제할 알림의 고유 ID + * @returns {Promise} 삭제가 성공하면 아무것도 반환하지 않음 + * @throws {Error} 삭제가 실패한 경우 에러를 throw + * + * @example + * await deleteNotification(1234); // 알림 ID 1234 삭제 + */ +const deleteNotification = async (notificationId: number): Promise => { + const res = await privateInstance.delete( + `/my-notifications/${notificationId}`, + ); + if (res.status === 204) return; + + throw new Error(`삭제 실패:status ${res.status}`); +}; + +/** + * 알림 삭제를 위한 커스텀 훅입니다. + * + * @returns {UseMutationResult} 알림 삭제 뮤테이션 훅 + * + * @description + * - TanStack Query의 `useMutation`을 활용하여 알림을 삭제합니다. + * - 삭제 성공 시 `['notifications']` 쿼리를 무효화하여 목록을 자동 갱신합니다. + * + * @example + * const { mutate: deleteNotification } = useDeleteNotification(); + * deleteNotification(1234); // 알림 ID 1234 삭제 + */ +export const useDeleteNotification = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (NotificationId: number) => deleteNotification(NotificationId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['notifications'] }); + }, + }); +}; diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 0000000..283a05e --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,41 @@ +import useUserStore from '@/stores/authStore'; +import axios from 'axios'; + +/** + * useLogout 훅은 사용자 로그아웃 기능을 제공합니다. + * + * - `/api/auth/logout` 엔드포인트에 POST 요청을 보내 서버 측 세션 및 토큰을 제거합니다. + * - 응답이 성공적이면 전역 상태에서 사용자 정보를 제거하고 페이지를 새로고침합니다. + * - 오류 발생 시 사용자에게 알림을 표시합니다. + * + * @returns {() => Promise} logout 함수 - 비동기 로그아웃 처리 함수 + * + * @example + * const logout = useLogout(); + * await logout(); + */ +export default function useLogout() { + const clearUser = useUserStore((state) => state.clearUser); + + /** + * 로그아웃을 수행하는 비동기 함수입니다. + * - 서버에 로그아웃 요청을 보낸 후 상태를 초기화하고 페이지를 리로드합니다. + * - 실패 시 alert로 오류를 알립니다. + */ + const logout = async () => { + try { + const res = await axios.post('/api/auth/logout'); + + if (res.status !== 200) { + throw new Error('로그아웃에 실패했습니다.'); + } + + clearUser(); + window.location.reload(); + } catch { + alert('로그아웃 오류 발생'); // 토스트 예정 + } + }; + + return logout; +} diff --git a/src/hooks/useNotification.ts b/src/hooks/useNotification.ts new file mode 100644 index 0000000..bd03aff --- /dev/null +++ b/src/hooks/useNotification.ts @@ -0,0 +1,55 @@ +import { privateInstance } from '@/apis/privateInstance'; +import { Notification, NotificationParams } from '@/types/notificationType'; +import { useQuery } from '@tanstack/react-query'; + +/** + * 사용자의 알림 목록을 백엔드에서 가져오는 비동기 함수입니다. + * + * @param {NotificationParams} params - 알림 요청 파라미터 + * @param {number} params.size - 가져올 알림 개수 + * @param {number} [params.cursorId] - 커서 기반 페이지네이션을 위한 기준 ID (선택) + * @returns {Promise} 알림 목록, 전체 개수 및 커서 정보를 포함한 객체 반환 + * + * @example + * const data = await getMyNotifications({ size: 10 }); + * console.log(data.notifications); // 알림 목록 출력 + */ +export const getMyNotifications = async ( + params: NotificationParams, +): Promise => { + const queryParams: Record = { + size: params.size, + ...(params.cursorId != null ? { cursorId: params.cursorId } : {}), + }; + + const res = await privateInstance.get('/my-notifications', { + params: queryParams, + }); + + return res.data; +}; + +/** + * 사용자 알림을 가져오기 위한 TanStack Query 훅입니다. + * + * @param {NotificationParams} params - 알림 요청 파라미터 + * @returns {UseQueryResult} 알림 데이터 및 쿼리 상태를 포함한 객체 + * + * @description + * - `getMyNotifications`를 내부적으로 호출하여 데이터를 불러옵니다. + * - `params`에 따라 쿼리 키가 고유하게 결정되어 캐싱됩니다. + * - 실패 시 fallback으로 빈 알림 배열과 totalCount 0을 반환합니다. + * + * @example + * const { data, isLoading } = useNotifications({ size: 10 }); + */ +export const useNotifications = (params: NotificationParams) => { + return useQuery({ + queryKey: ['notifications', params], + queryFn: async () => { + const data = await getMyNotifications(params); + return data || { notifications: [], totalCount: 0 }; + }, + refetchInterval: 10000, + }); +}; diff --git a/src/types/notificationType.ts b/src/types/notificationType.ts new file mode 100644 index 0000000..abaa5c2 --- /dev/null +++ b/src/types/notificationType.ts @@ -0,0 +1,20 @@ +export interface NotificationItem { + id: number; + teamId: string; + userId: number; + content: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface Notification { + cursorId?: number; + notifications: NotificationItem[]; + totalCount: number; +} + +export interface NotificationParams { + cursorId?: number; + size: number; +} diff --git a/src/utils/relativeTime.ts b/src/utils/relativeTime.ts new file mode 100644 index 0000000..b2a6a6d --- /dev/null +++ b/src/utils/relativeTime.ts @@ -0,0 +1,15 @@ +export default function relativeTime(dateString: string): string { + const now = new Date(); + const target = new Date(dateString); + const diff = now.getTime() - target.getTime(); + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (seconds < 60) return '방금 전'; + if (minutes < 60) return `${minutes}분 전`; + if (hours < 24) return `${hours}시간 전`; + return `${days}일 전`; +}