From b9af01c1c3ced0b98231401fac1cb79326014321 Mon Sep 17 00:00:00 2001 From: uiuuoq Date: Thu, 30 Apr 2026 17:32:21 +0900 Subject: [PATCH] =?UTF-8?q?DP-401:=20=ED=99=88=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=98=A4=EB=B2=84=EB=A0=88=EC=9D=B4=20=ED=8A=B8=EB=A0=8C?= =?UTF-8?q?=EB=93=9C=20=EB=B6=84=EC=84=9D/=EA=B2=80=EC=83=89=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../home/search/HomeSearchOverlay.tsx | 78 ++++++++++++++++--- lib/api/endpoints/search.ts | 20 +++++ 2 files changed, 86 insertions(+), 12 deletions(-) create mode 100644 lib/api/endpoints/search.ts diff --git a/components/features/home/search/HomeSearchOverlay.tsx b/components/features/home/search/HomeSearchOverlay.tsx index 4b73da1..e0e631c 100644 --- a/components/features/home/search/HomeSearchOverlay.tsx +++ b/components/features/home/search/HomeSearchOverlay.tsx @@ -4,10 +4,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { Search, X } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; -import { fetchHomeTrend } from "@/lib/mock/home-search-trend"; +import { isAxiosError } from "axios"; import type { TrendRange } from "@/types/search"; -import { searchMockResults } from "@/lib/mock/home-search-results"; +import { contentsEndpoints } from "@/lib/api/endpoints/contents"; import { useAuthStore } from "@/store/auth.store"; +import { SEARCH_QUERY_KEYS, searchEndpoints } from "@/lib/api/endpoints/search"; import { HomeRangeTabs } from "./HomeRangeTabs"; import { HomeTopPostsSection } from "./HomeTopPostsSection"; import { HomeCollectionSummarySection } from "./HomeCollectionSummarySection"; @@ -17,6 +18,18 @@ import { HomeSearchResultsSection } from "./HomeSearchResultsSection"; const normalizeTag = (value: string) => value.toLowerCase().replace(/\s+/g, "").replace(/[.#]/g, ""); +/** ApiErrorResponse 구조(data.error.code)와 대안 경로(data.code) 둘 다 안전하게 추출 */ +function getApiErrorCode(data: unknown): string | undefined { + if (typeof data !== "object" || data === null) return undefined; + const d = data as Record; + if (typeof d.error === "object" && d.error !== null) { + const e = d.error as Record; + if (typeof e.code === "string") return e.code; + } + if (typeof d.code === "string") return d.code; + return undefined; +} + interface HomeSearchOverlayProps { isOpen: boolean; onClose: () => void; @@ -56,13 +69,34 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { return () => window.removeEventListener("keydown", handleKey); }, [isOpen, onClose]); - const { data, isLoading } = useQuery({ - queryKey: ["trends", "analysis", unit], - queryFn: () => fetchHomeTrend(unit), + const { + data, + isLoading, + isError: isTrendError, + error: trendError, + } = useQuery({ + queryKey: SEARCH_QUERY_KEYS.trendAnalysis(unit), + queryFn: () => searchEndpoints.getTrendAnalysis(unit), staleTime: 5 * 60 * 1000, enabled: isOpen, }); + const { + data: searchData, + isLoading: isSearchLoading, + isError: isSearchError, + } = useQuery({ + queryKey: ["contents", "search", debouncedQuery], + queryFn: () => + contentsEndpoints.searchContents({ + query: debouncedQuery, + page: 0, + size: 10, + }), + staleTime: 30 * 1000, + enabled: isOpen && debouncedQuery.length > 0, + }); + const handleUnitChange = useCallback((next: TrendRange) => { setUnit(next); }, []); @@ -84,10 +118,19 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { return () => clearTimeout(timer); }, [inputValue]); - const searchResults = useMemo( - () => (debouncedQuery ? searchMockResults(debouncedQuery) : []), - [debouncedQuery], - ); + const searchResults = useMemo(() => { + const contents = searchData?.data?.contents ?? []; + return contents.map((c) => ({ + id: c.id, + title: c.title, + sourceName: c.sourceName, + publishedAt: c.publishedAt, + thumbnailUrl: c.thumbnailUrl, + summary: c.preview, + tags: c.tags, + url: `/home/${c.id}`, + })); + }, [searchData]); const isSearching = debouncedQuery.length > 0; @@ -126,6 +169,11 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { }; const rangeLabel = RANGE_LABEL[unit]; + const isTrendEmpty = + isAxiosError(trendError) && + trendError.response?.status === 404 && + getApiErrorCode(trendError.response?.data) === "TREND_001"; + // isOpen이 false면 렌더하지 않음 // createPortal은 user interaction 이후에만 호출되므로 document.body 접근 안전 if (!isOpen) return null; @@ -189,16 +237,22 @@ export function HomeSearchOverlay({ isOpen, onClose }: HomeSearchOverlayProps) { {/* 스크롤 영역 — 전체 너비로 확장해 여백 포함 스크롤 가능 */}
-
+
{isSearching ? ( + ) : isTrendError ? ( +

+ {isTrendEmpty + ? "집계된 트렌드가 없습니다" + : "데이터를 불러오지 못했습니다"} +

) : (
["trends", "analysis", unit] as const, +}; + +export const searchEndpoints = { + getTrendAnalysis: ( + unit: TrendRange, + scope: string = "global", + ): Promise => { + return apiClient + .get>("/trends/analysis", { + params: { unit, scope }, + }) + .then((r) => r.data.data); + }, +};