From edc82fc390b023b9cb2831489584cb87f22db0fb Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:22:53 +0900 Subject: [PATCH 01/13] =?UTF-8?q?feat(notification):=20notification=20API?= =?UTF-8?q?=20endpoint=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/config/endpoints.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/shared/config/endpoints.ts b/src/shared/config/endpoints.ts index 8d5c26c8..8d16da23 100644 --- a/src/shared/config/endpoints.ts +++ b/src/shared/config/endpoints.ts @@ -32,6 +32,11 @@ export const API_ENDPOINTS = { myRecentViews: "/api/v1/me/recentviews", deleteRecentView: (recentViewId: number) => `/api/v1/recentview/${recentViewId}`, + notifications: "/api/v1/notifications", + notificationsSubscribe: "/api/v1/notifications/subscribe", + notificationsReadAll: "/api/v1/notifications", + notificationsRead: (notificationId: number) => `/api/v1/notifications/${notificationId}`, + tagSearch: "/api/v1/tags/search", searchHistory: "/api/v1/searches", From 06702b36bdf07ea21c88d02b1690906e83f0b1f9 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:26:39 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat(notification):=20notification=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=83=80=EC=9E=85=20=EB=B0=8F=20interface?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/notification/model/types.ts | 41 ++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/features/notification/model/types.ts diff --git a/src/features/notification/model/types.ts b/src/features/notification/model/types.ts new file mode 100644 index 00000000..a2770f3f --- /dev/null +++ b/src/features/notification/model/types.ts @@ -0,0 +1,41 @@ +/** + * + * CHAT_MESSAGE 채팅 알림 + * AUCTION_START_WISHLIST 거래 시작 + * AUCTION_FAILED_SELLER 거래 유찰 (판매자) + * AUCTION_FAILED_BUYER 거래 유찰 (구매자) + * AUCTION_ENDED_OTHER 남의 물건 경매 종료 + * STOP_LOSS_TRIGGERED 스탑 로스 발생 (X) + * PRICE_DROP 가격 하락 + * SALE_SUCCESS_SELLER 판매 성공 (판매자) + * PAYMENT_SUCCESS_BUYER 결제 성공 (구매자) (X) + * PURCHASE_CONFIRMED_SELLER 구매 확정 (판매자) + * REVIEW_REGISTERED 리뷰 등록 + */ + +export type NotificationType = + | "CHAT_MESSAGE" + | "AUCTION_START_WISHLIST" + | "AUCTION_FAILED_SELLER" + | "AUCTION_FAILED_BUYER" + | "AUCTION_ENDED_OTHER" + | "STOP_LOSS_TRIGGERED" + | "PRICE_DROP" + | "SALE_SUCCESS_SELLER" + | "PAYMENT_SUCCESS_BUYER" + | "PURCHASE_CONFIRMED_SELLER" + | "REVIEW_REGISTERED" + | (string & {}); + +export type NotificationTarget = "chatRoom" | "auction" | "payment" | "review" | (string & {}); + +export interface NotificationItem { + notificationId: number; + type: NotificationType; + title: string; + message: string; + readStatus: boolean; + target: NotificationTarget; + targetId: number; + notificationAt: string; +} From 7ec9e09aba83eaaced04b3a196b78bc83e7da254 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:27:04 +0900 Subject: [PATCH 03/13] =?UTF-8?q?chore(config):=20next.config.ts=EC=9D=98?= =?UTF-8?q?=20image=20remotePatterns=20=EA=B5=AC=EC=A1=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- next.config.ts | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/next.config.ts b/next.config.ts index 09f2d56a..825bb4cf 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,29 +1,23 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ reactCompiler: true, images: { remotePatterns: [ - { - protocol: "https", - hostname: "images.unsplash.com", - }, - { - protocol: "http", - hostname: "k.kakaocdn.net", - }, + { protocol: "https", hostname: "images.unsplash.com" }, + { protocol: "http", hostname: "k.kakaocdn.net" }, { protocol: "https", hostname: "picsum.photos" }, { protocol: "https", hostname: "static.toss.im" }, { protocol: "https", hostname: "windfall-bucket.s3.ap-northeast-2.amazonaws.com" }, { protocol: "https", hostname: "wind-fall.store" }, { protocol: "http", hostname: "img1.kakaocdn.net" }, - { protocol: "http", hostname: "phinf.pstatic.net", }, - { protocol: "http", hostname: "ssl.pstatic.net", }, - { protocol: "http", hostname: "lh3.googleusercontent.com", }, - { protocol: "https", hostname: "phinf.pstatic.net", }, - { protocol: "https", hostname: "ssl.pstatic.net", }, - { protocol: "https", hostname: "lh3.googleusercontent.com", }, + { protocol: "http", hostname: "phinf.pstatic.net" }, + { protocol: "http", hostname: "ssl.pstatic.net" }, + { protocol: "http", hostname: "lh3.googleusercontent.com" }, + { protocol: "https", hostname: "phinf.pstatic.net" }, + { protocol: "https", hostname: "ssl.pstatic.net" }, + { protocol: "https", hostname: "lh3.googleusercontent.com" }, + { protocol: "https", hostname: "images.unsplash.com" }, ], }, async rewrites() { From 0cfd0f71766eb026a50917ab95cb2eca890cab8d Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:27:43 +0900 Subject: [PATCH 04/13] =?UTF-8?q?fix(search):=20SearchScreen=20=EB=B0=98?= =?UTF-8?q?=EC=9D=91=ED=98=95=20=EB=B8=8C=EB=A0=88=EC=9D=B4=ED=81=AC?= =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=EB=A5=BC=20768px=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/screens/search/ui/search-screen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/search/ui/search-screen.tsx b/src/screens/search/ui/search-screen.tsx index 151ed67b..4b6368a4 100644 --- a/src/screens/search/ui/search-screen.tsx +++ b/src/screens/search/ui/search-screen.tsx @@ -19,7 +19,7 @@ export function SearchScreen() { const { data: searchHistory = [], isPending } = useSearchHistory(); useEffect(() => { - const mediaQuery = window.matchMedia("(min-width: 1024px)"); + const mediaQuery = window.matchMedia("(min-width: 768px)"); const handleChange = () => { if (mediaQuery.matches) { From aeb8a0335539a836db1475cf4b87529aafc5cf35 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:42:11 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat(notification):=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=20=EC=98=81=EC=97=AD=EC=97=90=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20popover=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 목록을 popover 형태로 표시하는 HeaderNotificationPopover 컴포넌트 도입 - 기존 알림 placeholder를 제거하고 헤더 액션 영역에 통합 --- src/widgets/header/ui/header-actions.tsx | 7 +- .../header/ui/header-notification-popover.tsx | 111 ++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 src/widgets/header/ui/header-notification-popover.tsx diff --git a/src/widgets/header/ui/header-actions.tsx b/src/widgets/header/ui/header-actions.tsx index baa2dd18..7118ead7 100644 --- a/src/widgets/header/ui/header-actions.tsx +++ b/src/widgets/header/ui/header-actions.tsx @@ -6,6 +6,7 @@ import { Plus } from "lucide-react"; import { ROUTES } from "@/shared/config/routes"; import { Button } from "@/shared/ui"; +import HeaderNotificationPopover from "@/widgets/header/ui/header-notification-popover"; import HeaderUserMenu from "@/widgets/header/ui/header-user-menu"; export default function HeaderActions({ @@ -30,11 +31,7 @@ export default function HeaderActions({ - {/* TODO: 알림 기능 복구 시 다시 노출 */} - {/* */} + diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx new file mode 100644 index 00000000..6a37f2c0 --- /dev/null +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -0,0 +1,111 @@ +"use client"; + +import Link from "next/link"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { Bell, Clock, Gavel, MessageCircle, TrendingUp } from "lucide-react"; + +import { Popover, PopoverContent, PopoverTrigger, ScrollArea } from "@/shared/ui"; +import Button from "@/shared/ui/button/button"; + +const notificationTemplates = [ + { + title: "아이폰 14 프로 경매 종료 임박!", + message: "5분 후 경매가 종료됩니다.", + icon: TrendingUp, + }, + { + title: "입찰 성공", + message: "삼성 노트북 경매에서 최고가를 기록중입니다.", + icon: Gavel, + }, + { + title: "새로운 메시지", + message: "판매자가 메시지를 보냈습니다.", + icon: MessageCircle, + }, + { + title: "경매 시작", + message: "관심 물품의 경매가 시작되었습니다.", + icon: Clock, + }, +]; + +const timeLabels = ["5분 전", "15분 전", "1시간 전", "2시간 전", "3시간 전"]; + +const notifications = Array.from({ length: 15 }, (_, index) => { + const template = notificationTemplates[index % notificationTemplates.length]; + + return { + id: `notification-${index + 1}`, + isUnread: index < 4, + time: timeLabels[index % timeLabels.length], + ...template, + }; +}); + +export default function HeaderNotificationPopover() { + const hasUnread = notifications.some((notification) => notification.isUnread); + + return ( + + + + + +
+

알림

+ + + +
+
+ +
    + {notifications.slice(0, 15).map((notification) => { + const Icon = notification.icon; + + return ( +
  • + +
  • + ); + })} +
+
+
+ + + 내 알림 보기 + + +
+ + + ); +} From 6a34f46155463314669e7209bee0c282c0ae455e Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 06:50:44 +0900 Subject: [PATCH 06/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=95=84=EC=9D=B4=EC=BD=98=20=EB=B0=8F=20=ED=83=80=EA=B2=9F?= =?UTF-8?q?=20=EB=A7=A4=ED=95=91=20=EC=9C=A0=ED=8B=B8=EB=A6=AC=ED=8B=B0=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 타입을 Lucide 아이콘으로 매핑하는 유틸리티 함수 추가 - 알림 타겟을 라우트 href로 변환하는 매핑 로직 도입 --- .../notification/model/notification-mapper.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 src/features/notification/model/notification-mapper.ts diff --git a/src/features/notification/model/notification-mapper.ts b/src/features/notification/model/notification-mapper.ts new file mode 100644 index 00000000..7a52ec46 --- /dev/null +++ b/src/features/notification/model/notification-mapper.ts @@ -0,0 +1,61 @@ +import { + BadgeCheck, + Bell, + CheckCircle, + Clock, + CreditCard, + Gavel, + MessageCircle, + Star, + TrendingDown, + TriangleAlert, + XCircle, +} from "lucide-react"; + +import { ROUTES } from "@/shared/config/routes"; + +import type { NotificationTarget, NotificationType } from "./types"; +import type { LucideIcon } from "lucide-react"; + +export function getNotificationIcon(type: NotificationType): LucideIcon { + switch (type) { + case "CHAT_MESSAGE": + return MessageCircle; + case "AUCTION_START_WISHLIST": + return Clock; + case "AUCTION_FAILED_SELLER": + case "AUCTION_FAILED_BUYER": + return XCircle; + case "AUCTION_ENDED_OTHER": + return Gavel; + case "STOP_LOSS_TRIGGERED": + return TriangleAlert; + case "PRICE_DROP": + return TrendingDown; + case "SALE_SUCCESS_SELLER": + return BadgeCheck; + case "PAYMENT_SUCCESS_BUYER": + return CreditCard; + case "PURCHASE_CONFIRMED_SELLER": + return CheckCircle; + case "REVIEW_REGISTERED": + return Star; + default: + return Bell; + } +} + +export function getNotificationTargetHref(target: NotificationTarget, targetId: number): string { + switch (target) { + case "chatRoom": + return "/dm"; + case "auction": + return ROUTES.auctionDetail(targetId); + case "payment": + return `/payments/${targetId}`; + case "review": + return ROUTES.userReview(targetId); + default: + return ROUTES.main; + } +} From 61403d7aa809ff73bdff1a9c7155233e9b572026 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 07:19:42 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20API=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20popover=20UI=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TanStack Query를 사용해 API 알림을 조회하는 useNotifications hook 추가 - API 타입에 맞게 notification 타입 및 아이콘 매핑 로직 업데이트 - HeaderNotificationPopover를 리팩터링하여 실제 알림 데이터를 표시하도록 수정 - 알림 타입에 맞는 아이콘을 적용하고, 타임스탬프를 포맷팅해 표시 - 기존 mock 데이터 및 사용되지 않는 아이콘 제거 --- .../notification/api/use-notifications.ts | 49 ++++++++++ .../notification/model/notification-mapper.ts | 6 +- src/features/notification/model/types.ts | 44 ++++++--- .../header/ui/header-notification-popover.tsx | 98 ++++++++----------- 4 files changed, 122 insertions(+), 75 deletions(-) create mode 100644 src/features/notification/api/use-notifications.ts diff --git a/src/features/notification/api/use-notifications.ts b/src/features/notification/api/use-notifications.ts new file mode 100644 index 00000000..94d76ff6 --- /dev/null +++ b/src/features/notification/api/use-notifications.ts @@ -0,0 +1,49 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; + +import { httpClient } from "@/shared/api/client"; +import type { SliceResponseType } from "@/shared/api/types/response"; +import { API_ENDPOINTS } from "@/shared/config/endpoints"; + +import type { NotificationItem } from "../model/types"; + +const DEFAULT_PAGE = 0; +const DEFAULT_SIZE = 15; + +export const notificationKeys = { + all: ["notifications"] as const, + list: (page: number, size: number) => [...notificationKeys.all, "list", page, size] as const, +}; + +const fetchNotifications = async ( + page: number, + size: number +): Promise> => { + const response = await httpClient>( + API_ENDPOINTS.notifications, + { + method: "GET", + queryParams: { page, size }, + } + ); + + if (!response.data) { + return { + slice: [], + hasNext: false, + page, + size, + timeStamp: "", + }; + } + + return response.data; +}; + +export function useNotifications({ page = DEFAULT_PAGE, size = DEFAULT_SIZE } = {}) { + return useQuery({ + queryKey: notificationKeys.list(page, size), + queryFn: () => fetchNotifications(page, size), + }); +} diff --git a/src/features/notification/model/notification-mapper.ts b/src/features/notification/model/notification-mapper.ts index 7a52ec46..62c9d6b2 100644 --- a/src/features/notification/model/notification-mapper.ts +++ b/src/features/notification/model/notification-mapper.ts @@ -4,7 +4,6 @@ import { CheckCircle, Clock, CreditCard, - Gavel, MessageCircle, Star, TrendingDown, @@ -24,15 +23,14 @@ export function getNotificationIcon(type: NotificationType): LucideIcon { case "AUCTION_START_WISHLIST": return Clock; case "AUCTION_FAILED_SELLER": - case "AUCTION_FAILED_BUYER": + case "AUCTION_FAILED_SUBSCRIBER": return XCircle; - case "AUCTION_ENDED_OTHER": - return Gavel; case "STOP_LOSS_TRIGGERED": return TriangleAlert; case "PRICE_DROP": return TrendingDown; case "SALE_SUCCESS_SELLER": + case "SALE_SUCCESS_SUBSCRIBER": return BadgeCheck; case "PAYMENT_SUCCESS_BUYER": return CreditCard; diff --git a/src/features/notification/model/types.ts b/src/features/notification/model/types.ts index a2770f3f..16d917df 100644 --- a/src/features/notification/model/types.ts +++ b/src/features/notification/model/types.ts @@ -1,27 +1,43 @@ /** * - * CHAT_MESSAGE 채팅 알림 - * AUCTION_START_WISHLIST 거래 시작 - * AUCTION_FAILED_SELLER 거래 유찰 (판매자) - * AUCTION_FAILED_BUYER 거래 유찰 (구매자) - * AUCTION_ENDED_OTHER 남의 물건 경매 종료 - * STOP_LOSS_TRIGGERED 스탑 로스 발생 (X) - * PRICE_DROP 가격 하락 - * SALE_SUCCESS_SELLER 판매 성공 (판매자) - * PAYMENT_SUCCESS_BUYER 결제 성공 (구매자) (X) - * PURCHASE_CONFIRMED_SELLER 구매 확정 (판매자) - * REVIEW_REGISTERED 리뷰 등록 + * 채팅 + * CHAT_MESSAGE + * + * 경매 시작 + * AUCTION_START_WISHLIST + * + * 경매 유찰 + * AUCTION_FAILED_SELLER + * AUCTION_FAILED_SUBSCRIBER + * + * 경매 낙찰 + * SALE_SUCCESS_SELLER + * SALE_SUCCESS_SUBSCRIBER + * + * 가격 변동 + * STOP_LOSS_TRIGGERED + * PRICE_DROP + * + * 결제 성공 + * PAYMENT_SUCCESS_BUYER + * + * 구매 확정 + * PURCHASE_CONFIRMED_SELLER + * + * 리뷰 + * REVIEW_REGISTERED + * */ export type NotificationType = | "CHAT_MESSAGE" | "AUCTION_START_WISHLIST" | "AUCTION_FAILED_SELLER" - | "AUCTION_FAILED_BUYER" - | "AUCTION_ENDED_OTHER" + | "AUCTION_FAILED_SUBSCRIBER" + | "SALE_SUCCESS_SELLER" + | "SALE_SUCCESS_SUBSCRIBER" | "STOP_LOSS_TRIGGERED" | "PRICE_DROP" - | "SALE_SUCCESS_SELLER" | "PAYMENT_SUCCESS_BUYER" | "PURCHASE_CONFIRMED_SELLER" | "REVIEW_REGISTERED" diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx index 6a37f2c0..a565fa18 100644 --- a/src/widgets/header/ui/header-notification-popover.tsx +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -3,49 +3,23 @@ import Link from "next/link"; import * as PopoverPrimitive from "@radix-ui/react-popover"; -import { Bell, Clock, Gavel, MessageCircle, TrendingUp } from "lucide-react"; +import { Bell } from "lucide-react"; +import { useNotifications } from "@/features/notification/api/use-notifications"; +import { + getNotificationIcon, + getNotificationTargetHref, +} from "@/features/notification/model/notification-mapper"; +import { formatAgo } from "@/shared/lib/utils/time/format"; import { Popover, PopoverContent, PopoverTrigger, ScrollArea } from "@/shared/ui"; import Button from "@/shared/ui/button/button"; -const notificationTemplates = [ - { - title: "아이폰 14 프로 경매 종료 임박!", - message: "5분 후 경매가 종료됩니다.", - icon: TrendingUp, - }, - { - title: "입찰 성공", - message: "삼성 노트북 경매에서 최고가를 기록중입니다.", - icon: Gavel, - }, - { - title: "새로운 메시지", - message: "판매자가 메시지를 보냈습니다.", - icon: MessageCircle, - }, - { - title: "경매 시작", - message: "관심 물품의 경매가 시작되었습니다.", - icon: Clock, - }, -]; - -const timeLabels = ["5분 전", "15분 전", "1시간 전", "2시간 전", "3시간 전"]; - -const notifications = Array.from({ length: 15 }, (_, index) => { - const template = notificationTemplates[index % notificationTemplates.length]; - - return { - id: `notification-${index + 1}`, - isUnread: index < 4, - time: timeLabels[index % timeLabels.length], - ...template, - }; -}); - export default function HeaderNotificationPopover() { - const hasUnread = notifications.some((notification) => notification.isUnread); + const { data } = useNotifications({ page: 0, size: 15 }); + const notifications = data?.slice ?? []; + + console.log(notifications); + const hasUnread = notifications.some((notification) => !notification.readStatus); return ( @@ -69,27 +43,37 @@ export default function HeaderNotificationPopover() {
    - {notifications.slice(0, 15).map((notification) => { - const Icon = notification.icon; + {notifications.map((notification) => { + const Icon = getNotificationIcon(notification.type); + const targetHref = getNotificationTargetHref( + notification.target, + notification.targetId + ); return ( -
  • - +
  • + + + + + +
    +

    + {notification.title} +

    +

    {notification.message}

    +

    + {formatAgo(notification.notificationAt)} +

    +
    + {!notification.readStatus ? ( + + ) : null} + +
  • ); })} From adece257ac853a8bb316dceb67b85cca91c32477 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 07:23:51 +0900 Subject: [PATCH 08/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20mutation=20hook=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - react-query의 useMutation을 활용한 useReadNotification hook 도입 - 알림을 읽음 상태로 변경하는 API 연동 처리 - 성공 시 notifications 쿼리를 invalidate하여 목록이 갱신되도록 구성 --- .../notification/api/use-notifications.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/src/features/notification/api/use-notifications.ts b/src/features/notification/api/use-notifications.ts index 94d76ff6..18dfa04c 100644 --- a/src/features/notification/api/use-notifications.ts +++ b/src/features/notification/api/use-notifications.ts @@ -1,6 +1,6 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { httpClient } from "@/shared/api/client"; import type { SliceResponseType } from "@/shared/api/types/response"; @@ -47,3 +47,22 @@ export function useNotifications({ page = DEFAULT_PAGE, size = DEFAULT_SIZE } = queryFn: () => fetchNotifications(page, size), }); } + +const readNotification = async (notificationId: number) => { + const response = await httpClient(API_ENDPOINTS.notificationsRead(notificationId), { + method: "PATCH", + }); + + return response.data; +}; + +export function useReadNotification() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: readNotification, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} From 0fc84e6d67662e58718c621d2b4c12ccd676773a Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 07:25:52 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(notification):=20popover=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=95=8C=EB=A6=BC=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C?= =?UTF-8?q?=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 헤더 알림 popover에서 알림 클릭 시 읽음 상태로 변경되도록 로직 추가 - useReadNotification hook을 연동해 읽음 처리 API 호출 - 알림 링크의 onClick 핸들러를 수정해 사용자 액션과 읽음 처리 연결 --- .../header/ui/header-notification-popover.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx index a565fa18..c29a030f 100644 --- a/src/widgets/header/ui/header-notification-popover.tsx +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -5,7 +5,10 @@ import Link from "next/link"; import * as PopoverPrimitive from "@radix-ui/react-popover"; import { Bell } from "lucide-react"; -import { useNotifications } from "@/features/notification/api/use-notifications"; +import { + useNotifications, + useReadNotification, +} from "@/features/notification/api/use-notifications"; import { getNotificationIcon, getNotificationTargetHref, @@ -16,9 +19,8 @@ import Button from "@/shared/ui/button/button"; export default function HeaderNotificationPopover() { const { data } = useNotifications({ page: 0, size: 15 }); + const { mutate: readNotification } = useReadNotification(); const notifications = data?.slice ?? []; - - console.log(notifications); const hasUnread = notifications.some((notification) => !notification.readStatus); return ( @@ -55,6 +57,11 @@ export default function HeaderNotificationPopover() { { + if (!notification.readStatus) { + readNotification(notification.notificationId); + } + }} className="hover:bg-brand-surface focus-visible:ring-brand-text/30 flex w-full items-start gap-3 px-4 py-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none" > From 20a95885db3f86a77f3578dc8348e4bc6be59608 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 07:28:24 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=20=EC=9D=BD=EC=9D=8C=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 알림을 한 번에 읽음 처리하는 API 및 UI 로직 도입 - useReadAllNotifications hook을 추가해 전체 읽음 처리 mutation 구현 --- .../notification/api/use-notifications.ts | 19 +++++++++++++++++++ .../header/ui/header-notification-popover.tsx | 12 +++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/features/notification/api/use-notifications.ts b/src/features/notification/api/use-notifications.ts index 18dfa04c..9d62299f 100644 --- a/src/features/notification/api/use-notifications.ts +++ b/src/features/notification/api/use-notifications.ts @@ -56,6 +56,14 @@ const readNotification = async (notificationId: number) => { return response.data; }; +const readAllNotifications = async () => { + const response = await httpClient(API_ENDPOINTS.notificationsReadAll, { + method: "PATCH", + }); + + return response.data; +}; + export function useReadNotification() { const queryClient = useQueryClient(); @@ -66,3 +74,14 @@ export function useReadNotification() { }, }); } + +export function useReadAllNotifications() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: readAllNotifications, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + }); +} diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx index c29a030f..00c120fe 100644 --- a/src/widgets/header/ui/header-notification-popover.tsx +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -7,6 +7,7 @@ import { Bell } from "lucide-react"; import { useNotifications, + useReadAllNotifications, useReadNotification, } from "@/features/notification/api/use-notifications"; import { @@ -20,6 +21,7 @@ import Button from "@/shared/ui/button/button"; export default function HeaderNotificationPopover() { const { data } = useNotifications({ page: 0, size: 15 }); const { mutate: readNotification } = useReadNotification(); + const { mutate: readAllNotifications } = useReadAllNotifications(); const notifications = data?.slice ?? []; const hasUnread = notifications.some((notification) => !notification.readStatus); @@ -37,7 +39,15 @@ export default function HeaderNotificationPopover() {

    알림

    - From ab712ce63ee9124dd378f6f85c2618c38cf338d2 Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 08:12:36 +0900 Subject: [PATCH 11/13] =?UTF-8?q?feat(notification):=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EC=95=8C=EB=A6=BC=20=EC=B2=98=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20NotificationSseProvider=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 전송 이벤트를 처리하는 NotificationSseProvider 도입 - 앱 레이아웃에 provider를 통합해 실시간 알림 업데이트를 활성화 - 인증된 사용자에 대해 실시간 알림 수신 및 toast 알림 표시 지원 --- src/app/layout.tsx | 2 + .../ui/notification-sse-provider.tsx | 130 ++++++++++++++++++ .../auction-list/api/search-auctions.ts | 11 +- 3 files changed, 141 insertions(+), 2 deletions(-) create mode 100644 src/features/notification/ui/notification-sse-provider.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 328f5701..757d1e9b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Inter } from "next/font/google"; import { ThemeProvider } from "next-themes"; +import { NotificationSseProvider } from "@/features/notification/ui/notification-sse-provider"; import { QueryProvider } from "@/shared/api/query-provider"; import { ServerTimeStoreProvider } from "@/shared/lib/providers/server-time-store-provider"; import "@/shared/styles/globals.css"; @@ -39,6 +40,7 @@ export default function RootLayout({ > +
    {children}
    diff --git a/src/features/notification/ui/notification-sse-provider.tsx b/src/features/notification/ui/notification-sse-provider.tsx new file mode 100644 index 00000000..ee5e92cb --- /dev/null +++ b/src/features/notification/ui/notification-sse-provider.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { notificationKeys } from "@/features/notification/api/use-notifications"; +import type { NotificationItem } from "@/features/notification/model/types"; +import { useUserBasic } from "@/features/user/api/use-user-basic"; +import type { SliceResponseType } from "@/shared/api/types/response"; +import { API_ENDPOINTS } from "@/shared/config/endpoints"; +import { showToast } from "@/shared/lib/utils/toast/show-toast"; + +const PROXY_BASE_URL = "/api/proxy"; +const SSE_EVENT_TYPES = [ + "priceAlert", + "auctionStartAlert", + "auctionFailedSeller", + "auctionFailedSubscriber", + "auctionSuccessSeller", + "auctionSuccessSubscriber", +] as const; + +type NotificationPayload = Partial & Record; + +function parseNotificationPayload(rawData: string): NotificationPayload | null { + try { + return JSON.parse(rawData) as NotificationPayload; + } catch { + return null; + } +} + +function getPayloadId(payload: NotificationPayload | null): string | null { + if (!payload) return null; + const candidate = payload.notificationId ?? payload.id; + if (typeof candidate === "number" || typeof candidate === "string") { + return String(candidate); + } + return null; +} + +function getPayloadTitle(payload: NotificationPayload | null): string | null { + if (!payload) return null; + const candidate = payload.title; + return typeof candidate === "string" && candidate.length > 0 ? candidate : null; +} + +function prependNotification( + queryClient: ReturnType, + payload: NotificationPayload +) { + const payloadId = getPayloadId(payload); + if (!payloadId) return; + + queryClient.setQueriesData>( + { queryKey: notificationKeys.all }, + (data) => { + if (!data) return data; + const exists = data.slice.some((item) => String(item.notificationId) === payloadId); + if (exists) return data; + + const mergedSlice = [payload as NotificationItem, ...data.slice]; + const trimmedSlice = data.size ? mergedSlice.slice(0, data.size) : mergedSlice; + + return { + ...data, + slice: trimmedSlice, + }; + } + ); +} + +export function NotificationSseProvider() { + const { data: user } = useUserBasic(); + const queryClient = useQueryClient(); + const sourceRef = useRef(null); + const isAuthenticated = Boolean(user); + + useEffect(() => { + const url = `${PROXY_BASE_URL}${API_ENDPOINTS.notificationsSubscribe}`; + + const closeSource = () => { + if (!sourceRef.current) return; + sourceRef.current.close(); + sourceRef.current = null; + }; + + if (!isAuthenticated) { + closeSource(); + return; + } + + if (sourceRef.current) { + return; + } + + const source = new EventSource(url); + sourceRef.current = source; + + const handleSseEvent = (rawData: string) => { + const payload = parseNotificationPayload(rawData); + + if (payload) { + prependNotification(queryClient, payload); + + const title = getPayloadTitle(payload); + showToast.info(title ?? "새 알림이 도착했어요."); + + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + } + }; + + source.onmessage = (event) => { + handleSseEvent(event.data); + }; + + SSE_EVENT_TYPES.forEach((eventName) => { + source.addEventListener(eventName, (event) => { + handleSseEvent(event.data); + }); + }); + + return () => { + closeSource(); + }; + }, [isAuthenticated, queryClient]); + + return null; +} diff --git a/src/screens/auction/auction-list/api/search-auctions.ts b/src/screens/auction/auction-list/api/search-auctions.ts index 33e03a9c..d9629bd1 100644 --- a/src/screens/auction/auction-list/api/search-auctions.ts +++ b/src/screens/auction/auction-list/api/search-auctions.ts @@ -6,9 +6,16 @@ export const searchAuctions = async (params: AuctionListParams): Promise { - if (value !== undefined && value !== null && value !== "") { - query.set(key, String(value)); + if (value === undefined || value === null || value === "") return; + + if (Array.isArray(value)) { + value + .filter((item) => item !== undefined && item !== null && item !== "") + .forEach((item) => query.append(key, String(item))); + return; } + + query.set(key, String(value)); }); const response = await fetch( From 5703cd3d3632c3c7c092db1601a464aa62a61ccd Mon Sep 17 00:00:00 2001 From: Sage Date: Mon, 12 Jan 2026 09:07:52 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20popover=20UX=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20SSE=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0=20=EB=A1=9C=EC=A7=81=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 popover에서 로딩, 에러, 빈 상태를 처리하도록 UX 개선 - 전체 읽음(mark all as read) 컨트롤의 동작과 사용성을 개선 - SSE provider 재연결 로직 추가 - 중복 알림 수신을 방지하고 연결 상태 관리 로직을 개선 - next.config.ts의 image remotePatterns를 정렬 및 커버리지 관점에서 정리 --- next.config.ts | 9 +- .../ui/notification-sse-provider.tsx | 82 +++++++-- .../header/ui/header-notification-popover.tsx | 160 +++++++++++------- 3 files changed, 173 insertions(+), 78 deletions(-) diff --git a/next.config.ts b/next.config.ts index 825bb4cf..a20f13a9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,20 +4,19 @@ const nextConfig: NextConfig = { reactCompiler: true, images: { remotePatterns: [ - { protocol: "https", hostname: "images.unsplash.com" }, { protocol: "http", hostname: "k.kakaocdn.net" }, + { protocol: "http", hostname: "img1.kakaocdn.net" }, { protocol: "https", hostname: "picsum.photos" }, + { protocol: "https", hostname: "images.unsplash.com" }, { protocol: "https", hostname: "static.toss.im" }, { protocol: "https", hostname: "windfall-bucket.s3.ap-northeast-2.amazonaws.com" }, { protocol: "https", hostname: "wind-fall.store" }, - { protocol: "http", hostname: "img1.kakaocdn.net" }, { protocol: "http", hostname: "phinf.pstatic.net" }, - { protocol: "http", hostname: "ssl.pstatic.net" }, - { protocol: "http", hostname: "lh3.googleusercontent.com" }, { protocol: "https", hostname: "phinf.pstatic.net" }, + { protocol: "http", hostname: "ssl.pstatic.net" }, { protocol: "https", hostname: "ssl.pstatic.net" }, + { protocol: "http", hostname: "lh3.googleusercontent.com" }, { protocol: "https", hostname: "lh3.googleusercontent.com" }, - { protocol: "https", hostname: "images.unsplash.com" }, ], }, async rewrites() { diff --git a/src/features/notification/ui/notification-sse-provider.tsx b/src/features/notification/ui/notification-sse-provider.tsx index ee5e92cb..06e51dd0 100644 --- a/src/features/notification/ui/notification-sse-provider.tsx +++ b/src/features/notification/ui/notification-sse-provider.tsx @@ -20,6 +20,8 @@ const SSE_EVENT_TYPES = [ "auctionSuccessSeller", "auctionSuccessSubscriber", ] as const; +const MAX_RECONNECT_DELAY_MS = 30_000; +const RECONNECT_BASE_DELAY_MS = 1_000; type NotificationPayload = Partial & Record; @@ -51,7 +53,8 @@ function prependNotification( payload: NotificationPayload ) { const payloadId = getPayloadId(payload); - if (!payloadId) return; + if (!payloadId) return false; + let didPrepend = false; queryClient.setQueriesData>( { queryKey: notificationKeys.all }, @@ -59,6 +62,7 @@ function prependNotification( if (!data) return data; const exists = data.slice.some((item) => String(item.notificationId) === payloadId); if (exists) return data; + didPrepend = true; const mergedSlice = [payload as NotificationItem, ...data.slice]; const trimmedSlice = data.size ? mergedSlice.slice(0, data.size) : mergedSlice; @@ -69,21 +73,34 @@ function prependNotification( }; } ); + + return didPrepend; } export function NotificationSseProvider() { const { data: user } = useUserBasic(); const queryClient = useQueryClient(); const sourceRef = useRef(null); + const isConnectingRef = useRef(false); + const reconnectAttemptRef = useRef(0); + const retryTimeoutRef = useRef | null>(null); const isAuthenticated = Boolean(user); useEffect(() => { const url = `${PROXY_BASE_URL}${API_ENDPOINTS.notificationsSubscribe}`; + const clearRetryTimeout = () => { + if (!retryTimeoutRef.current) return; + clearTimeout(retryTimeoutRef.current); + retryTimeoutRef.current = null; + }; + const closeSource = () => { if (!sourceRef.current) return; sourceRef.current.close(); sourceRef.current = null; + isConnectingRef.current = false; + clearRetryTimeout(); }; if (!isAuthenticated) { @@ -91,35 +108,70 @@ export function NotificationSseProvider() { return; } - if (sourceRef.current) { - return; - } - - const source = new EventSource(url); - sourceRef.current = source; - const handleSseEvent = (rawData: string) => { const payload = parseNotificationPayload(rawData); if (payload) { - prependNotification(queryClient, payload); + const didPrepend = prependNotification(queryClient, payload); const title = getPayloadTitle(payload); showToast.info(title ?? "새 알림이 도착했어요."); - queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + if (!didPrepend) { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + } } }; - source.onmessage = (event) => { - handleSseEvent(event.data); + const scheduleReconnect = () => { + if (!isAuthenticated) return; + if (retryTimeoutRef.current) return; + + const attempt = reconnectAttemptRef.current; + const delay = Math.min(RECONNECT_BASE_DELAY_MS * 2 ** attempt, MAX_RECONNECT_DELAY_MS); + reconnectAttemptRef.current += 1; + + retryTimeoutRef.current = setTimeout(() => { + retryTimeoutRef.current = null; + connect(); + }, delay); }; - SSE_EVENT_TYPES.forEach((eventName) => { - source.addEventListener(eventName, (event) => { + const connect = () => { + if (!isAuthenticated) return; + if (sourceRef.current || isConnectingRef.current) return; + + isConnectingRef.current = true; + const source = new EventSource(url); + sourceRef.current = source; + + source.onopen = () => { + isConnectingRef.current = false; + reconnectAttemptRef.current = 0; + }; + + source.onmessage = (event) => { handleSseEvent(event.data); + }; + + source.onerror = () => { + isConnectingRef.current = false; + if (sourceRef.current) { + sourceRef.current.close(); + sourceRef.current = null; + } + scheduleReconnect(); + }; + + SSE_EVENT_TYPES.forEach((eventName) => { + source.addEventListener(eventName, (event) => { + handleSseEvent(event.data); + }); }); - }); + }; + + clearRetryTimeout(); + connect(); return () => { closeSource(); diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx index 00c120fe..68035378 100644 --- a/src/widgets/header/ui/header-notification-popover.tsx +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -15,15 +15,113 @@ import { getNotificationTargetHref, } from "@/features/notification/model/notification-mapper"; import { formatAgo } from "@/shared/lib/utils/time/format"; -import { Popover, PopoverContent, PopoverTrigger, ScrollArea } from "@/shared/ui"; +import { + EmptyState, + Popover, + PopoverContent, + PopoverTrigger, + ScrollArea, + Spinner, +} from "@/shared/ui"; import Button from "@/shared/ui/button/button"; export default function HeaderNotificationPopover() { - const { data } = useNotifications({ page: 0, size: 15 }); + const { data, isPending, error } = useNotifications({ page: 0, size: 15 }); const { mutate: readNotification } = useReadNotification(); const { mutate: readAllNotifications } = useReadAllNotifications(); const notifications = data?.slice ?? []; const hasUnread = notifications.some((notification) => !notification.readStatus); + const hasNotifications = notifications.length > 0; + + let markAllControl = ( + + ); + + if (hasUnread) { + markAllControl = ( + + + + ); + } + + let bodyContent = ( + + ); + + if (isPending) { + bodyContent = ( +
    + +
    + ); + } else if (error) { + bodyContent = ( + + ); + } else if (hasNotifications) { + bodyContent = ( +
      + {notifications.map((notification) => { + const Icon = getNotificationIcon(notification.type); + const targetHref = getNotificationTargetHref(notification.target, notification.targetId); + + return ( +
    • + + { + if (!notification.readStatus) { + readNotification(notification.notificationId); + } + }} + className="hover:bg-brand-surface focus-visible:ring-brand-text/30 flex w-full items-start gap-3 px-4 py-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none" + > + + + +
      +

      {notification.title}

      +

      {notification.message}

      +

      + {formatAgo(notification.notificationAt)} +

      +
      + {!notification.readStatus ? ( + + ) : null} + +
      +
    • + ); + })} +
    + ); + } return ( @@ -38,64 +136,10 @@ export default function HeaderNotificationPopover() {

    알림

    - - - + {markAllControl}
    - -
      - {notifications.map((notification) => { - const Icon = getNotificationIcon(notification.type); - const targetHref = getNotificationTargetHref( - notification.target, - notification.targetId - ); - - return ( -
    • - - { - if (!notification.readStatus) { - readNotification(notification.notificationId); - } - }} - className="hover:bg-brand-surface focus-visible:ring-brand-text/30 flex w-full items-start gap-3 px-4 py-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none" - > - - - -
      -

      - {notification.title} -

      -

      {notification.message}

      -

      - {formatAgo(notification.notificationAt)} -

      -
      - {!notification.readStatus ? ( - - ) : null} - -
      -
    • - ); - })} -
    -
    + {bodyContent}
    Date: Mon, 12 Jan 2026 09:11:19 +0900 Subject: [PATCH 13/13] =?UTF-8?q?feat(notification):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 알림 읽음 처리 실패 시 toast 에러 알림을 표시하도록 추가 - SSE provider에서 연결 이슈 발생 시 경고 toast를 노출하고 중복 경고를 방지 - 전체 읽음 버튼에 aria-label을 추가 --- src/features/notification/api/use-notifications.ts | 7 +++++++ .../notification/ui/notification-sse-provider.tsx | 8 +++++++- src/widgets/header/ui/header-notification-popover.tsx | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/features/notification/api/use-notifications.ts b/src/features/notification/api/use-notifications.ts index 9d62299f..dd52e4b9 100644 --- a/src/features/notification/api/use-notifications.ts +++ b/src/features/notification/api/use-notifications.ts @@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { httpClient } from "@/shared/api/client"; import type { SliceResponseType } from "@/shared/api/types/response"; import { API_ENDPOINTS } from "@/shared/config/endpoints"; +import { showToast } from "@/shared/lib/utils/toast/show-toast"; import type { NotificationItem } from "../model/types"; @@ -72,6 +73,9 @@ export function useReadNotification() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: notificationKeys.all }); }, + onError: () => { + showToast.error("알림 읽음 처리에 실패했습니다. 다시 시도해주세요."); + }, }); } @@ -83,5 +87,8 @@ export function useReadAllNotifications() { onSuccess: () => { queryClient.invalidateQueries({ queryKey: notificationKeys.all }); }, + onError: () => { + showToast.error("알림 전체 읽음 처리에 실패했습니다. 다시 시도해주세요."); + }, }); } diff --git a/src/features/notification/ui/notification-sse-provider.tsx b/src/features/notification/ui/notification-sse-provider.tsx index 06e51dd0..a0f8166b 100644 --- a/src/features/notification/ui/notification-sse-provider.tsx +++ b/src/features/notification/ui/notification-sse-provider.tsx @@ -84,6 +84,7 @@ export function NotificationSseProvider() { const isConnectingRef = useRef(false); const reconnectAttemptRef = useRef(0); const retryTimeoutRef = useRef | null>(null); + const hasNotifiedErrorRef = useRef(false); const isAuthenticated = Boolean(user); useEffect(() => { @@ -142,12 +143,13 @@ export function NotificationSseProvider() { if (sourceRef.current || isConnectingRef.current) return; isConnectingRef.current = true; - const source = new EventSource(url); + const source = new EventSource(url, { withCredentials: true }); sourceRef.current = source; source.onopen = () => { isConnectingRef.current = false; reconnectAttemptRef.current = 0; + hasNotifiedErrorRef.current = false; }; source.onmessage = (event) => { @@ -160,6 +162,10 @@ export function NotificationSseProvider() { sourceRef.current.close(); sourceRef.current = null; } + if (!hasNotifiedErrorRef.current) { + showToast.warning("실시간 알림 연결이 불안정합니다."); + hasNotifiedErrorRef.current = true; + } scheduleReconnect(); }; diff --git a/src/widgets/header/ui/header-notification-popover.tsx b/src/widgets/header/ui/header-notification-popover.tsx index 68035378..db1b834c 100644 --- a/src/widgets/header/ui/header-notification-popover.tsx +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -50,6 +50,7 @@ export default function HeaderNotificationPopover() {