Skip to content

Commit

Permalink
Merge pull request #9 from AmorGakCo/feat/Notification
Browse files Browse the repository at this point in the history
Feat/notification 알림페이지/ 실시간 알림
  • Loading branch information
phnml1 authored Dec 27, 2024
2 parents c581bd7 + ca91053 commit 03fd283
Show file tree
Hide file tree
Showing 24 changed files with 2,517 additions and 208 deletions.
2,124 changes: 1,955 additions & 169 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"clsx": "^2.1.1",
"cva": "^0.0.0",
"date-fns": "^3.6.0",
"firebase": "^11.1.0",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.424.0",
Expand Down
Binary file added public/close.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 17 additions & 0 deletions public/firebase-messaging-sw.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
importScripts('https://www.gstatic.com/firebasejs/9.14.0/firebase-app-compat.js');
importScripts('https://www.gstatic.com/firebasejs/9.14.0/firebase-messaging-compat.js');

// 이곳에 아까 위에서 앱 등록할때 받은 'firebaseConfig' 값을 넣어주세요.
const firebaseConfig = {
apiKey: "AIzaSyCguupCkfjsQ_8Bc0Je0o1aao80L4EzuUA",
authDomain: "amorgakco.firebaseapp.com",
projectId: "amorgakco",
storageBucket: "amorgakco.firebasestorage.app",
messagingSenderId: "191848766277",
appId: "1:191848766277:web:8ba8491d3e8197e8917c2c",
measurementId: "G-R8S8QTXXRL"
};
// Initialize Firebase
firebase.initializeApp(firebaseConfig);

const messaging = firebase.messaging();
27 changes: 27 additions & 0 deletions src/app/(afterLogin)/_lib/formatRelativeTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export function formatRelativeTime(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime(); // 차이(ms)

if (diffMs < 0) {
return "미래의 날짜입니다.";
}

const diffSeconds = Math.floor(diffMs / 1000);
if (diffSeconds < 60) {
return "방금 전";
}

const diffMinutes = Math.floor(diffSeconds / 60);
if (diffMinutes < 60) {
return `${diffMinutes}분 전`;
}

const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) {
return `${diffHours}시간 전`;
}

const diffDays = Math.floor(diffHours / 24);
return `${diffDays}일 전`;
}

2 changes: 1 addition & 1 deletion src/app/(afterLogin)/home/_lib/fetchJoinGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { fetchWithAuth } from "../../_lib/FetchWithAuth";

