Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion src/app/(non-header)/login/components/LoginForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export default function LoginForm() {

return (
<div className='flex justify-center bg-white'>
<div className='mx-13 mt-110 mb-57 flex w-full min-w-350 flex-col gap-24 md:mx-0 md:mt-180 md:max-w-640 md:gap-56'>
<div className='mx-13 mt-110 mb-57 flex w-full min-w-350 flex-col gap-24 md:mx-0 md:mt-118 md:max-w-640 md:gap-56'>
<div className='flex justify-center'>
<Link href='/'>
<BrandMark className='h-154 w-270 md:h-192 md:w-340' />
Expand Down
2 changes: 1 addition & 1 deletion src/app/(non-header)/signup/components/SignupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export default function SignupForm() {

return (
<div className='flex justify-center bg-white'>
<div className='mx-13 mt-110 mb-57 flex w-full min-w-350 flex-col gap-24 md:mx-0 md:mt-180 md:max-w-640 md:gap-56'>
<div className='mx-13 mt-110 mb-57 flex w-full min-w-350 flex-col gap-24 md:mx-0 md:mt-118 md:max-w-640 md:gap-56'>
<div className='flex justify-center'>
<Link href='/'>
<BrandMark className='h-154 w-270 md:h-192 md:w-340' />
Expand Down
43 changes: 43 additions & 0 deletions src/app/api/my-notifications/[notificationId]/route.ts
Original file line number Diff line number Diff line change
@@ -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<ServerErrorResponse>;
const message = error.response?.data?.error || '알람 데이터 조회 실패';
const status = error.response?.status || 500;

return NextResponse.json({ error: message }, { status });
}
}
71 changes: 71 additions & 0 deletions src/app/api/my-notifications/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse>} 응답 객체
* - 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<ServerErrorResponse>;
const message = error.response?.data?.error || '알람 데이터 조회 실패';
const status = error.response?.status || 500;

return NextResponse.json({ error: message }, { status });
}
}
36 changes: 30 additions & 6 deletions src/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<header className='fixed z-100 w-full border-b border-gray-300 bg-white'>
<div className='mx-auto flex min-h-70 max-w-1200 items-center justify-between px-20 py-20'>
<div className='relative mx-auto flex min-h-70 max-w-1200 items-center justify-between px-20 py-20'>
{/* 로고 */}
<Link
href='/'
Expand All @@ -31,14 +44,25 @@ export default function Header() {
</Link>

{/* 우측 메뉴 */}
<div className='text-md relative flex items-center gap-24 text-black'>
<div className='text-md flex items-center gap-24 text-black'>
{isLoggedIn ? (
<>
{/* 알림 아이콘 */}
<button aria-label='알림' className='hover:text-primary'>
<button
aria-label='알림'
onClick={toggleOpen}
className='hover:text-primary'
>
<IconBell />
</button>

{isOpen && (
<NotificationDropdown
className='md:right- fixed inset-0 md:absolute md:top-90 md:left-800'
onClose={() => setIsOpen(false)}
/>
)}

{/* 구분선 */}
<div className='mx-12 h-22 w-px bg-gray-300' />

Expand Down
2 changes: 1 addition & 1 deletion src/components/Loading.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

export default function Loading() {
return (
<div className='flex min-h-screen items-center justify-center'>
<div className='flex items-center justify-center'>
<div
className='animate-loader-spin aspect-square w-80 rounded-full bg-green-300 p-2'
style={{
Expand Down
112 changes: 112 additions & 0 deletions src/components/Notification/NotificationCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

import { useDeleteNotification } from '@/hooks/useDeleteNotification';
import cn from '@/lib/cn';
import relativeTime from '@/utils/relativeTime';
import IconClose from '@assets/svg/close';

type NotificationStatus = 'confirmed' | 'declined';

interface NotificationCardProps {
content: string;
id: number;
createdAt: string;
onDelete: (id: number) => 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<string, NotificationStatus> = {
승인: '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 (
<div className='w-full rounded-[5px] border border-gray-400 bg-white px-12 py-16'>
<div className='flex items-start justify-between'>
{status && (
<div
className={cn(
'mt-4 h-5 w-5 rounded-full',
statusColorMap[status].dot,
)}
/>
)}
<button
onClick={(e) => {
setTimeout(() => {
e.stopPropagation();
handleDelete();
}, 0);
}}
aria-label='알림 삭제'
>
<IconClose color='#a1a1a1' className='pointer-events-none' />
</button>
</div>

<p className='text-md font-regular whitespace-pre-line text-black'>
{formattedContent.split(/(승인|거절)/).map((text, i) => {
const matchedStatus = statusKeywordMap[text];
return (
<span
key={i}
className={cn(
matchedStatus && statusColorMap[matchedStatus].text,
)}
>
{text}
</span>
);
})}
</p>

<p className='mt-4 text-xs text-gray-600'>{relativeTime(createdAt)}</p>
</div>
);
}
Loading