From b53e0b487573b81dd505c8058f5ade21b3535122 Mon Sep 17 00:00:00 2001 From: shooukie Date: Tue, 15 Sep 2026 15:18:40 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=95=8C=EB=9E=8C=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C/=EC=9D=BD=EC=9D=8C=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis/notification.ts | 173 +++++++++++++ src/components/common/NotificationCard.tsx | 4 +- src/hooks/mutation/useNotification.ts | 73 ++++++ src/hooks/queries/useNotification.ts | 47 ++++ src/pages/NotificationPage.tsx | 270 ++++++++++----------- 5 files changed, 429 insertions(+), 138 deletions(-) create mode 100644 src/apis/notification.ts create mode 100644 src/hooks/mutation/useNotification.ts create mode 100644 src/hooks/queries/useNotification.ts diff --git a/src/apis/notification.ts b/src/apis/notification.ts new file mode 100644 index 0000000..01333fc --- /dev/null +++ b/src/apis/notification.ts @@ -0,0 +1,173 @@ +import { apiClient } from "@/apis/client"; +import type { CommonResponse } from "@/types/api"; + +// 알림 유형 +export type NotificationType = "MISSION_RECOMMENDATION"; + +// 딥링크 대상 유형 +export type NotificationReferenceType = "MISSION"; + +// 기기 유형 +export type DeviceType = "ANDROID" | "IOS" | "WEB"; + +// 알림 항목 +export interface NotificationItem { + id: number; + type: NotificationType; + title: string; + body: string; + referenceType: NotificationReferenceType | null; + referenceId: number | null; + read: boolean; + createdAt: string; +} + +// 알림 목록 조회 응답 +export interface Notifications { + notifications: NotificationItem[]; + nextCursor: number | null; + hasNext: boolean; +} + +// 알림 목록 조회 파라미터 +export interface NotificationsParams { + cursor?: number; + size?: number; +} + +// 미읽음 알림 개수 조회 응답 +export interface UnreadNotificationCount { + unreadCount: number; +} + +// 알림 설정 조회·변경 응답 +export interface NotificationSettings { + pushEnabled: boolean; +} + +// 알림 설정 변경 요청 +export interface UpdateNotificationSettingsParams { + pushEnabled: boolean; +} + +// FCM 토큰 등록 요청 +export type RegisterFcmTokenParams = { + token: string; + deviceType: DeviceType; +} & ( + | { + latitude: number; + longitude: number; + } + | { + latitude?: never; + longitude?: never; + } +); + +// FCM 토큰 등록 응답 +export interface RegisteredFcmToken { + id: number; + deviceType: DeviceType; +} + +// 인앱 알림 목록 조회 +// GET /api/notifications +export const getNotifications = async ( + params: NotificationsParams = {}, +): Promise => { + const response = await apiClient.get>( + "/api/notifications", + { + params: { + ...params, + size: params.size ?? 20, + }, + }, + ); + + return response.data.result; +}; + +// 미읽음 알림 개수 조회 +// GET /api/notifications/unread-count +export const getUnreadNotificationCount = + async (): Promise => { + const response = await apiClient.get< + CommonResponse + >("/api/notifications/unread-count"); + + return response.data.result; + }; + +// 알림 설정 조회 +// GET /api/users/me/notification-settings +export const getNotificationSettings = + async (): Promise => { + const response = await apiClient.get>( + "/api/users/me/notification-settings", + ); + + return response.data.result; + }; + +// 알림 설정 변경 +// PATCH /api/users/me/notification-settings +export const updateNotificationSettings = async ( + params: UpdateNotificationSettingsParams, +): Promise => { + const response = await apiClient.patch>( + "/api/users/me/notification-settings", + params, + ); + + return response.data.result; +}; + +// 알림 읽음 처리 +// PATCH /api/notifications/{notificationId}/read +export const markNotificationAsRead = async ( + notificationId: number, +): Promise => { + const response = await apiClient.patch>( + `/api/notifications/${notificationId}/read`, + ); + + return response.data.result; +}; + +// 알림 전체 읽음 처리 +// PATCH /api/notifications/read-all +export const markAllNotificationsAsRead = async (): Promise => { + const response = await apiClient.patch>( + "/api/notifications/read-all", + ); + + return response.data.result; +}; + +// FCM 토큰 등록 +// POST /api/notifications/fcm-tokens +export const registerFcmToken = async ( + params: RegisterFcmTokenParams, +): Promise => { + const response = await apiClient.post>( + "/api/notifications/fcm-tokens", + params, + ); + + return response.data.result; +}; + +// FCM 토큰 해제 +// DELETE /api/notifications/fcm-tokens +export const deleteFcmToken = async (token: string): Promise => { + const response = await apiClient.delete>( + "/api/notifications/fcm-tokens", + { + params: { token }, + }, + ); + + return response.data.result; +}; diff --git a/src/components/common/NotificationCard.tsx b/src/components/common/NotificationCard.tsx index 3b5aece..a92b41c 100644 --- a/src/components/common/NotificationCard.tsx +++ b/src/components/common/NotificationCard.tsx @@ -32,6 +32,7 @@ interface NotificationCardProps { amount?: number | null; createdAt: string; read?: boolean; + onRead?: (id: number, read: boolean) => void; } export function NotificationCard({ @@ -42,13 +43,14 @@ export function NotificationCard({ amount, createdAt, read = false, + onRead, }: NotificationCardProps) { const unread = !read; const { icon, iconBg } = notificationStyles[type]; return ( handleRead(notification.id)} + onClick={() => onRead?.(id, read)} className={`relative cursor-pointer transition ${unread ? "bg-white" : "!bg-[#F8FAFC]"}`} >
diff --git a/src/hooks/mutation/useNotification.ts b/src/hooks/mutation/useNotification.ts new file mode 100644 index 0000000..3d169a6 --- /dev/null +++ b/src/hooks/mutation/useNotification.ts @@ -0,0 +1,73 @@ +import { + deleteFcmToken, + markAllNotificationsAsRead, + markNotificationAsRead, + registerFcmToken, + updateNotificationSettings, +} from "@/apis/notification"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { notificationKeys } from "../queries/useNotification"; + +// 알림 설정 변경 +export const useUpdateNotificationSettings = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: updateNotificationSettings, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: notificationKeys.settings, + }); + }, + }); +}; + +// 알림 하나 읽음 처리 +export const useMarkNotificationAsRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: markNotificationAsRead, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: notificationKeys.lists, + }), + queryClient.invalidateQueries({ + queryKey: notificationKeys.unreadCount, + }), + ]); + }, + }); +}; + +// 알림 전체 읽음 처리 +export const useMarkAllNotificationsAsRead = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: markAllNotificationsAsRead, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: notificationKeys.lists, + }), + queryClient.invalidateQueries({ + queryKey: notificationKeys.unreadCount, + }), + ]); + }, + }); +}; + +// FCM 토큰 등록 +export const useRegisterFcmToken = () => + useMutation({ + mutationFn: registerFcmToken, + }); + +// FCM 토큰 해제 +export const useDeleteFcmToken = () => + useMutation({ + mutationFn: deleteFcmToken, + }); diff --git a/src/hooks/queries/useNotification.ts b/src/hooks/queries/useNotification.ts new file mode 100644 index 0000000..68496d1 --- /dev/null +++ b/src/hooks/queries/useNotification.ts @@ -0,0 +1,47 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; + +import { + getNotifications, + getUnreadNotificationCount, + getNotificationSettings, +} from "@/apis/notification"; + +// 쿼리 키 +export const notificationKeys = { + all: ["notifications"] as const, + lists: ["notifications", "list"] as const, + list: (size: number) => ["notifications", "list", size] as const, + unreadCount: ["notifications", "unread-count"] as const, + settings: ["notifications", "settings"] as const, +}; + +// 알림 목록 조회: 커서 기반 더보기 +export const useNotifications = (size = 20) => + useInfiniteQuery({ + queryKey: notificationKeys.list(size), + queryFn: ({ pageParam }) => + getNotifications({ + cursor: pageParam, + size, + }), + initialPageParam: undefined as number | undefined, + getNextPageParam: (lastPage) => + lastPage.hasNext ? (lastPage.nextCursor ?? undefined) : undefined, + staleTime: 30 * 1000, + }); + +// 미읽음 알림 개수 조회 +export const useUnreadNotificationCount = () => + useQuery({ + queryKey: notificationKeys.unreadCount, + queryFn: getUnreadNotificationCount, + staleTime: 30 * 1000, + }); + +// 알림 설정 조회 +export const useNotificationSettings = () => + useQuery({ + queryKey: notificationKeys.settings, + queryFn: getNotificationSettings, + staleTime: 5 * 60 * 1000, + }); diff --git a/src/pages/NotificationPage.tsx b/src/pages/NotificationPage.tsx index 03936b8..4a043c0 100644 --- a/src/pages/NotificationPage.tsx +++ b/src/pages/NotificationPage.tsx @@ -1,90 +1,57 @@ import { SubHeader } from "@/components/DeviceFrame"; import { useNavigate } from "react-router-dom"; -import React, { useState } from "react"; -import { Target, Gift, MapPin, Bell, Medal } from "lucide-react"; -import { ContentCard } from "@/components/common/ContentCard"; - -const initialNotifications = [ - { - id: 1, - icon: , - iconBg: "bg-sky-50", - title: "새 미션이 열렸어요!", - description: "'인사동 전통찻집 방문' 미션이 오픈됐어요.", - time: "방금", - unread: true, - }, - { - id: 2, - icon: , - iconBg: "bg-orange-50", - title: "보상이 지급됐어요 🎉", - description: "경복궁 미션 완료! +150P가 지급됐습니다.", - time: "1시간 전", - unread: true, - }, - { - id: 3, - icon: , - iconBg: "bg-emerald-50", - title: "근처 미션을 발견했어요", - description: "현재 위치 1.2km 내에 '경복궁' 미션이 있어요.", - time: "2시간 전", - unread: true, - }, - { - id: 4, - icon: , - iconBg: "bg-violet-50", - title: "공지사항", - description: "Questpot v1.2 업데이트 – 마스코트 꾸미기 기능 추가!", - time: "어제", - unread: false, - }, - { - id: 5, - icon: , - iconBg: "bg-orange-50", - title: "배지를 획득했어요 🏅", - description: "'탐험가' 배지를 획득했습니다. 축하해요!", - time: "어제", - unread: false, - }, - { - id: 6, - icon: , - iconBg: "bg-sky-50", - title: "미션 리마인더", - description: "저장한 '남산타워 야경' 미션을 완료해보세요.", - time: "2일 전", - unread: false, - }, -]; +import { useNotifications } from "@/hooks/queries/useNotification"; +import { + useMarkAllNotificationsAsRead, + useMarkNotificationAsRead, +} from "@/hooks/mutation/useNotification"; +import { NotificationCard } from "@/components/common/NotificationCard"; export default function NotificationPage() { const navigate = useNavigate(); - const [notifications, setNotifications] = useState(initialNotifications); + // 알림 목록 + const { + data: notificationData, + isPending: isNotificationPending, + isError: isNotificationError, + refetch, + fetchNextPage, + hasNextPage, + isFetching, + isFetchingNextPage, + isFetchNextPageError, + } = useNotifications(); + + const notifications = + notificationData?.pages.flatMap((page) => page.notifications) ?? []; // 하나 읽음 처리 - const handleRead = (id: number) => { - setNotifications((prev) => - prev.map((notification) => - notification.id === id - ? { ...notification, unread: false } - : notification, - ), - ); - }; + const { + mutate: readNotification, + isPending: isReadPending, + isError: isReadError, + } = useMarkNotificationAsRead(); // 모두 읽음 처리 + const { + mutate: readAllNotifications, + isPending: isReadAllPending, + isError: isReadAllError, + } = useMarkAllNotificationsAsRead(); + + const isReadUpdating = isReadPending || isReadAllPending; + + const handleRead = (id: number, read: boolean) => { + if (read || isReadUpdating) return; + + readNotification(id); + }; + const handleReadAll = () => { - setNotifications((prev) => - prev.map((notification) => ({ - ...notification, - unread: false, - })), - ); + if (isReadUpdating) return; + + readAllNotifications(); }; return ( @@ -94,80 +61,109 @@ export default function NotificationPage() { onBack={() => navigate(-1)} action={ } />
- {notifications.map((notification) => ( - handleRead(notification.id)} - className={` - relative cursor-pointer transition - ${notification.unread ? "bg-white" : "!bg-[#F8FAFC]"} - `} - > -
- {/* 아이콘 */} -
- {notification.icon} -
+ {(isReadError || isReadAllError) && ( +

+ 읽음 처리에 실패했어요. 다시 시도해 주세요. +

+ )} - {/* 내용 */} -
-

- {notification.title} -

- -

- {notification.description} -

+ {isNotificationPending ? ( +

+ 알림을 불러오는 중... +

+ ) : isNotificationError && !notificationData ? ( + /* 최초 조회 실패 */ +
+

+ 알림을 불러오지 못했어요. +

-

- {notification.time} -

+ +
+ ) : ( + <> + {isNotificationError && !isFetchNextPageError && ( +
+

최신 알림을 불러오지 못했어요.

+
+ )} - {/* 안 읽은 알림 표시 */} - {notification.unread && ( - + 아직 알림이 없어요. +

+ )} + + {/* 알림 목록 */} + {notifications.map((notification) => { + return ( + - )} -
- - ))} + ); + })} + + {/* 다음 페이지 조회 실패 */} + {isFetchNextPageError && ( +

+ 다음 알림을 불러오지 못했어요. 더보기를 다시 눌러 주세요. +

+ )} + + {/* 더보기 */} + {hasNextPage && ( + + )} + + )}
); From 05bf30f3f87367fb93710fee3a535e1120be20f5 Mon Sep 17 00:00:00 2001 From: shooukie Date: Tue, 15 Sep 2026 15:39:53 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EC=95=8C=EB=9E=8C=20=EA=B0=9C?= =?UTF-8?q?=EC=88=98=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/Header.tsx | 13 +++++++++---- src/pages/NotificationPage.tsx | 1 - 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/components/common/Header.tsx b/src/components/common/Header.tsx index d42ef16..eeb2820 100644 --- a/src/components/common/Header.tsx +++ b/src/components/common/Header.tsx @@ -3,12 +3,15 @@ import questyProfile from "@/assets/questy.svg"; import { useNavigate } from "react-router-dom"; import { PATH } from "@/routes/paths"; import { ReactNode } from "react"; +import { useUnreadNotificationCount } from "@/hooks/queries/useNotification"; interface HeaderProps { children?: ReactNode; } export function Header({ children }: HeaderProps) { const navigate = useNavigate(); + const { data: unreadData } = useUnreadNotificationCount(); + const unreadCount = unreadData?.unreadCount ?? 0; return (
diff --git a/src/pages/NotificationPage.tsx b/src/pages/NotificationPage.tsx index 4a043c0..99f4fe4 100644 --- a/src/pages/NotificationPage.tsx +++ b/src/pages/NotificationPage.tsx @@ -139,7 +139,6 @@ export default function NotificationPage() { /> ); })} - {/* 다음 페이지 조회 실패 */} {isFetchNextPageError && (