export async function fetchJoinGroup(groupId:number) {
const response = await fetchWithAuth(
`/groups/${groupId}/applications `,
`/groups/${groupId}/applications`,
{
method:'POST',
credentials: 'include',
Expand Down
33 changes: 25 additions & 8 deletions src/app/(afterLogin)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
import NavBar from "@/components/ui/Navbar";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { NotificationComponent } from '@/components/NotificationComponent';
import NavBar from '@/components/ui/Navbar';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
import { fetchNotificationServer } from './notification/_lib/fetchNotificationServer';
export default async function RootLayout({
children,

}: Readonly<{
children: React.ReactNode,
children: React.ReactNode;
}>) {
const token = cookies().get('accessToken');
if(!token) {
if (!token) {
redirect('/login');
}
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ['notification', { page: 0 }],
queryFn: fetchNotificationServer,
initialPageParam: 0,
});
const data = queryClient.getQueryData(['notification', { page: 0 }]);
const dehydratedState = data ? dehydrate(queryClient) : null;
return (
<>
{children}
<NavBar/>
<HydrationBoundary state={dehydratedState}>
{children}
<NavBar />
<NotificationComponent />
</HydrationBoundary>
</>
);
}
96 changes: 96 additions & 0 deletions src/app/(afterLogin)/notification/_components/NotificationItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { notificationMessage } from '@/app/_types/Api';
import { Button } from '@/components/ui/button';
import Image from 'next/image';
import { forwardRef } from 'react';
import { useApprovePariticipationMutation } from '../_hooks/useApprovePariticipationMutation';
import { useRejectPariticipationMutation } from '../_hooks/useRejectPariticipationMutation';
import { useDeleteNotificationMutation } from '../_hooks/useDeleteNotificationMutation';
import { formatRelativeTime } from '../../_lib/formatRelativeTime';
interface NotificationItemType {
data: notificationMessage;
}
// forwardRef를 사용한 컴포넌트
export const NotificationItem = forwardRef<
HTMLDivElement,
NotificationItemType
>(({ data }, ref) => {
const { mutate: approveGroup } = useApprovePariticipationMutation(
data.groupId,
data.senderMemberId,
data.notificationId
);
const { mutate: rejectGroup } = useRejectPariticipationMutation(
data.groupId,
data.senderMemberId,
data.notificationId
);
const { mutate: deleteGroup } = useDeleteNotificationMutation(
data.notificationId
);
const handleApproveGroup = async () => {
if (!confirm(`정말로 참여를 승인하시겠습니까?`)) return;
await approveGroup();
};
const handleRejectGroup = async () => {
if (!confirm(`정말로 참여를 거절하시겠습니까?`)) return;
await rejectGroup();
};
const handleDeleteGroup = async () => {
if (data.notificationType === 'PARTICIPATION_REQUEST') {
await handleRejectGroup();
return;
}
await deleteGroup();
};
return (
<div
ref={ref}
className="flex min-w-[312px] max-w-3xl w-full items-center gap-2 md:gap-4 py-[6px]"
>
<div className="h-full flex items-center">
<Image width={44} height={44} src="/coin.svg" alt="notify" />
</div>
<div
className='flex relative w-full flex-col gap-1 text-[#5D5D5D] text-sm md:text-base lg:text-lg'
>
{data.content}
<div className="flex justify-between items-center">
<div className="text-slate-400 md:text-sm text-xs">{formatRelativeTime(new Date(data.createdAt))}</div>
{data.notificationType === 'PARTICIPATION_REQUEST' && (
<div className="flex gap-2">
<Button
onClick={async () => {
handleApproveGroup();
}}
className="w-12 h-6 py-2"
>
승인
</Button>
<Button
onClick={async () => {
handleRejectGroup();
}}
className="w-12 h-6 py-2 bg-red-500 hover:bg-red-400"
>
거절
</Button>
</div>
)}
{data.notificationType !== 'PARTICIPATION_REQUEST' && (
<Button
onClick={async () => {
handleDeleteGroup();
}}
className="w-12 h-6 py-2"
>
읽음
</Button>
)}
</div>
</div>
</div>
);
});

// 디스플레이 네임 설정 (필수는 아니지만 디버깅에 유용)
NotificationItem.displayName = 'NotificationItem';
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { fetchWithAuth } from '@/app/(afterLogin)/_lib/FetchWithAuth';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchApprovePariticipation } from '../_lib/fetchApprovePariticipation';

export function useApprovePariticipationMutation(
groupId: number,
memberId: number,
notificationId: number
) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async () => {
// 그룹 탈퇴 API 요청
return await fetchApprovePariticipation(
groupId,
memberId,
notificationId
); // 그룹 탈퇴 API 호출
},
onSuccess: (data) => {
if (data.status === 'success') {
alert('참여를 승인했습니다.');
queryClient.invalidateQueries({ queryKey: ['groupDetail', groupId] });
queryClient.invalidateQueries({ queryKey: ['group', groupId] });
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'notification',
});
} else {
alert('참여 승인중 오류가 발생했습니다');
}
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchDeleteNotification } from "../_lib/fetchDeleteNotification";

