diff --git a/next.config.ts b/next.config.ts index 09f2d56a..a20f13a9 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,29 +1,22 @@ 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: "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: "https", hostname: "ssl.pstatic.net", }, - { protocol: "https", hostname: "lh3.googleusercontent.com", }, + { protocol: "http", hostname: "phinf.pstatic.net" }, + { 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" }, ], }, async rewrites() { 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/api/use-notifications.ts b/src/features/notification/api/use-notifications.ts new file mode 100644 index 00000000..dd52e4b9 --- /dev/null +++ b/src/features/notification/api/use-notifications.ts @@ -0,0 +1,94 @@ +"use client"; + +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"; + +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), + }); +} + +const readNotification = async (notificationId: number) => { + const response = await httpClient(API_ENDPOINTS.notificationsRead(notificationId), { + method: "PATCH", + }); + + return response.data; +}; + +const readAllNotifications = async () => { + const response = await httpClient(API_ENDPOINTS.notificationsReadAll, { + method: "PATCH", + }); + + return response.data; +}; + +export function useReadNotification() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: readNotification, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + onError: () => { + showToast.error("알림 읽음 처리에 실패했습니다. 다시 시도해주세요."); + }, + }); +} + +export function useReadAllNotifications() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: readAllNotifications, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + }, + onError: () => { + showToast.error("알림 전체 읽음 처리에 실패했습니다. 다시 시도해주세요."); + }, + }); +} diff --git a/src/features/notification/model/notification-mapper.ts b/src/features/notification/model/notification-mapper.ts new file mode 100644 index 00000000..62c9d6b2 --- /dev/null +++ b/src/features/notification/model/notification-mapper.ts @@ -0,0 +1,59 @@ +import { + BadgeCheck, + Bell, + CheckCircle, + Clock, + CreditCard, + 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_SUBSCRIBER": + return XCircle; + 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; + 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; + } +} diff --git a/src/features/notification/model/types.ts b/src/features/notification/model/types.ts new file mode 100644 index 00000000..16d917df --- /dev/null +++ b/src/features/notification/model/types.ts @@ -0,0 +1,57 @@ +/** + * + * 채팅 + * 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_SUBSCRIBER" + | "SALE_SUCCESS_SELLER" + | "SALE_SUCCESS_SUBSCRIBER" + | "STOP_LOSS_TRIGGERED" + | "PRICE_DROP" + | "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; +} 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..a0f8166b --- /dev/null +++ b/src/features/notification/ui/notification-sse-provider.tsx @@ -0,0 +1,188 @@ +"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; +const MAX_RECONNECT_DELAY_MS = 30_000; +const RECONNECT_BASE_DELAY_MS = 1_000; + +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 false; + let didPrepend = false; + + queryClient.setQueriesData>( + { queryKey: notificationKeys.all }, + (data) => { + 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; + + return { + ...data, + slice: trimmedSlice, + }; + } + ); + + 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 hasNotifiedErrorRef = useRef(false); + 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) { + closeSource(); + return; + } + + const handleSseEvent = (rawData: string) => { + const payload = parseNotificationPayload(rawData); + + if (payload) { + const didPrepend = prependNotification(queryClient, payload); + + const title = getPayloadTitle(payload); + showToast.info(title ?? "새 알림이 도착했어요."); + + if (!didPrepend) { + queryClient.invalidateQueries({ queryKey: notificationKeys.all }); + } + } + }; + + 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); + }; + + const connect = () => { + if (!isAuthenticated) return; + if (sourceRef.current || isConnectingRef.current) return; + + isConnectingRef.current = true; + const source = new EventSource(url, { withCredentials: true }); + sourceRef.current = source; + + source.onopen = () => { + isConnectingRef.current = false; + reconnectAttemptRef.current = 0; + hasNotifiedErrorRef.current = false; + }; + + source.onmessage = (event) => { + handleSseEvent(event.data); + }; + + source.onerror = () => { + isConnectingRef.current = false; + if (sourceRef.current) { + sourceRef.current.close(); + sourceRef.current = null; + } + if (!hasNotifiedErrorRef.current) { + showToast.warning("실시간 알림 연결이 불안정합니다."); + hasNotifiedErrorRef.current = true; + } + scheduleReconnect(); + }; + + SSE_EVENT_TYPES.forEach((eventName) => { + source.addEventListener(eventName, (event) => { + handleSseEvent(event.data); + }); + }); + }; + + clearRetryTimeout(); + connect(); + + 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( 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) { 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", 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..db1b834c --- /dev/null +++ b/src/widgets/header/ui/header-notification-popover.tsx @@ -0,0 +1,157 @@ +"use client"; + +import Link from "next/link"; + +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { Bell } from "lucide-react"; + +import { + useNotifications, + useReadAllNotifications, + useReadNotification, +} from "@/features/notification/api/use-notifications"; +import { + getNotificationIcon, + getNotificationTargetHref, +} from "@/features/notification/model/notification-mapper"; +import { formatAgo } from "@/shared/lib/utils/time/format"; +import { + EmptyState, + Popover, + PopoverContent, + PopoverTrigger, + ScrollArea, + Spinner, +} from "@/shared/ui"; +import Button from "@/shared/ui/button/button"; + +export default function HeaderNotificationPopover() { + 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 ( + + + + + +
+

알림

+ {markAllControl} +
+
+ {bodyContent} +
+ + + 내 알림 보기 + + +
+ + + ); +}