export function useDeleteNotificationMutation(notificationId:number) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async () => {
// 그룹 탈퇴 API 요청
return await fetchDeleteNotification(notificationId); // 그룹 탈퇴 API 호출
},
onSuccess: (data) => {
if (data.status === 'success') {
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'notification',
});
} else {
alert('참여 거절중 오류가 발생했습니다');
}
}
});
}
37 changes: 37 additions & 0 deletions src/app/(afterLogin)/notification/_hooks/useNotificationQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { GroupHistoryData, notificationMessage, ParticipantsHistory } from '@/app/_types/Api';
import { useMemo } from 'react';
import { QueryFunctionContext, useInfiniteQuery } from '@tanstack/react-query';
import { fetchNotification } from '../_lib/fetchNotification';

const useNotificationQuery = () => {
const { data, isLoading, isError, fetchNextPage, isFetchingNextPage, hasNextPage, hasPreviousPage } =
useInfiniteQuery({
queryKey: ['notification', { page:0 }],
queryFn: fetchNotification,
initialPageParam: 0,
getNextPageParam: (lastPage) =>
lastPage.hasNext ? lastPage.page + 1 : undefined,
staleTime: 1000 * 60 * 5,

});
const notifications = useMemo(() => {
if (!data?.pages) return [];

// 각 페이지의 histories를 합치기
return data?.pages.flatMap((page) => page.notificationMessages.map((notification:notificationMessage) => ({
notificationId: notification.notificationId,
createdAt: notification.createdAt,
title: notification.title,
content: notification.content,
groupId: notification.groupId,
senderMemberId: notification.senderMemberId,
receiverMemberId: notification.receiverMemberId,
notificationType: notification.notificationType,
})));
}, [data]);


return { notifications, isLoading, isError, fetchNextPage, isFetchingNextPage, hasNextPage};
};

export default useNotificationQuery;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { fetchWithAuth } from '@/app/(afterLogin)/_lib/FetchWithAuth';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchRejectParticipation } from '../_lib/fetchRejectParticipation';

export function useRejectPariticipationMutation(groupId: number, memberId:number, notificationId:number) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: async () => {
// 그룹 탈퇴 API 요청
return await fetchRejectParticipation(groupId, memberId,notificationId); // 그룹 탈퇴 API 호출
},
onSuccess: (data) => {
if (data.status === 'success') {
alert('참여를 거절했습니다.');
queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'notification',
});
} else {
alert('참여 거절중 오류가 발생했습니다');
}
}
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { fetchWithAuth } from "../../_lib/FetchWithAuth";

export async function fetchApprovePariticipation(groupId:number, memberId:number,notificationId:number) {
const response = await fetchWithAuth(
`/groups/${groupId}/applications/${memberId}/notifications/${notificationId}`,
{
method:'POST',
credentials: 'include',
cache: "no-cache",
},
);
return await response;
}
13 changes: 13 additions & 0 deletions src/app/(afterLogin)/notification/_lib/fetchDeleteNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { fetchWithAuth } from "../../_lib/FetchWithAuth";

export async function fetchDeleteNotification(notificationId:number) {
const response = await fetchWithAuth(
`/notifications/${notificationId} `,
{
method:'DELETE',
credentials: 'include',
cache: "no-cache",
},
);
return await response;
}
21 changes: 21 additions & 0 deletions src/app/(afterLogin)/notification/_lib/fetchNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { fetchWithAuth } from "@/app/(afterLogin)/_lib/FetchWithAuth";
import { noticationApiType } from "@/app/_types/Api";

export const fetchNotification = async ({
pageParam = 0, // 기본값으로 1을 설정
}): Promise<noticationApiType> => {
try {
const response = await fetchWithAuth(
`/notifications?page=${pageParam}`,
{ method: "GET" }
);
if (response.status !== "success") {
throw new Error(`Error: ${response.statusText}`);
}

return response.data as noticationApiType // 반환할 데이터
} catch (error) {
console.error("Failed to fetch data:", error);
throw error; // 에러를 던져서 React Query가 처리할 수 있도록 함
}
};
Loading

0 comments on commit 03fd283

Please sign in to comment.